>> Осторожно, модерн! 2 — 0.1. Спор на баксы и девчонок


Предисловие


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


Но не всё так радужно… после моих экспериментов в написании нетривиальных метапрограмм на связке Boost/Preprocessor + Boost/VMD я осознал, что с текущими ограничениями удобное и предсказуемое метапрограммирование препроцессора — нечто недостижимое (вы сами в этом убедитесь). И это не решается только бережностью по отношению к коду, это решается обёрткой над стандартным языком препроцессора в виде встроенного метаязыка.


Такой метаязык я создал, и назвал его agony-pp. Его цель — сделать встроенное в Си метапрограммирование удобоваримым (по сравнению с тем, что было). Это высокоуровневый язык программирования сам по себе, ведь он поставляет управляющие конструкции, типы данных (примитивные и пользовательские), коллекции и другие вещи, свойственные ЯВУ.


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


Сегодняшняя программа:


  1. Уточним терминологию из CS.
  2. Рассмотрим базовые техники, без которых макросоводство на базе стандартного языка препроцессора невозможно.
  3. Разработаем предметно-ориентированный язык для тестирования ПО.

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




Содержание


  1. Терминология
  2. Идиомы I и II: Конкатенация и стрингификация
  3. Идиома III: Сопоставление с образом
  4. Идиома IV: Раскрытие
  5. Идиома V: Пустота
  6. Идиомы VI и VII: Снятие и обрамление в круглые скобки
  7. Идиома VIII: Условные выражения
  8. Идиома IX: Ленивые вычисления
  9. Идиома X: Поглощение
  10. Идиома XI: Точка с запятой после вызова
  11. Предметная ориентация в деле: Язык для написания тестов
  12. Связывающая руки блокировка рекурсии



Пререквизиты


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




1. Терминология


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


Расширяемое программирование — парадигма, нацеленная на расширение инструментов разработки: языков программирования, компиляторов/интерпретаторов, сред исполнения, интегрированных сред разработки и т.д.

… что довольно логично, ведь мы посредством макросов неформально расширяем синтаксис основного языка (в нашем случае — Си).


В более широком смысле абстрагирование синтаксических конструкций в народе зовётся метапрограммированием — техника, позволяющая программам манипулировать другими программами. Эти программы зовутся метапрограммами (метапроцедурами, метафункциями), а язык, на котором пишут метапрограммы — метаязыком. Язык препроцессора — метаязык; макросы — метапрограммы.


Ключ к постижению истины — языково-ориентированное программирование:


Языко-ориентированное программирование — парадигма, при которой компьютерные языки являются такими же строительными блоками при написании кода, как и объекты, модули, функции, типы данных.

В этом определении компьютерные языки, выступающие в роли строительных блоков при написании кода, называются (встроенными) предметно-ориентированными языками:


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

Встроенные предметно-ориентированные языки — предметно-ориентированные языки, реализуемые в терминах основного языка (хост-языка).

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


Так каким же образом предметная ориентация коррелирует с макросами?


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


Использование оного должно выглядеть примерно так:


[test_rectangle.c]


TESTING(
    TEST(test_area) {
        Rectangle rectangle = {.height = 15, .width = 8};
        ASSERT(area(&rectangle) == 15 * 8);
    }

    TEST(test_perimeter) {
        Rectangle rectangle = {.height = 61, .width = 7};
        ASSERT(perimeter(&rectangle) == (61 + 7) * 2);
    });

API прямоугольника объявлен в rectangle.h и определён в rectangle.c.


Теперь, имея приблизительное представление о синтаксисе, не составит труда описать грамматику формально на EBNF:


<testing>   ::= "TESTING(" { <test> }* ")" ;
<test>      ::= "TEST(" <test-name> ")" <test-body> ;
<test-name> ::= <ident> ;
<test-body> ::= <compound-statement> ;

Реализовать — уже дело механическое, но перед этим я должен ознакомить вас с некоторыми идиомами препроцессорного метапрограммирования.




2. Идиомы I и II: Конкатенация и стрингификация


#define CAT(x, y)           PRIMITIVE_CAT(x, y)
#define PRIMITIVE_CAT(x, y) x##y

#define STRINGIFY(x)           PRIMITIVE_STRINGIFY(x)
#define PRIMITIVE_STRINGIFY(x) #x

Двоичный оператор ## производит конкатенацию параметров функционального макроса. Важна следующая выдержка из стандарта C11:


C11 6.10.3.3 The ## operator
If, in the replacement list of a function-like macro, a parameter is immediately preceded or followed by a ## preprocessing token, the parameter is replaced by the corresponding argument’s preprocessing token sequence;

Поэтому разумно определить две версии макроса, выполняющего конкатенацию: CAT и CAT_PRIMITIVE: первый раскрывает переданные аргументы, а уже потом конкатенирует результаты раскрытия, а второй конкатенирует сразу. Пример:


#define X 123
#define Y abc

CAT(X, Y) /* ---> 123abc */
PRIMITIVE_CAT(X, Y) /* ---> XY */

То есть можно считать, что аргументы функциональных макросов расширяются в теле макроса тогда и только тогда, когда не участвуют в качестве операндов операторов # и ##:


C11 6.10.3.1 Argument substitution
After the arguments for the invocation of a function-like macro have been identified, argument substitution takes place. A parameter in the replacement list, unless preceded by a # or ## preprocessing token or followed by a ## preprocessing token (see below), is replaced by the corresponding argument after all macros contained therein have been expanded. Before being substituted, each argument’s preprocessing tokens are completely macro replaced as if they formed the rest of the preprocessing file; no other preprocessing tokens are available.

Та же ситуация с унарным оператором #, который выполняет стрингификацию параметра функционального макроса:


C11 6.10.3.2 The # operator
If, in the replacement list, a parameter is immediately preceded by a # preprocessing token, both are replaced by a single character string literal preprocessing token that contains the spelling of the preprocessing token sequence for the corresponding argument.

Пример:


#define X 123

STRINGIFY(X) /* ---> "123" */
PRIMITIVE_STRINGIFY(X) /* ---> "X" */



3. Идиома III: Сопоставление с образом


#define MATCH(op, pattern) CAT(op, pattern)

Сопоставление с образом позволяет "перегрузить" функциональный макрос на предмет какого-то шаблона. Использование макроса MATCH следующее:


#define X(command)  MATCH(X_, command)()
#define X_print()   puts("Something")
#define X_compute() (1 + 3)

X(print) /* puts("Something") */
X(compute) /* (1 + 3) */



4. Идиома IV: Раскрытие


#define EXPAND(...) __VA_ARGS__

... на месте параметров обозначает их нефиксированное количество, а __VA_ARGS__ заменяется переданными аргументами. Так какой же прок в макросе EXPAND, если он то и делает, что просто раскрывается во входные значения? Дело в том, что не всякая последовательность препроцессорных лексем может быть раскрыта лишь одним сканом:


C11 6.10.3.4 Rescanning and further replacement
After all parameters in the replacement list have been substituted and # and ## processing has taken place, all placemarker preprocessing tokens are removed. The resulting preprocessing token sequence is then rescanned, along with all subsequent preprocessing tokens of the source file, for more macro names to replace.

Рассмотрим такой сценарий:


#define X(op, args) op ARGS(args)
#define ARGS(args)  args
#define OP(a, b, c) (a + b + c)

X(OP, (1, 2, 3)) /* OP (1, 2, 3) */
EXPAND(X(OP, (1, 2, 3))) /* (1 + 2 + 3) */

Представим, что мы и есть препроцессор (отличный метод, если не понимаете что вообще происходит!):


X(OP, (1, 2, 3)) EXPAND(X(OP, (1, 2, 3)))
Подстанавливаем аргументы, получается OP ARGS((1, 2,3 )). Это и есть наш resulting preprocessing token sequence. Затем сканируем на предмет вызовов макросов: OP остаётся OP, а ARGS((1, 2, 3)) расширяется в (1, 2, 3). Результат — OP (1, 2, 3). Единственный аргумент EXPAND поддаётся расширению. Теперь наш resulting preprocessing token sequence равен OP (1, 2, 3). Сканируем эту последовательность на предмет вызова макросов, получаем (1 + 2 + 3) — это и есть результат.

И ещё пример: макросы Y и Z имеют разную семантику:


#define X(a) (a)
#define Y X
#define Z(a) X(a)

X осуществляет одно сканирование параметра a после подстановки, следовательно и Y тоже, но Z уже два сканирования: при вызове X(a) и в самом теле X.




5. Идиома V: Пустота


#define EMPTY()

Макрос EMPTY раскрывается в пустую препроцессорную лексему. Использовать его следует исключительно в целях ясности кода (примеры далее).




6. Идиомы VI и VII: Снятие и обрамление в круглые скобки


#define PARENTHESISE(...)  (__VA_ARGS__)
#define UNPARENTHESISE(x)  EXPAND(EXPAND x)

Макрос PARENTHESISE уже должен быть предельно понятен. Кому-то может показаться удобным применять его в следующем шаблоне:


#define X PARENTHESISE

