Почему «чистый полиморфизм» предпочтительнее использования RTTI?


106

Почти каждый ресурс C ++, который я видел, где обсуждаются подобные вещи, говорит мне, что я должен предпочесть полиморфные подходы к использованию RTTI (идентификация типа во время выполнения). В общем, я серьезно отношусь к такому совету и постараюсь понять логику - в конце концов, C ++ - могущественный зверь, и его трудно понять во всей его глубине. Однако в этом конкретном вопросе я рисую пробел и хотел бы посмотреть, какие советы может предложить Интернет. Во-первых, позвольте мне резюмировать то, что я узнал до сих пор, перечислив общие причины, которые цитируются, почему RTTI «считается вредным»:

Некоторые компиляторы не используют его / RTTI не всегда включен

Я действительно не верю этому аргументу. Это все равно что сказать, что я не должен использовать возможности C ++ 14, потому что есть компиляторы, которые его не поддерживают. И все же никто не стал бы отговаривать меня от использования функций C ++ 14. Большинство проектов будут влиять на компилятор, который они используют, и на то, как он настроен. Даже цитируя справочную страницу gcc:

-fno-rtti

Отключите создание информации о каждом классе с виртуальными функциями для использования функциями идентификации типа времени выполнения C ++ (dynamic_cast и typeid). Если вы не используете эти части языка, вы можете сэкономить место, используя этот флаг. Обратите внимание, что обработка исключений использует ту же информацию, но G ++ генерирует ее по мере необходимости. Оператор dynamic_cast по-прежнему может использоваться для приведения типов, которые не требуют информации о типе времени выполнения, то есть приведения к "void *" или к однозначным базовым классам.

Это говорит мне о том, что если я не использую RTTI, я могу его отключить. Это все равно, что сказать, если вы не используете Boost, вам не нужно ссылаться на него. Мне не нужно планировать случай, когда кто-то компилирует с -fno-rtti. Кроме того, в этом случае компилятор выйдет из строя.

Это требует дополнительной памяти / может быть медленным

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

На самом деле dynamic_cast может быть медленным. Однако обычно есть способы избежать использования его в критических для скорости ситуациях. И я не совсем вижу альтернативы. Этот ответ SO предлагает использовать перечисление, определенное в базовом классе, для хранения типа. Это работает, только если вы знаете все свои производные классы априори. Это довольно большое «если»!

Из этого ответа также следует, что стоимость RTTI также не ясна. Разные люди измеряют разные вещи.

Элегантный полиморфный дизайн сделает RTTI ненужным

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

Скажем, я пишу библиотеку для обработки графиков каких-то объектов. Я хочу разрешить пользователям создавать свои собственные типы при использовании моей библиотеки (поэтому метод enum недоступен). У меня есть базовый класс для моего узла:

class node_base
{
  public:
    node_base();
    virtual ~node_base();

    std::vector< std::shared_ptr<node_base> > get_adjacent_nodes();
};

Теперь мои узлы могут быть разных типов. Как на счет этих:

class red_node : virtual public node_base
{
  public:
    red_node();
    virtual ~red_node();

    void get_redness();
};

class yellow_node : virtual public node_base
{
  public:
    yellow_node();
    virtual ~yellow_node();

    void set_yellowness(int);
};

Черт, почему бы ни одного из этих:

class orange_node : public red_node, public yellow_node
{
  public:
    orange_node();
    virtual ~orange_node();

    void poke();
    void poke_adjacent_oranges();
};

Последняя функция интересна. Вот как это записать:

void orange_node::poke_adjacent_oranges()
{
    auto adj_nodes = get_adjacent_nodes();
    foreach(auto node, adj_nodes) {
        // In this case, typeid() and static_cast might be faster
        std::shared_ptr<orange_node> o_node = dynamic_cast<orange_node>(node);
        if (o_node) {
             o_node->poke();
        }
    }
}

Все это кажется ясным и чистым. Мне не нужно определять атрибуты или методы там, где они мне не нужны, базовый класс узла может оставаться скудным и средним. С чего мне начать без RTTI? Может быть, я могу добавить атрибут node_type к базовому классу:

class node_base
{
  public:
    node_base();
    virtual ~node_base();

