Как известно, первым этапом компиляции 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-манифест все равно нужен, хоть он теперь и не является главным.
Наш perfcounters_ctr.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 int 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 для добавления\удаления счетчика. Спасибо препроцессору, который помог решить эту задачу, показывая в данном конкретном кейсе больше пользы, чем вреда.
Комментарии (15)
NeoCode
09.11.2019 20:47+2При грамотном и аккуратном применении макросы весьма полезны; хотя и грустно то, что они лексические, а не синтаксические, т.е. на них не распространяются области видимости, они могут конфликтовать с кодом в самых неожиданных местах и т.д.
Однако, если соблюдать принцип «неусложнения», то все ок, и на них можно делать весьма полезные вещи — например что-то типа рефлексии, например когда одновременно объявляются элементы перечисления и формируется массив строк с именами этих элементов.DistortNeo
10.11.2019 16:14К сожалению, это всё приводит к развитию велосипедостроительства, когда каждый изобретает свои макросы под свои нужды, и работа с множеством проектов становится болью.
NeoCode
10.11.2019 19:04+2Ну так была бы рефлексия встроенной в язык, и не было бы велосипедостроительства:)
mentin
10.11.2019 02:51Майкрософтовский XML поддерживает гораздо больше возможностей — множественные counter sets, разные типы счётчиков, дополнительная информация, скажем description. Макросы ОК для ограниченного случая, но использование внешнего описания даёт гораздо больше функциональности ценой всего-то запуска генератора при добавлении счётчика (что обычно довольно редко).
Я бы в укор МС поставил только использование XML вместо создания простенького DSL для счётчиков. Но время такое было, XML использовался где надо и не надо :)
Starl1ght Автор
10.11.2019 02:52В моем случае это было не нужно. Но другие типы счетчиков так же легко добавлялись дополнительным параметром в макросе.
mentin
10.11.2019 07:57Дополнительные параметры вряд-ли подойдут, придется при добавлении каждого нового параметра модифицировать все существующие определения., Скорее надо добавить макро — XMACRO, YMACRO и так далее, все с разным числом аргументов. Это по крайней мере можно поддерживать.
Но перед включением заголовка надо будет определять их всех. Выглядеть всё равно будет ужасно.
То есть для узкой задачи как у вас — макро подходит и наверное что-то улучшает. Для общей задачи, стоявшей перед МС — они сделали более менее правильный выбор.
a-tk
10.11.2019 11:14Был как-то у меня в жизни проект, где в хидере писался DSL, и файл включался 3 или 4 раза с разными определениями слов в DSL. Никогда больше…
amosk
10.11.2019 23:44Для шаблонизации регулярных структур можно намного более читаемый подход использовать.
#define DEFINE_ENUM_MEMBER(name) name, #define DEFINE_ENUM(name, list) enum name { list(DEFINE_ENUM_MEMBER) }; #define DEFINE_ENUM_CASE(name) case name: return #name; #define DEFINE_ENUM_TO_STRING(name, list) const char* name ## _tostring(name v) { switch (v) { list(DEFINE_ENUM_CASE) } return "?"; } // user code #define enum_test1(handler) handler(field1) handler(field2) DEFINE_ENUM(test1, enum_test1) DEFINE_ENUM_TO_STRING(test1, enum_test1) int main() { LOG_TRACE(test1_tostring(field1)); return 0; }
Тут применяется возможность передать имя макроса в качестве параметра в другой макрос.4eyes
11.11.2019 16:48+1Можно, только с первого взгляда непонятно наверняка, что это и что оно делает. Как и со второго. А в боевых условиях основная задача ж обычно стоит даже не в том, чтоб понять код, а в том, чтобы сделать что-то полезное, да еще и ничего не отломать.
Если я правильно понимаю, что это писалось для того, чтобы автоматически переводить enum в string, то не лучше ли более простое решение?
Да, оно требует синхронизировать массив со значениями enum, но оно заставит это сделать.enum class MyEnum { first, second, _count }; const char* to_string(MyEnum value) { static const char* const s_values[] = { "first", "second" }; static_assert(std::size(s_values) == static_cast<size_t>(MyEnum::_count), "Please add all enum values in the list above"); return s_values[static_cast<size_t>(value)]; }
4eyes
11.11.2019 18:07На случай, если кто-то не верит в человечество, и считает, что нужно защитить программиста от ошибочной перестановки строк местами, есть код
с красивыми шаблонами и constexprenum class MyEnum { first, second, third, _count }; // specialize this for your enum template <typename Enum> constexpr auto enum_to_strings(); template <> constexpr auto enum_to_strings<MyEnum>() { using EnumStringPair = const std::pair<MyEnum, const char*>; // constexpr auto s_values = std::to_array<EnumStringPair> in C++2a return std::array<EnumStringPair, (size_t)MyEnum::_count> { EnumStringPair {MyEnum::third, "third"}, EnumStringPair {MyEnum::first, "first"}, EnumStringPair {MyEnum::second, "second"} }; } // --- implementation details --- template <typename Enum, typename ArrayIt> constexpr const char* find_constexpr(Enum what, ArrayIt begin, ArrayIt end) { assert(begin != end && "value not found"); return begin == end ? "?" : ( begin->first == what ? begin->second : find_constexpr(what, ++begin, end) ); } template <typename Enum> constexpr const char* to_string(Enum e) { constexpr auto values = enum_to_strings<Enum>(); static_assert(std::size(values) == static_cast<size_t>(MyEnum::_count), "Please ensure that all enum values has their string representation"); return find_constexpr(e, values.begin(), values.end()); }
mapron
Главный вопрос, если вы все равно генерируете xml файл prebuild скриптом, почему так же не генерировать ими и хедеры с нужными enum-ами, функциями и тп?
Никогда никакой боли с автогенерацией подобного кода в том же CMake не имел :)
Starl1ght Автор
Потому-что в варианте генерации enum'ов — это выглядит как: «добавили enum, запустили генератор, дописали нужный код, скомпилировали». А тут у нас всегда корректный (хоть и странный) C++ код сразу. А вещи, которые не относятся к коду, такие как XML и .rc нужны уже сильно позже, и их можно генерировать.
mapron
Не очень понимаю. Обычно правило на обновление кодогенерации прописано как зависимое от источника этой генерации)
В моем случае действия такие:
— дописал нужный enum где-то в конфиге;
— нажал build / build (target)
все собралось. Т.е. «запуск генератора» никак особо от компиляции не выделяется. Да, вы сборку два раза запускаете, если надо заприменять уже новые значения, но это знаете, минимальное неудобство, имхо.
qw1
Вот для это фрагмента
генератор должен сгенерить только список внутри фигурных скобок? И подключаем его так: Или должен сгенерить весь .h-файл? и тогда куча сишного кода будет включена в текст генератора.Имхо, разбросанные по коду
читаются/редактируются лучше, чем
Если же .h или .cpp файл целиком генерится скриптом, и не содержит #include-ов, то читать его удобно, но неудобно вносить изменения в скрипт.
mapron
Ну видимо всё и сводится к расхождению мнений по поводу этого утверждения :)
Тут пошли субъективные вещи, поэтому привести какие-то доводы более уже не смогу. Пусть так. Мне в какой-нибудь cmake.in.h (либо в список значений где-нибудь в json или cmake) вносить изменения более чем удобно.