На Хабре периодически появляются статьи с анализом феномена Python - как один из самых медленных языков программирования стал королём нейросетей, и ответ всегда один: за счёт простого синтаксиса и развитой экосистемы. Но экосистема у C++ значительно больше и богаче, значит, дело не в ней и остаётся только синтаксис. А если проблема действительно в синтаксисе, тогда должен быть ответ и на другой вопрос: почему C++ не стал основой для исследований в этой (или любой другой) области?

Раньше я уже подходил к вопросу об эмпирической оценке сложности синтаксиса языков программирования, что называется «в лоб»: взять исходный код компилятора и посмотреть, сколько строк в нём занимает синтаксический анализатор. Ведь чем сложнее синтаксис, тем больше кода нужно, чтобы его распознать. И сотни тысяч строк кода только на анализ синтаксиса C++ - это измеримое свидетельство того, в какого монстра превратился C++ за сорок лет развития.

Но есть и другой способ оценить то же самое - причём гораздо проще, доступнее и без единой строчки анализа кодовой базы. И этот способ даёт неожиданно точный ответ на вопрос, почему именно Python в машинном обучении, несмотря на его репутацию «медленного» языка, стал стандартом.

Два мира в одном языке

Когда программист пишет код на любом языке программирования, он работает с двумя принципиально разными группами понятий одновременно.

Первая группа понятий описывает - что делает программа - это различные бизнес-правила, прикладные и предметные сущности, которые используются для описания алгоритма. Например: class Order, enum Status { Paid, Pending }, if balance < amount: raise InsufficientFunds - всё это может прочитать и понять любой человек при первом взгляде на код, даже без знания синтаксиса конкретного языка.

Вторая группа понятий, которой оперирует программист, - это описание того, как это исполняется - управление памятью, диспетчеризация вызовов, синхронизация потоков и различные директивы компилятору. Это может быть alignas(64), volatile, reinterpret_cast, __slots__ - и здесь непрограммист уже беспомощен, и это нормально: эти конструкции адресованы компилятору (машине), а не человеку из предметной области.

И такое разделение синтаксиса у языков программирования не декоративное. Это принципиальное архитектурное разделение, которое содержит практически каждый язык программирования без исключения. И чем сложнее «машинная» часть языка, тем хуже понимается текст программы непрограммистом.

Критерии оценки сложности синтаксиса ЯП

Может показаться, что отнесение языка программирования к категориям «низкоуровневый/высокоуровневый» или «системный/прикладной» - это сугубо экспертная оценка, причём довольно относительная. Например, C++ или Rust будут языками высокого уровня по сравнению с Ассемблером. Однако использование терминов «системный» или «прикладной» зависит даже не от языка, а от назначения конкретной программы.

Но если говорить именно про синтаксис, то можно взять полный список ключевых слов (включая встроенные аннотации) и задать по каждому из них всего два вопроса:

  1. Может ли эксперт предметной области, т. е. непрограммист, понять назначение этой конструкции без знания языка?

  2. Влияет ли эта конструкция языка на компилятор, рантайм или архитектуру железа?

Тогда получается вполне понятный критерий для классификации ключевых слов синтаксиса: относить их к системной или прикладной категории, после чего можно посчитать процент прикладных терминов в общей лексике языка.

C++

Полный список ключевых слов по стандарту C++20 - 92 слова, плюс разные операторы и стандартные атрибуты ([[nodiscard]], [[deprecated]], [[likely]] и др.), плюс break, continue. Всего 115 лексических конструкций, из которых:

  • Прикладные (говорят о предметной области) всего 34. Ключевые слова: bool, enum, enum class, const, struct, return, override, operator, if, else, switch, case, default, for, while, do, try, catch, throw, class, public, private, protected, co_await, co_yield, co_return, concept, requires, текстовые псевдонимы логических операторов: and, or, not (хотя они и не рекомендованы к использованию, но в лексике языка всё равно присутствуют) и несколько атрибутов: [[nodiscard]], [[deprecated]], [[fallthrough]].

  • Системные (говорят о компиляторе, памяти, железе) 81 из 115. Ключевые слова: int, short, long, float, double, char, wchar_t, char8_t, char16_t, char32_t, void, unsigned, signed и т. д. Текстовые псевдонимы побитовых и составных операторов: and_eq, or_eq, not_eq, xor, xor_eq, bitand, bitor, compl и различные атрибуты: [[maybe_unused]], [[likely]], [[unlikely]], [[noreturn]], [[carries_dependency]], [[no_unique_address]], [[optimize_for_synchronized]].