    std::vector< std::shared_ptr<node_base> > get_adjacent_nodes();

  private:
    std::string my_type;
};

Является ли std :: string хорошей идеей для типа? Может и нет, но что еще можно использовать? Придумайте число и надеетесь, что его еще никто не использует? Кроме того, в случае моего orange_node, что, если я хочу использовать методы из red_node и yellow_node? Придется ли мне хранить несколько типов на каждом узле? Это кажется сложным.

Вывод

Эти примеры не кажутся слишком сложными или необычными (я работаю над чем-то похожим в своей повседневной работе, где узлы представляют собой реальное оборудование, которое контролируется с помощью программного обеспечения, и которое делает совершенно разные вещи в зависимости от того, что они собой представляют). Тем не менее, я не знал бы чистого способа сделать это с помощью шаблонов или других методов. Обратите внимание, что я пытаюсь понять проблему, а не защищать свой пример. Мое чтение таких страниц, как ответ SO, на который я указал выше, и эта страница в Викиучебниках, похоже, наводят на мысль, что я злоупотребляю RTTI, но я хотел бы узнать, почему.

Итак, вернемся к моему первоначальному вопросу: почему «чистый полиморфизм» предпочтительнее использования RTTI?


9
То, что вам «не хватает» (как языковой особенности) для решения вашего примера poke oranges, - это множественная отправка («мультиметоды»). Таким образом, поиск способов подражания, который мог бы быть альтернативой. Обычно для этого используется шаблон посетителя.
Даниэль Джур,

1
Использование строки в качестве типа не очень помогает. Использование указателя на экземпляр некоторого «типового» класса сделало бы это быстрее. Но тогда вы в основном делаете вручную то, что делал бы RTTI.
Даниэль Джур,

4
@MargaretBloom Нет, не будет, RTTI означает информацию о типе времени выполнения, в то время как CRTP предназначен только для шаблонов - статических типов, поэтому.
edmz 03

2
@ mbr0wn: все инженерные процессы подчиняются некоторым правилам; программирование не исключение. Правила можно разделить на два сегмента: мягкие правила (ДОЛЖНЫ) и жесткие правила (ДОЛЖНЫ). (Есть также набор рекомендаций / вариантов (МОЖЕТ), так сказать.) Прочтите, как стандарт C / C ++ (или любой другой стандарт английского языка, на самом деле) определяет их. Я предполагаю, что ваша проблема связана с тем, что вы ошиблись «не используйте RTTI» как жесткое правило («вы НЕ ДОЛЖНЫ использовать RTTI»). На самом деле это мягкое правило («НЕ СЛЕДУЕТ использовать RTTI»), означающее, что вам следует избегать его, когда это возможно - и просто использовать, когда вы не можете этого избежать

3
Я отмечаю, что во многих ответах не упоминается идея, которую предлагает ваш пример, node_baseявляется частью библиотеки, и пользователи будут создавать свои собственные типы узлов. Тогда они не могут изменить, node_baseчтобы разрешить другое решение, поэтому, возможно, тогда RTTI станет их лучшим вариантом. С другой стороны, есть и другие способы спроектировать такую ​​библиотеку, чтобы новые типы узлов могли вписываться в нее гораздо более элегантно, без необходимости использования RTTI (а также других способов разработки новых типов узлов).
Мэтью Уолтон,

Ответы:


69

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

Например, ваш "тычок соседних апельсинов" означает, что я, как третья сторона, не могу подражать апельсину! Вы в частном порядке объявили оранжевый тип, а затем используете RTTI, чтобы ваш код вел себя по-особенному при взаимодействии с этим типом. Если я хочу «быть оранжевым», я должен быть в вашем частном саду.

Теперь каждый, кто соединяется с "оранжевым", соединяется со всем вашим оранжевым типом и неявно со всем вашим частным садом, а не с определенным интерфейсом.

Хотя на первый взгляд это выглядит как отличный способ расширить ограниченный интерфейс без необходимости изменять всех клиентов (добавлять am_I_orange), вместо этого обычно происходит окостенение кодовой базы и предотвращение дальнейшего расширения. Особая оранжевость становится неотъемлемой частью функционирования системы и не позволяет вам создать «мандариновую» замену апельсина, которая реализована по-другому и, возможно, удаляет зависимость или элегантно решает некоторые другие проблемы.

