cppfront: Великий архитектурный вызов или маркетинговая утка?

от автора

Главный вопрос статьи заключается:

Почему вместо простого решения (форк парсера с запретом легаси) они выбрали новый синтаксис, который несовместим со старым кодом?

Спойлер

Ответ — не инженерия. Ответ — политика, эго и деньги.

Предисловие

В последние годы 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

Проблема первая: cppfront — это не исправление старого кода — а создание нового

Саттер утверждает, что cppfront — это эволюция C++, а не новый язык. Правда? Рассмотрим поподробнее:

  1. Это текстуально разные языки. Как TypeScript совместим с JavaScript, так и здесь нет ни грамма совместимости.

  2. cppfront — это транслятор, который на вход принимает файлы .cpp2, а на выходе генерирует .cpp.

  3. Использует дополнительный этап обвязки, вместо использование готового 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? В основном — явные аннотации вроде inoutmove и 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/