Итого прикладных терминов в синтаксисе C++ всего 34 из 115 (около 30%).

Python

Официальный список ключевых слов Python 3.12 - 35 слов, плюс 8 встроенных декораторов (@property, @staticmethod, @classmethod, @abstractmethod, @override, @dataclass, @cached_property, @functools.wraps). Итого 43 конструкции, из которых:

  • Прикладных 35 из 43. Это ключевые слова: True, False, None, def, return, lambda, yield, class, if, elif, else, for, while, try, except, raise, finally, with, import, from, as, async, await, del, not, and, or, is, in и декораторы: @property, @staticmethod, @classmethod, @abstractmethod, @override, @dataclass.

  • Системные ключевые слова Python: global, nonlocal, pass, break, continue, assert, type - всего 7 слов и два декоратора: @cached_property, @functools.wraps.

Итого прикладных терминов Python: 35 из 43 (более 81%)!

Rust

Официальный список ключевых слов Rust - 47 ключевых слов (из которых 9 зарезервированных) и 49 встроенных атрибутов, всего 96 терминов, из которых:

  • Прикладные ключевые слова: true, false, enum, const, let, struct, fn, return, trait, pub, where, if, else, match, for, in, async, await и атрибуты: #[derive(...)], #[must_use], #[deprecated], #[test] и т. д. - всего 26 прикладных терминов.

  • Системные ключевые слова: type, impl, dyn, Self, mut, ref, static, move, unsafe, extern, break, continue, super, as, loop, use, crate, mod, become, box, do, final, macro, override, priv, typeof, unsized, virtual, yield, abstract, alignof, offsetof, pure, sizeof - всего 29 ключевых слов, плюс 41 системный атрибут, таких как #[cfg(...)], #[cfg_attr(...)], #[inline] и т. д.

Итого прикладных терминов в Rust 26 из 96 (всего 27%).

В итоге прикладная часть синтаксиса ЯП выглядит так

Язык

Всего конструкций

Прикладных

Системных

% прикладных

Python

43

35

9

81%

Rust

96

26

70

27%

C++20

115

34

81

30%

C++26

121

37

84

31%

C++ по праву считается самым сложным. У него не только самый большой синтаксический словарь (121 конструкций против 43 у Python), но и очень неблагоприятное соотношение для прикладного пользователя: 84 системных термина против 34 прикладных. Программист на C++ вынужден держать в голове не только логику предметной области, но и управление памятью (new/delete, RAII, alignas, alignof), приведения типов (несколько разных *_cast), оптимизации компилятора (constexpr, consteval, constinit, inline, noexcept), диспетчеризацию (virtual, override, final) и метапрограммирование (template, typename, decltype). Ни один другой промышленный язык программирования не требует явного управления таким количеством системных деталей одновременно.

