Современные тренды разработки на C++ предполагают максимально возможный отказ от макросов в коде. Но иногда без макросов, причем в особо уродливом их проявлении, не обойтись, так как без них еще хуже. Об этом и рассказ.
Как известно, первым этапом компиляции C и C++ является препроцессор, который заменяет макросы и директивы препроцессора простым текстом.
Это позволяет делать нам странные вещи, например, такие:
После работы препроцессора это недоразумение превратится в корректный код:
Само собой, никуда более этот страшный header инклудить нельзя. И да, в связи с тем, что мы будем этот header добавлять несколько раз в один и тот же файл — никаких #pragma once или include guard-ов.
Собственно, давайте напишем более сложный пример, который будет делать разные вещи при помощи макросов и заодно защитимся от случайных #include:
Это всё так же некрасиво, но некий шарм уже появляется: при добавлении нового элемента в 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-манифестом в аргументе.
Суммируя вышесказанное: чтобы добавить 1 счетчик нужно:
Итого: 4-5 измененных файлов в diff-e ради одного счетчика и постоянное страдание от работы с XML-манифестом, являющимся источником информации в плюсовом коде. Это то, что нам предлагает Microsoft.
Собственно, придуманное решение выглядит страшно, однако добавление нового счетчика делается ровно 1 строчкой в одном файле. Далее всё генерируется автоматически при помощи макросов и, к сожалению, pre-build скрипта, так как XML-манифест все равно нужен, хоть он теперь и не является главным.
Наш counters.h выглядит почти идентично примеру выше:
Как я писал ранее, добавление счетчиков производится загрузкой XML-манифеста при помощи lodctr.exe. Из нашей программы мы можем их только инициализировать и изменять.
Интересные нам фрагменты инициализации в сгенерированном сишнике выглядят вот так:
Итого: нам нужно соответствие вида «имя счетчика — возрастающий индекс», а на этапе компиляции необходимо знать количество счетчиков и собрать массив инициализации из индексов счетчиков. Тут-то и приходит на помощь X-macro.
Сделать соответствие имени счетчика его возрастающему индексу достаточно просто.
Код ниже превратится в enum class, чьи внутренние индексы начинаются с 0, и инкрементируются на единичку. Добавив руками последний элемент, мы сразу узнаем сколько у нас суммарно счетчиков:
И далее на основе нашего же enum-а нужно инициализировать счетчики:
Результатом стало то, что инициализация нового счетчика теперь занимает 1 строку и не требует дополнительных изменений в других файлах (ранее каждая перегенерация меняла 3 куска кода только в инициализации).
И давайте добавим удобное API для инкремента счетчиков. Что-то в духе:
Препроцессор сгенерирует для нас красивые геттеры\сеттеры, которые мы сразу можем использовать в коде, например:
Но у нас еще остались 2 грустные вещи: XML-манифест и .rc файл (увы, он необходим).
Мы сделали достаточно просто — pre-build скрипт, который читает изначальный файл с макросами, определяющими счетчики, парсит то, что находится между «NV_COUNTER(» и ")", и на основе этого генерирует оба файла, которые находятся в .gitignore, чтобы не засорять diff'ы.
Было: Специальный софт на основе XML-манифеста генерировал сишный код. Очень много изменений в проекте на каждое добавление\удаление счетчика.
Стало: Препроцессор и prebuild скрипт генерируют все счетчики, XML-манифест и .rc файл. Ровно одна строка в diff-e для добавления\удаления счетчика. Спасибо препроцессору, который помог решить эту задачу, показывая в данном конкретном кейсе больше пользы, чем вреда.
Как известно, первым этапом компиляции 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-манифестом в аргументе.
Что такое .rc файл?
Это изобретение Microsoft, никак не относящееся к стандартному C++. При помощи этих файлов можно встраивать ресурсы в exe\dll, такие как строки\иконки\картинки и т.д., а потом забирать их при помощи специального Windows API.
Perfcounters используют эти .rc файлы для локализации имён счетчиков, причем не очень понятно, зачем эти имена локализировать.
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<int>(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<int>(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 для добавления\удаления счетчика. Спасибо препроцессору, который помог решить эту задачу, показывая в данном конкретном кейсе больше пользы, чем вреда.