Почему C++ или Rust никогда не смогут заменить Python
На Хабре периодически появляются статьи с анализом феномена 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 будут языками высокого уровня по сравнению с Ассемблером. Однако использование терминов «системный» или «прикладной» зависит даже не от языка, а от назначения конкретной программы.
Но если говорить именно про синтаксис, то можно взять полный список ключевых слов (включая встроенные аннотации) и задать по каждому из них всего два вопроса:
- Может ли эксперт предметной области, т. е. непрограммист, понять назначение этой конструкции без знания языка?
- Влияет ли эта конструкция языка на компилятор, рантайм или архитектуру железа?
Тогда получается вполне понятный критерий для классификации ключевых слов синтаксиса: относить их к системной или прикладной категории, после чего можно посчитать процент прикладных терминов в общей лексике языка.
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, который позволяет очень быстро делать прикладные вещи.