Этот код будет компилироваться!
Этот код будет компилироваться!

C++ - один из языков, который можно назвать "легендарным". Его история насчитывает несколько десятилетий, принципы программирования на нем революционным образом менялись не раз, а черновик стандарта уже разросся до 1800+ страниц мелкого шрифта.

На C++ есть много хороших библиотек. Но нередко изменения в самом языке делали неактуальными большие куски кода, потому что они становились менее надёжными и быстрыми по сравнению с функционалом в самом языке. Правки в стандарт имеют несоизмеримо более сильное влияние, чем любая библиотека.

В этой статье мы в учебных целях напишем для C++ поддержку нового ключевого слова defer, которое будет работать во многом аналогично такому в языках Go и Swift. Это будет сделано через правку исходного кода Clang.

Маскот LLVM держит в лапах C++
Маскот LLVM держит в лапах C++

Описание команды defer

В некоторых языках, например в Go и Swift, есть ключевое слово defer. В Go не существует ни исключений (exceptions), ни деструкторов, поэтому идиома для очистки ресурсов - прямое указание языку вызвать cleanup-метод по выходу из функции.

func write(fileName string, text string) error {
    file, err := os.Create(fileName)
    if err != nil {
        return err
    }
    defer file.Close()
    // ... сколько угодно return-ов, file.Close() всегда вызовется
}

Таким образом, C++ не нужен defer, так как аналогичную задачу выполняет идиома RAII. То есть в аналогичном C++-коде был бы использован некий объект, который делает условный os.Create в конструкторе, а file.Close() в деструкторе. Добавление defer в этой публикации несёт иллюстративный характер.


defer в open-source проектах

defer с разным успехом имитировали в существующих проектах "руками".

Boost.ScopeExit - специальная библиотека для имитации defer-а. Она написана на C++11 вперемешку с C++03, поэтому, на мой взгляд, излишне многословна и мало вписывается в текущие реалии.

Хотя Boost - один из самых популярных наборов библиотек, далеко не все его куски находятся в ажурном состоянии. В Boost 167 библиотек, во многом независимых друг от друга. Многие из библиотек либо не обновлялись с ~2006 года, либо стали неактуальными после вхождения их функционала в стандарт, либо повторяют друг друга по функционалу, либо уже есть библиотека вне Boost с лучшим функционалом.

CatBoost - в этой библиотеке для машинного обучения есть хороший вариант Y_DEFER.

Оба варианта самописного defer-а основаны на том, что они скрытым образом создают объект, который в деструкторе будет вызывать нужный код. Для примера рассмотрим хитроумное определение из последней библиотеки:

#define Y_SCOPE_EXIT(...) const auto Y_GENERATE_UNIQUE_ID(scopeGuard) Y_DECLARE_UNUSED = ::NPrivate::TMakeGuardHelper{} | [__VA_ARGS__]() mutable -> void
#define Y_DEFER Y_SCOPE_EXIT(&)

Y_GENERATE_UNIQUE_ID(scopeGuard) генерирует уникальное имя для этого объекта.

Y_DECLARE_UNUSED говорит компилятору не обращать внимание на неиспользуемый объект.

[__VA_ARGS__]() mutable -> void - заготовка для лямбда-выражения. В квадратных скобках захватываемые выражения, по умолчанию туда запишется &. mutable значит, что мы сможем изменять захваченные объекты. -> void это trailing return type, чтобы пользователь не возвращал значения из лямбды.

TMakeGuardHelper написан так, что он принимает лямбда-выражения через оператор. То есть возможна запись TMakeGuardHelper{} | <лямбда-выражение>.

Момент вызова defer-а зависит от места его написания - он вызывается во время выхода из того scope, где он был объявлен, в стандартном для очистки объектов порядке:

{
    A a;
    B b;
    Y_DEFER { <body> };
    C c;
    D d;

    // какой-то код...

    // вызов по очереди d.~D(), c.~C(), { <body> }, b.~B(), a.~A()
}

Код внутри тела defer не должен бросать необработанное исключение, так как это спровоцирует std::terminate.


Clang и LLVM

Про само устройство Clang и LLVM написано уже много статей. На хабре я бы посоветовал эту статью, чтобы понять их краткую историю и общую схему.

В современном мире компиляторы с модульным устройством победили. Clang используется как в больших компаниях - Yandex, Apple, Google (в нём 400-450млн строк кода на C++) и т.д.; так и в больших проектах - FreeBSD, OpenBSD, Android, Chrome, Firefox, LibreOffice и т.д.

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

  • Front-end: переводит исходник из C/C++/Ada/Rust/Haskell/... в LLVM IR - особое промежуточное представление. Фронтендом для C-like языков является Clang.

  • Middle-end: LLVM IR оптимизируется в зависимости от настроек.

  • Back-end: LLVM IR переводится в машинный код под нужную платформу - x86/Arm/PowerPC/...

Для простых языков реально написать компилятор под 1000 строк и получить всю мощь фреймворка LLVM - для этого нужно реализовать фронтенд. Также можно использовать lex/yacc - готовые синтаксические парсеры.

На менее абстрактном уровне находится фронтенд Clang, который выполняет такие действия (не рассматривая препроцессор и прочие "микро"-шаги):

  • Лексический анализ: перевод символов в токены, например []() { return 13 + 37; } преобразуются в (l_square) (r_square) (l_paren) (r_paren) (l_brace) (return) (numeric_constant:13) (plus) (numeric_constant:37) (semi) (r_brace).

  • Синтаксический анализ: создание AST (Abstract Syntax Tree), то есть перевод токенов из предыдущего пункта в вид (lambda-expr (body (return-expr (plus-expr (number 13) (number 37))))).

  • Кодогенерация: создание LLVM IR по данному AST.

Таким образом, "области ответственности" очень четко определены, но исходники Clang всё равно гигантские. На мой субъективный взгляд, это связано не столько с распухшим стандартом, сколько с фактом, что C++ - максимально контекстно-зависимый язык.

Стандартом для построения компиляторов считается DragonBook. Clang придерживается его, но C++ слишком сложен, чтобы не заполонить фронтенд ad-hoc проверками и костылями.

Загрузка и сборка Clang

Полная инструкция расположена здесь. Я использую такие команды:

git clone https://github.com/llvm/llvm-project.git
cd llvm-project && mkdir build && cd build
cmake -DLLVM_ENABLE_PROJECTS=clang -DCMAKE_BUILD_TYPE=Release ../llvm
make -j 4

Для пересборки после правки кода можно писать make clang -j 4.

Первый билд будет работать довольно долго. Я выбрал release-сборку -DCMAKE_BUILD_TYPE=Release, так как debug-символов будет столько, что бинарник clang скорее всего не слинкуется (процесс линкера убивается по Out-Of-Memory после пожирания всей оперативки), но вам может повезти с этим больше.

Чтобы бороться с отсутствием debug-символов, для многих объектов можно вызывать метод dump(), который выведет его структуру в stderr. Также, если спровоцировать падение clang-а, то будет выводиться стектрейс вызовов.

Clang использует систему сборки CMake, поэтому можно использовать любой IDE, который умеет его поддерживать.


Новое ключевое слово defer

Если написать слово defer, Clang распознает его как идентификатор (токен вида identifier) в составе выражения (в expression), и код не скомпилируется из-за того, что этот идентификатор нигде не был ранее объявлен (в каком-нибудь declaration).

int main() {
    defer;
}
file.cpp:2:5: error: use of undeclared identifier 'defer'
    defer;
    ^
1 error generated.

Список всех токенов, в том числе ключевых слов, находится в clang/include/clang/Basic/TokenKinds.def. Подобные файлы нужны для того, чтобы разные куски Clang-а могли определять макросы для их обработки и инклюдить их к себе в рандомных местах кода: #include "clang/Basic/TokenKinds.def".

Clang является фронтендом для всех стандартов языков C, C++, Objective-C; для надстроек над языками OpenMP, OpenCL, CUDA и пр.; и для различных костылей и расширений в язык от Microsoft, GNU, самого Clang и пр.

Поэтому значительная часть логики Clang является общей. Если в файле содержится логика для конкретного языка, это отображено в названии: ParseExprCXX.cpp, ParseOpenMP.cpp. Список токенов - общий для всех. Добавим туда новое ключевое слово для C++:

KEYWORD(defer                       , KEYCXX)

И скомпилируем Clang. К счастью, этого достаточно, чтобы лексер (лексический анализатор) научился разбирать его сразу. Если бы мы добавляли что-то наподобии spaceship operator, то пришлось бы дописать код в лексер: коммит со spaceship.

Теперь Clang не думает, что defer это какой-то identifier, но и не понимает, что за выражение перед ним находится:

file.cpp:2:5: error: expected expression
    defer;
    ^
1 error generated.

Это непонимание появляется в парсере (синтаксическом анализаторе) в Parser::ParseCastExpression.

Стектрейс к этому месту
 #3 0x000056335ad3d395 clang::Parser::ParseCastExpression(clang::Parser::CastParseKind, bool, bool&, clang::Parser::TypeCastState, bool, bool*) (/home/izaron/hack/llvm-project/build/bin/clang-14+0x54f8395)
 #4 0x000056335ad3ff2e clang::Parser::ParseCastExpression(clang::Parser::CastParseKind, bool, clang::Parser::TypeCastState, bool, bool*) (/home/izaron/hack/llvm-project/build/bin/clang-14+0x54faf2e)
 #5 0x000056335ad4011d clang::Parser::ParseAssignmentExpression(clang::Parser::TypeCastState) (/home/izaron/hack/llvm-project/build/bin/clang-14+0x54fb11d)
 #6 0x000056335ad448ed clang::Parser::ParseExpression(clang::Parser::TypeCastState) (/home/izaron/hack/llvm-project/build/bin/clang-14+0x54ff8ed)
 #7 0x000056335adbac80 clang::Parser::ParseExprStatement(clang::Parser::ParsedStmtContext) (/home/izaron/hack/llvm-project/build/bin/clang-14+0x5575c80)
 #8 0x000056335adaec0e clang::Parser::ParseStatementOrDeclarationAfterAttributes(llvm::SmallVector<clang::Stmt*, 32u>&, clang::Parser::ParsedStmtContext, clang::SourceLocation*, clang::ParsedAttributesWithRange&) (/home/izaron/hack/llvm-project/build/bin/clang-14+0x5569c0e)
 #9 0x000056335adafeca clang::Parser::ParseStatementOrDeclaration(llvm::SmallVector<clang::Stmt*, 32u>&, clang::Parser::ParsedStmtContext, clang::SourceLocation*) (/home/izaron/hack/llvm-project/build/bin/clang-14+0x556aeca)
#10 0x000056335adb0cf1 clang::Parser::ParseCompoundStatementBody(bool) (/home/izaron/hack/llvm-project/build/bin/clang-14+0x556bcf1)
#11 0x000056335adb1862 clang::Parser::ParseFunctionStatementBody(clang::Decl*, clang::Parser::ParseScope&) (/home/izaron/hack/llvm-project/build/bin/clang-14+0x556c862)
#12 0x000056335ace6997 clang::Parser::ParseFunctionDefinition(clang::ParsingDeclarator&, clang::Parser::ParsedTemplateInfo const&, clang::Parser::LateParsedAttrList*) (/home/izaron/hack/llvm-project/build/bin/clang-14+0x54a1997)
#13 0x000056335ad163af clang::Parser::ParseDeclGroup(clang::ParsingDeclSpec&, clang::DeclaratorContext, clang::SourceLocation*, clang::Parser::ForRangeInit*) (/home/izaron/hack/llvm-project/build/bin/clang-14+0x54d13af)
#14 0x000056335ace12fa clang::Parser::ParseDeclOrFunctionDefInternal(clang::ParsedAttributesWithRange&, clang::ParsingDeclSpec&, clang::AccessSpecifier) (/home/izaron/hack/llvm-project/build/bin/clang-14+0x549c2fa)
#15 0x000056335ace1965 clang::Parser::ParseDeclarationOrFunctionDefinition(clang::ParsedAttributesWithRange&, clang::ParsingDeclSpec*, clang::AccessSpecifier) (.part.0) Parser.cpp:0:0
#16 0x000056335ace9543 clang::Parser::ParseExternalDeclaration(clang::ParsedAttributesWithRange&, clang::ParsingDeclSpec*) (/home/izaron/hack/llvm-project/build/bin/clang-14+0x54a4543)
#17 0x000056335acea87d clang::Parser::ParseTopLevelDecl(clang::OpaquePtr<clang::DeclGroupRef>&, bool) (/home/izaron/hack/llvm-project/build/bin/clang-14+0x54a587d)
#18 0x000056335aceae19 clang::Parser::ParseFirstTopLevelDecl(clang::OpaquePtr<clang::DeclGroupRef>&) (/home/izaron/hack/llvm-project/build/bin/clang-14+0x54a5e19)
#19 0x000056335acdb5ca clang::ParseAST(clang::Sema&, bool, bool) (/home/izaron/hack/llvm-project/build/bin/clang-14+0x54965ca)
#20 0x000056335a018636 clang::CodeGenAction::ExecuteAction() (/home/izaron/hack/llvm-project/build/bin/clang-14+0x47d3636)
#21 0x000056335990bd91 clang::FrontendAction::Execute() (/home/izaron/hack/llvm-project/build/bin/clang-14+0x40c6d91)
#22 0x000056335989c9db clang::CompilerInstance::ExecuteAction(clang::FrontendAction&) (/home/izaron/hack/llvm-project/build/bin/clang-14+0x40579db)
#23 0x00005633599e6070 clang::ExecuteCompilerInvocation(clang::CompilerInstance*) (/home/izaron/hack/llvm-project/build/bin/clang-14+0x41a1070)
#24 0x0000563356a35f44 cc1_main(llvm::ArrayRef<char const*>, char const*, void*) (/home/izaron/hack/llvm-project/build/bin/clang-14+0x11f0f44)
#25 0x0000563356a3311b ExecuteCC1Tool(llvm::SmallVectorImpl<char const*>&) driver.cpp:0:0
#26 0x0000563356959dd0 main (/home/izaron/hack/llvm-project/build/bin/clang-14+0x1114dd0)
#27 0x00007f3492a290b3 __libc_start_main /build/glibc-eX1tMB/glibc-2.31/csu/../csu/libc-start.c:342:3
#28 0x0000563356a32cbe _start (/home/izaron/hack/llvm-project/build/bin/clang-14+0x11edcbe)

На этом месте можно придать defer какой-нибудь смысл, например идентичный ключевому слову new, написав case tok::kw_defer: после case tok::kw_new::

  case tok::kw_new: // [C++] new-expression
  case tok::kw_defer: // [hacked C++] defer-expression
    if (NotPrimaryExpression)
      *NotPrimaryExpression = true;
    Res = ParseCXXNewExpression(false, Tok.getLocation());
    AllowSuffix = false;
    break;

Это позволит компилироваться коду наподобии:

int main() {
    int* i = defer int[13];
    delete[] i;
}

Теперь напишем код, который при встрече defer распарсит составное выражение (compound statement) вслед за ним, сдампит его AST в stderr и выведет warning о том, что defer-выражение было проигнорировано.

Напишем в clang/include/clang/Basic/DiagnosticParseKinds.td наш новый warning:

def warn_unimplemented_defer :
   Warning<"defer statements are not implemented yet, ignore it">;

И в методе Parser::ParseCastExpression:

  case tok::kw_defer: {
    SourceLocation DeferLoc = ConsumeToken(); // skip "defer" token
    StmtResult DeferredStmt(ParseCompoundStatementBody());
    if (!DeferredStmt.isInvalid()) {
        DeferredStmt.get()->dump();
    }

    Diag(DeferLoc, diag::warn_unimplemented_defer);
    return ExprError();
  }

Если по автокомплиту перейти к соответствующим методам и почитать комментарии к ним, то станет понятно, что парсер продвинется вперед на 1 токен (т.е. пропустит слово defer), потом распарсит составное выражение (вида { ... }), и также пропустит его, выведет в stderr его структуру, и добавит новый warning под словом defer.

Пересоберем Clang еще раз. Для диагностических сообщений есть кодогенерация, написанная непосредственно в конфиге CMake, поэтому если Clang "не увидит" новую диагностику, перед билдом придется запустить еще раз cmake -DLLVM_ENABLE_PROJECTS=clang -DCMAKE_BUILD_TYPE=Release ../llvm.

Запустим Clang на более сложном примере:

#include <cstdio>
int main() {
    defer {
        int var = 1 + 2;
        printf("1 + 2 is %d\n", var);
    };
    printf("hello world!\n");
}
izaron@izaron:~/check$ ~/hack/llvm-project/build/bin/clang++ file.cpp 
CompoundStmt 0x560115cc6e80
|-DeclStmt 0x560115cc6c68
| `-VarDecl 0x560115cc6ba0  used var 'int' cinit
|   `-BinaryOperator 0x560115cc6c48 'int' '+'
|     |-IntegerLiteral 0x560115cc6c08 'int' 1
|     `-IntegerLiteral 0x560115cc6c28 'int' 2
`-CallExpr 0x560115cc6e20 'int'
  |-ImplicitCastExpr 0x560115cc6e08 'int (*)(const char *__restrict, ...)' <FunctionToPointerDecay>
  | `-DeclRefExpr 0x560115cc6d90 'int (const char *__restrict, ...)' lvalue Function 0x560115cac748 'printf' 'int (const char *__restrict, ...)'
  |-ImplicitCastExpr 0x560115cc6e50 'const char *' <ArrayToPointerDecay>
  | `-StringLiteral 0x560115cc6d48 'const char [13]' lvalue "1 + 2 is %d\n"
  `-ImplicitCastExpr 0x560115cc6e68 'int' <LValueToRValue>
    `-DeclRefExpr 0x560115cc6d70 'int' lvalue Var 0x560115cc6ba0 'var' 'int'
file.cpp:3:5: warning: defer statements are not implemented yet, ignore it
    defer {
    ^
1 warning generated.
izaron@izaron:~/check$ ./a.out 
hello world!

Примечание: когда нужно, printf используется вместо std::cout для более понятных AST.


Как реализовать defer?

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

Атрибут cleanup

Про этот атрибут можно почитать в этой статье. К автоматической переменной (любого типа) можно "прицепить" функцию, которая как аргумент принимает ссылку на эту переменную и будет вызываться в момент выхода из scope.

#include <iostream>

struct dummy_t {
    dummy_t() {
        std::cout << "on constructor for obj " << this << std::endl;
    }
    ~dummy_t() {
        std::cout << "on destructor for obj " << this << std::endl;
    }
};

void on_cleanup(dummy_t* dummy) {
    std::cout << "on cleanup function for obj " << dummy << std::endl;
}

int main() {
    std::cout << "before scope" << std::endl;
    {
        __attribute__((cleanup(on_cleanup))) dummy_t dummy;
        std::cout << "inside scope" << std::endl;
    }
    std::cout << "after scope" << std::endl;
}
before scope
on constructor for obj 0x7fff060964b8
inside scope
on cleanup function for obj 0x7fff060964b8
on destructor for obj 0x7fff060964b8
after scope

После синтаксического анализа видим метод on_cleanup:

TranslationUnitDecl 0x205f068 <<invalid sloc>> <invalid sloc>
|-FunctionDecl 0x2ac5ac8 <line:12:1, line:14:1> line:12:6 used on_cleanup 'void (dummy_t *)'
| |-ParmVarDecl 0x2ac5a08 <col:17, col:26> col:26 used dummy 'dummy_t *'
| `-CompoundStmt 0x2ac8fc8 <col:33, line:14:1>
...

И на variable declaration кроме неявного вызова конструктора еще повешен атрибут CleanupAttr со ссылкой на метод on_cleanup:

    | |-DeclStmt 0x2acba70 <line:19:9, col:59>
    | | `-VarDecl 0x2acb7d0 <col:9, col:54> col:54 dummy 'dummy_t' callinit destroyed
    | |   |-CXXConstructExpr 0x2acba48 <col:54> 'dummy_t' 'void ()'
    | |   `-CleanupAttr 0x2acb838 <col:24, col:42> Function 0x2ac5ac8 'on_cleanup' 'void (dummy_t *)'

Вызов нужного метода со всеми проверками прописывается во время кодогенерации из AST в LLVM IR в этом месте. В исходниках Clang есть понятие "cleanup" - кодогенерация для вещей по типу деструкторов и CleanupAttr. Cleanup-ы организованы в виде LIFO-стека, и при выходе из scope, N последних cleanup-ов производятся в обратном порядке.

Для имитации defer-а эта конструкция не очень подходит, потому что потребуется зарегистрировать метод-болванку для defer-выражения и создать переменную-болванку, на которой будет висеть CleanupAttr.

Лямбда-выражения

Если какой-то функциональности в языке нет изначально, и на ее реализацию не сильно закладывались при дизайне, то велик шанс того, что при ее добавлении будут использоваться костыли (скрывая их за эвфемизмами) на основе уже существующих идиом. Не обошлось без них при введении лямбда-выражений в С++11.

В C++ исторически нет и не было такой возможности, что внутри метода можно написать другой метод, и использовать его, как в Python:

def sample():
    def sum(a, b):
        return a + b
    print(sum(1, 2))
sample()

Хотя, как ни странно, объявлять class, struct, enum, union (это близкородственные сущности) можно почти везде, в том числе внутри методов.

Есть статья про историю лямбд, где описано, как прото-лямбды существовали уже в C++03, и сегодняшние лямбда-выражения используют тот же подход, скрывая его за чертогами компилятора.

Прото-лямбда для [](int x) { std::cout << x << std::endl; }
#include <iostream>
#include <algorithm>
#include <vector>

struct PrintFunctor {
    void operator()(int x) const {
        std::cout << x << std::endl;
    }
};

int main() {
    std::vector<int> v;
    v.push_back(1);
    v.push_back(2);
    std::for_each(v.begin(), v.end(), PrintFunctor());   
}

Таким образом, посмотрев AST для лямбда-выражения, можно увидеть скрытый от посторонних глаз класс (он же CXXRecordDecl):

Пример AST для лямбда-выражения
#include <cstdio>
int main() {
    int gamma = 3;
    const auto l = [&](int alpha, int beta) { printf("%d\n", alpha + beta + gamma); };
    l(1, 2);
}
`-FunctionDecl 0x24bbc38 <lambda.cpp:2:1, line:6:1> line:2:5 main 'int ()'
  `-CompoundStmt 0x24beb38 <col:12, line:6:1>
    |-DeclStmt 0x24bbd78 <line:3:5, col:18>
    | `-VarDecl 0x24bbcf0 <col:5, col:17> col:9 used gamma 'int' cinit
    |   `-IntegerLiteral 0x24bbd58 <col:17> 'int' 3
    |-DeclStmt 0x24be9c0 <line:4:5, col:86>
    | `-VarDecl 0x24bbdf0 <col:5, col:85> col:16 used l 'const (lambda at lambda.cpp:4:20)':'const (lambda at lambda.cpp:4:20)' cinit
    |   `-ExprWithCleanups 0x24be9a8 <col:20, col:85> 'const (lambda at lambda.cpp:4:20)':'const (lambda at lambda.cpp:4:20)'
    |     `-CXXConstructExpr 0x24be978 <col:20, col:85> 'const (lambda at lambda.cpp:4:20)':'const (lambda at lambda.cpp:4:20)' 'void ((lambda at lambda.cpp:4:20) &&) noexcept' elidable
    |       `-MaterializeTemporaryExpr 0x24be848 <col:20, col:85> '(lambda at lambda.cpp:4:20)' xvalue
    |         `-LambdaExpr 0x24be268 <col:20, col:85> '(lambda at lambda.cpp:4:20)'
    |           |-CXXRecordDecl 0x24bdc00 <col:20> col:20 implicit class definition
    |           | |-DefinitionData lambda pass_in_registers trivially_copyable can_const_default_init
    |           | | |-DefaultConstructor
    |           | | |-CopyConstructor simple trivial has_const_param implicit_has_const_param
    |           | | |-MoveConstructor exists simple trivial
    |           | | |-CopyAssignment trivial has_const_param needs_implicit implicit_has_const_param
    |           | | |-MoveAssignment
    |           | | `-Destructor simple irrelevant trivial
    |           | |-CXXMethodDecl 0x24bdd40 <col:43, col:85> col:20 used operator() 'void (int, int) const' inline
    |           | | |-ParmVarDecl 0x24bbe70 <col:24, col:28> col:28 used alpha 'int'
    |           | | |-ParmVarDecl 0x24bbef0 <col:35, col:39> col:39 used beta 'int'
    |           | | `-CompoundStmt 0x24be098 <col:45, col:85>
    |           | |   `-CallExpr 0x24be050 <col:47, col:82> 'int'
    |           | |     |-ImplicitCastExpr 0x24be038 <col:47> 'int (*)(const char *__restrict, ...)' <FunctionToPointerDecay>
    |           | |     | `-DeclRefExpr 0x24bdfc0 <col:47> 'int (const char *__restrict, ...)' lvalue Function 0x249f198 'printf' 'int (const char *__restrict, ...)'
    |           | |     |-ImplicitCastExpr 0x24be080 <col:54> 'const char *' <ArrayToPointerDecay>
    |           | |     | `-StringLiteral 0x24bde88 <col:54> 'const char [4]' lvalue "%d\n"
    |           | |     `-BinaryOperator 0x24bdfa0 <col:62, col:77> 'int' '+'
    |           | |       |-BinaryOperator 0x24bdf18 <col:62, col:70> 'int' '+'
    |           | |       | |-ImplicitCastExpr 0x24bdee8 <col:62> 'int' <LValueToRValue>
    |           | |       | | `-DeclRefExpr 0x24bdea8 <col:62> 'int' lvalue ParmVar 0x24bbe70 'alpha' 'int'
    |           | |       | `-ImplicitCastExpr 0x24bdf00 <col:70> 'int' <LValueToRValue>
    |           | |       |   `-DeclRefExpr 0x24bdec8 <col:70> 'int' lvalue ParmVar 0x24bbef0 'beta' 'int'
    |           | |       `-ImplicitCastExpr 0x24bdf88 <col:77> 'int' <LValueToRValue>
    |           | |         `-DeclRefExpr 0x24bdf68 <col:77> 'int' lvalue Var 0x24bbcf0 'gamma' 'int'
    |           | |-FieldDecl 0x24be200 <col:77> col:77 implicit referenced 'int &'
    |           | |-CXXDestructorDecl 0x24be2b0 <col:20> col:20 implicit referenced ~ 'void () noexcept' inline default trivial
    |           | |-CXXConstructorDecl 0x24be510 <col:20> col:20 implicit constexpr  'void (const (lambda at lambda.cpp:4:20) &)' inline default trivial noexcept-unevaluated 0x24be510
    |           | | `-ParmVarDecl 0x24be628 <col:20> col:20 'const (lambda at lambda.cpp:4:20) &'
    |           | `-CXXConstructorDecl 0x24be6c8 <col:20> col:20 implicit used constexpr  'void ((lambda at lambda.cpp:4:20) &&) noexcept' inline default trivial
    |           |   |-ParmVarDecl 0x24be7d8 <col:20> col:20 used '(lambda at lambda.cpp:4:20) &&'
    |           |   |-CXXCtorInitializer Field 0x24be200 '' 'int &'
    |           |   | `-MemberExpr 0x24be8f0 <col:20> 'int' lvalue . 0x24be200
    |           |   |   `-CXXStaticCastExpr 0x24be8c0 <col:20> '(lambda at lambda.cpp:4:20)' xvalue static_cast<class (lambda at lambda.cpp:4:20) &&> <NoOp>
    |           |   |     `-DeclRefExpr 0x24be890 <col:20> '(lambda at lambda.cpp:4:20)' lvalue ParmVar 0x24be7d8 '' '(lambda at lambda.cpp:4:20) &&'
    |           |   `-CompoundStmt 0x24be968 <col:20>
    |           |-DeclRefExpr 0x24be1c8 <col:21> 'int' lvalue Var 0x24bbcf0 'gamma' 'int'
    |           `-CompoundStmt 0x24be098 <col:45, col:85>
    |             `-CallExpr 0x24be050 <col:47, col:82> 'int'
    |               |-ImplicitCastExpr 0x24be038 <col:47> 'int (*)(const char *__restrict, ...)' <FunctionToPointerDecay>
    |               | `-DeclRefExpr 0x24bdfc0 <col:47> 'int (const char *__restrict, ...)' lvalue Function 0x249f198 'printf' 'int (const char *__restrict, ...)'
    |               |-ImplicitCastExpr 0x24be080 <col:54> 'const char *' <ArrayToPointerDecay>
    |               | `-StringLiteral 0x24bde88 <col:54> 'const char [4]' lvalue "%d\n"
    |               `-BinaryOperator 0x24bdfa0 <col:62, col:77> 'int' '+'
    |                 |-BinaryOperator 0x24bdf18 <col:62, col:70> 'int' '+'
    |                 | |-ImplicitCastExpr 0x24bdee8 <col:62> 'int' <LValueToRValue>
    |                 | | `-DeclRefExpr 0x24bdea8 <col:62> 'int' lvalue ParmVar 0x24bbe70 'alpha' 'int'
    |                 | `-ImplicitCastExpr 0x24bdf00 <col:70> 'int' <LValueToRValue>
    |                 |   `-DeclRefExpr 0x24bdec8 <col:70> 'int' lvalue ParmVar 0x24bbef0 'beta' 'int'
    |                 `-ImplicitCastExpr 0x24bdf88 <col:77> 'int' <LValueToRValue>
    |                   `-DeclRefExpr 0x24bdf68 <col:77> 'int' lvalue Var 0x24bbcf0 'gamma' 'int'
    `-CXXOperatorCallExpr 0x24beaf8 <line:5:5, col:11> 'void':'void'
      |-ImplicitCastExpr 0x24beab8 <col:6, col:11> 'void (*)(int, int) const' <FunctionToPointerDecay>
      | `-DeclRefExpr 0x24bea38 <col:6, col:11> 'void (int, int) const' lvalue CXXMethod 0x24bdd40 'operator()' 'void (int, int) const'
      |-DeclRefExpr 0x24be9d8 <col:5> 'const (lambda at lambda.cpp:4:20)':'const (lambda at lambda.cpp:4:20)' lvalue Var 0x24bbdf0 'l' 'const (lambda at lambda.cpp:4:20)':'const (lambda at lambda.cpp:4:20)'
      |-IntegerLiteral 0x24be9f8 <col:7> 'int' 1
      `-IntegerLiteral 0x24bea18 <col:10> 'int' 2

На cppreference есть хорошее описание lambda expression, где описывается устройство создаваемого класса. При этом описание может сбить с толку неподготовленного читателя:

The lambda expression is a prvalue expression of unique unnamed non-union non-aggregate class type

Дело в том, что во время костылизации новой фичи, внутри компилятора можно делать вещи, которые решительно запрещены или отсутствуют в стандарте - например, создавать нетривиальные конструкторы/деструкторы безымянным классам. В стандарте невозможно прописать это внятным образом, поэтому у многих возникает представление о лямбдах и прочих нетривиальных вещах как о "черном ящике".

Таким образом, для имитации defer вполне подошел бы путь лямбда-выражений: создавать объект "магического" класса, который выполнит некие действия в деструкторе. При этом не надо создаваться новые сущности (как в случае с cleanup-аттрибутом: новый атрибут для AST).

Poor man's defer

defer-ы в production C++ коде в том формате, про который мы говорим, сейчас практически всегда выглядят так:

    struct dummy_t {
        ~dummy_t() {
            // какой-то код
        }
    } dummy;

Тут много лишнего: структура не безымянная, а имеет имя, и к тому же для этой структуры создаётся объект. Эти названия, конечно, выкинутся оптимизатором, и объект не станет занимать память на стеке, но лучше бы, чтобы их не было.

Выбранный подход

В учебном примере defer будет скрывать под собой объявление struct таким образом: объявление

defer {
    <compound-expression>
};

Будет работать идентично объявлению

struct <MAGIC-STRUCT-NAME> {
    ~<MAGIC-STRUCT-NAME>() {
        <compound-expression>
    }
} <MAGIC-OBJECT-NAME>;

При этом желательно либо совсем убрать из скоупа идентификаторы <MAGIC-STRUCT-NAME> и <MAGIC-OBJECT-NAME> (с компиляторскими фокусами как для лямбда-выражений), либо дать им уникальные имена, чтобы было возможно иметь несколько defer-ов в одном скоупе.


Создаем defer

Полный коммит доступен по этой ссылке.

defer как анонимный struct

В исходниках Clang в парсере разделяется обработка объявлений (ParseDecl.cpp/ParseDeclCXX.cpp) и выражений (ParseExpr.cpp/ParseExprCXX.cpp).

"По умолчанию" считается, что мы парсим выражение, поэтому чтобы defer начали принимать за объявление (причём за объявление структуры), нужно добавить кое-где условия:

В Parser::isCXXDeclarationSpecifier и в Parser::ParseDeclarationSpecifiers добавим case tok::kw_defer:.

В методе Parser::ParseClassSpecifier делаем defer аналогичным struct:

  const bool IsDefer = TagTokKind == tok::kw_defer;
  if (TagTokKind == tok::kw_struct || IsDefer)
    TagType = DeclSpec::TST_struct;

В реальном коде, скорее всего, defer лучше было бы сделать отдельным типом class-specifier (DeclSpec::TST_struct), но в учебных целях не будем заморачиваться с дизайном, потому что будет очень много копипаста.

Чуть ниже в этом же методе происходит парсинг имени класса (IdentifierInfo *Name). Запретим объявлять имена у defer-структуры:

def err_named_defer_definition : Error<"defer structs should not have name">;
    // In case of "defer" it should not have name
    if (IsDefer) {
      if (Name != nullptr) {
        Diag(NameLoc, diag::err_named_defer_definition);
      }
    }

Вывод компилятора при нарушении этого правила:

izaron@izaron:~/check$ ~/hack/llvm-project/build/bin/clang++ test.cpp 
test.cpp:3:11: error: defer structs should not have name
    defer defer_t { };
          ^
1 error generated.

Имя для struct "из воздуха"

Теперь, если захочется дать структуре какое-то имя, которое юзер не писал в исходниках (другими словами, самовольно сделать из struct { ... } объявление struct defer012345 { ... }), можно столкнуться с тем, что неясно, как это сделать.

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

  // If this is a defer struct, create custom name for it
  // "defer {" should imitate "struct defer012345 {"
  if (IsDefer) {
    Name = &Context.Idents.getOwn("defer012345");
  }

Если бы мы захотели, можно было бы самовольно присвоить defer-структуре уникальное имя в зависимости от SourceLocation (положения defer-а в исходнике), как сделано в Y_DEFER в начале статьи.

Деструктор для анонимного struct

Однако предыдущий пункт мы не будем использовать, так как анонимной структуре можно присвоить деструктор, и это будет работать.

Если посмотреть, как парсятся деструкторы в "обычных" условиях, видно, что это нетривиальное дело. Это происходит в два прохода. В первый проход распарсится unqualified-id (понятие из стандарта), который являет собой запись ~classname().

Стектрейс парсинга до метода getDestructorName
 #3 0x000056503ce39c00 clang::Parser::ParseUnqualifiedId(clang::CXXScopeSpec&, clang::OpaquePtr<clang::QualType>, bool, bool, bool, bool, bool, clang::SourceLocation*, clang::UnqualifiedId&) (/home/izaron/hack/llvm-project/build/bin/clang-14+0x5515c00)
 #4 0x000056503cdfd200 clang::Parser::ParseDirectDeclarator(clang::Declarator&) (/home/izaron/hack/llvm-project/build/bin/clang-14+0x54d9200)
 #5 0x000056503cde36d0 clang::Parser::ParseDeclaratorInternal(clang::Declarator&, void (clang::Parser::*)(clang::Declarator&)) (/home/izaron/hack/llvm-project/build/bin/clang-14+0x54bf6d0)
 #6 0x000056503ce0dd4d clang::Parser::ParseCXXMemberDeclaratorBeforeInitializer(clang::Declarator&, clang::VirtSpecifiers&, clang::ActionResult<clang::Expr*, true>&, clang::Parser::LateParsedAttrList&) (/home/izaron/hack/llvm-project/build/bin/clang-14+0x54e9d4d)
 #7 0x000056503ce10bd1 clang::Parser::ParseCXXClassMemberDeclaration(clang::AccessSpecifier, clang::ParsedAttributes&, clang::Parser::ParsedTemplateInfo const&, clang::ParsingDeclRAIIObject*) (/home/izaron/hack/llvm-project/build/bin/clang-14+0x54ecbd1)
 #8 0x000056503ce1381c clang::Parser::ParseCXXClassMemberDeclarationWithPragmas(clang::AccessSpecifier&, clang::ParsedAttributesWithRange&, clang::TypeSpecifierType, clang::Decl*) (/home/izaron/hack/llvm-project/build/bin/clang-14+0x54ef81c)
 #9 0x000056503ce13e2b clang::Parser::ParseCXXMemberSpecification(clang::SourceLocation, clang::SourceLocation, clang::ParsedAttributesWithRange&, unsigned int, clang::Decl*) (/home/izaron/hack/llvm-project/build/bin/clang-14+0x54efe2b)
#10 0x000056503ce15fde clang::Parser::ParseClassSpecifier(clang::tok::TokenKind, clang::SourceLocation, clang::DeclSpec&, clang::Parser::ParsedTemplateInfo const&, clang::AccessSpecifier, bool, clang::Parser::DeclSpecContext, clang::ParsedAttributesWithRange&) (/home/izaron/hack/llvm-project/build/bin/clang-14+0x54f1fde)

В этом коде довольно сложно разбираться, и сами разработчики Clang жалуются на то, что стандарт запутывает.

Как бы то ни было, после того, как деструктор зарегистрирован, он является unevaluated (понятие из Clang), и вторым проходом является определение тела метода.

Определение тел методов класса откладывается до того момента, как не будут зарегистрированы все поля и методы класса. Видимо, для того, чтобы внутри методов компилятор понимал обращения к другим методам и полям этого класса.

Мы будем имитировать эти два прохода - зарегистрируем деструктор, и подсунем токены для определения его тела.

Для регистрации деструктора нам поможет метод Sema::DeclareImplicitDestructor, который строит неявный деструктор. Мы сделаем похожий Sema::DeclareUserDestructor, который зарегистрирует заготовку user-defined деструктора. Почти всё там - копипаст из исходного метода, но мы пока не заморачиваемся с дизайном:

Определение Sema::DeclareUserDestructor
CXXDestructorDecl *Sema::DeclareUserDestructor(CXXRecordDecl *ClassDecl) {
  DeclaringSpecialMember DSM(*this, ClassDecl, CXXDestructor);
  if (DSM.isAlreadyBeingDeclared())
    return nullptr;

  // Create the actual destructor declaration.
  CanQualType ClassType
    = Context.getCanonicalType(Context.getTypeDeclType(ClassDecl));
  SourceLocation ClassLoc = ClassDecl->getLocation();
  DeclarationName Name
    = Context.DeclarationNames.getCXXDestructorName(ClassType);
  DeclarationNameInfo NameInfo(Name, ClassLoc);
  CXXDestructorDecl *Destructor = CXXDestructorDecl::Create(
      Context, ClassDecl, ClassLoc, NameInfo, QualType(), nullptr,
      getCurFPFeatures().isFPConstrained(),
      /*isInline=*/false,
      /*isImplicitlyDeclared=*/false,
      ConstexprSpecKind::Unspecified);
  Destructor->setAccess(AS_public);

  setupImplicitSpecialMemberType(Destructor, Context.VoidTy, None);

  Destructor->setTrivial(false);

  ++getASTContext().NumImplicitDestructorsDeclared;

  Scope *S = getScopeForContext(ClassDecl);
  CheckImplicitSpecialMemberDeclaration(S, Destructor);

  // Introduce this destructor into its scope.
  if (S)
    PushOnScopeChains(Destructor, S, false);
  ClassDecl->addDecl(Destructor);

  return Destructor;
}

Теперь можно зайти в метод Parser::ParseCXXMemberSpecification. Сделаем, чтобы он принимал еще один аргумент bool IsDefer, и после этой строки произведём оба прохода:

Новый код в Parser::ParseCXXMemberSpecification
  // defer structs should have only destructor definition instead of whole class body
  if (TagDecl && IsDefer) {
    Actions.ActOnStartCXXMemberDeclarations(getCurScope(), TagDecl, FinalLoc,
                                            IsFinalSpelledSealed, IsAbstract,
                                            TagDecl->getBeginLoc());

    // First pass - register user-defined destructor
    CXXRecordDecl* ClassDecl = dyn_cast<CXXRecordDecl>(TagDecl);
    CXXDestructorDecl* ClassDestructor = Actions.DeclareUserDestructor(ClassDecl);

    // Second pass - add "late parsed" destructor body declaration
    // I didn't found the method to get all tokens from "{ ... }", so I wrote the algo by hand
    LexedMethod* LM = new LexedMethod(this, ClassDestructor);
    getCurrentClass().LateParsedDeclarations.push_back(LM);
    CachedTokens& Toks = LM->Toks;

    Toks.push_back(Tok);
    unsigned tokenIndex = 1;
    unsigned bracesNum = 1;
    while (bracesNum > 0) {
        const Token& t = GetLookAheadToken(tokenIndex);
        Toks.push_back(t);
        if (t.is(tok::l_brace)) {
            ++bracesNum;
        } else if (t.is(tok::r_brace)) {
            --bracesNum;
        }
        ++tokenIndex;
    }

    // Finish defer struct definition
    ParsedAttributes attrs(AttrFactory);
    Actions.ActOnFinishCXXMemberSpecification(getCurScope(), RecordLoc, TagDecl,
                                              TagDecl->getBeginLoc(),
                                              TagDecl->getEndLoc(), attrs);

    ParseLexedMethodDefs(getCurrentClass());

    Actions.ActOnTagFinishDefinition(getCurScope(), TagDecl, SourceRange(TagDecl->getBeginLoc(), TagDecl->getEndLoc()));

    // Leave the class scope.
    ParsingDef.Pop();
    ClassScope.Exit();

    // Skip defer body
    BalancedDelimiterTracker T(*this, tok::l_brace);
    T.consumeOpen();
    T.skipToEnd();

    return;
  }

Здесь регистрируется деструктор, и подсовываются токены, которые планируются быть его телом. Я не нашел метода, который отдаст мне все токены между открывающей скобкой { и закрывающей }, поэтому добавил их "руками". Затем указатель на текущий токен сдвигается и начинает указывать на следующий токен после }. Никаких других полей и методов у defer-структуры не будет.

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

