Создаем новое ключевое слово в C++

Kate

Administrator
Команда форума
Этот код будет компилироваться!
Этот код будет компилироваться!
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))__ { ... }. Но минусы такие же, как в прошлом пункте. Их можно использовать, если полагаться на то, что в конкретном компиляторе это расширение есть и всегда будет прилично работать.

Конец​

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

 
Сверху