Шаблон посетителя, если у вас очень много типов узлов

#c #visitor-pattern

#c #посетитель-шаблон

Вопрос:

Что мы имеем?

Программная система, над которой мы работаем, нуждается в обмене большим количеством данных между компонентами. Данные структурированы в том, что мы называем деревьями переменных. Эти данные по сути являются интерфейсом между компонентами. Код C , представляющий определенный интерфейс, автоматически генерируется из описаний интерфейса. Существуют различные базовые реализации для выполнения фактического обмена данными, такие как OPC / UA, но большая часть кода защищена от этого. Важными типами узлов являются те, которые хранят значения и массив значений, оба из которых могут быть созданы практически для любых типов.

 class node { /* whatever all nodes have in common */ };

class value_node : public node { /* polymorphic access to value */ };

template<typename T>
class typed_value_node : public value_node { /* type-safe access to value */ };

// imagine pretty much the same for array_node and typed_array_node
  

Следовательно, базовый класс visitor, используемый для обхода узлов в этих деревьях, имеет функции для приема всех целочисленных типов (со знаком и без знака), всех плавающих типов, логических значений и строк, как для постоянных, так и для неконстантных узлов. (В настоящее время мы планируем сопоставить типы перечислений с парами int / string, но ничего не было установлено в отношении этого.) Все эти перегрузки существуют для значений и для массива.
На данный момент это около 70 перегрузок:

 class visitor {
public:
    virtual ~visitor() = default;

    virtual void accept(      typed_value_node<         char     >amp;)  = 0;
    virtual void accept(const typed_value_node<         char     >amp;)  = 0;


    virtual void accept(      typed_value_node<  signed char     >amp;)  = 0;
    virtual void accept(const typed_value_node<  signed char     >amp;)  = 0;

    ...

    virtual void accept(      typed_value_node<  signed long long>amp;)  = 0;
    virtual void accept(const typed_value_node<  signed long long>amp;)  = 0;


    virtual void accept(      typed_value_node<unsigned char     >amp;)  = 0;
    virtual void accept(const typed_value_node<unsigned char     >amp;)  = 0;

    ...

    virtual void accept(      typed_value_node<unsigned long long>amp;)  = 0;
    virtual void accept(const typed_value_node<unsigned long long>amp;)  = 0;


    virtual void accept(      typed_value_node<bool              >amp;)  = 0;
    virtual void accept(const typed_value_node<bool              >amp;)  = 0;
    ...


    // repeat for typed_array_node
};
  

Чтобы иметь возможность действительно работать с этим, мы используем CRTP для создания шаблонов функций вызова посетителя для производного класса:

 template<typename Derived>
class visitor_impl : public visitor {
public:
    void accept(      typed_value_node<char>amp; node) override
    {static_cast<Derived*>(this)->do_visit(node);}
    void accept(const typed_value_node<char>amp; node) override
    {static_cast<Derived*>(this)->do_visit(node);}

    // etc.
};
  

Это делает работу только с некоторыми узлами терпимой:

 class my_value_node_visitor : public visitor_impl<my_value_node_visitor> {
public:
    template<typename T>
    void accept(const typed_value_node<T>amp;) {/* I wanna see these*/}

    template<typename T>
    void accept(const Tamp;)                   {/* I don't care about those */}
};
  

What do we want?

Начиная разработку нового программного компонента C в области приложений, насыщенной электронной инженерией, мы решили использовать библиотеку модулей для проверки времени компиляции (она же «библиотека размерного анализа»).). Модульные библиотеки хороши тем, что они используют систему типов для проверки правильности вашего кода во время компиляции. Они делают это, создавая почти несвязанное количество типов, которые кодируют не только базовый встроенный тип (int, double, …), Но и физическую единицу (масса, энергия), масштаб (милли-, мега-) и некоторый тег (активный / реактивный / очевидныймощность, градус Кельвина / Цельсия).

В чем проблема?

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

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

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

Что нам теперь делать?

Будучи прочно привязанными к C 03 до конца прошлого года (мир встраиваемых приложений движется медленно), мы, конечно, не слишком знакомы со многими инструментами, которые другие программисты на C используют уже почти десять лет. Поэтому я надеюсь, что здесь мы упускаем очевидное решение.

Или, может быть, мы упускаем что-то не столь очевидное?

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

1. Использование шаблона посетителя кажется хорошим выбором, а многие типы algos проблематичны. Одним из хороших подходов для удовлетворения «принципа открытого-закрытого» (чтобы избежать модификации установленного кода, но разрешить расширение до новых типов) является использование «двойной отправки». рефакторинг.guru/design-patterns/visitor-двойная отправка Это распространенный прием для реализации синтаксических деревьев для компиляторов, которым также приходится иметь дело с миллионами типов.

2. @wcochran Это двойная отправка. Вопрос в том, как разумно отправлять несколько сотен типов.

3. @user253751 ok. Было std::variant бы полезно использовать для объединения некоторых типов (может использоваться в сочетании с std::visit )?

4. 1. Возможно, объединить некоторые группы типов в типы, которые являются более общими. Например, все целочисленные типы со знаком в long long, все типы с плавающей запятой в double и так далее. 2. Для библиотеки модулей, возможно, это подходящее место для рассмотрения во время выполнения? — Решением проблем здесь в конечном итоге будет поиск правильного подмножества вещей для перехода от времени компиляции к времени выполнения.

5. Итак, означает ли это, что существует 15 различных вариантов каждого типа с разными префиксами SI, каждый со своей собственной записью посетителя? Блин. Похоже, что это хорошая цель для упрощения — есть ли какое-либо взаимодействие между ними, которое не просто «умножается, чтобы получить их в одном масштабе»? После этого тип number может быть хорошей целью — есть ли какие-либо взаимодействия, которые не являются «приведением к более точному типу»?

Ответ №1:

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

Узлы, использующие количество единиц, теперь являются производными от узлов их базового модуля (обычно double ). Эти узлы также предоставляют виртуальные функции для определения единицы измерения (an enum ) и масштаба (milli, kilo) количества. Для чистых POD-узлов по умолчанию используются значения «без единицы измерения» и «без масштаба». Узлы, полученные из них, хранящие количество, переопределяют их, возвращая вместо них соответствующие данные.

Таким образом, это сочетание безопасности во время компиляции за счет двойной отправки (нельзя путать целое число с плавающей запятой) и проверок, которые необходимо выполнить во время выполнения (есть ли у этого подключенный модуль?) что позволило предотвратить увеличение количества перегрузок до тысяч.

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