Скомпилируем этот код и запустим его:

#include <cstdio>

int main() {
    defer { printf("deferred 0\n"); } d0;

    defer {
        defer { printf("inner deferred\n"); } d;
        printf("deferred 1\n");
    } d1;

    printf("hello!\n");
    defer { printf("deferred 2\n"); } d2;
    printf("world!\n");
}
hello!
world!
deferred 2
deferred 1
inner deferred
deferred 0

Посмотрим, что же за деструкторы вызываются в LLVM IR, потому что на уровне AST они действительно безымянные. Запускаем ~/hack/llvm-project/build/bin/clang++ -emit-llvm -S test.cpp, открываем test.ll, и видим подобные вызовы:

call void @"_ZZ4mainEN3$_0D2Ev"(%struct.anon.2* nonnull align 1 dereferenceable(1) %5) #4

С названиями методов происходит name mangling. Произведем demangle имён:

main::$_0::~$_0()
main::$_1::~$_1()
main::$_2::~$_2()
main::$_1::~$_1()::$_3::~$_3()

Названиям анонимных структур все-таки присвоились имена, но на позднем этапе - вызовы по типу (*this).~$_0() и d0.~() не будут компилироваться.

Анонимные переменные-"болванки"

В выражениях defer { ... } d; переменная d является "болванкой", нужной только потому, что без нее defer-структура не получит объект, у которого будет вызываться деструктор. Однако переменные тоже можно сделать "анонимными".