Это означает, что вашего интерфейса должно быть достаточно для решения вашей проблемы. С этой точки зрения, почему вам нужно тыкать только апельсины, и если да, то почему апельсин был недоступен в интерфейсе? Если вам нужен нечеткий набор тегов, которые можно добавлять по отдельности, вы можете добавить его к своему типу:

class node_base {
  public:
    bool has_tag(tag_name);

Это обеспечивает аналогичное массовое расширение вашего интерфейса от узко заданного до широкого, основанного на тегах. За исключением того, что это делается через RTTI и детали реализации (также известные как «как вы реализованы? С оранжевым шрифтом? Хорошо, вы проходите»), он делает это с помощью чего-то, что легко эмулировать с помощью совершенно другой реализации.

Это можно распространить даже на динамические методы, если вам это нужно. «Поддерживаете ли вы, что вас обманывают аргументами Баз, Том и Элис? Хорошо, обманываю вас». В широком смысле это менее навязчиво, чем динамическое приведение, чтобы понять, что другой объект является типом, который вы знаете.

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

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

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


14
+1 за самый последний абзац; не только потому, что я согласен с вами, но и потому, что здесь дело в молотке.

7
Как получить конкретную функциональность, если известно, что объект помечен как поддерживающий эту функциональность? Либо это включает в себя приведение типов, либо существует класс God со всеми возможными функциями-членами. Первая возможность - это либо неконтролируемое приведение, и в этом случае тегирование - это просто собственная очень ошибочная схема проверки динамического типа, либо оно проверено dynamic_cast(RTTI), и в этом случае теги избыточны. Вторая возможность - класс бога - отвратительна. Подводя итог, в этом ответе много слов, которые, на мой взгляд, звучат хорошо для программистов на Java, но фактическое содержание бессмысленно.
Приветствия и hth. - Альф,

2
@Falco: Это (один вариант) первая возможность, о которой я упоминал, - неконтролируемое приведение на основе тега. Здесь тегирование - это собственная очень хрупкая и очень ошибочная схема проверки динамического типа. Любое незначительное некорректное поведение клиентского кода, а в C ++ оно отключено в UB-мире. Вы не получаете исключений, как в Java, но получаете неопределенное поведение, такое как сбои и / или неверные результаты. Помимо того, что он крайне ненадежен и опасен, он также крайне неэффективен по сравнению с более разумным кодом на C ++. IOW., Это очень нехорошо; крайне так.
Приветствия и hth. - Alf

1
Гм. :) Типы аргументов?
Приветствия и hth. - Альф,

2
@JojOatXGME: Потому что «полиморфизм» означает способность работать с множеством типов. Если вам нужно проверить, является ли это конкретным типом, помимо уже существующей проверки типа, которую вы использовали для получения указателя / ссылки для начала, тогда вы ищете полиморфизм. Вы не работаете с разными типами; вы работаете с определенным типом. Да, есть «(большие) проекты на Java», которые это делают. Но это Java ; язык допускает только динамический полиморфизм. C ++ также имеет статический полиморфизм. Кроме того, это не лучшая идея только потому, что это делает кто-то «большой».
Никол Болас

31

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

Где моралисты неудачу в том , что они предполагают ВСЕ обычаи бытуют превратное мнение, в то время как на самом деле существуют особенности по причине.

У них есть то , что я имел обыкновение называть «Водопроводчик комплексом»: они думают , что все краны имеют неточности , потому что все краны они призваны на ремонт. На самом деле большинство кранов работают хорошо: для них просто не вызвать сантехника!

Сумасшедшая вещь, которая может случиться, когда, чтобы избежать использования данной функции, программисты пишут много шаблонного кода, фактически в частном порядке повторно реализуя именно эту функцию. (Вы когда-нибудь встречали классы, которые не используют ни RTTI, ни виртуальные вызовы, но имеют значение для отслеживания того, какой фактический производный тип они представляют? Это не более чем RTTI замаскированное переосмысление .)

Существует общий способ думать о полиморфизме: IF(selection) CALL(something) WITH(parameters). (Извините, но программирование, если не считать абстракции, - это все)

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

Идея в том, что:

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

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

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

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

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

  • Сведите к одному измерению с помощью Goedelization : вот где виртуальные основы и множественное наследование с ромбами и сложенными параллелограммами , но для этого необходимо, чтобы количество возможных комбинаций было известно и было относительно небольшим.
  • Объедините размеры одно в другое (как в шаблоне составных посетителей, но для этого требуется, чтобы все классы знали о своих других братьях и сестрах, поэтому он не может «масштабироваться» из того места, где он был задуман)
  • Диспетчерские вызовы на основе нескольких значений. Именно для этого и существует RTTI.

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

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

Вот что морализация на самом деле толкает избежать. Но это не означает, что проблем, живущих в самых нижних доменах, не существует!

Бить RTTI просто для того, чтобы его разбить, все равно что бить gotoпросто для того, чтобы разбить его. Вещи для попугаев, а не для программистов.


Хороший отчет об уровнях, на которых применим каждый подход. Но я не слышал о «геделизации» - известно ли это еще под каким-то другим названием? Не могли бы вы добавить ссылку или дополнительные пояснения? Спасибо :)
j_random_hacker 03

1
@j_random_hacker: Мне тоже любопытно, как используется Godelization. Обычно о годелизации думают, как, во-первых, отображении некоторой строки в некоторое целое число, а во-вторых, как использование этой техники для создания самореферентных операторов на формальных языках. Я не знаком с этим термином в контексте виртуальной рассылки и хотел бы узнать больше.
Эрик Липперт,

1
На самом деле я злоупотребляю этим термином: согласно Гедлу, поскольку каждое целое число соответствует целому n-элементу (степени его простых множителей), а каждое n-кратное число соответствует целому числу, каждая дискретная n-мерная проблема индексирования может быть сводится к одномерному . Это не означает, что это единственный способ сделать это: это просто способ сказать «это возможно». Все, что вам нужно, это механизм «разделяй и властвуй». виртуальные функции - это «разделять», а множественное наследование - это «побеждать».
Эмилио Гаравалья,

... Когда все, что происходит внутри конечного поля (диапазона), более эффективны линейные комбинации (классический i = r * C + c, получающий индекс в массиве ячейки матрицы). В этом случае разделенный идентификатор «посетитель» и «победитель» является «составным». Поскольку задействована линейная алгебра, техника в данном случае соответствует «диагонализации»
Эмилио Гаравалья

Не думайте обо всем этом как о технике. Это просто аналогии
Эмилио Гаравалья

23

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

А как насчет dark_orange_node, или black_and_orange_striped_node, или dotted_node? Может на нем точки разного цвета? Что если большинство точек оранжевого цвета, тогда можно ли ткнуть?

И каждый раз, когда вам нужно добавить новое правило, вам придется повторно посещать все poke_adjacentфункции и добавлять дополнительные операторы if.


Как всегда, сложно создать общие примеры, я вам это дам.

Но если бы я сделал этот конкретный пример, я бы добавил poke()члена ко всем классам и позволил бы некоторым из них игнорировать call ( void poke() {}), если они не заинтересованы.

Конечно, это было бы даже дешевле, чем сравнивать typeids.


3
Вы говорите «конечно», но почему вы так уверены? Это действительно то, что я пытаюсь понять. Допустим, я переименовал orange_node в pokable_node, и это единственные, для которых я могу вызвать poke (). Это означает, что в моем интерфейсе потребуется реализовать метод poke (), который, скажем, выдает исключение («этот узел не может быть удален»). Это кажется более дорогим.
mbr0wn 03

2
Зачем ему нужно генерировать исключение? Если вас заботит, является ли интерфейс «поддерживаемым», просто добавьте функцию «isPokeable» и сначала вызовите ее перед вызовом функции «тыкать». Или просто делайте то, что он говорит, и «ничего не делайте, в классах, не требующих покера».
Брэндон

1
@ mbr0wn: Лучше спросить, почему вы хотите, чтобы pokable и nonpokable узлы использовали один и тот же базовый класс.
Никол Болас,

