Главный вопрос статьи заключается:
Почему вместо простого решения (форк парсера с запретом легаси) они выбрали новый синтаксис, который несовместим со старым кодом?
Спойлер
Ответ — не инженерия. Ответ — политика, эго и деньги.
Предисловие
В последние годы C++ получает системный вызов со стороны Rust — единственного языка, который предлагает статические гарантии безопасности памяти без сборщика мусора.
Рассмотрим поподробнее что это за язык программирования.
-
Rust предлагает безопасность памяти на уровне компилятора. Рассмотрим подробнее в чем именно заключается определение безопасности:
Безопасность памяти в Rust обеспечивается на этапе компиляции без использования сборщика мусора (Garbage Collector). Компилятор Rust (rustc) использует строгие математические правила владения и систему типов для предотвращения таких ошибок, как обращение к освобожденной памяти (use-after-free), двойное освобождение (double-free) и состояния гонки (data races).
-
Система владения (Ownership)
-
Каждое значение в Rust имеет переменную, которая называется его владельцем.
-
У значения может быть только один владелец в каждый момент времени.
-
Когда владелец выходит из области видимости (scope), значение автоматически удаляется (вызывается функция
drop, освобождающая память).
Rust:
let s1 = String::from("text");let s2 = s1; // move: владение перешло к s2// println!("{}", s1); // ошибка компиляции: s1 больше не владеет даннымиЕсли вы присваиваете переменную
s1другой переменнойs2, владение данными переходит (move) кs2. Попытка использоватьs1после этого вызовет ошибку компиляции. В C++ аналогичный код привел бы к созданию двух указателей на один ресурс, что чревато double-free.C++:
std::string* s1 = new std::string("text");std::string* s2 = s1; // два указателя на одну памятьdelete s1;delete s2; // undefined behavior: double-freeЧто здесь происходит:
Оператор
newзапрашивает у аллокатора (управленца кучей) блок памяти, достаточный для хранения объектаstd::string. Аллокатор выделяет этот блок, помечает его в своей внутренней таблице как «занятый» и возвращает адрес.Переменная
s2просто копирует адрес изs1. Никакого дублирования самой строки в памяти не происходит. Оба указателя смотрят на одну и ту же ячейку.Первое удаление (
delete s1): Вызывается деструктор~std::string(), который освобождает внутренний буфер строки (где хранилось слово «text»). Операторdeleteвозвращает память, занимаемую самим объектом строки, обратно аллокатору. Аллокатор помечает этот адрес как свободный для будущих нужд. Но значения в указателяхs1иs2не обнуляются автоматически — они становятся висячими (dangling pointers).Второе удаление (
delete s2): Программа снова просит аллокатор освободить память по тому же адресу. Для аллокатора это UB, так как этот блок памяти не принадлежит. -
-
Проверка заимствований (Borrow Checker)
Чтобы код был гибким, Rust позволяет временно использовать данные без смены владельца через ссылки (
&). Безопасность ссылок контролирует статический анализатор компилятора — Borrow Checker.Он строго соблюдает правило алиасинга и мутабельности:
Rust:
let mut v = vec![1, 2, 3];let r1 = &v[0]; // неизменяемая ссылка// v.push(4); // Ошибка компиляции: нельзя мутировать, пока есть r1println!("{}", r1); // Можно: r1 гарантированно валиднаВы можете иметь сколько угодно неизменяемых ссылок (
&T) на ресурс.
Вы можете иметь только одну изменяемую ссылку (&mut T) на ресурс.C++
std::vector<int> v = {1, 2, 3};int& ref = v[0]; // ссылка на элементv.push_back(4); // может вызвать реаллокациюstd::cout << ref; // Use-after-free (ссылка висит)Если произошла реаллокация, старый адрес, на который указывала ссылка
ref(&v[0]), освобождается. Ссылка становится «висячей» (dangling reference). Попытка обратиться к ней черезstd::cout << ref— это чтение из уже освобожденной памяти (Use-After-Free). -
Время жизни (Lifetimes)
Lifetimes — это конструкции, с помощью которых компилятор проверяет, что все заимствования (ссылки) остаются валидными до тех пор, пока они используются. Это главный инструмент борьбы с висячими указателями (dangling pointers).
-
Неявные времена жизни: Компилятор использует алгоритм Lifetime Elision, автоматически расставляя времена жизни в стандартных сценариях.
-
Явные аннотации (
'a): Нужны в сложных структурах и функциях, чтобы «объяснить» компилятору взаимосвязь между временем жизни входных параметров и возвращаемого значения. -
Суть проверки: Компилятор строит граф направленного потока управления и гарантирует, что область видимости ссылки строго меньше или равна области видимости самого владельца данных. Ссылка не может пережить свои данные.
Rust:
// Rust — компилятор не даст собрать даже без аннотацийfn get() -> &i32 { let x = 42; &x // ошибка: x живёт меньше, чем возвращаемая ссылка}C++
// C++int* get() { int x = 42; return &x; // компилятор может предупредить}int* p = get(); // p висит*p = 10; // UB
-
-
Что такое cppfront и почему он появился?
Герб Саттер решил доказать, что C++ не нуждается в новом языке. Достаточно заменить старый синтаксис на новый.
Рассмотрим на примере формат синтаксисов языков
// C++#include <vector>#include <memory>struct Data { explicit Data(int x) : value(x) {} int value;};int main() { std::vector<int> v = {1, 2, 3}; std::unique_ptr<Data> ptr = std::make_unique<Data>(42); // Cкобочная инициализация (Most Verse Parsing) Data d1(100); // Инициализация множественных указателей int* a, b; // b — это int, а не указатель return ptr->value;}
// cppfrontmain: () = { v : std::vector<int> = (1, 2, 3); ptr : std::unique_ptr<Data> = std::make_unique<Data>(42); // Работает только единообразная инициализация d1 : Data = 100; // ошибка // a, b : int*; — каждый тип инициализируется явно // b : int*; a : int*; // правильно // Авторазименование умных указателей через . ret ptr.value; // вместо ptr->value}
Так родился cppfront — экспериментальный компилятор, который преобразует «чистый C++ 2.0» в обычный C++. Как это выглядит на деле:
Проблема первая: cppfront — это не исправление старого кода — а создание нового
Саттер утверждает, что cppfront — это эволюция C++, а не новый язык. Правда? Рассмотрим поподробнее:
-
Это текстуально разные языки. Как TypeScript совместим с JavaScript, так и здесь нет ни грамма совместимости.
-
cppfront — это транслятор, который на вход принимает файлы
.cpp2, а на выходе генерирует.cpp. -
Использует дополнительный этап обвязки, вместо использование готового Clang и и запретить опасные конструкции через набор флагов компиляции
Вторая проблема: Индустрия могла бы просто «вырезать легаси», но выбрала новую грамматику
Вместо введения нового синтаксиса вроде x : int = 42; или создания отдельного транслятора с нуля, комитету стоило бы пойти по более прагматичному пути. Clang — это уже готовая, отполированная годами эксплуатации машина для анализа, трансформации и генерации кода на C++. В ней выстроена вся инфраструктура: парсер, семантический анализатор, оптимизатор и генерация объектного кода. Зачем писать новый инструмент, если можно надстроить поведение поверх уже существующего?
Достаточно добавить в Clang флаг, например, -std=cpp_not_deprecated. В этом режиме компилятор получает дополнительные ограничения: он просто проходит по абстрактному синтаксическому дереву (AST) и на каждом узле проверяет, не нарушает ли код новые правила. Если встречается запрещённая конструкция — выбрасывается ошибка. Это не требует переписывания ядра компилятора, только расширение набора диагностик и семантических проверок.
В черный список должны попасть: скобочная инициализация (чтобы не путать с объявлением функции, именуемая как Most Verse Parsing), объявление нескольких указателей в одной строке или аргументов, где один из них является указателем, перегруженный оператор -> (вместо него использовать только точку с автоматическим разыменованием), ручное управление памятью через new/delete, а также использование NULL или 0 для указателей. Кроме того, компилятор должен требовать обязательной инициализации всех переменных при объявлении.
Проблема №3: cppfront не решает главную проблему C++ — управление памятью
Саттер в своём подходе не затрагивает ABI, не меняет модель памяти и не вводит borrow checker.
Что предлагает cppfront? В основном — явные аннотации вроде inout, move и out, а также запрет на использование «голых» указателей (хотя std::unique_ptr всё равно можно разыменовать через .get()).
Однако ключевые проблемы C++ — гонки данных, использование после освобождения и двойное освобождение — остаются нерешёнными. По сути, cppfront лишь автоматически заменяет -> на . и добавляет несколько поверхностных проверок.
В то время как Rust предлагает принципиальное решение для управления памятью, cppfront, по сути, занимается косметическими изменениями, не устраняя глубинных проблем языка.
И всё же, если очень хочется, cppfront предоставляет как минимум три «легальных» способа получить двойной указатель — несмотря на всю свою риторику о безопасности.
Первый способ — через вставку классического C++. Блок /* lang:cpp */ работает как чёрный ход: cppfront просто пропускает его содержимое насквозь, и финальный компилятор получает обычный голый C++ код с любыми указателями. Компилятор cppfront делает вид, что ничего не видел.
main: () -> int = { /* lang:cpp */ int** cpp_ptr = new int*(new int(42)); std::cout << **cpp_ptr << std::endl; delete *cpp_ptr; std::cout << **cpp_ptr << std::endl; // UB delete cpp_ptr; /* lang:cpp */ return 0;}
Второй способ — через блок unsafe и оператор &. Внутри такого блока разрешается брать адреса, и двойной указатель создаётся буквально одной строкой: это полноценный int**.
main: () -> int = { x := 42; unsafe { p1: int* = &x; // Одинарный указатель p2: int** = &p1; // Двойной указатель std::println(p2**); p2 = nullptr; std::println(p2**); // Segmentation fault (Null pointer dereference) } return 0;}
Третий способ — самый интересный. Можно обойтись вообще без unsafe, просто вложив один std::unique_ptr в другой. На уровне ассемблера получается ровно та же двойная индирекция, а cppfront не может ничего запретить, потому что формально это «безопасные» умные указатели. Получается, что запрет на голые указатели обходится через их же безопасную обёртку — иронично, не правда ли?
main: () = { // Выделяем память под внутренний указатель inner := unique.new<int>(42); // Выделяем память под внешний указатель и перемещаем туда первый // Получается аналог std::unique_ptr<std::unique_ptr<int>> outer := unique.new<std::unique_ptr<int>>(move(inner)); // Доступ к значению std::cout << outer*** << "\n"; }
И вот в чём парадокс: если даже такой строгий и переосмысленный язык, как Cpp2, вынужден оставлять лазейки (блоки lang:cpp, режим unsafe, обёртки), то значит, полностью искоренить опасные конструкции на уровне синтаксиса невозможно. Рано или поздно разработчик всё равно найдёт способ написать то, что ему нужно.
Итоговая проблема
Стандарт C++ — это договор между корпорациями. Любое изменение существующего поведения — это риск для миллиардов строк кода.
В комитете C++ исторически поощряются крупные, «эпохальные» изменения, а не скромные, но эффективные патчи. Новая версия языка продаётся лучше, чем новый флаг компилятора. Вторая — маркетинговая: проект с отдельным синтаксисом и громким названием «C++ 2.0» привлекает внимание, конференции, спонсоров — всё то, чего не получишь с «очередным режимом безопасности».
Индустрия не заинтересована в решении проблем. Индустрия заинтересована в их продаже. Rust — исключение, но оно только подтверждает правило. Его безопасность — это результат работы исследовательской группы, а не рыночного спроса. В коммерческом мире никто не предлагал «сделать язык безопасным бесплатно» — потому что это убивает бизнес-модели тысяч компаний, живущих за счёт сложности C++.
ссылка на оригинал статьи https://habr.com/ru/articles/1064398/