Если не объявлять болванку, то есть написать struct { ... };/defer { ... };, то выведется несколько ошибок, и будет создана "битая" болванка:

AST выражения struct{};
    `-DeclStmt 0x559d6c6c98d8 <line:17:5, col:14>
      |-CXXRecordDecl 0x559d6c6c96d0 <col:5, col:13> col:5 struct definition
      | `-DefinitionData is_anonymous pass_in_registers empty aggregate standard_layout trivially_copyable pod trivial literal has_constexpr_non_copy_move_ctor can_const_default_init
      |   |-DefaultConstructor exists trivial constexpr needs_implicit defaulted_is_constexpr
      |   |-CopyConstructor simple trivial has_const_param needs_implicit implicit_has_const_param
      |   |-MoveConstructor exists simple trivial needs_implicit
      |   |-CopyAssignment simple trivial has_const_param needs_implicit implicit_has_const_param
      |   |-MoveAssignment exists simple trivial needs_implicit
      |   `-Destructor simple irrelevant trivial needs_implicit
      `-VarDecl 0x559d6c6c9858 <col:5> col:5 implicit invalid '(anonymous struct at test.cpp:17:5)'

Подебажив Clang, можно увидеть, что "битая" болванка создаётся в Parser::ParseSimpleDeclaration.

