为什么“纯多态性”比使用RTTI更可取?

几乎所有我见过的关于这种事情的C ++资源都告诉我,我应该更喜欢使用RTTI(运行时types标识)的多态方法。 总的来说,我认真对待这种build议,并试图理解其中的基本原理 – 毕竟,C ++是一个强大的野兽,它的全面深入难以理解。 然而,对于这个特定的问题,我画了一个空白,想看看互联网可以提供什么样的build议。 首先,让我总结一下我所学到的东西,列举出为什么RTTI被认为是“有害”的常见原因:

一些编译器不使用它/ RTTI并不总是启用

我真的不买这个说法。 这就像说我不应该使用C ++ 14的function,因为有编译器不支持它。 然而,没有人会阻止我使用C ++ 14的function。 大多数项目将影响他们正在使用的编译器,以及如何configuration。 甚至引用海合会的手册页:

-fno-rtti

禁止生成有关虚拟函数的每个类的信息,供C ++运行时types标识特性(dynamic_cast和typeid)使用。 如果你不使用这些语言的部分,你可以使用这个标志来节省一些空间。 请注意,exception处理使用相同的信息,但G ++根据需要生成它。 dynamic_cast操作符仍然可以用于不需要运行时types信息的转换,即转换为“void *”或明确的基类。

这告诉我, 如果我不使用RTTI,我可以禁用它。 这就像说,如果你不使用Boost,你不必链接到它。 我不需要计划有人用-fno-rtti编译的情况。 另外,在这种情况下,编译器将会失败。

它需要额外的内存/可以很慢

每当我试图使用RTTI,这意味着我需要访问我的类的某种types的信息或特点。 如果我实现一个不使用RTTI的解决scheme,这通常意味着我将不得不添加一些字段到我的类来存储这些信息,所以内存参数是无效的(我将举一个更进一步的例子)。

dynamic_cast的确可以很慢。 不过,通常有避免使用速度危机的方法。 我不太明白这个select。 这个答案build议使用在基类中定义的枚举来存储types。 只有当你知道你所有的派生类时,这才有效。 这是一个相当大的“如果”!

从这个答案看来,RTTI的成本似乎也不明确。 不同的人测量不同的东西。

优雅的多态devise将使RTTI不必要

这是我认真对待的那种build议。 在这种情况下,我根本无法提出涵盖我的RTTI用例的好的非RTTI解决scheme。 让我举个例子:

假设我正在编写一个库来处理某些对象的graphics。 我想让用户在使用我的库时生成自己的types(所以枚举方法不可用)。 我有我的节点的基类:

 class node_base { public: node_base(); virtual ~node_base(); std::vector< std::shared_ptr<node_base> > get_adjacent_nodes(); }; 

现在,我的节点可以是不同的types。 这些怎么样:

 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(); }; 

最后一个function很有趣。 这是一个写它的方法:

 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; }; 

是一个types的std :: string一个好主意? 也许不是,但我还能用什么? 编一个号码,希望没有人使用它呢? 另外,在我的orange_node的情况下,如果我想使用red_node和yellow_node的方法呢? 我需要为每个节点存储多个types吗? 这似乎很复杂。

结论

这个例子看起来不太复杂或者不寻常(我正在做一些类似于我日常工作的东西,其中节点代表通过软件进行控制的实际硬件,而根据它们是什么完成不同的事情)。 然而,我不知道用模板或其他方法做一个干净的方式。 请注意,我试图理解这个问题,而不是捍卫我的例子。 我阅读过的页面,例如上面链接的SO回答,以及Wikibooks上的这个页面,似乎表明我在滥用RTTI,但是我想知道为什么。

所以,回到我的原始问题: 为什么'纯多态'比使用RTTI更可取?

一个接口描述了为了在给定的情况下进行交互而需要知道的代码。 一旦你用“你的整个types层次”扩展接口,你的接口“表面区域”变得巨大,这使得推理更难

举个例子,你的“捅橙子”意味着作为第三方,我不能效仿橙子! 你私下宣布了一个橙色types,然后使用RTTI使你的代码在与这种types交互时performance得特别。 如果我想成为“橙”,我必须在你的私人花园里。

现在,所有与“橙色”搭配的人都会与你的整个橙色types相连,并隐含在整个私人花园中,而不是一个明确的界面。

乍看起来,这看起来像是扩展有限接口而不必更改所有客户端(添加am_I_orange )的好方法,而是会发生什么,而不是僵化代码库,并阻止进一步的扩展。 特殊的橙色变成了系统function的固有特性,并且阻止你创build一个以不同的方式实现的橘子的“橘子”替代品,也许可以消除依赖或者优雅地解决其他一些问题。

这确实意味着你的界面必须足以解决你的问题。 从这个angular度来看,为什么你只需要捅橙子,如果是这样的话,为什么界面上没有橘子? 如果你需要一些模糊的标签,可以添加临时的,你可以添加到你的types:

 class node_base { public: bool has_tag(tag_name); 

这提供了一个类似的大规模扩大您的界面从狭义指定到广泛的标签为基础。 除了通过RTTI和实现细节(也就是“如何实现?使用橙色types?好吧,你通过了”),而是通过一个完全不同的实现来轻松地模拟。

如果你需要的话,这甚至可以扩展到dynamic方法。 “你是否支持与巴兹,汤姆和艾丽丝的辩论成为富人?好的,好吧,给你。 从一个大的意义上来说,这比dynamic演员阵容的侵扰性要一些,以确定另一个对象是你知道的types。

现在橘子物体可以有橙色的标签,一边玩,一边实施解耦。

它仍然会导致巨大的混乱,但至less是一堆消息和数据,而不是实现层次结构。

抽象是一个解耦和隐藏不相干的游戏。 它使代码更容易在本地推理。 RTTI是一个无聊的漏洞,贯穿于实现细节。 这可以使得解决问题变得更容易,但是却很容易将你locking在一个特定的实现中。

反对这种或那种特征的道德劝说的大部分是典型性来源于观察到这种特征有许多错误的使用。

道德家们失败的地方在于,他们认为所有的用法都是错误的,而事实上,这些特征是有原因的。

他们有我所谓的“水暖工”:他们认为所有的水龙头都是故障的,因为他们被要求修理的水龙头都是。 事实上,大多数水龙头都工作得很好:你根本就不给他们打水pipe工!

一个疯狂的事情是,为了避免使用一个给定的function,程序员编写了大量的样板代码,实际上私下重新实现了这个function。 (你有没有遇到过不使用RTTI或者虚拟调用的类,但是有一个跟踪它们的实际派生types的值呢?这只不过是伪装而已。

有一种通用的方法来思考多态: IF(selection) CALL(something) WITH(parameters) 。 (对不起,但是编程,不pipe抽象,都是关于这个的)

使用devise时(概念)编译时(基于模板推导),运行时(基于inheritance和虚函数)还是数据驱动(RTTI和交换)多态性取决于多less决策是已知的在生产的每一个阶段,以及它们在各个环境中的变化如何。

这个想法是:

您可以预见的越多,捕获错误的机会就越大,并避免影响最终用户的错误。

如果一切都是恒定的(包括数据),你可以使用模板元编程做所有事情。 在实际常量编译后,整个程序归结为一个返回语句,吐出结果

如果在编译时已经知道了一些情况,但是你不知道他们必须执行的实际数据,那么编译时多态性(主要是CRTP或类似的)可以是一个解决scheme。

如果案例的select取决于数据(不是编译时间已知的值),并且切换是单维的(可以做什么只能减less到一个值),那么基于虚函数的调度(或者一般的“函数指针表“)是必要的。

如果切换是多维的,因为在C ++中不存在本地的多个运行时分派 ,那么你必须:

  • 通过Goedelization减less到一个维度:这就是虚拟基础和多重inheritance,有钻石堆叠平行四边形的地方,但这需要知道可能的组合的数量并且相对较小。
  • 将维度链接到另一个维度(比如在复合访问者模式中,但是这要求所有的类都意识到他们的其他兄弟姐妹,因此它不能从它被设想的地方“缩放”出来)
  • 根据多个值调度调用。 这正是RTTI所要做的。

如果不仅仅是切换,但即使这些操作不是编译时间已知的,那么脚本和parsing是必需的:数据本身必须描述对它们采取的动作。

现在,由于我列举的每个案例都可以看作是后续案例的特例,所以可以通过滥用最底层的解决scheme解决所有问题,也可以解决最顶层的问题。

这是道德化实际上推动避免。 但这并不意味着生活在最底层的问题是不存在的!

抨击RTTI只是为了抨击它,就好像抨击它一样。 事情鹦鹉,而不是程序员。

在一个小例子中,它看起来很整洁,但在现实生活中,你很快就会遇到一系列可以互相攻击的types,其中一些types可能只是朝着一个方向发展。

那么dark_orange_node ,或者black_and_orange_striped_node或者dotted_node呢? 它可以有不同颜色的点吗? 如果大多数点是橙色的,那么它可以戳吗?

每次你必须添加一个新的规则,你将不得不重新访问所有的poke_adjacent函数,并添加更多的if语句。


一如往常,很难创build通用的例子,我会给你的。

但是,如果我要做这个具体的例子,我会添加一个poke()成员到所有的类,让他们中的一些忽略了这个调用( void poke() {} ),如果他们不感兴趣。

当然,这将比比较typeid s更便宜。

一些编译器不使用它/ RTTI并不总是启用

我相信你误解了这样的论点。

有很多C ++编码的地方,RTTI是不会被使用的。 编译器开关用于强制禁用RTTI。 如果你在这样一个范例中编码,那么你几乎肯定已经被告知了这个限制。

所以问题在于图书馆 。 也就是说,如果您正在编写一个依赖RTTI的库,那么closuresRTTI的用户将无法使用您的库。 如果你希望你的图书馆被那些人使用,那么即使你的图书馆也被可以使用RTTI的人使用,它也不能使用RTTI。 同样重要的是,如果你不能使用RTTI,那么你必须为图书馆稍微费力一些,因为RTTI的使用对你来说是一个难题。

它需要额外的内存/可以很慢

有很多事情你不做在热循环。 你不分配内存。 你不要通过链表来迭代。 等等。 RTTI当然可以是另外一个“不要在这里做”的东西。

但是,请考虑您的所有RTTI示例。 在所有情况下,您都有一个或多个不确定types的对象,并且您希望对它们执行某些操作,而这些操作可能对某些操作是不可能的。

这是你必须在devise层面解决的问题。 您可以编写不分配符合“STL”范例的内存的容器。 您可以避免链接列表数据结构,或限制其使用。 你可以将结构数组重新组织为数组的结构或其他。 它改变了一些东西,但你可以保持它的划分。

将一个复杂的RTTI操作改为常规的虚拟函数调用? 这是一个devise问题。 如果你必须改变这个,那么就需要对每个派生类进行修改。 它改变了很多代码与各种类的交互。 这种变化的范围远远超出了性能关键的代码部分。

所以……你为什么写错了方法呢?

我不需要定义属性或方法,我不需要它们,基本节点类可以保持精简和平均。

为了什么目的?

你说基类是“精益而意味”的。 但真的…这不存在 。 它实际上没有做任何事情

看看你的例子: node_base 。 它是什么? 这似乎是一个与其他事物相邻的东西。 这是一个Java接口(在此之前的genericsJava):只存在于用户可以转换为真实types的类。 也许你添加一些像adjacency(Java增加ToString )的基本function,但就是这样。

“精益和平均”与“透明”是有区别的。

正如Yakk所说,这种编程风格限制了互操作性,因为如果所有function都在派生类中,那么系统之外的用户无法访问该派生类,就无法与系统进行交互操作。 他们不能覆盖虚拟function并添加新的行为。 他们甚至无法调用这些function。

但是他们也做的是让实际上做新东西,即使在系统内,也是一大痛苦。 考虑你的poke_adjacent_oranges函数。 如果有人想要一个lime_nodetypes,可以像orange_node s那样戳动,会发生什么? 那么,我们不能从orange_node派生orange_node ; 这是没有意义的。

相反,我们必须添加一个从node_base派生的新的node_base 。 然后将poke_adjacent_oranges的名称poke_adjacent_orangespoke_adjacent_pokables 。 然后,尝试投射到orange_nodelime_node ; 无论哪个演员作品都是我们所追求的。

但是, lime_node需要它自己的 poke_adjacent_pokables 。 而这个function需要做相同的铸造检查。

如果我们添加第三种types,我们不仅要添加自己的function,而且还必须改变其他两个类的function。

显然,现在你可以使poke_adjacent_pokables成为一个自由的函数,这样它就可以工作。 但是,如果有人添加第四种types并忘记将其添加到该函数,您会发生什么?

你好, 无声的破损 。 该程序似乎工作或多或less好,但事实并非如此。 如果poke是一个实际的虚函数,那么当你没有覆盖来自node_base的纯虚函数时,编译器会失败。

用你的方式,你没有这样的编译器检查。 哦,当然,编译器不会检查非纯虚拟,但至less在有可能进行保护的情况下(例如:没有默认操作),您有保护。

透明的基类与RTTI的使用导致维护的噩梦。 事实上,RTTI的大多数用途导致维护令人头痛。 这并不意味着RTTI是无用的 (例如,对于进行boost::any工作都是至关重要的)。 但是对于非常专业的需求来说这是一个非常专业的工具

这样就像goto一样“有害”。 这是一个有用的工具,不应该被废除。 但是在你的代码中使用它应该是很less见的。


所以,如果你不能使用透明的基类和dynamic转换,你怎么避免胖接口? 你如何避免冒泡每一个你可能需要调用的函数,从冒泡到基类呢?

答案取决于基类是什么。

node_base这样的透明基类只是使用错误的工具来解决问题。 链接列表最好由模板处理。 节点types和邻接将由模板types提供。 如果你想在列表中放置一个多态types,你可以。 只需在模板参数中使用BaseClass*作为T 或者你喜欢的智能指针。

但还有其他的情况。 一个是做很多事情的types,但是有一些可选的部分。 一个特定的实例可能会实现某些function,而另一个则不会。 但是,这种types的devise通常会提供正确的答案。

“实体”课是一个很好的例子。 这个类已经长期困扰游戏开发者。 从概念上讲,它有一个巨大的界面,生活在十几个完全不同的系统的交汇处。 而不同的实体有不同的属性。 一些实体没有任何可视化表示,所以它们的渲染function什么都不做。 这一切都是在运行时确定的。

现代的解决scheme是一个组件式的系统。 Entity仅仅是一组组件的容器,它们之间有一些粘合剂。 一些组件是可选的; 没有可视化表示的实体没有“graphics”组件。 没有AI的实体没有“控制器”组件。 等等。

这样的系统中的实体只是指向组件的指针,其大部分接口是通过直接访问组件来提供的。

开发这样一个组件系统需要在devise阶段认识到,某些function在概念上是组合在一起的,所有实现它的types将全部实现。 这使您可以从预期的基类中提取类,并使其成为一个独立的组件。

这也有助于遵循单一责任原则。 这样的组件化的类只有成为组件持有者的责任。


来自Matthew Walton:

我注意到许多答案没有注意到你的例子build议node_base是一个库的一部分,用户将做出自己的节点types的想法。 然后他们不能修改node_base来允许另一个解决scheme,所以RTTI可能成为他们最好的select。

好的,我们来探讨一下。

为了有意义,你将不得不有一个库L提供一个容器或其他结构化的数据持有人的情况。 用户可以添加数据到这个容器,遍历它的内容等等。但是,这个库并没有对这个数据做任何事情。 它只是pipe理它的存在。

但它甚至没有像它的毁灭那样pipe理它的存在。 原因是,如果你希望使用RTTI来达到这个目的,那么你正在创buildL是无知的类。 这意味着您的代码分配对象并将其交给L进行pipe理。

现在,有些情况下,这样的东西是合法的devise。 事件信号传递/消息传递,线程安全的工作队列等等。这里的一般模式是这样的:有人在适合于任何types的两段代码之间执行服务,但服务不需要知道涉及的具体types。

在C中,这种模式拼写为void* ,它的使用需要非常小心以避免被破坏。 在C ++中,这个模式拼写std::experimental::any (很快被拼写std::any )。

这个工作的方式是L提供了一个node_base类,它接受一个代表你的实际数据的类。 当你收到消息,线程队列工作项目,或者你正在做的任何事情时,你把它们转换为适当的types,发送者和接收者都知道。

因此, orange_nodenode_data派生orange_node ,而只需在node_dataany成员字段内部node_data一个orange 。 最终用户提取它并使用any_cast将其转换为orange 。 如果演员失败,那么它不是orange

现在,如果你完全熟悉any的实现,你可能会说,“嘿等一下: any 内部使用RTTI来使any_cast工作。” 我回答,“是的”。

这是抽象的一点。 深入细节,有人正在使用RTTI。 但是在你应该在的层面上,直接RTTI不是你应该做的事情。

你应该使用提供你想要的function的types。 毕竟,你不是真的想要RTTI。 你想要的是一个数据结构,可以存储给定types的值,除了所需的目的地之外,将其隐藏起来,然后将其转换回该types,并validation存储的值实际上是那种types。

这就是所谓的。 它使用 RTTI,但使用any远远优于直接使用RTTI,因为它更正确地适合所需的语义。

如果你调用一个函数,通常你并不在乎它将采取什么精确的步骤,只是在一定的约束条件下(以及函数如何实现这个问题实际上是它自己的问题)才能实现一些更高层次的目标。

当你使用RTTI来预先select可以完成某项工作的特殊对象,而同一组中的其他对象则不能这样做时,你就打破了这个舒适的世界观。 突然间,主叫方应该知道谁可以做什么,而不是简单地告诉他的随从继续。 有人对此感到困扰,我怀疑这是RTTI被认为有点脏的原因的很大一部分。

是否有性能问题? 也许,但是我从来没有经历过,可能是二十年前的智慧,或者是那些诚实地相信用三条汇编指令而不是两条指令的人是不可接受的膨胀。

因此,如何处理它…根据您的情况,将任何节点特定的属性捆绑到单独的对象(即整个“橙色”API可以是单独的对象)可能是有意义的。 然后根对象可以有一个虚函数来返回“橙色”的API,默认返回nullptr非橙色的对象。

虽然这可能是根据您的情况矫枉过正,但它可以让您在根级别上查询特定节点是否支持特定的API,如果是,则执行特定于该API的函数。

C ++build立在静态types检查的基础上。

[1] RTTI,即dynamic_casttype_id ,是dynamictypes检查。

So, essentially you're asking why static type checking is preferable to dynamic type checking. And the simple answer is, whether static type checking is preferable to dynamic type checking, depends . On a lot. But C++ is one of the programming languages that are designed around the idea of static type checking. And this means that eg the development process, in particular testing, is typically adapted to static type checking, and then fits that best.


回覆

I wouldn't know a clean way of doing this with templates or other methods

you can do this process-heterogenous-nodes-of-a-graph with static type checking and no casting whatsoever via the visitor pattern, eg like this:

 #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(); } 

This assumes that the set of node types is known.

When it isn't, the visitor pattern (there are many variations of it) can be expressed with a few centralized casts, or, just a single one.


For a template-based approach see the Boost Graph library. Sad to say I am not familiar with it, I haven't used it. So I'm not sure exactly what it does and how, and to what degree it uses static type checking instead of RTTI, but since Boost is generally template-based with static type checking as the central idea, I think you'll find that its Graph sub-library is also based on static type checking.


[1] Run Time Type Information .

Of course there is a scenario where polymorphism can't help: names. typeid lets you access the name of the type, although the way this name is encoded is implementation-defined. But usually this is not a problem since you can compare two typeid -s:

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

The same holds for hashes.

[…] RTTI is "considered harmful"

harmful is definitely overstating: RTTI has some drawbacks, but it does have advantages too.

You don't truly have to use RTTI. RTTI is a tool to solve OOP problems: should you use another paradigm, these would likely disappear. C doesn't have RTTI, but still works. C++ instead fully supports OOP and gives you multiple tools to overcome some issue that may require runtime information: one of them is indeed RTTI, which though comes with a price. If you can't afford it, thing you'd better state only after a secure performance analysis, there is still the old-school void* : it's free. Costless. But you get no type safety. So it's all about trades.


  • Some compilers don't use / RTTI is not always enabled
    I really don't buy this argument. It's like saying I shouldn't use C++14 features, because there are compilers out there that don't support it. And yet, no one would discourage me from using C++14 features.

If you write (possibly strictly) conforming C++ code, you can expect the same behavior regardless of the implementation. Standard-compliant implementations shall support standard C++ features.

But do consider that in some environments C++ defines («freestanding» ones), RTTI need not be provided and neither do exceptions, virtual and so on. RTTI needs an underlying layer to work correctly that deals with low-level details such as the ABI and the actual type information.


I agree with Yakk regarding RTTI in this case. Yes, it could be used; but is it logically correct? The fact that the language allows you to bypass this check does not mean it should be done.