Вместо


#define X(...) (__VA_ARGS__)

Макрос UNPARENTHESISE работает так:


  1. UNPARENTHESISE((a, b, c))
  2. EXPAND(EXPAND (a, b, c))
    (подстановка аргументов)
  3. a, b, c
    (сканирование полученной последовательности препроцессорных лексем)

Второй EXPAND намеренно оставлен без скобок, ведь они уже есть в x; первый EXPAND выполняет второе сканирование, потому что иначе EXPAND (a, b, c) так бы и остался нераскрытым: препроцессор раскрывает макрос вместе с содержимым всего остального файла, но не наоборот:


C11 6.10.3.4 Rescanning and further replacement
After all parameters in the replacement list have been substituted and # and ## processing has taken place, all placemarker preprocessing tokens are removed. The resulting preprocessing token sequence is then rescanned, along with all subsequent preprocessing tokens of the source file, for more macro names to replace.

Что и демонстрируется в примере с MATCH: после конкатенации препроцессор получает имя макроса, затем видит оператор вызова и совершает раскрытие.




7. Идиома VIII: Условные выражения


#define IF(cond, x, y) MATCH(IF, cond)((x), (y))
#define IF_0(_x, y)    UNPARENTHESISE(y)
#define IF_1(x, _y)    UNPARENTHESISE(x)

Пример использования:


IF(0, "ABC", NULL) /* ---> NULL */
IF(1, "ABC", NULL) /* ---> "ABC" */

Одно не совсем ясно: зачем сначала обрамлять аргументы в круглые скобки, а затем UNPARENTHESISE? Попробуем обойтись без этого:


#define IF(cond, x, y) MATCH(IF, cond)(x, y)
#define IF_0(_x, y)    y
#define IF_1(x, _y)    x

#define X 1, 2, 3

// error: macro "IF_0" passed 4 arguments, but takes just 2.
IF(0, "ABC", X)

Посмотрим по какой причине это произошло:


  1. IF(0, "ABC", X)
  2. MATCH(IF, 0)("ABC", 1, 2, 3)
    (подстановка аргументов)
  3. IF_0("ABC", 1, 2, 3)
    (сканирование полученной последовательности препроцессорных лексем)

То есть запятые в параметрах x и y макроса IF препроцессор считает за запятые-разделители-аргументов при вызове IF_0 и IF_1. Если их предварительно обрамить в круглые скобки, как это делает изначальная реализация IF, тогда они будут всегда считаться за аргументы по-отдельности:


C11 6.10.3 Macro replacement
The sequence of preprocessing tokens bounded by the outside-most matching parentheses forms the list of arguments for the function-like macro. The individual arguments within the list are separated by comma preprocessing tokens, but comma preprocessing tokens between matching inner parentheses do not separate arguments.

Рассмотрим раскрытие того же вызова, но с правильной реализацией IF:


  1. IF(0, "ABC", X)
  2. MATCH(IF, 0)(("ABC"), (1, 2, 3))
  3. IF_0(("ABC"), (1, 2, 3))
  4. 1, 2, 3



8. Идиома IX: Ленивые вычисления


Рассмотрим следующий блок кода:


IF(0, X(1, 2, 3), Y(1, 2, 3))

Производительность препроцессирования может просесть ввиду энергичной природы макроподстановки, если макросы X и Y расширяются в достаточно внушительный выхлоп. Адресует эту неприятность концепция ленивых вычислений, классически реализуемая через дополнительные функции (в нашем случае — функциональные макросы):


IF(0, X, Y)(1, 2, 3)

Теперь IF расширяется в подходящий функциональный макрос, в который поставляются одни и те же аргументы для конечного раскрытия. Из этого правила следует, что макросы X и Y должны иметь одинаковую сигнатуру, что ни в коем случае не снижает применимость данной идиомы, так как лишние параметры в X и Y можно просто по-отдельности игнорировать.




9. Идиома X: Поглощение


#define CONSUME(...) EMPTY()

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


#define X(a, b, c) (a + b + c)

IF(0, CONSUME, X)(1, 2, 3) /* ---> (1 + 2 + 3) */
IF(1, CONSUME, X)(1, 2, 3) /* ---> пустая препроцессорная лексема */



10. Идиома XI: Точка с запятой после вызова


#include <stdbool.h>
#include <assert.h>

#define FORCE_SEMICOLON() static_assert(true, "")

Использование:


#define DefGetter(name, type) type get_##name(void) {} FORCE_SEMICOLON()

// int get_counter(void) {} _Static_assert(1, "");
DefGetter(counter, int);

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


Существует устаревшая техника добиться того же:


#define FORCE_SEMICOLON_OLD(...) do { __VA_ARGS__ } while (0)

Различие в том, что FORCE_SEMICOLON можно использовать и вне функций, как было продемонстрировано примером выше. (Но учтите, что статические проверки были стандартизированы только в C11, в то время как FORCE_SEMICOLON_OLD работает везде, если исключить его вариадическую сигнатуру (использовать одношаговый цикл напрямую в макросе).)




11. Предметная ориентация в деле: Язык для написания тестов


Реализация декомпозирована на три заголовочных файла:



Последний файл aux.h содержит все вышеперечисленные идиомы, test.h тоже должен быть понятен, а вот tests_for_each.h выглядит довольно устрашающе...



Но всё-таки попробуем разобрать что же в нём происходит. TEST_NAME и TEST_BODY реализованы с использованием MATCH:


#define TEST_NAME(test)                    MATCH(TEST_NAME_AUX_, test)
#define TEST_NAME_AUX_test(test_name, ...) test_name

#define TEST_BODY(test)                     MATCH(TEST_BODY_AUX_, test)
#define TEST_BODY_AUX_test(_test_name, ...) __VA_ARGS__

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


Переходим к рассмотрению макроса высшего порядка TESTS_FOR_EACH. Он принимает название другого макроса и применяет его к каждому переданному тесту:


#define TESTS_FOR_EACH(op, ...) TESTS_FOR_EACH_0(op, __VA_ARGS__, end(), EMPTY())

Его реализация разбита на пары макросов (TESTS_FOR_EACH_* (i), TESTS_FOR_EACH_*_PROGRESS (ii)), определённые по следующему шаблону:


(i)


#define TESTS_FOR_EACH_0(op, head, ...)                                                                IF(TESTS_FOR_EACH_IS_END(head), STOP, TESTS_FOR_EACH_0_PROGRESS)(op, head, __VA_ARGS__)

...

#define TESTS_FOR_EACH_9(op, head, ...)                                                                IF(TESTS_FOR_EACH_IS_END(head), STOP, TESTS_FOR_EACH_9_PROGRESS)(op, head, __VA_ARGS__)

(ii)


#define TESTS_FOR_EACH_0_PROGRESS(op, head, ...)                                                       op(TEST_NAME(head), TEST_BODY(head)) TESTS_FOR_EACH_1(op, __VA_ARGS__)

...

#define TESTS_FOR_EACH_9_PROGRESS(op, head, ...)                                                       op(TEST_NAME(head), TEST_BODY(head)) TESTS_FOR_EACH_10_LIMIT_REACHED(op, __VA_ARGS__)

TESTS_FOR_EACH передаёт TESTS_FOR_EACH_0 исходные аргументы вместе с end(), EMPTY() — детекторами конца списка тестов (об этом далее). Каждый TESTS_FOR_EACH_* действует в зависимости от значения параметра head: если тестов больше нет (TESTS_FOR_EACH_IS_END(head)) — остановить процессирование, в противном случае — передать исходные аргументы в соответствующий TESTS_FOR_EACH_*_PROGRESS, который, в свою очередь, вызывает op с именем теста и его телом, а затем передает эстафету следующему TESTS_FOR_EACH_*.


В данном механизме прослеживаются несколько рассмотренных выше идиом: условные выражения, ленивые вычисления и поглощение (#define STOP CONSUME). Заметьте, что после замены TESTS_FOR_EACH_*_PROGRESS-макросов энергичными вычислениями сразу в аргументах IF наш TESTS_FOR_EACH в конечном итоге упрётся в лимит.


Предикат TESTS_FOR_EACH_IS_END реализован аналогично TEST_NAME и TEST_BODY:


#define TESTS_FOR_EACH_IS_END(test)                 MATCH(TESTS_FOR_EACH_IS_END_, test)
#define TESTS_FOR_EACH_IS_END_test(_test_name, ...) 0
#define TESTS_FOR_EACH_IS_END_end()                 1

Причина, по которой в TESTS_FOR_EACH_0 последним аргументом передаётся EMPTY(), может быть сперва неочевидна, но давайте посмотрим что будет, если не передавать:


  1. TESTS_FOR_EACH(op, test)
  2. TESTS_FOR_EACH_0(op, test, end())
  3. IF(TESTS_FOR_EACH_IS_END(test), STOP, TESTS_FOR_EACH_0_PROGRESS)op, test, end())
  4. TESTS_FOR_EACH_0_PROGRESS(op, test, end())
  5. op(TEST_NAME(test), TEST_BODY(test)) TESTS_FOR_EACH_1(op, end() ???)

Предупреждение компилятора:


$ gcc --version
gcc (Ubuntu 7.5.0-3ubuntu1~18.04) 7.5.0
...

$ gcc test_rectangle.c rectangle.c -Wall -Wextra -pedantic -std=c11
...
warning: ISO C99 requires at least one argument for the "..." in a variadic macro
...

Ещё раз взглянем на сигнатуру TESTS_FOR_EACH_1_PROGRESS:


#define TESTS_FOR_EACH_1_PROGRESS(op, head, ...)  

Согласно следующей выдержки из стандарта, он требует как минимум три аргумента:


C11 6.10.3 Macro replacement
If there is a ... in the identifier-list in the macro definition, then the trailing arguments, including any separating comma preprocessing tokens, are merged to form a single item: the variable arguments. The number of arguments so combined is such that, following merger, the number of arguments is one more than the number of parameters in the macro definition (excluding the ...).

… но на пятом шагу раскрытия было предоставлено лишь два (op, end()), на что компилятор справедливо указал. Поэтому необходимо следить за тем, чтобы в каждый вариадичный макрос всегда поступало хотя бы на один аргумент больше, чем было специфицировано именованных параметров в его сигнатуре. И пусть даже дополнительный аргумент будет пустым списком препроцессорных токенов (наш случай).




Вот и всё с реализацией. Запустим нашу тарахтелку:


$ ./compile.sh
$ ./a.out
test_area passed!
test_perimeter passed!
Testing passed!

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


Конечное содержимое test_rectangle.c:


#include "rectangle.h"
#include "test.h"

TESTING(
    TEST(test_area) {
        Rectangle rectangle = {.height = 15, .width = 8};
        ASSERT(area(&rectangle) == 15 * 8);
    }

    TEST(test_perimeter) {
        Rectangle rectangle = {.height = 61, .width = 7};
        ASSERT(perimeter(&rectangle) == (61 + 7) * 2);
    });

До применимости в реальных условиях не хватает атрибутов к тестам, параллелизации тестирования и много чего другого. Смысл в создании данного фреймворка был лишь продемонстрировать связь предметной ориентации с Си-макросами.




12. Связывающая руки блокировка рекурсии


Слишком много вышло возни… Более того, с ростом наших потребностей придётся вручную увеличивать предел итерирования в tests_for_each.h. И так будет происходить со всеми метапрограммами, чьи реализации используют итерирование, то бишь со всеми хоть сколько-то комплексными метапрограммами! И к этому моменту мы даже не дошли до нового камня преткновения — рекурсия, возникающая внутри макроса высшего порядка:


#define X(op)        op(123)
#define CALL_X(_123) X(ID)
#define ID(x)        x

X(CALL_X)

Шаги раскрытия:


  1. X(CALL_X)
  2. CALL_X(123)
  3. X(ID)
    (блокировка повторного вызова X)

Причём X(ID) сам по себе раскрывается 123, как и ожидалось.


Boost/Preprocessor решил проблему отсутствия рекурсии путём… не решил.



… Но хотя бы нашёл компромисс в виде машинерии по вычислению шага псевдорекурсии, полученной копированием макросов (Topics --> Reentrancy в документации).


К счастью, средство для обобщённой рекурсии (до какого-то разумного предела; препроцессор C/C++ не Тьюринг-полный) возможно и уже реализовано мною. Суть концепции в том, чтобы наложить на раскрытие макросов свой собственный порядок редукции, "забывая" всех родителей в иерархии вызовов после каждого следующего вызова любого функционального макроса, тем самым сведя на нет встроенную в препроцессор блокировку рекурсии. Об этом — в следующем опусе.




Заключение


Предметно-ориентированные языки позволяют программисту яснее выражать его намерения. Способ реализации встроенных предметно-ориентированных языков в Си — метапрограммирование препроцессора. Жаль только, что оно кастрированное: отсутствуют управляющие конструкции, пользовательские типы данных и много всего остального. С таким скудным арсеналом далеко не улететь. Проект agony-pp адресует эту проблему путём создания интерпретатора метаязыка поверх стандартного препроцессора (описано в следующих опусах).


Другая проблема макросов всё ещё не решена, да и вряд ли может быть решена в рамках стандартного препроцессора — макросы пропускают внутрь себя невалидные конструкции, вследствие чего возникают кошмарные ошибки на много тысяч строк. Обычно я запускаю GCC с опцией -ftrack-macro-expansion=0, чтобы не засорять терминал лишним выхлопом и снизить потребление ресурсов во время препроцессирования.


Продолжение следует...