В самом начале этого метода зафиксируем факт наличие defer-а:

  const bool IsDefer = Tok.is(tok::kw_defer);

После парсинга класса и перед проверкой на tok::semi (это ;) запретим defer-структурам иметь что-то после закрывающей скобки }:

  if (IsDefer && !Tok.is(tok::semi)) {
    Diag(Tok.getLocation(), diag::err_named_defer_variable);
    return nullptr;
  }
def err_named_defer_variable : Error<"defer structs should not have variables">;

В метод ParsedFreeStandingDeclSpec и дальше до метода BuildAnonymousStructOrUnion мы прокинем флаг IsDefer и сделаем так, чтобы для defer создавалась нормальная болванка - безымянный объект безымянной структуры. (Что именно поменялось, можно увидеть в ссылке на коммит).

Теперь успешно компилируется и запускается этот код:

#include <iostream>
using std::cout;
using std::endl;

int main() {
    defer { cout << "deferred 0" << endl; };

    defer {
        defer { cout << "inner deferred" << endl; };
        cout << "deferred 1" << endl;
    };

    cout << "hello" << endl;
    defer { cout << "deferred 2" << endl; };
    cout << "world!" << endl;
}
hello
world!
deferred 2
deferred 1
inner deferred
deferred 0

Захват автоматических переменных

Если объявить класс внутри метода, он не будет "видеть" автоматические переменные, объявленные в этом методе до класса. В самописных defer-ах делается руками (а в лямбда-выражениях компилятором) такие вещи:

Захват автоматических переменных
#include <string>

int main() {
    int alpha;
    double beta;
    std::string gamma;

    class dummy_t {
    public:
        dummy_t(int& alpha, double& beta, std::string& gamma)
            : alpha{alpha}
            , beta{beta}
            , gamma{gamma}
        {}

        ~dummy_t() {
            // some code...
            // "alpha", "beta" and "gamma" are available
        }

    private:
        int& alpha;
        double& beta;
        std::string& gamma;
    } dummy{alpha, beta, gamma};
}

В "нашу" defer-структуру надо добавить самописный конструктор, подсунуть несколько полей, и вызвать его у переменной-"болванки". Это сделать нетривиально, поэтому реализации в рамках этой статьи нету. Но вы можете попробовать это сделать!


Как можно было бы сделать по-другому?

На правах шутки: Так как пример учебный, и defer не сильно нужен в C++, то его можно было бы сделать никак =)

Вопрос реализации имеет две стороны:

Сторона компилятора

Можно было бы не генерировать структуру из defer, а сделать DeferExpr - новую сущность для AST. Тогда во время кодогенерации из AST в LLVM IR можно поместить кусок кода в конец метода. Однако это имеет более высокую сложность реализации.

LLVM IR оперирует "блоками" - это несколько подряд идущих инструкций, которые всегда выполняются один за другим. Блок начинается с метки (label), и заканчивается либо терминирующей инструкцией, либо прыжком в начало другого блока в зависимости от результата проверки.

По-хорошему "последний" блок (который оканчивает выполнение метода) должен быть ровно один, но в реальности их может быть сколько угодно из-за того, что unwinding стека по exception-у обрабатывается одной инструкцией, а "просто" возврат из метода - другим. В принципе работа с блоками это отдельная история, и там есть свои причуды.

Другой путь - если видим defer-блок, можно в AST-дереве "передвинуть" этот блок в конец скоупа, где он был объявлен. Но тогда надо будет исправлять проблему, что defer-блок будет "видеть" автоматические переменные, объявленные после него.

Сторона языка

В C++ молятся на сохранение обратной совместимости новых версий стандарта с программами, которые были написаны очень давно. В то время как многие другие языки позволяют себе несколько раз в год "кидать" программистов по этой части, в C++ так не очень принято.

Понятно, что ввод нового ключевого слова может плохо повлиять на программы, где есть одноименные переменные/методы/классы. Есть пара способов снизить урон:

  1. Сделать не keyword, а identifier with special meaning. Например, слова final и override парсятся Clang-ом как токен-идентификатор, и приобретают особый смысл только в некоторых ситуациях, а в прочих случаях могут являться именем переменной или метода.

  2. Использовать атрибуты, например [[defer]] { ... }. Минус в том, что неизвестные компилятору атрибуты игнорируются с warning-ом, а не падают с ошибкой компиляции: warning: unknown attribute 'defer' ignored [-Wunknown-attributes].

  3. Использовать вне-стандартовые аттрибуты: __attribute((defer))__ { ... }. Но минусы такие же, как в прошлом пункте. Их можно использовать, если полагаться на то, что в конкретном компиляторе это расширение есть и всегда будет прилично работать.


Конец

Надеюсь, что во время создания своего ключевого слова вы узнали много интересных вещей про устройство компиляторов!

Комментарии (43)


  1. staticmain
    04.09.2021 21:44
    -2

    В C++ молятся на сохранение обратной совместимости новых версий стандарта с программами, которые были написаны очень давно.


    Не совсем понятна логика авторов языка, ведь все новые ключевые слова, синтаксис и атрибуты включаются только при соответствующем флаге std. Тот же «космический корабль» ниже 2Х не соберется, поэтому формально ломает совместимость.


    1. Ritan
      04.09.2021 22:10
      +2

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

      Когда модули будут повсеместными и можно будет парсить модули в отдельном режиме, возможно будет менять язык менее осторожно


      1. staticmain
        05.09.2021 01:18
        +2

        Так а зачем компилировать новым стандартом то, что писалось под тот, который был актуален при написании (грубо, писалась софтина в 95, под с90. Мне и в голову ге придет её сегодня компилить с -std=c2x. Не дай боже там что-то поменяется при этом, концов же не найдешь! Много кто пишет, ориентируясь на какие-то ежесекундные хаки и баги, если они сломаются при компиляции под новый стаедарт - это будет большой проблемой, потому что патчить чужие исходники - то еще удовольствие, особенно с учетом того, что большинство программистов на си/си++ писатт под него не умеют (я имею в виду не синтаксис а так, что это можно было саппортить. Откройте статью штеуда про aio_read, последний блок кода, где они ломающие синтаксис макросы впихнули в центр кода, присыпали это мегафункцией и отвратительной структурой. И это штеуд!)


        1. Ritan
          05.09.2021 03:23
          +2

          Буст. Хотите вы того или нет, но если вы его используете, то компилируете под стандарт новее, чем тот, под который он писался


          1. staticmain
            05.09.2021 15:12

            Буст сам по себе эволюционирует похлеще чем сам С++. Поэтому тем более нужно компилировать с той версией буста, под которую это писалось, желательно в контейнере с окружением из того времени.

            Мы недавно напоролись на то, что бинари скомпилированные на ubuntu 18.04 не работают на slackware 14 из-за того, что версия glibc на пару субверсий ниже. И это никак не решается кроме как билдить на окружении в контейнере с нужной версией glibc. Поэтому все эти разговоры о бесконечной совместимости — миф.


            1. Tujh
              06.09.2021 12:35
              +1

              Мы недавно напоролись на то, что бинари скомпилированные на ubuntu 18.04 не работают на slackware 14 из-за того, что версия glibc на пару субверсий ниже

              Вы путаете ABI совместимость и совместимость синтаксиса языка.


    1. mentin
      04.09.2021 22:11
      +4

      Это другая совместимость. Что космический корабль не компилируется со старыми опциями нормально.

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


      1. Izaron Автор
        04.09.2021 22:29

        Но все хотят чтобы старый код можно было скомпилировать под новый компилятор.

        Это еще не самое сильное требование.

        Еще в C и С++ заложено, что если какая-то библиотека (по сути архив из объектных файлов) была создана X лет назад, то она должна успешно линковаться с библиотеками и бинарниками, которые пишутся сейчас, и которые будут писаться через X лет.

        Это называется Application Binary Interface, его нарушение сделало бы всем очень плохо.

        Ну, а некоторые языки-"убийцы С++" балуются нарушениями своих собственных аналогов ABI, пока их использует 2.5 человека.


        1. slonopotamus
          04.09.2021 23:48
          +9

          Вы кажется пропустили эпичный полом ABI в C++, когда изменился std::string: https://gcc.gnu.org/onlinedocs/libstdc++/manual/using_dual_abi.html

          Я уж не говорю о том что нет никакого стандарта на name mangling.


          1. Izaron Автор
            05.09.2021 00:16

            Вы кажется пропустили эпичный полом ABI в C++, когда изменился std::string

            Спасибо! Я не знал про этот факт. Пишут, что поломали из-за того, что стандарт запретил CoW-строки - странное изменение стандарта. Еще бы name mangling занесли в стандарт, чтобы убить неугодные компиляторы...

            Я уж не говорю о том что нет никакого стандарта на name mangling.

            Для интереса посмотрел на How different compilers mangle the same functions: тут некоторые компиляторы каких-то лохматых годов, не хватает статистики по процентам пользователей (возможно, у 99-99.9% библиотек под Linux, mangling сейчас одинаковый). Но если прикинуть, что было 20-25 лет назад, и взять нечто, сбилженное в то время... Да, есть риск, что будет негодно к употреблению.

            Тогда вся надежда на безпроблемную сборку из исходников с нуля.


            1. slonopotamus
              05.09.2021 00:38
              +8

              Дело не в компиляторах 20-летней давности, проблема с манглингом C++актуальна сегодня как и раньше. Компиляторам приходится как-то договариваться об ABI, чтобы код, собранный разными компиляторами, мог взаимодействовать в рамках одной платформы. Но никакой заслуги C++ в этом нет. Например: https://bugzilla.redhat.com/show_bug.cgi?id=1435825 Обратите внимание, речь идёт о том чтобы сделать Clang "совместимым с GCC". Но не о том что "Clang неудовлетворяет стандарту C++".

              Тогда вся надежда на безпроблемную сборку из исходников с нуля.

              Это тоже не работает. Возьмите любой крупную программу на C++ и я уверен что найдёте коммиты вида "изменяем код, чтобы он стал совместим с новым стандартом C++".

              ----

              Мой посыл в том что C++ вообще не специфицирует ABI.


            1. Tujh
              06.09.2021 12:41
              +4

              поломали из-за того, что стандарт запретил CoW-строки - странное изменение стандарта

              Начнём с того, что CoW строки ни когда не описывались в стандарте, а были на совести авторов конкретной реализации STL.

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


              1. Izaron Автор
                06.09.2021 15:32

                Это выглядит неправильно, комитет не должен исправлять неудачные решения в частных реализациях STL за счёт правок в стандарт (как попробовали со std::string в C++11).

                Авторам реализаций нужно самим брать на себя ответственность и принимать решения, с аргументацией как в вашем комментарии. Тянули бы CoW-строки до 2040 года - их выбор.


                1. Tujh
                  06.09.2021 16:16

                  Тянули бы CoW-строки до 2040 года - их выбор

                  Или вы не понимаете разницы между однопоточным и многопоточным приложениями или не понимаете проблем с CoW. Была явно запрещена опасная для многопоточных приложений оптимизация библиотеки.

                  Вместо этого комитет стандартизировал small/short strings optimisation, безопасные для многопоточки.


                  1. Izaron Автор
                    06.09.2021 17:07

                    Или вы не понимаете разницы между однопоточным и многопоточным приложениями или не понимаете проблем с CoW.

                    Вы меня не так поняли - я за отмену CoW, но предпочел бы, чтобы это делал сам GCC (и прочие, к кому относится) без пинков от комитета.

                    Проще сказать "нас злой комитет заставил сломать строки", чем "мы сделали плохой дизайн, и поэтому решили его поломать". Пусть авторы реализаций сами берут на себя всю ответственность. Комитет уже попробовал один раз, вышло плохо, тонна хейта (именно к комитету) и напоминают уже 10 лет.

                    Но это личная точка зрения.


                    1. Tujh
                      07.09.2021 10:00

                      Вы меня не так поняли - я за отмену CoW, но предпочел бы, чтобы это делал сам GCC (и прочие, к кому относится) без пинков от комитета.

                      Я вас понял, но проблему с CoW строками вы всё же не понимаете. Данная оптимизация абсолютно легальная для С++98/03, так как язык сам по себе был однопоточный и вся многопоточность была "отдана" программистам by design. В С++11 внесли многопоточность как часть языка и логично, что изменились требования к реализациям. Ни кого же не смущает требование произвольного доступа в массивах со сложностью О(1), или линейное расположение данных той же строки в памяти?

                      И да, CoW использовала далеко не только STL реализованная в GCC. Если правильно помню те же Микрософт использовали строки с CoW в STL до VC++ 6.0 (включительно), но уже в VC++ 7.0 отказались, задолго до изменений стандарта.

                      Ну и версий STL великое множество, не привязанных к компилятору, STLPort (не актуально, так как проект умер, но одно время эта версия была очень популярна), Apache STDCXX, коммерческая Dinkum STL и её реализации для QNX (C++03, C++11), ElectronicArts STL, Microsoft STL, Embedded STL и ещё куча, которых сразу и не вспомнишь.

                      Поэтому шаг комитета о явном запрете оптимизаций, которые не вписываются в новый стандарт совершенно логичен, а хейтеры были и будут всегда. Вспомните хотя бы историю с оптимизированной версией memcpy в glibc сломавшей flash плеер и youtube у всех пользователей Linux? А ведь там разработчики в принципе ни чего не меняли, что бы не было прописано в стандарте, но выяснилось, что многие полагались на фактическое, а не стандартное поведение.


        1. 0xd34df00d
          05.09.2021 10:00
          +3

          ABI поменялся в C++17 (noexcept стал частью сигнатуры, и, как следствие, mangled-имени) и в C++20 (requires с концептами). Пока что количество релизов C++, где ABI ломали, больше, чем тех, где не ломали.


          1. tzlom
            05.09.2021 23:25
            +1

            Это не совсем так, C++11 компиляторы ввели Dual ABI, с C++17/20 все становится сложнее но возможность использовать Old ABI все равно есть, так что в принципе возможность запускать программы 20 летней давности на современных ОС сохраняется (на усмотрение мейнтейнеров ОС)


            1. 0xd34df00d
              05.09.2021 23:41
              +1

              Это не совсем так, C++11 компиляторы ввели Dual ABI

              Это не является частью стандарта. Если вы собираете C++11-компилятором код с совместимостью строк с C++03, то компилятор вам на самом деле врёт.


              C++17/20 все становится сложнее но возможность использовать Old ABI все равно есть

              Ну ок, куда мне здесь нажать, чтобы имена в центральном окошке манглились так же, как в правом, но чтобы при этом сборка там была с C++17?


              в принципе возможность запускать программы 20 летней давности на современных ОС сохраняется (на усмотрение мейнтейнеров ОС)

              Ну так у современных ОС «сишный ABI», ещё бы. Плюсы там уж точно нигде не торчат.


              1. tzlom
                06.09.2021 13:03
                +1

                Это не является частью стандарта. Если вы собираете C++11-компилятором код с совместимостью строк с C++03, то компилятор вам на самом деле врёт.

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

                Ну ок, куда мне здесь нажать, чтобы имена в центральном окошке манглились так же

                Никуда. С++ обеспечивает обратную совместимость, т.е. вы всегда можете писать новый код который будет работать со старым, поддержки старым кодом новых фич нет. Никто не заявлял гарантию что компиляция старого кода новым компилятором даст тот же результат. Однако при необходимости обеспечить совместимость вы можете путём корректировки исходников.

                Ну так у современных ОС «сишный ABI», ещё бы.

                Ну очевидно же что я имел ввиду системные библиотеки завязанные на С++, такие как glibc а не интерфейсы ОС. И у современных ОС не "сишный" интерфейс - SYSCALL в СИ программах никто не дёргает.

                ABI поменялся в C++17 (noexcept стал частью сигнатуры, и, как следствие, mangled-имени) и в C++20 (requires с концептами). Пока что количество релизов C++, где ABI ломали, больше, чем тех, где не ломали.

                С++20 ABI не ломает - его фичи на предыдущих версиях вообще не работают, так что 1 поломка (noexcept) которая многими замечена вообще не была (не самая популярная фича, хоть и зря) и поломка строк/листов в С++11 для которой есть решение сохраняющее обратную совместимость. 1.5 из 6 стандартов за 22 года.


                1. 0xd34df00d
                  06.09.2021 21:23
                  +1

                  Как выше заметили манглинг не является частью стандарта

                  Но требования стандарта на него влияют, иначе манглинг не менялся бы по ссылке выше.


                  однако он не так глупо сделан и линковать не совместимые вещи не получится.

                  Этого для ABI-совместимости недостаточно. ABI-совместимость стандартов — это когда, в том числе, я могу взять .o, собранный с одним стандартом, и слинковать его с .o от другого стандарта, даже если они используют общие определения. Изменения манглинга это ломают.


                  С++ обеспечивает обратную совместимость, т.е. вы всегда можете писать новый код который будет работать со старым, поддержки старым кодом новых фич нет.

                  Во-первых, мы тут обсуждали ABI-совместимость, а про это см. выше.
                  Во-вторых, обратная совместимость в том смысле, который вы, похоже, имеете в виду — это когда я могу собрать старый код новым стандартом. И это, к слову, тоже регулярно ломается. Примеры для C++17 (по-другому обрабатываются deleted-конструкторы для aggregate types, но это хотя бы ошибка компиляции) и C++20 (запилили partial aggregate initialization from parenthesized list, и это тихое изменение, в компилтайме совершенно не обязанное проявляться) я могу просто привести. Разницу между C++14 и 11 в тонкостях уже, увы, не так хорошо помню, чтобы что-то определённое про те стандарты сказать, но на проблемы из-за того, что ' посреди токенов интерпретируется по-разному, я натыкался в своё время во всякой макросне.


                  Никто не заявлял гарантию что компиляция старого кода новым компилятором даст тот же результат.

                  Значит, обратной совместимости нет. Потому что «компиляция старого кода» — это, в том числе, и компиляция хедеров от библиотек для предыдущих версий стандарта.


                  Однако при необходимости обеспечить совместимость вы можете путём корректировки исходников.

                  Это верно вообще для любого языка, от хаскеля (где периодически ломают что-то, но так, чтобы это было ошибкой компиляции) до питона.


                  Ну очевидно же что я имел ввиду системные библиотеки завязанные на С++, такие как glibc а не интерфейсы ОС.

                  Сможете в glibc (которая действительно очень хорошо следит за обратной совместимостью) найти хоть что-то плюсовое? Я вот сходу не смог.


                  А если вы имели в виду libstdc++ или libc++, то там так себе с обратной совместимостью.


                  И у современных ОС не "сишный" интерфейс — SYSCALL в СИ программах никто не дёргает.

                  Не понял аргумент, ну да ладно.


                  С++20 ABI не ломает — его фичи на предыдущих версиях вообще не работают

                  Однако, они требуют изменения ABI. По крайней мере, те пропозалы на изменение Itanium ABI, которые я видел пару лет назад, это самое ABI ломали даже для некоторых случаев имеющегося кода (потому что теперь надо в имени кодировать больше информации о темплейтах — N4198 в качестве примера, который, к слову, в компиляторах пока не реализовали).


                  так что 1 поломка (noexcept) которая многими замечена вообще не была

                  И поэтому вы её считаете за половинку. Ну ок.


                  Кстати, допишите там себе в список изменение возвращаемого типа у std::filesystem::path::u8string() между C++17 и C++20. Есть даже пропозал, это упоминающий, но всем пофиг.


                  1.5 из 6 стандартов за 22 года.

                  Учитывать первый стандарт (C++98) — это круто. Как он вообще мог сломать (или не сломать) ABI, если до него ничего не было?


                  Я настаиваю на трёх из пяти (хотя считать ли багфикс-релиз C++03 отдельным стандартом — вопрос спорный).


                  1. Tujh
                    07.09.2021 10:04
                    +1

                    хотя считать ли багфикс-релиз C++03 отдельным стандартом — вопрос спорный

                    ПРосто к слову, многие (и я склонен согласиться с этим мнением) считают С++14 тоже баг-фиксным релизом для С++11.


                    1. 0xd34df00d
                      07.09.2021 18:52
                      +1

                      Ну, C++14 дал сильно больше новых фич относительно C++11, чем C++03 относительно C++98. Лично я бы не называл его багфикс-релизом.


                      1. Tujh
                        07.09.2021 19:16

                        Ну не будем, так не будем.


      1. slonopotamus
        04.09.2021 22:32

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

        При такой цели стоит выбрать другой язык программирования, в C++ это не работает на практике.


        1. mentin
          04.09.2021 22:48
          +5

          У меня в этом плане опыт с с++ нормальный, по крайней мере гораздо лучше чем переход с Питон 2 на 3.

          А что у вас ломалось?


  1. technic93
    05.09.2021 00:43
    +5

    Интересно, но патчить компилятор это довольно лихо! Интереснее для более широких публики я думаю как писать какие-то расширения по типу qt-moc, на основе libclang.


    1. Akon32
      05.09.2021 08:42

      Практичнее было бы написать класс, вызывающий в деструкторе переданную лямбду.


      1. Kotofay
        05.09.2021 14:38

        #include <iostream>
        #include <functional>
        using std::cout;
        using std::endl;
        
        class defer {
           std::function<void()> _t { []() {} };
           defer() {};
        public:
           explicit defer( std::function<void()> t ) :_t( t ) {};
           ~defer() { _t(); };
        };
        
        int main() {
           int i = 0, *m = new int[ 123456789 ];
        
           defer d0 { [&] { cout << "deferred 0 i: " << i << endl; i = 3;  m[ i ] = i; delete[] m; } };
        
           defer d1 { [&] {
              int j = 0;
              defer di { [&] { cout << "inner deferred i: " << i << endl; i = 8; m[ i ] = i; } };
              cout << "deferred 1 j: " << j << endl; j = 1;  m[ j ] = j;
           } };
           
           cout << "hello" << endl;
           defer d2 { [&] { cout << "deferred 2 i: " << i << endl; i = 2; m[ i ] = i; } };
           cout << "world!" << endl;
        
           // auto error = new defer;
           // auto error = defer();
           // defer error;
           // defer error {};
        }

        2 минуты и буст не нужен.


        1. technic93
          05.09.2021 14:53
          +3

          Лучше лямбду хранить не в std::function а в темплейт параметре.


          1. Kotofay
            05.09.2021 20:41

            Так пойдёт?

            template< typename T = std::function<void()> >
            class defer : T {
               defer() {};
            public:
               explicit defer( T t ) : T( t ) {};
               ~defer() { (*this)(); };
            };
            


            1. 0xd34df00d
              05.09.2021 20:47
              +1

              Нет: не работает с обычными указателями на функции (хотя это, конечно, не совсем то условие, которое было изначально, но на практике тоже бывает полезно).


              1. Kotofay
                05.09.2021 23:05

                Это слишком просто и, думаю, не нужно.

                template< typename T >
                class defer {
                   T _t;
                   defer() {};
                public:
                   explicit defer( T t ) : _t( t ) {};
                   ~defer() { _t(); };
                };


                1. 0xd34df00d
                  05.09.2021 23:34
                  +2

                  Не поддерживаются movable-only-лямбды (по крайней мере, по беглому взгляду — компилятор я не расчехлял). Захватите в лямбду unique_ptr по значению, например.


                  1. Kotofay
                    06.09.2021 13:53

                    Для бустового defer-а такой сценарий использования не предусмотрен, поэтому там жёстко зашито &.
                    Захват по значению компилятор отвергает, ессно.


                    1. technic93
                      06.09.2021 15:44

                      Бустовые макросы это что-то из времён до С++11.


                    1. 0xd34df00d
                      06.09.2021 21:24
                      +1

                      Захват по значению компилятор отвергает, ессно.

                      А вы захватите с init-capture-list, как


                      std::unique_ptr<int> ptr = ...;
                      auto lam = [ptr = std::move(ptr)] { ... };


            1. technic93
              05.09.2021 23:09
              +1

              а если ещё вспомнить про правило трёх/пяти и forward в конструкторе... плюс не думаю что стоит тут наследовать потому что вряд ли есть смысл вызывать функцию явно... а и по новым гайдлайнам вместо приватного конструктора наверное надо делать = delete.


  1. agmt
    05.09.2021 09:05

    defer с разным успехом имитировали в существующих проектах «руками».

    defer в Go вызывается при выходе из функции. ~auto() вызывается при выходе из области видимости.
    play.golang.org/p/Iw9SZNjSUQH

    Надо менять в компиляторе функции как делали с корутинами.


  1. Spectrum-Hyena
    05.09.2021 11:00
    -7

    Столько сложностей, лишь бы лиспы или форты не учить


  1. Uint32
    05.09.2021 20:23
    +1

    Спасибо, очень интересно. Как я понимаю, через plugin для clang добавление нового ключевого слова не провернуть?


    Я выбрал release-сборку -DCMAKE_BUILD_TYPE=Release, так как debug-символов будет столько, что бинарник clang скорее всего не слинкуется (процесс линкера убивается по Out-Of-Memory после пожирания всей оперативки)

    В моём случае помог -j1 но ценность дебаговой сборки весьма сомнительна — огромное кол-во отладочной информации грузится ну оочень долго.


    1. Izaron Автор
      06.09.2021 16:24

      через plugin для clang добавление нового ключевого слова не провернуть?

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

      Но сами плагины тоже супер мощные, и с помощью них можно форматировать код (clang-format), фиксить простые ошибки (clang-fixit), рефакторить, делать линтеры (которые ищут опасные паттерны и предлагают их убрать). Это вспомогательные программы.

      Выше пишут, что на libclang сделан qt-moc. Судя по описанию, эта надстройка ищет макрос внутри класса и генерирует по нему исходник. Я не разбираюсь в Qt, не могу сказать правда ли так, но теоретически это возможно.


      1. Uint32
        06.09.2021 21:03

        Выше пишут, что на libclang сделан qt-moc.

        Нет, не на нём. (См. здесь )