2
@NicolBolas Почему вы хотите, чтобы дружественные и враждебные монстры разделяли один и тот же базовый класс, или фокусируемые и нефокусируемые элементы пользовательского интерфейса, или клавиатуры с цифровой клавиатурой и клавиатуры без цифровой клавиатуры?
user253751 04

1
@ mbr0wn Это похоже на шаблон поведения. Базовый интерфейс имеет два метода, supportsBehaviourи invokeBehaviourкаждый класс может иметь список поведений. Одним из вариантов поведения будет Poke, и он может быть добавлен в список поддерживаемых Behaviors всеми классами, которые хотят быть доступными.
Falco

20

Некоторые компиляторы не используют его / RTTI не всегда включен

Я считаю, что вы неправильно поняли такие аргументы.

Есть ряд мест для кодирования C ++, в которых не следует использовать RTTI. Где переключатели компилятора используются для принудительного отключения RTTI. Если вы пишете код в рамках такой парадигмы ... то вы почти наверняка уже были проинформированы об этом ограничении.

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

Это требует дополнительной памяти / может быть медленным

Есть много вещей, которые вы не делаете в горячих петлях. Вы не выделяете память. Вы не перебираете связанные списки. И так далее. RTTI, безусловно, может быть еще одним из тех, что «не делайте этого здесь».

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

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

Превратить сложную операцию RTTI в обычный вызов виртуальной функции? Это проблема дизайна. Если вам нужно это изменить, то это потребует изменений в каждом производном классе. Это меняет способ взаимодействия большого количества кода с различными классами. Возможности такого изменения выходят далеко за пределы критических для производительности участков кода.

Итак ... почему вы с самого начала написали неправильно?

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

С какой целью?

Вы говорите, что базовый класс «худощавый и средний». Но на самом деле ... его не существует . На самом деле он ничего не делает .

Просто посмотрите на ваш пример: node_base. Что это? Кажется, что это нечто, к которому примыкают другие вещи. Это интерфейс Java (к тому же предварительные обобщения Java): класс, который существует исключительно для того, чтобы быть чем-то, что пользователи могут преобразовывать в реальный тип. Возможно, вы добавите какую-то базовую функцию, например, смежность (Java добавляет ToString), но это все.

Есть разница между «худым и скупым» и «прозрачным».

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

Но что они также делают, так это делают серьезной проблемой действительно делать что-то новое, даже внутри системы. Подумайте о своей poke_adjacent_orangesфункции. Что произойдет, если кому-то понадобится lime_nodeтип, который можно тыкать точно так же, как orange_nodes? Ну, мы не можем извлечь lime_nodeиз orange_node; это не имеет смысла.

Вместо этого мы должны добавить новый lime_nodeпроизводный от node_base. Затем измените имя poke_adjacent_orangesна poke_adjacent_pokables. А затем попробуйте выполнить приведение к orange_nodeи lime_node; какой бы бросок ни сработал, мы и тыкаем.

Однако lime_nodeнеобходимо собственное poke_adjacent_pokables . И эта функция должна выполнять те же проверки приведения.

А если мы добавим третий тип, мы должны не только добавить его собственную функцию, но и изменить функции в двух других классах.

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

Здравствуйте, тихая поломка . Программа работает более или менее нормально, но это не так. Если pokeбы это была настоящая виртуальная функция, компилятор отказал бы, если бы вы не переопределили чистую виртуальную функцию из node_base.

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

Использование прозрачных базовых классов с RTTI приводит к кошмару обслуживания. Действительно, большинство случаев использования RTTI приводит к головной боли при обслуживании. Это не означает, что RTTI бесполезен (например, он жизненно важен для boost::anyработы). Но это очень специализированный инструмент для очень специализированных нужд.

В этом смысле он «вреден» так же, как goto. Это полезный инструмент, с которым нельзя отказываться. Но его использование в вашем коде должно быть редким .


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

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

Прозрачные базовые классы вроде node_baseпросто используют неправильный инструмент для решения проблемы. Связанные списки лучше всего обрабатываются шаблонами. Тип узла и смежность будут предоставлены типом шаблона. Если вы хотите поместить в список полиморфный тип, вы можете. Просто используйте BaseClass*как Tв аргументе шаблона. Или ваш предпочтительный умный указатель.

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

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

Современное решение для этого - система компонентного типа. Entityэто просто контейнер из набора компонентов, между которыми есть клей. Некоторые компоненты не являются обязательными; объект, не имеющий визуального представления, не имеет «графического» компонента. Сущность без ИИ не имеет "контролирующего" компонента. И так далее.

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

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

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


От Мэтью Уолтона:

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

Хорошо, давайте исследуем это.

Чтобы это имело смысл, вам потребуется ситуация, когда некоторая библиотека L предоставляет контейнер или другой структурированный держатель данных. Пользователь может добавлять данные в этот контейнер, перебирать его содержимое и т. Д. Однако библиотека на самом деле ничего не делает с этими данными; он просто управляет своим существованием.

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

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

В языке C этот шаблон пишется по буквам void*, и его использование требует особой осторожности, чтобы избежать нарушения. В C ++ этот шаблон пишется std::experimental::any(скоро будет написано std::any).

Это должно работать следующим образом: L предоставляет node_baseкласс, который принимает anyваши фактические данные. Когда вы получаете сообщение, рабочий элемент очереди потока или что-то еще, вы затем приводите anyего к соответствующему типу, который известен как отправителю, так и получателю.

Таким образом , вместо того , чтобы извлечения orange_nodeиз node_data, вы просто придерживаться в orangeвнутри node_data«S anyобласти члена. Конечный пользователь извлекает его и использует any_castдля преобразования в orange. Если бросок не удастся, значит, это не так orange.

Теперь, если вы хоть немного знакомы с реализацией any, вы, скорее всего, скажете: «Эй, погоди минутку: any внутренне использует RTTI для any_castработы». На что я отвечаю "... да".

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

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

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


10

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

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

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

Итак, как с этим бороться ... В зависимости от вашей ситуации может иметь смысл объединить любые специфичные для узла свойства в отдельные объекты (т.е. весь «оранжевый» API может быть отдельным объектом). Тогда у корневого объекта может быть виртуальная функция для возврата «оранжевого» API, возвращающего nullptr по умолчанию для не оранжевых объектов.

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


6
Re: стоимость производительности - я измерил, что dynamic_cast <> в нашем приложении стоит около 2 мкс на процессоре с тактовой частотой 3 ГГц, что примерно в 1000 раз медленнее, чем проверка перечисления. (У нашего приложения крайний срок основного цикла 11,1 мс, поэтому мы очень заботимся о микросекундах.)
Crashworks

6
Производительность сильно различается между реализациями. GCC использует быстрое сравнение указателей typeinfo. MSVC использует сравнение строк, которое не является быстрым. Однако метод MSVC будет работать с кодом, связанным с различными версиями библиотек, статических или DLL, где метод указателя GCC считает, что класс в статической библиотеке отличается от класса в общей библиотеке.
Zan Lynx

1
@Crashworks Просто чтобы здесь была полная запись: какой компилятор (и какая версия) это был?
Х. Гийт, 04

@Crashworks поддерживает запрос информации о том, какой компилятор выдал наблюдаемые вами результаты; Спасибо.
underscore_d

@underscore_d: MSVC.
Crashworks

9

C ++ построен на идее проверки статического типа.

[1] RTTI, то есть dynamic_castи type_id, это проверка динамического типа.

Итак, по сути, вы спрашиваете, почему проверка статического типа предпочтительнее проверки динамического типа. И простой ответ: будет ли проверка статического типа предпочтительнее проверки динамического типа, зависит от этого . О многом. Но C ++ - один из языков программирования, созданных на основе идеи проверки статического типа. А это означает, что, например, процесс разработки, в частности тестирования, обычно адаптируется к проверке статического типа, а затем подходит для этого лучше всего.


Re

" Я не знаю, как сделать это чисто с помощью шаблонов или других методов.

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

#include <iostream>
#include <set>
#include <initializer_list>

namespace graph {
    using std::set;

    class Red_thing;
    class Yellow_thing;
    class Orange_thing;

    struct Callback
    {
        virtual void handle( Red_thing& ) {}
        virtual void handle( Yellow_thing& ) {}
        virtual void handle( Orange_thing& ) {}
    };

    class Node
    {
    private:
        set<Node*> connected_;

    public:
        virtual void call( Callback& cb ) = 0;

        void connect_to( Node* p_other )
        {
            connected_.insert( p_other );
        }

        void call_on_connected( Callback& cb )
        {
            for( auto const p : connected_ ) { p->call( cb ); }
        }

        virtual ~Node(){}
    };

    class Red_thing
        : public virtual Node
    {
    public:
        void call( Callback& cb ) override { cb.handle( *this ); }

        auto redness() -> int { return 255; }
    };

    class Yellow_thing
        : public virtual Node
    {
    public:
        void call( Callback& cb ) override { cb.handle( *this ); }
    };

    class Orange_thing
        : public Red_thing
        , public Yellow_thing
    {
    public:
        void call( Callback& cb ) override { cb.handle( *this ); }

        void poke() { std::cout << "Poked!\n"; }

        void poke_connected_orange_things()
        {
            struct Poker: Callback
            {
                void handle( Orange_thing& obj ) override
                {
                    obj.poke();
                }
            } poker;

            call_on_connected( poker );
        }
    };
}  // namespace graph

auto main() -> int
{
    using namespace graph;

    Red_thing   r;
    Yellow_thing    y1, y2;
    Orange_thing    o1, o2, o3;

    for( Node* p : std::initializer_list<Node*>{ &y1, &y2, &r, &o2, &o3 } )
    {
        o1.connect_to( p );
    }
    o1.poke_connected_orange_things();
}

Это предполагает, что известен набор типов узлов.

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


Для подхода на основе шаблонов см. Библиотеку Boost Graph. К сожалению, я не знаком с ним, я не использовал его. Поэтому я не совсем уверен, что он делает и как, и в какой степени он использует проверку статического типа вместо RTTI, но поскольку Boost обычно основан на шаблоне, а проверка статического типа является центральной идеей, я думаю, вы обнаружите, что его подбиблиотека Graph также основана на проверке статического типа.


[1] Информация о типе времени выполнения .


1
Следует отметить одну «забавную вещь»: можно уменьшить объем кода (изменений при добавлении типов), необходимого для шаблона посетителя, используя RTTI для «подъема» по иерархии. Я знаю это как «ациклический шаблон посетителя».
Daniel Jour

3

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

if ( typeid(5) == "int" )
    // may be false

if ( typeid(5) == typeid(int) )
   // always true

То же самое и с хешами.

[...] RTTI «считается вредным»

вредно , безусловно, завышено: у RTTI есть недостатки, но есть имеет преимущество.

На самом деле вам не обязательно использовать RTTI. RTTI - это инструмент для решения проблем ООП: если вы воспользуетесь другой парадигмой, они, скорее всего, исчезнут. C не имеет RTTI, но все еще работает. Вместо этого C ++ полностью поддерживает ООП и предоставляет несколько инструментов для решения некоторых проблем, которые могут потребовать информации во время выполнения: одним из них действительно является RTTI, хотя и имеет свою цену. Если вы не можете себе это позволить, то, что вам лучше сказать, только после безопасного анализа производительности, все еще существует старая школа void*: это бесплатно. Без затрат. Но вы не получаете никакой безопасности типов. Так что все дело в сделках.


  • Некоторые компиляторы не используют / RTTI не всегда включен.
    Я действительно не верю этому аргументу. Это все равно что сказать, что я не должен использовать возможности C ++ 14, потому что есть компиляторы, которые его не поддерживают. И все же никто не стал бы отговаривать меня от использования функций C ++ 14.

Если вы пишете (возможно, строго) соответствующий код C ++, вы можете ожидать такого же поведения независимо от реализации. Соответствующие стандартам реализации должны поддерживать стандартные функции C ++.

Но учтите, что в некоторых средах, определяемых C ++ («автономных»), RTTI не требуется, как и исключения, virtualи так далее. Для правильной работы RTTI необходим нижележащий уровень, который имеет дело с низкоуровневыми деталями, такими как ABI и фактическая информация о типе.


Я согласен с Якком относительно RTTI в этом случае. Да, его можно было использовать; но логически ли это правильно? Тот факт, что язык позволяет обойти эту проверку, не означает, что ее нужно делать.

Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.