Применение 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-манифестом в аргументе.

Что такое .rc файл?
Это изобретение Microsoft, никак не относящееся к стандартному C++. При помощи этих файлов можно встраивать ресурсы в exe\dll, такие как строки\иконки\картинки и т.д., а потом забирать их при помощи специального Windows API.

Perfcounters используют эти .rc файлы для локализации имён счетчиков, причем не очень понятно, зачем эти имена локализировать.

Суммируя вышесказанное: чтобы добавить 1 счетчик нужно:

  1. Изменить XML-манифест
  2. Сгенерировать новые .c и .rc файлы проекта на основе манифеста
  3. Написать новую функцию, которая будет инкрементить новый счетчик
  4. Написать новую функцию, которая будет забирать значение счетчика

Итого: 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 для добавления\удаления счетчика. Спасибо препроцессору, который помог решить эту задачу, показывая в данном конкретном кейсе больше пользы, чем вреда.
Поделиться публикацией
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 15

    +9
    Главный вопрос, если вы все равно генерируете xml файл prebuild скриптом, почему так же не генерировать ими и хедеры с нужными enum-ами, функциями и тп?
    Никогда никакой боли с автогенерацией подобного кода в том же CMake не имел :)
      0
      Потому-что в варианте генерации enum'ов — это выглядит как: «добавили enum, запустили генератор, дописали нужный код, скомпилировали». А тут у нас всегда корректный (хоть и странный) C++ код сразу. А вещи, которые не относятся к коду, такие как XML и .rc нужны уже сильно позже, и их можно генерировать.
        +2
        Не очень понимаю. Обычно правило на обновление кодогенерации прописано как зависимое от источника этой генерации)
        В моем случае действия такие:
        — дописал нужный enum где-то в конфиге;
        — нажал build / build (target)
        все собралось. Т.е. «запуск генератора» никак особо от компиляции не выделяется. Да, вы сборку два раза запускаете, если надо заприменять уже новые значения, но это знаете, минимальное неудобство, имхо.
          +1
          Вот для это фрагмента
          enum class counter_enum : int
          {  
            #define NV_PERFCOUNTER(ctr) ctr,
            #include "perfcounters_ctr.h"
            total_counters
          };
          генератор должен сгенерить только список внутри фигурных скобок? И подключаем его так:
          enum class counter_enum : int
          {  
            #include "generated1.h"
            total_counters
          };
          Или должен сгенерить весь .h-файл? и тогда куча сишного кода будет включена в текст генератора.

          Имхо, разбросанные по коду
          #define NV_PERFCOUNTER(ctr) macro1
          #include "perfcounters_ctr.h"
          ...
          #define NV_PERFCOUNTER(ctr) macro2
          #include "perfcounters_ctr.h"
          ...

          читаются/редактируются лучше, чем
            #include "generated1.h"
          ...
            #include "generated2.h"
          ...

          Если же .h или .cpp файл целиком генерится скриптом, и не содержит #include-ов, то читать его удобно, но неудобно вносить изменения в скрипт.
            +1
            то читать его удобно, но неудобно вносить изменения в скрипт.

            Ну видимо всё и сводится к расхождению мнений по поводу этого утверждения :)
            Тут пошли субъективные вещи, поэтому привести какие-то доводы более уже не смогу. Пусть так. Мне в какой-нибудь cmake.in.h (либо в список значений где-нибудь в json или cmake) вносить изменения более чем удобно.
      +6
      При грамотном и аккуратном применении макросы весьма полезны; хотя и грустно то, что они лексические, а не синтаксические, т.е. на них не распространяются области видимости, они могут конфликтовать с кодом в самых неожиданных местах и т.д.
      Однако, если соблюдать принцип «неусложнения», то все ок, и на них можно делать весьма полезные вещи — например что-то типа рефлексии, например когда одновременно объявляются элементы перечисления и формируется массив строк с именами этих элементов.
        +1

        К сожалению, это всё приводит к развитию велосипедостроительства, когда каждый изобретает свои макросы под свои нужды, и работа с множеством проектов становится болью.

          +2
          Ну так была бы рефлексия встроенной в язык, и не было бы велосипедостроительства:)
        0

        Майкрософтовский XML поддерживает гораздо больше возможностей — множественные counter sets, разные типы счётчиков, дополнительная информация, скажем description. Макросы ОК для ограниченного случая, но использование внешнего описания даёт гораздо больше функциональности ценой всего-то запуска генератора при добавлении счётчика (что обычно довольно редко).


        Я бы в укор МС поставил только использование XML вместо создания простенького DSL для счётчиков. Но время такое было, XML использовался где надо и не надо :)

          0
          В моем случае это было не нужно. Но другие типы счетчиков так же легко добавлялись дополнительным параметром в макросе.
            0

            Дополнительные параметры вряд-ли подойдут, придется при добавлении каждого нового параметра модифицировать все существующие определения., Скорее надо добавить макро — XMACRO, YMACRO и так далее, все с разным числом аргументов. Это по крайней мере можно поддерживать.
            Но перед включением заголовка надо будет определять их всех. Выглядеть всё равно будет ужасно.
            То есть для узкой задачи как у вас — макро подходит и наверное что-то улучшает. Для общей задачи, стоявшей перед МС — они сделали более менее правильный выбор.

          +1
          Был как-то у меня в жизни проект, где в хидере писался DSL, и файл включался 3 или 4 раза с разными определениями слов в DSL. Никогда больше…
            +1
            Для шаблонизации регулярных структур можно намного более читаемый подход использовать.

            #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;
            }
            


            Тут применяется возможность передать имя макроса в качестве параметра в другой макрос.
              +1
              Можно, только с первого взгляда непонятно наверняка, что это и что оно делает. Как и со второго. А в боевых условиях основная задача ж обычно стоит даже не в том, чтоб понять код, а в том, чтобы сделать что-то полезное, да еще и ничего не отломать.

              Если я правильно понимаю, что это писалось для того, чтобы автоматически переводить enum в string, то не лучше ли более простое решение?

              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)];
              }
              Да, оно требует синхронизировать массив со значениями enum, но оно заставит это сделать.
                0
                На случай, если кто-то не верит в человечество, и считает, что нужно защитить программиста от ошибочной перестановки строк местами, есть код
                с красивыми шаблонами и constexpr
                enum 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());
                }

                Который даже с отладочной оптимизаций довольно неплохо пахнет «на выходе» из компилятора: https://godbolt.org/z/HRFveS, а в релизе так вообще обнять и плакать: https://godbolt.org/z/J2e-H6

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое