В C++ существует такое понятие, как динамическая идентификация типа данных (RTTI). Это механизм, который позволяет определить тип переменной или объекта на этапе выполнения программы. Однако, чтобы уменьшить размер собранных бинарей, во многих проектах RTTI отключают, и от этого перестают работать dynamic_cast и typeid. Но способ проверить, порожден ли инстанс объекта от какого-то базового класса, все же есть, и в своей статье я покажу, как сделать это элегантно.
Отключение RTTI приводит к тому, что для верификации начинают делать разные странные вещи — например, заводят целочисленные class_id и проверяют их вручную, бегая глазами по иерархии классов. Мы же используем магию шаблонов и полиморфизм, чтобы обойтись без этого. Это поможет уменьшить количество бредокода, а также упростит приведение к нужному типу.
Как обойтись без динамической идентификации типов в C++
Традиционно C++ считается надежным инструментом построения API с проверкой типов еще на этапе компиляции. Исключения составляют шаблоны и ссылки на базовый тип, которые дают относительную свободу в выборе типа. Как правило, когда проект разрастается, стараются экономить каждый мегабайт получающихся бинарников и в первую очередь под нож идет система RTTI.
С одной стороны, конечно, RTTI дает возможность делать dynamic_cast вверх по иерархии наследования, а также узнавать идентификатор типа по typeid, но, с другой стороны, кто и когда этим пользуется? Как правило, dynamic_cast без проблем заменяется static_cast (если библиотека C++ не содержит действительно ветвистое дерево наследования).
Бывает, конечно, что наследование в C++ не сводится к наличию банального базового интерфейса IClassName и наследованию ClassName в недрах библиотеки. Вместо этого может быть представлена полноценная иерархия типов. Тогда без RTTI будет сложно обойтись, просто потому, что мы не сможем взять и проверить тип на инстанцирование определенного типа иерархии через проверку dynamic_cast.
1 2 |
void somefunc(base* b) { if (derived* d = dynamic_cast<derived*>(b)) |
Как правило, либо есть стопроцентная уверенность, что это инстанс определенного класса (которая в половине случаев в итоге оказывается далеко не стопроцентной), либо инстанс типа проверяется на некий уникальный CLASS_ID, переданный при создании экземпляра класса. А это, разумеется, крайне дырявый способ проверить инстанцирование класса, ведь наследники будут иметь свой уникальный CLASS_ID. Поэтому такая проверка возможна не столько на имплементацию класса, сколько на соответствие ровно одному типу. Все это выливается в целые цепочки проверок вида
1 2 3 |
void somefunc(const creature& c) { if (c.class_id() == animal::CLASS_ID) ... if (c.class_id() == cow::CLASS_ID) ... |
Однако идея, в общем-то, неплоха, давай только сделаем проверку проще и эффективнее. Пусть у нас будет возможность проверить любые два класса иерархии на наследование, а любой инстанс класса этой иерархии — на имплементацию определенного класса.
Делаем проверку удобнее
Помогут нам в этом шаблоны и полиморфизм. Если у нас всего два класса, то, кроме шаблона, нам ничего не нужно.
Итак, дано дерево наследования, пусть каждый класс X однозначно идентифицируется по методу X::id(), а также задан typedef для базового класса, поименованный как X::base для каждого класса иерархии.
1 2 3 4 |
class B : public A { public: typedef A base; static someunique id(); |
В этом случае шаблон is_class<X>::of<Y>() будет достаточно простым: нужна проверка на соответствие X::id() или непосредственно Y::id(), либо X — один из наследников Y, и, рекурсивно обходя предков, мы найдем соответствие.
1 2 3 4 5 |
template <typename X> struct is_class { template <typename Y> static bool of() { return X::id() == Y::id() || is_class<X::base>::of<Y>(); } |
Теперь осталось только обеспечить корректный выход из рекурсии в базовом классе. Для корректного обхода классов иерархии нам нужно, чтобы базовый класс завершал поиск предка.
1 2 3 4 |
template <> struct is_class<root> { template <typename Y> static bool of() { return root::id() == Y::id(); } |
Теперь если есть иерархия creature => animal => cat и animal => dog, то можно спокойно проверять любые классы на наследование друг от друга.
1 2 3 4 |
template <typename X> void meet(X&& x) { if (is_class<X>::of<dog>()) feed(std::forward<dog>(x)); |
Полиморфизм вместо RTTI
С проверкой объекта на инстанцирование класса иерархии будет сложнее. Нам понадобится специальный виртуальный метод who(), который в каждом классе возвращает свой статический уникальный id().
1 2 3 4 5 |
class B : public A { public: typedef A base; static someunique id(); virtual someunique who() const { return id(); } |
Также было бы удобно у любого инстанса иерархии спрашивать, инстанцирует ли он один из ее классов. Для этого в корневом классе иерархии заведем метод is<X>(), который будет шаблонным, а значит, несколько поломает нам полиморфизм при проверке. Для выхода из ситуации заведем еще один вспомогательный метод is_base_id, перегруженный от типа, которым определяется уникальность класса в методе id().
1 2 3 4 5 6 7 8 |
class root { public: template <typename X> bool is() const { return is_base_id(X::id()); } virtual bool is_base_id(const someunique& base_id) const { return base_id == id(); } |
У наследников метод is_base_id должен переопределяться аналогично проверке is_class<X>::of<Y> с использованием проверки базового класса на соответствие id().
1 2 3 4 5 6 7 8 9 10 |
class B : public A { public: typedef A base; static someunique id(); virtual someunique who() const { return id(); } virtual bool is_base_id(const someunique& base_id) const { return base_id == id() || base::is_base_id(base_id); } |
Таким образом, каждый класс иерархии можно будет проверить на инстанцирование любого ее класса и безопасно приводить тип по ссылке в соответствии с иерархией.
1 2 3 |
void somefunc(const creature& c) { if (c.is<animal>()) { const animal& a = static_cast<const animal&>(c); |
Поскольку после проверки на инстанцирование чаще всего идет именно приведение типа (обычно для этого проверка и нужна), то удобно также в корневом классе сделать еще один метод as<X>(), который инкапсулирует проверку is<X>() и статическое преобразование типа.
1 2 3 4 |
class root { public: template <typename X> const X& as() const; template <typename X> X& as(); |
Разумеется, это удобно делать, только если вместе с RTTI в проекте не отключены еще и исключения. Иначе будет сложно кинуть исключение в том случае, если, например, некий объект x типа animal не инстанцирует тип cat, а его просят x.as<cat>().say_meow().
Уметь готовить C++
Язык C++ очень гибкий и мощный, а если немножко уделять внимание деталям, то и удобный. Мы получили высокоуровневые способы удобной проверки наследования классов и инстанцирования от них объектов. Накладные расходы при этом не так страшны.
- Наследуемый класс должен определять предка через typedef с одинаковым именем, например base.
- Каждый класс иерархии должен уметь идентифицировать себя методом id(). Тип идентификатора я рассмотрю ниже, но он непринципиален, это может быть просто int.
- Корневой класс иерархии должен завершать рекурсивный обход вверх по иерархии, что весьма логично.
- Каждый класс иерархии должен уметь проверить произвольный id() на соответствие либо своему id(), либо id() одного из своих предков — для проверки инстанцирования класса.
Также для удобства были введены методы who(), is<X>() и as<X>() в базовом классе.
Единственный минус этой схемы — обязательное объявление base, is_base_id(), id() и who() в каждом классе иерархии. Для удобства можно завернуть все в макрос с аргументом базового типа, делающий однотипные объявления.
Теперь внутри иерархии классов мы можем:
- проверять, является ли класс X наследником другого класса Y, конструкцией is_class<X>::of<Y>();
- проверять, инстанцирует ли объект z класс W методом z.is<W>().
Бонусом мы получили рантайм-информацию об идентификаторе типа методом who(). Если идентификатор поддерживает человекочитаемый формат, это может быть полезно для логирования и отладки.
Другим бонусом может стать безопасное приведение типов вверх по иерархии вспомогательным методом as<X>(), даже если исключения отключены. Просто в этом случае придется возвращать указатель и проверять его в лучших традициях пещерных предков, писавших на чистом C.
Уникальный идентификатор класса
Осталось разобраться, как идентифицировать класс. На самом деле подойдет абсолютно любой тип, поддерживающий сравнение. Имеет смысл создавать константу с идентификатором в первый вызов метода id() каждого класса.
Будет полезно, если при этом по id() можно будет понять, чей это идентификатор, то есть не лишним будет имя типа. Также важно понимать, что тело метода должно находиться не в заголовочном файле, а идентификатор должен единожды экспортировать уникальную сгенерированную константу.
Рассмотрим пример такого типа, по которому можно идентифицировать наследование любого класса иерархии.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
class class_id { public: class_id(const char* const name) : m_name(name), m_index(generate_unique()) { } const char* const name() const { return m_name; } int index() const { return m_index; } bool operator == (const class_id& another) const { return m_index == another.m_index; } private: const char* const m_name; int m_index; }; |
Если мы используем int, то его достаточно просто потокобезопасно инкрементить для генерации уникальных значений и получить миллиарды разных типов. Выходит, что class_id в целом необязателен, но узнать у объекта иерархии z его z.who().name() бывает очень и очень полезно.
В методе id() каждого класса нам в помощь static-константа с нужным именем.
1 2 3 4 |
const class_id& cat::id() { static const class_id cat_id("cat"); return cat_id; } |
Таким образом, все объявления методов id() и who() сведутся к объявлениям вида
1 2 3 4 |
class creature { public: static const class_id& id(); virtual const class_id& who() const { return id(); } |
Подчеркну, что генерация уникальных констант не должна инлайниться, а должна экспортироваться из единственного места, где создан ровно один экземпляр данного типа для каждого класса иерархии, включая инстансы шаблонов.
Безопасность — в удобстве
В очередной раз можно убедиться, что чем удобнее мы делаем для себя окружение разработки, тем безопаснее получается код и тем проще его поддерживать. Вместо грозящего нам леса проверок на идентификаторы в каждом втором методе мы автоматизировали проверку обхода всей иерархии до самого ее корня и запаковали в удобные читаемые методы.
Слабое место такого подхода — принудительное инстанцирование шаблонов наследников. Также этот метод не получится применять для проверки более одного предка, но можно добавить необходимые модификации.
В общем, мой совет — не мириться с вещами, которые вызывают сложности в поддержке кода — твоего или унаследованного. Простейшее объявление одного-единственного макроса в теле класса и объявление по одному методу аналогичного id() для класса решит любые проблемы с рекурсивной проверкой наследования и инстанцирования классов иерархии.