Как стать автором
Обновить

Добавляем рефлексию для перечислений (enum) в C++

Время на прочтение8 мин
Количество просмотров29K
Недавно в нашем проекте возникла необходимость программно получать информацию о перечислениях (enum), например, имена констант в виде строк, а также общий список всех имеющихся в enum-е констант.

enum Suit { Spades, Hearts, Diamonds, Clubs };

Обычно решение данной задачи базируется на дублировании значений, например, внутри switch-а:

switch(value)
{
    case Spades:   return "Spades";
    case Hearts:   return "Hearts";
    case Diamonds: return "Diamonds";
    case Clubs:    return "Clubs";
    default:       return ""
};

И возможно, для небольших перечислений такое решение действительно является приемлемым, однако если значений много, и особенно, если они время от времени меняются, то рано или поздно разработчик может забыть дописать или изменить соответствующие строки в switch. Сюда прибавляются и другие очевидные минусы, например сам факт необходимости дублирования значений уже вызывает у меня некоторое недовольство.

Поэтому я постарался найти путь, который вообще не требовал бы дублирования, но при этом полностью справлялся бы с поставленной задачей. Думаю, у меня получилось.

Далее в статье я опишу способ, позволяющий организовать рефлексию для enum-ов. Кому интересно — добро пожаловать под кат.

Зачем это вообще нужно


Полезных применений может быть много. Одно из них — сериализация значений, например в JSON.
Также это может пригодиться для взаимодействия кода на C++ со скриптовыми языками (например, Lua).

Требования


Раз мы хотим избежать дублирования констант в коде, то нам нужно как-то сохранить информацию о всех значениях прямо в месте определения перечисления. Как вы уже, возможно, догадались, для этой цели придется использовать макрос. Учитывая это, можно выделить некоторые дополнительные требования:

  1. Синтаксис макроса для описания перечисления должен быть совместим с обычным enum
  2. Само перечисление (как тип) не должно отличаться от обычного enum (в т. ч. должно быть возможно потом использовать typedef)
  3. При описании значений должны сохраняться те же возможности, что и в обычном перечислении
Иными словами, мы должны быть способны без труда обернуть уже существующее перечисление в наш макрос, после чего нам сразу будет (программно) доступна информация о нем.

Обязательным условием также является полная портируемость.

Результат


Сначала, привожу краткое описание того, что получилось. Ниже в статье будет описание деталей реализации.

Для добавления рефлексии, перечисление вместо ключевого слова enum следует объявлять с помощью макроса Z_ENUM. Например, для enum CardSuit из начала статьи, это выглядит следующим образом:

Z_ENUM( CardSuit,
    Spades,
    Hearts,
    Diamonds,
    Clubs
)

После этого в любом месте можно по типу перечисления получить ссылку на объект EnumReflector, который хранит о нем информацию:

auto& reflector = EnumReflector::For< CardSuit >();

Далее всё просто:

reflector.EnumName();               // == "CardSuit"
reflector.Find("Diamonds").Value(); // == 2
reflector.Count();                  // == 4
reflector[1].Name();                // == "Hearts"


Следующий пример показывает более сложное перечисление:

class SomeClass
{
public:
    static const int Constant = 100;
    Z_ENUM( TasteFlags,
        None      = 0,
        Salted    = 1 << 0,
        Sour      = 1 << 1,
        Sweet     = 1 << 2,
        SourSweet = (Sour | Sweet),
        Other     = Constant,
        Last
    )
};

На этот раз получим всю имеющуюся информацию:

auto& reflector = EnumReflector::For< SomeClass::TasteFlags >();

cout << "Enum " << reflector.EnumName() << endl;
for (auto& val : reflector)
{
    cout << "Value " << val.Name() << " = " << val.Value() << endl;
}

Вывод:

Enum TasteFlags
Value None = 0
Value Salted = 1
Value Sour = 2
Value Sweet = 4
Value SourSweet = 6
Value Other = 100
Value Last = 101

Особенности


  • В отличие от обычного enum, после последнего значения не допускается запятая
  • Если перечисление объявляется вне класса (на уровне namespace), то вместо Z_ENUM следует использовать полностью аналогичный ему Z_ENUM_NS
Причины появления этих двух пунктов рассматриваются в следующей секции.

Детали реализации


Итак, самое интересное.

Примечание: код, приводимый здесь упрощен в целях повышения читаемости. Полную версию вы можете найти на гитхабе, ссылка в конце статьи.

Макрос Z_ENUM:

#define Z_ENUM(enumName, ...)\
    enum enumName : int                                                   \
    {                                                                     \
        __VA_ARGS__                                                       \
    };                                                                    \
    friend const ::EnumReflector& _detail_reflector_(enumName)            \
    {                                                                     \
        static const ::EnumReflector reflector( []{                       \
            static int sval;                                              \
            sval = 0;                                                     \
            struct val_t                                                  \
            {                                                             \
                val_t(const val_t& rhs) : _val(rhs) { sval = _val + 1; }  \
                val_t(int val)          : _val(val) { sval = _val + 1; }  \
                val_t()                 : _val(sval){ sval = _val + 1; }  \
                                                                          \
                val_t& operator=(const val_t&) { return *this; }          \
                val_t& operator=(int) { return *this; }                   \
                operator int() const { return _val; }                     \
                int _val;                                                 \
            } __VA_ARGS__;                                                \
            const int vals[] = { __VA_ARGS__ };                           \
            return ::EnumReflector( vals, sizeof(vals)/sizeof(int),       \
                    #enumName, Z_ENUM_DETAIL_STR((__VA_ARGS__))  );       \
        }() );                                                            \
        return reflector;                                                 \
    }
#define Z_ENUM_DETAIL_STR(x) #x

Пример того, во что он разворачивается
enum TasteFlags:int
{
    None = 0,
    Salted = 1 << 0,
    Sour = 1 << 1,
    Sweet = 1 << 2,
    SourSweet = (Sour | Sweet),
    Other = Constant,
    Last 
}; 
friend const ::EnumReflector& _detail_reflector_(TasteFlags)
{
    static const ::EnumReflector reflector( []
    {
        static int sval;
        sval = 0;
        struct val_t
        {
            val_t(const val_t& rhs) : _val(rhs) { sval = _val + 1; }
            val_t(int val)          : _val(val) { sval = _val + 1; }
            val_t()                 : _val(sval){ sval = _val + 1; }
            
            val_t& operator=(const val_t&) { return *this; }
            val_t& operator=(int)          { return *this; }
            
            operator int() const { return _val; } 
            
            int _val; 
        } None = 0, Salted = 1 << 0, Sour = 1 << 1, Sweet = 1 << 2, SourSweet = (Sour | Sweet), Other = Constant, Last;
        const int vals[] = { None = 0, Salted = 1 << 0, Sour = 1 << 1, Sweet = 1 << 2, SourSweet = (Sour | Sweet), Other = Constant, Last };
        return ::EnumReflector( vals, sizeof(vals)/sizeof(int), "TasteFlags", "( None = 0, Salted = 1 << 0, Sour = 1 << 1, Sweet = 1 << 2, SourSweet = (Sour | Sweet), Other = Constant, Last)" ); 
    }());
    return reflector;
}

Рассмотрим его по частям:

В начале Z_ENUM раскрывается в обычный enum. Можно заметить, что явно указывается нижележащий тип данных — int. Так сделано только потому, что в EnumReflector сейчас значения хранятся с типом int. При необходимости int можно заменить на более большой тип.

После объявляется friend-функция _detail_reflector_. Она принимает значение типа нашего перечисления и возвращает ссылку на объект EnumReflector, который на самом деле является статическим объектом, объявленным внутри нее.

Немного забегая вперед, приведу функцию EnumReflector::For, которая служит внешним интерфейсом для получения объекта EnumReflector:

template<typename EnumType>
inline const EnumReflector& EnumReflector::For(EnumType val)
{
    return _detail_reflector_(val);
}
Хитрость тут только в том, что используется ADL для поиска функции _detail_reflector_ по типу аргумента. Именно благодаря ADL мы можем получить информацию для перечислений вне зависимости от их класса или пространства имен.

Но вернемся в функцию _detail_reflector_.

Для обеспечения атомарности, вся инициализация статического объекта EnumReflector происходит внутри безымянной лямбда-функции. Рассмотрим её поподробнее.

Сначала в ней объявляется статическая переменная-счетчик sval. Статическая она потому, что нам потребуется обращаться к ней из локального класса val_t, определенного далее. Не имея дополнительного состояния, локальный класс, очевидно, может обращаться только к статическим переменным внешнего блока. В переменной sval будет храниться следующее значение для константы. Следующей строчкой мы инициализируем её в 0.

Зачем?
На первый взгляд, это бессмысленное действие: изначально статическая переменная и так инициализирована в 0, а этот код будет выполнятся всего лишь один раз. Однако, проведя некоторые тесты, я заметил, что компиляторы гораздо лучше оптимизируют этот код, если мы явно сбросим значение перед использованием. Это, вероятно, обусловлено тем, что компилятору в этом случае не нужно исходить из возможных предыдущих значений sval

Далее определяется тип val_t. После описания типа еще раз раскрывается __VA_ARGS__ (значения нашего перечисления). То есть мы определяем локальные переменные типа val_t — и их количество соответствует количеству значений в перечислении, а имена соответствуют самим константам (они перекрывают собой настоящие константы определенного до этого enum-а). Для того, чтобы инициализация этих переменных правильно работала, у типа val_t есть три конструктора. Каждый из них дополнительно устанавливает sval в следующее после себя значение, на случай если у следующей константы нет специально заданного значения.

Именно в этом месте, если после последнего значения имеется запятая — возникнет синтаксическая ошибка.

После, нам необходимо «перегнать» значения из переменных в массива типа int. Благодаря оператору преобразования в int у val_t это сделать довольно просто — мы можем в качестве инициализаторов массива сразу использовать наши переменные типа val_t, просто еще раз раскрыв __VA_ARGS__. Поскольку при таком раскрытии могут присутствовать присваивания, то мы добавляем в val_t два оператора присваивания, которые ничего не делают — таким образом мы полностью игнорируем присваивания.

Теперь, когда у нас есть массив всех значений и известно их количество, нужно получить названия констант в виде строк. Для этого все значения оборачиваются в строку вида "(__VA_ARGS__)". Эта строка, наряду с указателем на массив и количеством элементов, передается в конструктор EnumReflector. Ему осталось только распарсить строку, выделив из нее имена констант, и сохранить все значения.

Сам парсер для быстродействия организован в виде простого конечного автомата.

Код парсера в EnumReflector
struct EnumReflector::Private
{
    struct Enumerator
    {
        std::string name;
        int value;
    };
    std::vector<Enumerator> values;
    std::string enumName;
};

static bool IsIdentChar(char c)
{
    return (c >= 'A' && c <= 'Z') ||
           (c >= 'a' && c <= 'z') ||
           (c >= '0' && c <= '9') ||
           (c == '_');
}

EnumReflector::EnumReflector(const int* vals, int count, const char* name, const char* body)
    : _data(new Private)
{
    _data->enumName = name;
    _data->values.resize(count);
    enum states
    {
        state_start, // Before identifier
        state_ident, // In identifier
        state_skip, // Looking for separator comma
    } state = state_start;
    assert(*body == '(');
    ++body;
    const char* ident_start = nullptr;
    int value_index = 0;
    int level = 0;
    for (;;)
    {
        assert(*body);
        switch (state)
        {
        case state_start:
            if (IsIdentChar(*body))
            {
                state = state_ident;
                ident_start = body;
            }
            ++body;
            break;
        case state_ident:
            if (!IsIdentChar(*body))
            {
                state = state_skip;
                assert(value_index < count);
                _data->values[value_index].name = std::string(ident_start, body - ident_start);
                _data->values[value_index].value = vals[value_index];
                ++value_index;
            }
            else
            {
                ++body;
            }
            break;
        case state_skip:
            if (*body == '(')
            {
                ++level;
            }
            else if (*body == ')')
            {
                if (level == 0)
                {
                    assert(value_index == count);
                    return;
                }
                --level;
            }
            else if (level == 0 && *body == ',')
            {
                state = state_start;
            }
            ++body;
        }
    }
}

Мы просто идем по строке, сохраняя идентификаторы (названия констант). После очередного идентификатора, мы ищем начало следующего идентификатора, и так далее. В конце имеем готовую структуру данных, содержащую всю информацию о перечислении.

Остальная часть реализации класса EnumReflector служит для получения этой информации и на мой взгляд, не представляет особого интереса для данной статьи. Напоминаю, в конце есть ссылка на полную версию.

При объявлении перечисления вне класса функция _detail_reflector_ должна быть объявлена не как friend, а как inline. Отсюда необходимость в отдельном макросе Z_ENUM_NS. Чтобы случайно не использовать Z_ENUM_NS в теле класса, в нем также присутствует пустой блок extern «C» {} (напоминаю, его использование в теле класса не допускается стандартом, так что получим ошибку компиляции).

Также, во избежание возникновения коллизий имён с константами, в полной версии все идентификаторы внутри функции _detail_reflector_ имеют префикс _detail_.

Что можно улучшить


Можно попробовать выполнять парсинг для получения названий прямо на этапе компиляции, используя user-defined литералы для строк и constexpr функции из C++14.

Также было бы неплохо избавиться от необходимости в двух разных макросах для определения перечисления в классе и вне класса, но пока что я не нашел способа это сделать, не сломав при этом поиск ADL.

Ссылки


Полная версия кода из статьи: github.com.
Argument-Dependent Lookup: cppreference.com.

На этом всё. Надеюсь, статья получилась интересной.

P.S.: Приветствуются предложения по улучшению данного способа.
Теги:
Хабы:
Всего голосов 37: ↑35 и ↓2+33
Комментарии14

Публикации

Истории

Работа

QT разработчик
4 вакансии
Программист C++
106 вакансий

Ближайшие события

7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань