Как стать автором
Обновить

Занимательный C++: Счетчик времени компиляции

Время на прочтение 5 мин
Количество просмотров 19K
Предлагается разработать безопасную альтернативу встроенного макроса __COUNTER__. Первое вхождение макроса заменяется на 0, второе на 1, и так далее. Значение __COUNTER__ подставляется на этапе препроцессирования, следовательно его можно использовать в контексте constant expression.

К сожалению, макрос __COUNTER__ опасно использовать в заголовочных файлах — при другом порядке включения заголовочных файлов подставленные значения счетчика поменяются. Это может привести к ситуации, когда например в foo.cpp значение константы AWESOME равно 42, в то время как в bar.cpp AWESOME≡33. Это нарушение принципа one definition rule, что есть страшный криминал во вселенной C++.

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

По мотивам этого вопроса на Stack Overflow.

Мотивирующий пример

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

STRUCT(Point3D)
  FIELD(x, float)
  FIELD(y, float)
  FIELD(z, float)
END_STRUCT

Здесь мы не просто определяем структуру Point3D со списком полей x, y и z. Мы также автоматически получаем функции сериализации и десериализации. Невозможно добавить новое поле, и забыть для него поддержку сериализации. Писать приходится значительно меньше, чем например для boost.

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

Определим макрос FIELD следующим образом (для наглядности используем __COUNTER__):

#define FIELD(name, type) \
  type name; // определение поля \
  template<> \
  void serialize<__COUNTER__/2>(Archive &ar) { \
    ar.write(name); \
    serialize<(__COUNTER__-1)/2+1>(ar); \
  }

При разворачивании FIELD(x, float) получится

float x; // определение поля x
template<>
void serialize<0>(Archive &ar) {
  ar.write(x);
  serialize<1>(ar);
}

При разворачивании FIELD(y, float) получается

float y; // определение поля y
template<>
void serialize<1>(Archive &ar) {
  ar.write(x);
  serialize<2>(ar);
}

Каждое последующее вхождение макроса FIELD() разворачивается в определение поля, плюс специализацию функции serialize<i>() где i=0,1,2,…N. Функция serialize<i>() вызывает serialize<i+1>(), и так далее. Cчетчик помогает связать разрозненные функции вместе.

По ссылке рабочий пример кода.


Однобитный счетчик времени компиляции

Для начала, покажем реализацию однобитного счетчика.

// (1)
template<size_t n>
struct cn {
    char data[n+1];
};

// (2)
template<size_t n> 
cn<n> magic(cn<n>);

// (3) текущее значение счетчика
sizeof(magic(cn<0>())) - 1; // 0

// (4) «инкремент»
cn<1> magic(cn<0>);

// (5) текущее значение счетчика
sizeof(magic(cn<0>())) - 1; // 1

  1. Определяем шаблонную структуру cn<n>. Отметим, что sizeof(cn<n>) ≡ n+1.
  2. Определяем шаблонную функцию magic.
  3. Оператор sizeof, примененный к выражению, выдает размер типа, который имеет данное выражения. Так как выражение не вычисляется, определения тела функции magic не требуется.
    Единственная определенная на данный момент функция magic — шаблон из п. 2. Поэтому тип возвращаемого значения и всего выражения — cn<0>.
  4. Определим перегруженную функцию magic. Отметим, что неоднозначности при вызове magic не возникает, потому что перегруженные функции имеют приоритет перед шаблонными функциями.
  5. Теперь при вызове magic(cn<0>()) будет использован другой вариант функции; тип выражения внутри sizeofcn<1>().

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

Определим макросы для чтения и «инкрементации» однобитного счетчика.

#define counter_read(id) \
  (sizeof(magic(cn<0>())) - 1)

#define counter_inc(id) \
  cn<1> magic(cn<0>)

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


N-битный счетчик времени компиляции

N-битный счетчик строится на тех же принципах, что и однобитный. Вместо одного вызова magic внутри sizeof у нас будет цепочка вложенных вызовов a(b(c(d(e( … ))))).

Вот он, наш базовый строительный блок. Это функция от одного аргумента типа T0. В зависимости от доступных деклараций в области видимости, тип возвращаемого значения или T0 или T1. Это устройство напоминает стрелку на железной дороге. В начальном состоянии, «стрелка» направлена влево. «Стрелку» можно переключить единственный раз.

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

При поиске подходящего варианта функции, компилятор C++ учитывает только типы параметров а тип возврашаемого значения игнорирует. Если в выражении есть вложенные вызовы функций, компилятор «движется» изнутри наружу. Например в следующем выражении: M1(M2(M4( T0() ))), компилятор сначала разрешает («резолвит») вызов функции M4(T0). Затем, в зависимости от типа возвращаемого значения функции M4, он разрешает вызов M2(T0) или M2(T4), и так далее.

Продолжая железнодорожную аналогию, можно сказать, что компилятор движется по железнодорожной сети сверху вниз, «сворачивая» на стрелках вправо или влево. Выражение из N вложенных вызовов функций порождает сеть с 2N выходами. Переключая стрелки в правильном порядке, можно последовательно получить все 2N возможных типов Ti на выходе сети.

Можно показать, что если текущий тип на выходе сети Ti, то следующей нужно переключить стрелку M[(i+1)&~i, (i+1)&i].

Окончательный вариант кода доступен по ссылке.

Вместо заключения

Счетчик времени компиляции целиком основан на механизме перегруженных функций. Эту технику я подсмотрел на Stack Overflow. Как правило, нетривиальные вычисления времени компиляции в C++ реализуются на шаблонах, именно поэтому представленное решение особенно интересно, так как вместо шаблонов эксплуатирует иные механизмы.

Насколько такие решения практичны?

ИМХО если единственный C++ файл компилируется более 5 минут, причем справиться с ним может только самая последняя версия компилятора — это точно непрактично. Многие «креативные» варианты использования языковых возможностей в C++ представляют исключительно академический интерес. Как правило, те же задачи можно лучше решить иными способами, например путем привлечения внешнего кодогенератора. Хотя, надо сказать, автор несколько предвзят в данном вопросе, категорически не признавая spirit, и испытывая некоторую слабость по отношению к bison.

Кажется, счетчик времени компиляции так же не особо практичен, как хорошо видно на следующем графике. По оси x отложена абсолютная величина приращения счетчика в тестовой программе (тестовая программа состоит из строк counter_inc(int)), по оси y — время компиляции в секундах. Для сравнения, там же отложено время компиляции nginx-1.5.2.

Теги:
Хабы:
+21
Комментарии 5
Комментарии Комментарии 5

Публикации

Истории

Работа

Программист C++
122 вакансии
QT разработчик
13 вакансий

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн