Недавно в нашем проекте возникла необходимость программно получать информацию о перечислениях (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.: Приветствуются предложения по улучшению данного способа.

Комментарии (14)


  1. newnon
    08.02.2016 13:17

    Я писал очень похожую статью habrahabr.ru/post/236403 но у моего метода тоже есть проблемы
    Вот еще одна реализация github.com/aantron/better-enums там ограничение только на включение внутрь класса. тем не менее очень хорошо сделана в том числе и compile time преобразования.


  1. isotoxin
    08.02.2016 13:47
    +2

    Я использую метод на макросах. По сравнению с предложенным в статье, этот метод не имеет оверхеда, а также дает дополнительные возможности.

    // объявляем список
    #define MY_ENUM_DESC     EITM( vasya )     EITM( petya )     EITM( vova ) 
    // генерируем enum
    enum my_enum {
    #define EITM(itm) itm,
        MY_ENUM_DESC
    #undef EITM
        my_enum_max
    };
    // функция для получения строки
    const char * my_enum_s(my_enum e) {
        static const char * tbl[] = {
        #define EITM(itm) #itm,
            MY_ENUM_DESC
        #undef EITM
        "" };
        return tbl[e];
    }
    

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


    1. semenyakinVS
      08.02.2016 13:55
      +1

      Метод интересный, но очень уж громоздкий. Особенно с учётом того, что нумераторов в проекте могут быть десятки. И вот это:

      Недостаток — трудночитаемый код. Но мне кажется, это приемлемая цена за надежность


      на мой взгляд, большой недостаток. Будет море копи-пасты — ведь подобные группы из макроса, нумератора и функции придётся копи-пастить каждый тип (засунуть под один макрос эти три штуки я особо не представляю как).


      1. isotoxin
        08.02.2016 14:16

        Если много enum-ов, требующих рефлексии, то да, громоздко.
        Метод на макросах лучше применять не часто. По моему опыту, даже крупные проекты имеют не более 5 таких списков.
        Кстати, на макросах — это даже не рефлексия. Это больше похоже на мета программирование. enum — это порождение списка и лишь малая часть возможностей.
        Например, в своем проекте я сделал список таблиц, которые читаются из sqlite базы. Для каждой таблицы создаются свои классы обработки (по списку инстанцируются шаблоны), всего 8 мест генерации. Добавляя в список новую таблицу, я экономлю кучу времени на написании оснастки этой таблицы — весь код генерирует за меня компилятор.


        1. semenyakinVS
          08.02.2016 15:33

          Да. Я работал в компании, где тоже в одном месте такой приём использовался. Сильный механизм, который, тем не менее, стоит использовать не больше нескольких раз в проекте — очень уж громоздкий.

          На самом деле, на мой взгляд, подобные ходули — так же как 90% извращений на шаблонно-темплейтной чёрной магии в С++ — возникают потому, что нет нормальной возможности редактирования AST программы на этапе компиляции. Нужен полноценный язык (скорее всего — функциональный) с отладчиком — для выполнения кодогенерации для С++. Задача очень непростая (прежде всего потому, что требуется сохранить читабельность runtime-кода), но, на мой взгляд, рано или поздно придётся это сделать — иначе язык утонет в разнотипных шаблонных конструкциях.

          О необходимости возможности редактирования AST говорят такие ребята, как Эрик Ниблер (один из экспертов в С++, я как раз тут эту мысль встретил и заболел ею). Эту штуку я обсуждал ещё позже с ребятами вот тут — как продолжение дискуссии…

          Вы не встречали нигде подобных обсуждений? Интересно было бы глянуть что ещё люди думают по этому поводу.


          1. thedsi666
            08.02.2016 16:16

            Конечно, это далеко не редактирование AST, но возможно в C++17 добавят настоящую compile-time рефлексию. К примеру, для перечислений: N3815


  1. degs
    08.02.2016 22:21
    +1

    Статья безусловно получилась. Даже независимо от практического результата вышло очень изящно, просто приятно почитать.


  1. skor
    08.02.2016 22:39

    Рекомендую сделать header-only решение.


  1. Lol4t0
    08.02.2016 23:30

    Я так и не понял, зачем нужен val_t и пляски вокруг него, при том что и без этого все отлично работает ( о чем я писал еще 4 года назад)

    std::vector<std::string> parse(const char* args);
    
    template<typename T, typename ...Ts>
    std::map<T, std::string> make_map(const char* text, Ts... args)
    {
        std::vector<T> keys{args...};
        std::vector<std::string> vals = parse(text);
        auto k = keys.cbegin();
        auto v = vals.cbegin();
        std::map<T, std::string> r;
        for (; k != keys.cend(); k++, v++) {
            r.emplace(*k, *v);
        }
        return r;
    
    }
    
    #define ENUM(name, ...)                                                              enum name                                                                            {                                                                                        __VA_ARGS__                                                                      };                                                                                   static std::string to_string(const name v) {                                             static std::map<name, std::string> m {make_map<name>(#__VA_ARGS__, __VA_ARGS__)};    return m.at(v);                                                                  }
    


    1. thedsi666
      09.02.2016 00:12

      В вашей версии нельзя присваивать произвольные значения константам перечисления.


      1. Lol4t0
        09.02.2016 00:13

        Почему же?


        1. thedsi666
          09.02.2016 00:24

          Допустим, если я допишу A=8 в ваш пример с ideone, то получаем ошибку компиляции:

          prog.cpp: In static member function 'static std::string X::to_string(X::Y)':
          prog.cpp:60:13: error: lvalue required as left operand of assignment
               ENUM(Y,A=8,B,C)
                       ^


  1. deema35
    09.02.2016 02:34

    Ещё есть способ, вместо enuma использовать структуру со статическими членами:

    struct Nums
    {
    	static const std:string One;
    	static const std:string Two;
    };
    
    const static const std:string Nums:One = "One";
    const static const std:string Nums:Two = "Two";
    


    Вызов не сильно отличается от enuma Nums:One, но вместо цифры мы передадим строку.


    1. semenyakinVS
      09.02.2016 11:51

      При этом итерироваться по элементам не выйдет.