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

enum Fruit {
    Apple,
    Orange,
    Peach
};

Просто и чисто. Таким образом, ваша IDE может дать вам аккуратный список автозаполнения, компилятор может предупредить вас, если вы пропустите значение в переключателе, и все будут довольны. Конец истории, да?

К сожалению нет.

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

enum Fruit { Apple, Orange, Peach };
enum Color { Red, Green, Blue };
int main() {
    Fruit favorite_fruit = Apple; // not Fruit::Apple
    Color favorite_color = Red; // not Color::Red
}

Это не очень хорошая идея на многих разных уровнях. При объявлении значения типа Fruit использование Apple вместо Fruit::Apple заставляет меня думать, что поскольку Red находится в том же пространстве имен, оно также может быть допустимым значением Fruit, хотя на самом деле это Color. Это также дает мне плохое представление о том, каковы значения Fruit в целом, и довольно часто мне приходится вставать со стула и искать объявление Fruit, чтобы быть полностью уверенным.

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

И, наконец, вы не можете одновременно иметь фрукт с именем Оранжевый и цвет с именем Оранжевый в одном пространстве имен.

В C++11 эти проблемы решаются с помощью новых правил доступа к перечислению и оператора enum class. Вот как это работает.

enum class Fruit { Apple, Orange, Peach };
enum class Color { Red, Green, Blue };
int main() {
    Fruit favorite_fruit = Fruit::Apple;
    Color favorite_color = Color::Red;
}

Счастлив теперь? Еще не совсем.

Что, если бы я написал простую библиотеку вокруг двух моих перечислений и какое-то время использовал ее в программах, связанных с фруктами. Затем, после хорошего отпуска на Карибах, я решил создать libtropical, что бы добавить еще немного фруктов?

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

Это больше не является хорошим вариантом использования для перечислений. Полиморфизм во время выполнения спешит на помощь, я прав?

namespace fruits {
    struct FruitBase {};
    struct Apple: FruitBase {};
    struct Orange: FruitBase {};
    struct Peach: FruitBase {};
}
// Later, in libtropical:
namespace fruits {
    struct Pineapple: FruitBase {};
    struct Coconut: FruitBase {};
}

Это снова выглядит красиво и чисто. А позже любая библиотека может пойти дальше и объявить некоторые расширения FruitBase в своем пространстве имен. Но как вы используете эту концепцию на практике?

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

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

enum Param { First, Second, Third };
struct FirstParamData { /* ... */ };
struct SecondParamData { /* ... */ };
struct ThirdParamData { /* ... */ };
FirstParamData data1;
SecondParamData data2;
ThirdParamData data3;
// This method gets called when data arrives.
// `input_data` must point to 32 bytes of memory
void read_param(Param param, const void* input_data) {
    switch (param) {
        case Param::First:
            data1 = *(FirstParamData*) input_data;
            break;
        case Param::Second:
            data2 = *(SecondParamData*) input_data;
            break;
        case Param::Third:
            data3 = *(ThirdParamData*) input_data;
            break;
    }
}

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

Мы перемещаем все из метода чтения в объявление структур param, заканчивая кучей структур и виртуальных методов.

struct ParamBase {
    virtual void read_data(const void* input_data) const = 0;
};
struct First: ParamBase {
    virtual void read_data(const void* input_data) const override {
        data1 = *(FirstParamData*) input_data;
    }
};
struct Second: ParamBase {
    virtual void read_data(const void* input_data) const override {
        data2 = *(SecondParamData*) input_data;
    }
};
// This method gets called when data arrives.
// `input_data` must point to 32 bytes of memory
void read_param(std::shared_ptr<ParamBase> param, 
                const void* input_data) {
    param->read_data(input_data);
}

Это не плохо, но и не хорошо.

Во-первых, обратите внимание, что код для структур параметров выглядит почти одинаково в обоих случаях. Много повторяться при написании кода — нехороший знак.

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

Есть ли лучшее решение? Я думаю, что, возможно, нашел один.

Мы можем использовать оператор typeid() вместе с некоторыми шаблонами, чтобы полностью обойти полиморфизм времени выполнения и получить что-то столь же расширяемое и аккуратное, как примеры фруктов, без хлопот постоянного перераспределения памяти и вызовов виртуальных методов. .

Для тех, кто, как и я, изначально понятия не имел, что делает typeid(): он сопоставляет типы и переменные с std::type_info во время компиляции, давая вам возможность извлекать введите имя или сравните типы различных переменных во время выполнения. Однако есть небольшая загвоздка — std::type_info не является CopyConstructible, поэтому вы не можете слишком много перемещать его. Для этого вы можете использовать std::type_index, который сохраняет все свои свойства, будучи CopyConstructible. Подробнее здесь.

Давайте начнем со структур параметров в пространстве имен. Кроме того, пусть каждый параметр объявляет свой тип структуры данных.

struct FirstParamData { /* ... */ };
struct SecondParamData { /* ... */ };
struct ThirdParamData { /* ... */ };
namespace params {
    struct ParamBase {};
    struct First: ParamBase { using data_type = FirstParamData; };
    struct Second: ParamBase { using data_type = SecondParamData; };
    struct Third: ParamBase { using data_type = ThirdParamData; };
}

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

// `data` must point to 32 bytes of memory
template<typename ParamType, 
         typename TargetType = typename ParamType::data_type>
void read_param_helper(std::type_index param,
                       TargetType& target,
                       const void* data) {
    if (param != typeid(ParamType)) return;
    target = *(TargetType*) data;
}

Тогда его использование в исходном методе чтения довольно простое и элегантное.

FirstParamData data1;
SecondParamData data2;
ThirdParamData data3;
// This method gets called when data arrives.
// `data` must point to 32 bytes of memory
void read_param_data(std::type_index param, const void* input) {
    read_param_helper<param::First>(param, data1, input);
    read_param_helper<param::Second>(param, data2, input);
    read_param_helper<param::Third>(param, data3, input);
}

Этот метод будет немного сложнее исходного, основанного на перечислении, из-за всех вызовов вспомогательных методов (в оригинале вызывалась только одна ветвь switch). Однако, если вы используете современный компилятор, это не имеет значения, так как он будет оптимизирован до -O3. И не будет уродливого объема памяти или виртуальных вызовов, как в предыдущей попытке.

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

Кроме того, мы получаем дополнительное преимущество проверки типов во время компиляции. Это то, чего мы не получили бы в исходной системе, основанной на перечислении, или в первой неудачной попытке полиморфного решения.

// Compiles:
FirstParamData data1;
read_param_helper<param::First>(param, data1, input);
// Does not compile – converting FirstParamData to SecondParamData:
SecondParamData data2;
read_param_helper<param::First>(param, data2, input);

И, наконец, мы можем полностью избежать имен структур, таких как FirstParamData, используя оператор using в объявлениях.

param::First::data_type data1;

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