Применение X-Macro в модерновом C++ коде

в 17:12, , рубрики: c++, microsoft, грусть, макросы, печаль, разработка под windows, ужас

Современные тренды разработки на 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++. При помощи этих файлов можно встраивать ресурсы в exedll, такие как строкииконкикартинки и т.д., а потом забирать их при помощи специального Windows API.

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

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

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

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

Автор: Денис Панин

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js