Доля прикладных терминов в грамматике Rust - 27%, это даже меньше, чем в C++. Но несмотря на это, с ним работать всё равно проще. А секрет заключается в удачном архитектурном решении: в Rust системные концепции не размазаны по ключевым словам, как в C++, а вынесены в атрибуты с однотипным синтаксисом. За счёт чего ключевые слова остаются относительно прикладными (trait, match, pub, where), а системные детали (#[repr], #[inline], #[cfg]) вынесены в отдельный синтаксический слой, который визуально легко отличим в исходном тексте программы.

Тем не менее Python всё равно не просто «проще» - его синтаксис специально ориентирован на предметную область значительно сильнее, чем C++ или Rust. И это не субъективное ощущение, а архитектурное свойство языка: из 43 конструкций 35 говорят о предметной области, и только 9 - о деталях рантайма. Поэтому if balance > 0 and status == "active" сможет понять любой человек даже без знания Python.

Почему Python выиграл в ML?

Оказывается, нет ничего удивительного в том, что Python предпочтительнее для использования в любых экспериментах и прототипах. Исследователь, пришедший из физики или биологии, читает:

for batch in dataloader:
    logits = classifier(batch.features)
    loss = cross_entropy(logits, batch.labels)
    loss.backward()
    optimizer.step()

Здесь каждое слово - из его профессионального словаря. for, loss, backward, step - это математика, записанная кодом. Системные детали (event loop, GIL, reference counting) не имеют здесь ключевых слов, потому что Python намеренно убрал их из синтаксиса.

И как бы выглядел аналогичный фрагмент на C++:
for (size_t i = 0; i < dataloader.size(); ++i) {
    const auto& batch = dataloader[i];
    
    // RAII-обёртка для выровненной памяти
    alignas(64) std::vector<float> logits;
    logits.reserve(num_classes);
    
    // Проверка на nullptr перед dynamic_cast
    if (auto* derived = dynamic_cast<DerivedClassifier*>(classifier.get())) {
        derived->forward(batch.features, logits);
    } else {
        throw std::runtime_error("Invalid classifier type");
    }
    
    // Вычисление функции потерь
    const float loss = cross_entropy(
        logits.data(), 
        batch.labels.data(),
        static_cast<size_t>(batch.labels.size())
    );
    
    // Обратное распространение с проверкой переполнения
    if (!std::isfinite(loss)) {
        throw std::overflow_error("Loss is infinite or NaN");
    }
    
    backward(loss, classifier->parameters());
    
    // Обновление весов с mutex для thread-safety
    {
        std::lock_guard<std::mutex> lock(optimizer_mutex);
        optimizer.step(classifier->parameters());
    }
}

C++ не спасает ничего: ни обобщённое программирование, ни умные указатели. В C++ доминирует низкоуровневый системный словарь: alignas, std::vector::reserve, dynamic_cast, static_cast, std::isfinite, std::lock_guard, std::mutex, try/catch, std::bad_alloc, std::bad_cast, std::exception, throw. Каждая строка требует явного управления типами (const auto&, size_t, float*), безопасностью памяти (даже RAII-обёртки), проверками корректности (nullptr, isfinite) и синхронизацией потоков (mutex, lock_guard).

Исследователь видит не математику обучения - он видит внутренности инфраструктуры выполнения. И это не недостаток C++: это запись тонкостей реализации, которые в Python скрыты за интерпретатором. Но это другой уровень языка - язык управления ресурсами и полного контроля над исполнением, но не предметной области.

Для честности, вот код Python с обработкой ошибок:

for batch in dataloader:
    try:
        logits = classifier(batch.features)
        loss = cross_entropy(logits, batch.labels)

        if not loss.isfinite():
            raise ValueError(f"Loss is not finite: {loss.item()}")

        loss.backward()
        optimizer.step()

    except RuntimeError as e:
        logger.error("Forward/backward pass failed: %s", e)
        classifier.zero_grad()
        raise
    except ValueError as e:
        logger.error("Invalid loss value: %s", e)
        raise

И даже в этом случае Python остаётся в прикладном словаре: loss, backward, step, isfinite - всё это математика и предметная область. Системные детали (RuntimeError, zero_grad) появляются только в блоках except - то есть в исключительных ситуациях, а не в основном потоке логики. Тогда как в C++ низкоуровневые системные термины (std::isfinite, std::lock_guard, dynamic_cast, static_cast) стоят прямо в основном потоке, рядом с loss и backward.

Немного другая ситуация получается с программой на Rust:

for batch in &dataloader {
    let logits = classifier.forward(&batch.features)?;
    let loss = cross_entropy(&logits, &batch.labels)?;

    if !loss.is_finite() {
        return Err(TrainingError::InvalidLoss(loss));
    }

    loss.backward()?;
    optimizer.step(classifier.parameters())?;
}

Данный вариант читается почти так же легко, как Python - for, loss, backward, step остаются на месте. Но системный словарь всё равно просачивается: & перед каждым аргументом - это явное заимствование, которое borrow checker требует обозначать всегда. ? после каждого вызова - это явная обработка Result<T, E>, которую нельзя проигнорировать молча. И практически любой специалист предметной области (т. е. непрофессиональный программист) споткнётся именно здесь: не на логике машинного обучения, а на том, почему нельзя написать просто classifier.forward(batch.features).

Тот же самый код Rust с явным управлением владением, синхронизацией и обработкой ошибок:
for batch in dataloader.iter() {
    // Явное заимствование: borrow checker требует знать,
    // кто владеет данными и на какой срок
    let logits = match classifier.forward(&batch.features) {
        Ok(output) => output,
        Err(e) => {
            eprintln!("Forward pass failed: {}", e);
            classifier.reset_gradients();
            return Err(TrainingError::ForwardFailed(e));
        }
    };

    let loss = match cross_entropy(&logits, &batch.labels) {
        Ok(l) if l.is_finite() => l,
        Ok(l) => {
            return Err(TrainingError::InvalidLoss(l));
        }
        Err(e) => return Err(TrainingError::LossFailed(e)),
    };

    // backward() потребляет loss - владение передаётся,
    // после этой строки loss использовать нельзя
    loss.backward().map_err(|e| {
        classifier.reset_gradients();
        TrainingError::BackwardFailed(e)
    })?;

    // Обновление весов через Mutex - явная синхронизация,
    // lock живёт ровно до конца блока (RAII)
    {
        let mut params = classifier
            .parameters()
            .lock()
            .map_err(|_| TrainingError::PoisonedMutex)?;

        optimizer.step(&mut params).map_err(|e| {
            TrainingError::OptimizerFailed(e)
        })?;
    } // lock освобождается здесь автоматически
}

В Rust каждая конструкция несёт конкретную системную гарантию, но за это Rust платит тем, что его системный словарь виден даже в прикладном коде (точно так же, как и в случае с C++). И это происходит везде, даже в мелочах. Самая простая операция - проверка конечности loss:

# Python: скрыто за исключением фреймворка
loss.backward()
// C++: явная проверка + явное исключение + явный тип
if (!std::isfinite(loss)) {
    throw std::overflow_error("Loss is infinite or NaN");
}
// Rust: компилятор требует обработать Result явно
if !loss.is_finite() {
    return Err(TrainingError::InvalidLoss(loss));
}

В Python не нужно ничего проверять - фреймворк сделает это сам или упадёт с понятным сообщением, тогда как C++ требует явной проверки, потому что арифметика с double не бросает исключений по стандарту, и это знание о внутреннем устройстве числового представления, а не о задаче. Rust идёт ещё дальше: он запрещает проигнорировать ошибку на уровне системы типов.

Именно в таких деталях и живёт разница между 80% прикладных конструкций у Python и всеми остальными языками: не в синтаксическом сахаре и не в длине кода, а в том, сколько системных знаний нужно держать в голове, чтобы написать казалось бы, очень простую вещь.

Все языки эволюционируют в сторону предметной области

Практически каждое крупное обновление промышленных языков за последние десять лет добавляло в основном прикладные, а не системные термины. Общий тренд однозначен: системные механизмы уходят в автоматику, прикладные конструкции становятся выразительнее.

Особенно сильно это заметно в C++ - но с парадоксальным результатом. C++20 принёс concept и requires - конструкции, которые говорят о намерении, а не о механизме. C++23 добавил std::expected. C++26 делает самый смелый шаг: статическая рефлексия через std::meta убирает макросы и кодогенераторы, контракты pre/post превращают бизнес-ограничения в часть сигнатуры функции, std::execution прячет асинхронность за читаемым пайплайном.

double sqrt(double x)
    pre(x >= 0.0)
    post(result: result * result <= x);

Это поймет любой человек с математическим образованием. Но те же контракты имеют несколько режимов проверки, взаимодействуют с noexcept и по-особому ведут себя при наследовании. std::meta требует понимания constexpr-вычислений и нового оператора ^. std::execution вводит пять новых абстракций: sender, receiver, scheduler, operation state, completion signatures.

Вот в чём парадокс: каждая прикладная добавка тянет за собой новый слой системных концепций. Когда Python добавляет @dataclass, новичок пользуется им на следующий день. Когда C++26 добавляет std::meta, между «понял идею» и «применяю правильно» лежат недели. Язык становится выразительнее для тех, кто уже знает его глубоко - и сложнее для всех остальных. Это не провал комитета, а честная цена универсальности: язык, который одновременно управляет байтами и выражает бизнес-контракты, не может быть простым по определению.

Короче, выглядит это как-то так:

Итог

Споры «Python vs C++» или «C++ vs Rust» традиционно ведутся в терминах производительности, безопасности типов или размера экосистемы. Но есть ещё одно измерение, которое редко называют явно: для кого разработан синтаксис языка?

Когда Python-код читает непрограммист и понимает его без погружения в детали рантайма - это не случайность, а результат дизайна языка. Когда C++ программист пишет [[nodiscard]] constexpr auto compute() noexcept - каждое слово адресовано компилятору. Это не плохо: это честная запись системных гарантий, но это не прикладной, а системный словарь.

Python выиграл в ML потому, что думает на том же языке, на котором думают исследователи. А это, как выясняется, вполне конкретно и даже поддаётся измерению. Python структурно ориентирован на предметную область почти втрое сильнее, чем C++ или Rust, и это не просто «синтаксический сахар», а принципиальная архитектурная разница языков, и именно поэтому ни C++, ни Rust не смогут заменить «медленный» Python, который позволяет очень быстро делать прикладные вещи.