Добро пожаловать в чистилище препроцессора — место, где здравый смысл уступает место макросам. Сегодня мы заставим C++ притвориться Haskell-ем и внедрим do-нотацию, за которую любой адепт «чистого языка» предаст нас анафеме.
Программисты на C++ делятся на два типа: те, кто боится препроцессора, и те, кто познал сие древнее чудо с сишных времён.
Сегодня мы перейдем черту. Функциональное программирование манит своими абстракциями, но когда дело доходит до цепочек вычислений в монадах,C++ встречает нас бесконечными лямбдами и вложенностью, от которой рябит в глазах. В Haskell эта проблема решена элегантным do-синтаксисом. А что, если я скажу, что мы можем получить то же самое в C++, используя лишь тёмную магию макросов, простые шаблоны и полное пренебрежение здравым смыслом?
Приготовьтесь: мы будем дорабатывать парсер и превращать ваш код в нечто, что заставит коллег вызвать экзорциста. Это история о том, как затащить чистую красоту монад в суровый мир C++.
Внимание: автор не несет ответственности за разбитые мониторы ваших коллег при попытке протащить это в прод.
Для познания основ колдовства и макросов, используемых в этой статье, рекомендую ознакомиться с моей предыдущей статьёй, хотя большая часть этой статьи должна быть понятна и без этого.
Структура статьи
❯ Мост между мирами: От чистоты к безумию
Чтобы понять, зачем нам это нужно, давайте посмотрим на одну и ту же логику - сложение двух чисел,
обернутых в контекст (в данном случае std::optional), — в четырёх разных ипостасях.
Haskell
Здесь do-нотация изначально вписана в язык. Код выглядит как обычная последовательность действий, когда внутри скрывается распаковка монад
через bind (>>=).
result = do a <- maybeA b <- maybeB return (a + b)
Обычный C++
А вот так выглядит код в C++ без такого сахара. Мы вынуждены писать огромную вложенность, что пробуждает въетнамские флешбеки про callback-hell в асинхронном коде.
Код уезжает вправо, а количество скобочек в конце напоминает результат страшных военных преступлений, отраженных в коде.
auto result = maybeA.and_then([&](int a) { return maybeB.and_then([&](int b) { return std::optional{a + b}; }); });
C++ препроцессор-edition
А что про сегодняшний гвоздь программы? Макросы работают таким образом, что вся та вложенная структура строится сама, а код
остаётся линейным.
auto result = DO( LET a IS(maybeA); LET b IS(maybeB); return std::optional{a + b}; );
Сначала может показаться, что это простые макросы для проверки optional-а, но это не так. Эти макросы раскрываются во вложенный вызов bind с лямбдами. Это позволяет ему работать не только с optional-like типами, но и с различными другими монадами через один интерфейс.
C++ через корутины
Но знающий читатель может возразить, что можно сделать это же через корутины и будет абсолютно прав! Но у корутин есть свои ограничения, потому о них поподробнее.
auto result = [&] -> maybe<int> { auto a = co_await maybeA; auto b = co_await maybeB; co_return a + b; }();
Корутины сразу вносят свои ограничения.
Код становится невозможно выполнять в constexpr контексте, могут вылезти аллокация, да ещё и работает не со всеми монадами.
Реализация же сего через макросы таких недостатков лишена.
Таблица
Метод | Универсальность | Читаемость | constexpr | Zero-overhead | Сложность реализации |
|---|---|---|---|---|---|
Обычный C++ | Да | Ужасно | Да | Да | Низкая |
Корутины | Нет | Отлично | Нет | Нет (обычно) | Высокая |
Макросы | Да | Нормально | Да | Да | Чёрная магия |
❯ Секрет волшебства
Раз с преимуществами разобрались, то можно перейти и к сути решения.
Если в прошлой статье был описан парсинг данных, состоящих из элементов одного множества, то здесь предстоит сделать нечто более ужасное.
Проблема в том, что невозможно заранее задать таблицу для произвольного кода. Из-за чего прошлой вариант парсинга и не подходит н��прямую, но можно подойти и по-другому.
Можно задать конструкции, которые «разрежут» поток токенов на несколько частей запятыми. Тогда весь остальной код, который не специальные конструкции, сам уедет в метку _CODE.
Проще показать на примере:
DO( LET a IS(maybeA); LET b IS(maybeB); return std::optional{a + b}; )
=>
PARSE_DO( _CODE(), _LET_IS(a, (maybeA)), _CODE(;), _LET_IS(b, (maybeB)), _CODE(; return std::optional{a + b};) )
Дальше уже можно разобрать тем же методом через таблицу.
Вот, собственно, и вся идея.
Построение основы
Как и полагается любой магии, для начала нужно задать её основу. Препроцессорная магия исключением тут не станет.
Определение интерфейса
Для начала следует определить логику, во что оно должно раскрываться. Задаётся функция bind в глобальном имени космоса пространстве имён. Первым аргументом передаётся монада, а вторым функция. Для std::optional можно реализовать через and_then.
template <typename T, typename F> constexpr auto bind(std::optional<T> arg, F&& f) -> decltype(std::move(arg).and_then(std::forward<F>(f))) { return std::move(arg).and_then(std::forward<F>(f)); }
Ниже можно наблюдать код и то, во что оно должно раскрываться (реальные макросы будут раскрываться немного не так, но об этом позже):
auto result = DO( LET a IS(maybeA); LET b IS(maybeB); return std::optional{a + b}; ); /* auto result = [&] { return ::bind(maybeA, [&](auto a) { return ::bind(maybeB, [&](auto b) { return std::optional{a + b}; }); }); }(); */
Базовые макросы
В последующем коде будут использоваться макросы из предыдущей статьи:
#define OUT #define EVAL_2(...) __VA_ARGS__ #define EVAL_1(...) EVAL_2(EVAL_2(EVAL_2(EVAL_2(__VA_ARGS__)))) #define EVAL_0(...) EVAL_1(EVAL_1(EVAL_1(EVAL_1(__VA_ARGS__)))) #define EVAL(...) EVAL_0(EVAL_0(EVAL_0(EVAL_0(__VA_ARGS__)))) #define DELAY_OPEN_BRACE_0() ( #define DELAY_OPEN_BRACE_1() DELAY_OPEN_BRACE_0 OUT() #define DELAY_OUT_0() OUT #define DELAY_OUT_1() DELAY_OUT_0 OUT() #define DELAY_OUT_2() DELAY_OUT_1 OUT() #define DELAY_OUT_3() DELAY_OUT_2 OUT() #define CONCAT_1(a, b, ...) a##b __VA_ARGS__ #define CONCAT_0(...) CONCAT_1 OUT(__VA_ARGS__)
Разбиение кода на части
Оно должно трансформироваться в поток меток, у которых закрыты скобки. Предположим, что прошлая часть начинается с _CODE( code и дальше поток из LET IS. Тогда LET name IS(value) должно закрывать _CODE( слева от себя и начинать _CODE( справа от себя, чтобы остались те же условия.
#define LET ), _LET_IS( #define IS(...) , (__VA_ARGS__)) \ , _CODE(
Тогда останется лишь в начале добавить _CODE( и в конце ). Это будет делаться в самом макросе DO.
Обработка метки _LET_IS
Нужно задать сам вызов ::bind, лямбду. И внутри вызвать парсинг всего кода, что идёт после, задать задержку парсинга для избежания проблемы блюпринтинга, которая более подробно разобрана в прошлой статье. Для этого задаётся макрос PARSE_DO_ITERATION_HELPER:
#define PARSE_DO_ITERATION_HELPER(...) PARSE_DO_ITERATION OUT(__VA_ARGS__)
Следует задать и способ замыканий в лямбде. Этот DSL может использоваться не только с энергичным выполнением, которое есть в коде с optional, но и с ленивым. Тогда при захвате по ссылке код сломается из-за смерти объектов. Тогда понадобится другой подход, но при энергичном выполнении нет смысла брать по значению. Потому стоит определить точку кастомизации в виде LAMBDA_CAPTURE.
#define LAMBDA_CAPTURE & #define DO_WORK_LET_IS(name, is, ...) \ return ::bind(is, [LAMBDA_CAPTURE](auto&& name) { \ PARSE_DO_ITERATION_HELPER DELAY_OUT_3()(__VA_ARGS__) \ });
И задать это в таблицу:
#define CLOSE_MACRO(...) , __VA_ARGS__ ) #define DO_TABLE_LET_IS(name, is) DO_WORK_LET_IS DELAY_OPEN_BRACE_0() name, is CLOSE_MACRO
Обработка метки _CODE
В коде дальше используется __VA_OPT__, который был добавлен в C++20, но можно переписать и с тем же подходом для определения конца, который был в MAP из предыдущей статьи.
Теперь нужно обработать и саму метку _CODE. Первый вызов будет с данными метки внутри кода, а второй со всем последующим. Нужно просто вернуть слева всё то, что было при первом вызове, а потом вызвать PARSE_DO_HELPER для следующего (если ещё есть что-то).
#define DO_WORK_CODE(...) __VA_OPT__(PARSE_DO_ITERATION_HELPER DELAY_OUT_0()(__VA_ARGS__)) #define DO_TABLE_CODE(...) __VA_ARGS__ DO_WORK_CODE
Макрос парсинга
Нужно задать PARSE_DO_ITERATION, но он тривиальный. Просто ищет по таблице и передаёт туда аргументы.
#define PARSE_DO_ITERATION(check, ...) DO_TABLE##check(__VA_ARGS__)
Макрос обёртка
Осталось задать сам макрос DO. Как и было сказано выше, с начала нужно добавить _CODE(, а в конце ).
#define DO(...) \ [&] { \ EVAL(PARSE_DO_ITERATION(_CODE(__VA_ARGS__))) \ }()
Вот и основа волшебства заложена. Уже сейчас можно прогнать тот пример и получить верный результат.
auto result = DO( LET a IS(maybeA) LET b IS(maybeB) return std::optional{a + b}; ); /* auto result = [&] { return ::bind((maybeA), [&](auto&& a) { return ::bind((maybeB), [&](auto&& b) { return std::optional{a + b}; }); }); }(); */
Текущую версию кода можно посмотреть на Godbolt или в репозитории Github
❯ Расширение
Обычные LET ... IS(...); это, конечно, хорошо, но хотелось бы иметь ещё и нормальные циклы и условия. Но при попытке использовать LET ... IS(...); внутри другого скоупа разделит код неправильно и чёрная магия сломает весь синтаксис. Если же внутри if или while нет LET ... IS(...);, код будет работать правильно, и ничего не сломается. Исправим же код и для первого случая.
Принцип работы while
Цикл будет преобразовываться в рекурсивные лямбды.
return [&](this auto&& while_self, bool break_flag = false) { if(break_flag || !cond) { // Логика для продолжения после цикла return ...; } // Логика для самого цикла return ...; // внутри будет вызывать while_self после всего }();
Используется флаг break_flag для возможности реализации BREAK в цикле. А while_self хранит эту же лямбду для создания рекурсии. Тут используется deducting this из C++23, но можно переписать и без этого.
Но с таким подходом возникнут проблемы с вложенными циклами. Потому следует каждый while_self помечать уникальным идентификатором. Для генерации идентификаторов задаётся таблица INC:
#define INC_0 1 #define INC_1 2 #define INC_2 3 // ... #define INC(x) INC_##x
Для if тоже будет использоваться свой идентификатор, он будет отдельный.
Пример раскрытия:
// Это: WHILE(cond)( LET x IS(foo()); ++x; ) return bar; // Становится этим: return [&](this const auto& while_self_0, bool break_flag = false) { if (break_flag || !cond) { return bar; } return ::bind(foo(), [&](auto&& x) { ++x; return while_self_0(); }); }();
Реализация while
Для нач��ла нужно создать саму метку while:
#define WHILE_0(...) , (_CODE(__VA_ARGS__))) \ , _CODE( #define WHILE(cond) ), _WHILE((cond) WHILE_0
WHILE(cond)(body) => ), _WHILE((cond), (_CODE(body))), _CODE(
Тут _CODE() добавляется уже сразу. Т.к. в body работа идёт с такой же последовательностью, что и в DO, и добавлять нужно тоже.
Далее макросы для работы с этой меткой. Внутри будут использоваться id_while и id_if, для чего нужно будет изменить все вызовы PARSE_DO_ITERATION и добавить к ним эти id. Каждый цикл увеличивает id для следующих циклов.
В качестве вспомогательного макроса используется UNWRAP для раскрытия скобок.
Тут нужно добавить return while_self_##id_while() в конце, а вся вложенность и так сделается в результате вычисления макросов. Нужно это сделать, обернув в _CODE(), ведь иначе оно не окажется внутри блока кода. В body добавлять _CODE() не нужно, т.к. это уже было сделано ранее.
#define UNWRAP(...) __VA_ARGS__ #define DO_WORK_WHILE(cond, body, id_while, id_if, ...) \ return [LAMBDA_CAPTURE](this const auto& while_self_##id_while, bool break_flag = false) { \ if(break_flag || !cond) { \ PARSE_DO_ITERATION_HELPER DELAY_OUT_3()(id_while, id_if, __VA_ARGS__) \ } \ PARSE_DO_ITERATION_HELPER DELAY_OUT_3()(INC(id_while), id_if, UNWRAP body, _CODE(return while_self_##id_while();)) \ }(); #define DO_TABLE_WHILE(cond, body) DO_WORK_WHILE DELAY_OPEN_BRACE_0() cond, body CLOSE_MACRO
BREAK и CONTINUE
Работа CONTINUE заключается в том, чтобы просто вызвать текущий цикл и вернуть результат. BREAK это CONTINUE, но с добавленным флагом break_flag. Из примечательного тут то, что даже нет необходимости парсить весь код, что идёт после, ведь он всё равно не выполнится.
Для получения доступа к текущему id цикла, а не следующего, необходима ещё и таблица DEC:
#define DEC_1 0 #define DEC_2 1 #define DEC_3 2 // ... #define DEC(x) DEC_##x
Реализация:
#define DO_WORK_CONTINUE_BASE(break_flag, id_while, id_if, ...) return CONCAT_0(while_self_, DEC(id_while))(break_flag); #define DO_TABLE_CONTINUE DO_WORK_CONTINUE_BASE DELAY_OPEN_BRACE_0() false CLOSE_MACRO #define DO_TABLE_BREAK DO_WORK_CONTINUE_BASE DELAY_OPEN_BRACE_0() true CLOSE_MACRO
Принцип работы if
Код с if будет преобразовываться в следующий вид:
return [&](this const auto& if_self, bool is_cont = false) { if(is_cont) { // Продолжение кода return ... } if(!cond) { return ...; // Ветка els и в конце вызов if_self(true) } // Ветка body и в конце вызов if_self(true) return ...; }();
Тут используется deducting this и флаг для продолжения, а подход с лямбдой с продолжением бы не сработал полностью. Текущий код может работать и с захватом по значению, но с новой лямбдой было бы лишнее копирование.
Реализация if
Реализация будет не только для простого if, но и для if constexpr и if consteval. Сами метки:
#define IF__1(...) , (_CODE(__VA_ARGS__))) , \ _CODE( #define IF__0(...) , (_CODE(__VA_ARGS__))IF__1 #define IF(cond) ) , _IF((cond) IF__0 #define IF_CONSTEXPR(cond) ) , _IF( constexpr(cond) IF__0 #define IF_CONSTEVAL ), _IF( consteval IF__0
IF(cond)(body)(els)
=>
), _IF((cond), (_CODE(body)), (_CODE(els))), _CODE(
И нужно реализовать сам IF.
#define DO_WORK_IF(check, body, els, id_while, id_if, ...) \ return [LAMBDA_CAPTURE](this const auto& if_self_##id_if, bool is_cont = false) { \ if(is_cont) { \ PARSE_DO_ITERATION_HELPER DELAY_OUT_3()(id_while, INC(id_if), __VA_ARGS__) \ } \ if check { \ PARSE_DO_ITERATION_HELPER DELAY_OUT_3()(id_while, INC(id_if), UNWRAP body, _CODE(return if_self_##id_if(true);)) \ } else { \ PARSE_DO_ITERATION_HELPER DELAY_OUT_3()(id_while, INC(id_if), UNWRAP els, _CODE(return if_self_##id_if(true);)) \ } \ }(); #define DO_TABLE_IF(check, body, els) DO_WORK_IF DELAY_OPEN_BRACE_0() check, body, els CLOSE_MACRO
Добавление параметров в PARSE_DO_ITERATION и DO_WORK_LET_IS
В PARSE_DO_ITERATION в макросы работы нужно пробросить аргументы id, а в DO_WORK_LET_IS нужно их взять и исправить вызовы. Нужно изменить и обёртку DO.
#define DO_WORK_LET_IS(name, is, id_while, id_if, ...) \ return ::bind(is, [LAMBDA_CAPTURE](auto&& name) { \ PARSE_DO_ITERATION_HELPER DELAY_OUT_3()(id_while, id_if, __VA_ARGS__) \ }); #define PARSE_DO_ITERATION(id_while, id_if, check, ...) \ DO_TABLE##check(id_while, id_if __VA_OPT__(,) __VA_ARGS__) #define DO(...) \ [&] { \ EVAL(PARSE_DO_ITERATION(0, 0, _CODE(__VA_ARGS__))) \ }()
Вот и всё, DSL создан. Ссылка на получившийся код будет в конце статьи.
❯ Использование: Призываем демонов на практике
Наш DO-монстр всеяден. Ему плевать на законы категорий, чистоту функций и ваше душевное спокойствие. Если есть реализация bind для типа, макрос проглотит её и попросит добавки. А ведь реализовать его можно не только для монад.
Давайте посмотрим на примеры работы этой адовой конструкции.
Монада List
Реализация bind для std::optional уже была выше. Почему бы теперь не повторить монаду List?
Достаточно подружить std::ranges из C++23 с нашим интерфейсом:
template <typename T, typename F> constexpr auto bind(const std::vector<T>& range, F&& f) -> std::invoke_result_t<F, T> { return range | std::views::transform(std::forward<F>(f)) | std::views::join | std::ranges::to<std::vector>(); }
И пример использования:
constexpr auto list_function(const std::vector<int>& a, const std::vector<int>& b) { return DO( LET a IS(a); LET b IS(b); return std::vector{a + b, a + b}; ); } static_assert(list_function({1, 2, 3}, {1, 2, 3}) == std::vector{2, 2, 3, 3, 4, 4, 3, 3, 4, 4, 5, 5, 4, 4, 5, 5, 6, 6});
Самое интересное в этой монаде то, что корутины тут бессильны — они не умеют вызывать своё продолжение (continuation) больше одного раза. А что про наш макросный комбайн? Ему всё равно, он просто преобразовывает код в цепочку лямбд.
В этом и есть некоторая красота монад. Под один интерфейс можно сделать абсолютно разную логику и всё оно будет работать. Эту монаду ещё и не реализовать на корутинах, ведь они не могут вызывать продолжение несколько раз, а на макросах можно вполне.
Работа с std::variant
Если вам по какой-то причине не хочется вызывать std::visit руками, мы можем вызывать его через bind и DO. Просто подсунем visit в bind:
template <typename... Ts, typename F> constexpr auto bind(const std::variant<Ts...>& var, F&& f) { return std::visit(std::forward<F>(f), var); }
И вуаля! Даже std::variant поддался решению:
constexpr auto variant_function(const std::variant<int, float>& a, const std::variant<int, double>& b) { return DO( LET a IS(a); LET b IS(b); return std::variant<double, int>{a + b}; ); } static_assert(std::get<int>(variant_function(3, 3)) == 6);
Ну и повторю, что DO работать не только с монадами. А вообще со всем!
Циклы
Выше описывались циклы. И их вполне можно использовать!
constexpr auto loop(const std::vector<std::optional<int>>& vec) { return DO( auto it = vec.begin(); int result = 0; WHILE(it != vec.end())( LET value IS(*it); result += value; ++it; ) return std::optional{result}; ); } static_assert(loop({1, 2, 3}) == 6); static_assert(loop({1, std::nullopt, 3}) == std::nullopt);
Тулинг в коме и другие проблемы
Будьте готовы: ваш IDE — это первое, что падёт жертвой этого колдовства.
clangd при попытке заглянуть внутрь кода с
DOможет уйти в глубокую депрессию и отказаться работать с кодом через выход в краш.clang-format смотрит на этот код с немым ужасом и форматирует так, будто символа новой строки просто не существует.
Разве не восхищает идея владеть столь страшной техникой, что современные анализаторы сходят от неё с ума?
В связи с ломанием форматирования от clang-format, с этими макросами его лучше отключать. Руками через комментарии это делать неудобно, а потому надо прописать в конфиге вот это:
... WhitespaceSensitiveMacros: [DO]
И форматирование внутри DO будет отключаться автоматически.
❯ Заключение
Мы только что совершили техническое святотатство: заставили древний препроцессор C++ косплеить Haskell, внедрили do-нотацию через цепочки лямбд и заставили clangd молить о пощаде.
Стоит ли тащить в продакшен?
Если вы хотите, чтобы на следующее утро ваше рабочее место было окроплено святой водой, а доступ в репозиторий аннулирован пожизненно — определённо да. В остальном же, сейчас это упражнение в чистом, дистиллированном безумии, которое показывает: границы C++ пролегают гораздо дальше, чем ка��ется на первый взгляд. А переступать ли эти границы, решать уже вам.
В статье был разобрана не только do-нотация, но и идеи для реализации таких DSL.
Мы получили инструмент, который:
Generic: Работает с
optional,expected,vector,variantи любым вашим типом, для которого вы не поленились написать bindConstexpr: Весь этот ад из макросов прекрасно вычисляется на этапе компиляции, если ваш bind и тип это позволяют.
Allocation Free: Всё это вполне может работать без аллокаций вообще, тогда как с корутинами они были необходимы.
Конструкции управления: Код может работать со сложными циклами и ветвлениями.
Помните: с большой силой приходит большая ответственность... и адские мучения при попытке отдебажить макросы.
Финальный код и примеры можно увидеть на Godbolt, а ещё в Github репозитории — https://github.com/j4niwzis/do_let_is
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩

Перед оплатой в разделе «Бонусы и промокоды» в панели управления активируйте промокод и получите кэшбэк на баланс.
