[Из песочницы] Применение X-Macro в модерновом C++ коде
Современные тренды разработки на C++ предполагают максимально возможный отказ от макросов в коде. Но иногда без макросов, причем в особо уродливом их проявлении, не обойтись, так как без них еще хуже. Об этом и рассказ.
Как известно, первым этапом компиляции C и C++ является препроцессор, который заменяет макросы и директивы препроцессора простым текстом.
Это позволяет делать нам странные вещи, например, такие:
// xmacro.h
"look, I'm a string!"
// xmacro.cpp
std::string str =
#include "xmacro.h"
;
После работы препроцессора это недоразумение превратится в корректный код:
std::string str =
"look, I'm a string!"
;
Само собой, никуда более этот страшный header инклудить нельзя. И да, в связи с тем, что мы будем этот header добавлять несколько раз в один и тот же файл — никаких #pragma once или include guard-ов.
Собственно, давайте напишем более сложный пример, который будет делать разные вещи при помощи макросов и заодно защитимся от случайных #include:
// xmacro.h
#ifndef XMACRO
#error "Never include me directly"
#endif
XMACRO(first)
XMACRO(second)
#undef XMACRO
// xmacro.cpp
enum class xenum {
#define XMACRO(x) x,
#include "xmacro.h"
};
std::ostream& operator<<(std::ostream& os, xenum enm) {
switch (enm) {
#define XMACRO(x) case xenum::x: os << "xenum::" #x; break;
#include "xmacro.h"
}
return os;
}
Это всё так же некрасиво, но некий шарм уже появляется: при добавлении нового элемента в enum class он автоматически добавится и в перегруженный оператор вывода.
Здесь же можно формализировать ареал применения данного метода: необходимость кодогенерации в разных местах на основе одного источника.
А теперь грустная история о X-Macro и Windows. Есть такая система как Windows Performance Counters, позволяющая отдавать некие счетчики в операционную систему, чтобы другие приложения могли их забирать. Например, Zabbix можно настроить на сбор и мониторинг любых Performance Counter-ов. Это достаточно удобно, и не нужно изобретать велосипед с отдачей\запросом данных.
Я искренне думал, что добавление нового счетчика выглядит а-ля HANDLE counter = AddCounter («name»). Ах, как же я ошибался.
Для начала необходимо написать специальный XML-манифест (пример), или сгенерировать его программой ecmangen.exe из Windows SDK, но этот ecmangen почему-то удален из новых версий Windows 10 SDK. Далее надо сгенерировать сишный код и .rc файл при помощи утилиты ctrpp на основе нашего XML-манифеста. Само добавление новых счетчиков в систему делается только при помощи утилиты lodctr с нашим XML-манифестом в аргументе.
Perfcounters используют эти .rc файлы для локализации имён счетчиков, причем не очень понятно, зачем эти имена локализировать.
Суммируя вышесказанное: чтобы добавить 1 счетчик нужно:
- Изменить XML-манифест
- Сгенерировать новые .c и .rc файлы проекта на основе манифеста
- Написать новую функцию, которая будет инкрементить новый счетчик
- Написать новую функцию, которая будет забирать значение счетчика
Итого: 4–5 измененных файлов в diff-e ради одного счетчика и постоянное страдание от работы с XML-манифестом, являющимся источником информации в плюсовом коде. Это то, что нам предлагает Microsoft.
Собственно, придуманное решение выглядит страшно, однако добавление нового счетчика делается ровно 1 строчкой в одном файле. Далее всё генерируется автоматически при помощи макросов и, к сожалению, pre-build скрипта, так как XML-манифест все равно нужен, хоть он теперь и не является главным.
Наш counters.h выглядит почти идентично примеру выше:
#ifndef NV_PERFCOUNTER
#error "You cannot do this!"
#endif
...
NV_PERFCOUNTER(copied_bytes)
NV_PERFCOUNTER(copied_files)
...
#undef NV_PERFCOUNTER
Как я писал ранее, добавление счетчиков производится загрузкой XML-манифеста при помощи lodctr.exe. Из нашей программы мы можем их только инициализировать и изменять.
Интересные нам фрагменты инициализации в сгенерированном сишнике выглядят вот так:
#define COPIED_BYTES 0 // Счетчики всегда начинаются с 0
#define COPIED_FILES 1 // и далее инкрементируются на единичку
const PERF_COUNTERSET_INFO counterset_info{
...
2, // количество счетчиков в XML-манифесте захардкожено
...
};
struct {
PERF_COUNTERSET_INFO set;
PERF_COUNTER_INFO counters[2]; // Захардкоженный размер статического массива
} counterset {
counterset_info, { // Сгенерированное описание каждого счетчика
{ COPIED_BYTES, ... },
{ COPIED_FILES, ... }
}
}
Итого: нам нужно соответствие вида «имя счетчика — возрастающий индекс», а на этапе компиляции необходимо знать количество счетчиков и собрать массив инициализации из индексов счетчиков. Тут-то и приходит на помощь X-macro.
Сделать соответствие имени счетчика его возрастающему индексу достаточно просто.
Код ниже превратится в enum class, чьи внутренние индексы начинаются с 0, и инкрементируются на единичку. Добавив руками последний элемент, мы сразу узнаем сколько у нас суммарно счетчиков:
enum class counter_enum : int
{
#define NV_PERFCOUNTER(ctr) ctr,
#include "perfcounters_ctr.h"
total_counters
};
И далее на основе нашего же enum-а нужно инициализировать счетчики:
static constexpr counter_count = static_cast(counter_enum::total_counters);
const PERF_COUNTERSET_INFO counterset_info{
...
counter_count,
...
};
struct {
PERF_COUNTERSET_INFO set;
PERF_COUNTER_INFO counters[counter_count];
} counterset {
counterset_info, { // Сгенерированное описание каждого счетчика
#define NV_PERFCOUNTER(ctr) \
{ static_cast(counter_enum::ctr), ... },
#include "perfcounters_ctr.h"
}
}
Результатом стало то, что инициализация нового счетчика теперь занимает 1 строку и не требует дополнительных изменений в других файлах (ранее каждая перегенерация меняла 3 куска кода только в инициализации).
И давайте добавим удобное API для инкремента счетчиков. Что-то в духе:
#define NV_PERFCOUNTER(ctr) \
inline void ctr##_tick(size_t diff = 1) { /* Увеличение счетчика counter_enum::ctr */ }
#include "perfcounters_ctr.h"
#define NV_PERFCOUNTER(ctr) \
inline size_t ctr##_get() { /* Возврат значения счетчика counter_enum::ctr */ }
#include "perfcounter_ctr.h"
Препроцессор сгенерирует для нас красивые геттеры\сеттеры, которые мы сразу можем использовать в коде, например:
inline void copied_bytes_tick(size_t diff = 1);
inline size_t copied_bytes_get();
Но у нас еще остались 2 грустные вещи: XML-манифест и .rc файл (увы, он необходим).
Мы сделали достаточно просто — pre-build скрипт, который читает изначальный файл с макросами, определяющими счетчики, парсит то, что находится между «NV_COUNTER (» и »)», и на основе этого генерирует оба файла, которые находятся в .gitignore, чтобы не засорять diff’ы.
Было: Специальный софт на основе XML-манифеста генерировал сишный код. Очень много изменений в проекте на каждое добавление\удаление счетчика.
Стало: Препроцессор и prebuild скрипт генерируют все счетчики, XML-манифест и .rc файл. Ровно одна строка в diff-e для добавления\удаления счетчика. Спасибо препроцессору, который помог решить эту задачу, показывая в данном конкретном кейсе больше пользы, чем вреда.