Добро пожаловать в чистилище препроцессора — место, где здравый смысл уступает место макросам. Сегодня мы заставим 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 и любым вашим типом, для которого вы не поленились написать bind

  • Constexpr: Весь этот ад из макросов прекрасно вычисляется на этапе компиляции, если ваш bind и тип это позволяют.

  • Allocation Free: Всё это вполне может работать без аллокаций вообще, тогда как с корутинами они были необходимы.

  • Конструкции управления: Код может работать со сложными циклами и ветвлениями.

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

Финальный код и примеры можно увидеть на Godbolt, а ещё в Github репозитории — https://github.com/j4niwzis/do_let_is


Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале

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