为什么“纯多态性”比使用运行时类型识别更可取?

111
几乎我所见过的所有关于这种问题的C++资源都告诉我,我应该优先考虑使用多态方法而不是运行时类型识别(RTTI)。一般来说,我会认真对待这种建议,并试图理解其原理 - 毕竟,C ++是一个强大的野兽,很难完全理解。然而,对于这个特定的问题,我还是有些困惑,想知道互联网能提供什么样的建议。首先,让我总结一下到目前为止我学到的东西,列出引用RTTI被认为“有害”的常见原因:

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

我真的不认同这个论点。这就像说我不应该使用C ++14功能,因为存在不支持它的编译器。然而,没有人会劝阻我使用C ++14功能。大多数项目将对他们正在使用的编译器及其配置产生影响。即使引用gcc手册:

-fno-rtti

禁用生成关于每个具有虚函数的类的信息,以供C++运行时类型识别功能(dynamic_cast和typeid)使用。如果您不使用语言的这些部分,则可以通过使用此标志节省一些空间。请注意,异常处理使用相同的信息,但G ++根据需要生成它。动态转换运算符仍然可以用于不需要运行时类型信息的转换,即转换为“void *”或非歧义基类。

这告诉我,如果我没有使用RTTI,我可以禁用它。这就像说,如果您没有使用Boost,则不必链接到它。我不必考虑某人正在使用-fno-rtti进行编译的情况。此外,编译器在这种情况下会明确失败。

它会消耗额外的内存/可能会变慢

每当我想使用RTTI时,这意味着我需要访问类的某种类型信息或特征。如果我实现一个不使用RTTI的解决方案,通常意味着我将不得不向我的类添加一些字段来存储这些信息,所以内存参数有点无效(我将在下面举例说明)。

dynamic_cast可能会很慢。但是通常有方法可以避免在速度关键的情况下使用它。而且我并没有看到其他替代方法。 这个SO答案 建议在基类中定义一个枚举来存储类型。这仅适用于您预先知道所有派生类的情况。那是一个相当大的“如果”!

从那个答案中,似乎RTTI的成本也不清楚。不同的人测量不同的东西。

优雅的多态设计将使RTTI变得不必要

这是我认真考虑的建议。在这种情况下,我无法想出良好的非RTTI解决方案来覆盖我的RTTI使用情况。让我举个例子:

假设我正在编写一个处理某种对象图形的库。我希望允许用户在使用我的库时生成他们自己的类型(因此枚举方法不可用)。我有一个基类来表示节点:

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答案和this page on Wikibooks等页面,似乎表明我误用了RTTI,但我想学习原因。

所以,回到我的最初问题:为什么“纯粹的多态性”比使用RTTI更可取?


9
要解决你所提出的橘子戳问题,你需要一种叫做多重派发(“多方法”)的语言特性。因此,寻找模拟它的方式可能是一种替代方法。通常情况下,使用访问者模式来实现。 - Daniel Jour
4
不会,RTTI代表“运行时类型信息”,而CRTP仅适用于模板——静态类型。 - edmz
2
@mbr0wn:所有的工程过程都受到一些规则的约束,编程也不例外。这些规则可以分为两个桶:规则(SHOULD)和规则(MUST)。 (还有一个建议/选项桶(COULD),可以这么说。)阅读C/C++标准(或任何其他工程标准)如何定义这些规则。我猜你的问题来自于你误将“不使用RTTI”视为规则(“你不能使用RTTI”)。实际上,它是一个软规则(“你不应该使用RTTI”),意味着你应该尽可能避免使用它-只有在无法避免时才使用 - user719662
4
我注意到很多答案没有提及你的例子暗示了 node_base 是库的一部分,用户将会创建他们自己的节点类型。那么他们无法修改 node_base 来允许另一个解决方案,所以也许运行时类型信息(RTTI)就成为他们最好的选择。另一方面,有其他方法来设计这样一个库,使得新的节点类型可以更加优雅地适应其中,而不需要使用 RTTI(也有其他设计新节点类型的方式)。 - Matthew Walton
1
@ruakh 我不这么认为;我甚至看到了一场关于关闭/重新打开这个问题的风暴,主要是因为我所描述的原因。目前,这里所有对这个问题的回答都基于推理、逻辑和经验——而不是硬性、可验证的事实——因为当涉及设计决策时,通常没有硬性的可验证事实。OP希望我们给他一些——他不会得到它们,因为它们不存在。这个问题是一个“为什么……?”——而不应该是“何时……?”!顺便说一句,总是有可能用足够多的猴子来替换计算引擎。 - user719662
显示剩余6条评论
8个回答

70

接口描述了在代码中与特定情况进行交互所需的知识。一旦你使用“整个类型层次结构”扩展了接口,你的接口表面积将变得很大,这使得对其进行推理变得更加困难。

例如,你的“poke相邻的橙子”意味着我作为第三方无法模拟成为一个橙子!你私下声明了一个橙子类型,然后使用RTTI使你的代码在与该类型交互时表现特殊。如果我想“成为橙子”,我必须在你的私人花园内。

现在,每个与“orangeness”相关的人都与你的整个橙子类型以及隐含的整个私人花园耦合,而不是与一个定义好的接口耦合。

虽然乍一看这似乎是一种扩展有限接口而无需更改所有客户端(添加am_I_orange)的好方法,但实际上它会使代码库僵化,并防止进一步扩展。特殊的“orangeness”成为系统运行的固有部分,并阻止你创建一个“橘子替代品”,这个替代品以不同的方式实现,也许可以消除某些依赖或优雅地解决其他问题。

这确实意味着你的接口必须足以解决你的问题。从这个角度来看,你为什么只需要poke橙子?如果需要一些模糊的标签集合可以随意添加,那么你可以将其添加到你的类型中:

class node_base {
  public:
    bool has_tag(tag_name);

这提供了一种类似的、从狭隘的指定界面到广泛的基于标签的界面的大规模扩展。但是,它不是通过RTTI和实现细节(也就是“你是如何实现的?使用橙色类型吗?好的,你通过了。”)来实现的,而是通过某些东西,在完全不同的实现中很容易模拟。

如果需要的话,这甚至可以扩展到动态方法。"你支持用Baz、Tom和Alice作为参数Foo吗?好的,Fooing你。"在很大程度上,这比通过动态转换获取其他对象是您所知道的类型更不具有侵入性。

现在,橘子对象可以拥有橙色标签并参与其中,同时又与实现分离。

这仍然可能会导致一团糟,但至少它是一堆信息和数据的混乱,而不是实现层次结构的混乱。

抽象是解耦和隐藏无关因素的游戏。它使得代码在局部推理起来更容易。RTTI直接穿过抽象,进入实现细节。这可能会使解决问题变得更容易,但代价是很容易将你锁定在一个特定的实现中。


14
+1 是指赞同最后一个段落;这不仅是因为我同意你的观点,而且也因为它正中要点。 - user719662
7
一旦知道某个对象支持特定功能,如何获取该特定功能?这可能涉及到强制类型转换,或者有一个拥有所有可能成员函数的上帝类。第一种可能性是未经检查的强制类型转换,这种情况下标记只是自己非常容易出错的动态类型检查方案,或者进行了检查的dynamic_cast(RTTI),这种情况下标记是多余的。第二种可能性是上帝类,这是可憎的。总之,这个答案听起来很好,适合Java程序员,但实际内容是毫无意义的。 - Cheers and hth. - Alf
2
@Falco:这是我提到的第一种可能性之一,基于标签的未检查转换。在这里,标记是自己非常脆弱和容易出错的动态类型检查方案。任何小的客户端代码不当行为,在 C++ 中就会进入 UB(未定义行为)状态。你不会像在 Java 中一样得到异常,但会出现 Undefined Behavior,例如崩溃和/或错误结果。除了极其不可靠和危险之外,与更合理的 C++ 代码相比,它还极其低效。换句话说,它非常非常不好;极其如此。 - Cheers and hth. - Alf
3
因为“多态性”意味着能够处理各种类型。如果你必须检查它是否是一个特定类型,超出了你用来获取指针/引用的已有类型检查,那么你就不是在使用多态性。你没有使用多种类型;你正在使用一个特定类型。是的,在Java中有一些“(大型)项目”这样做。但那是Java;该语言只允许动态多态性。C++也有静态多态性。另外,仅仅因为某个“大佬”这样做并不意味着这是一个好主意。 - Nicol Bolas
1
@NicolBolas 我同意在大多数情况下有更好的选择,特别是在 C++ 模板的强大支持下。如果有人认为在他的场景中使用 RTTI 是个好主意,也许他应该再考虑一下。但我不认为这可以概括。我看到过几个 C++ 库使用枚举和类成员来标识类的类型。通常用于类似事件的东西。这就是库本身实现的 RTTI。为什么要为每个派生类添加这个不必要的行?这样做容易出错,灵活性差,而且需要更多的工作量。 - JojOatXGME
显示剩余4条评论

34

大部分针对某个特性的道德劝诱都是因为观察到该特性存在一些被误解的用法。

但是,道德主义者失败的地方在于他们假设所有使用都是误解的,而实际上每个特性都有存在的理由。

他们有一个我曾经称之为“水管工情结”的想法:他们认为所有的水龙头都失灵了,因为他们只被叫去修理损坏的水龙头。事实上,大多数水龙头运作良好:你只是不需要叫水管工来修理它们!

一个疯狂的事情是,为了避免使用某个给定的特性,程序员会写很多样板代码来私下重新实现那个特性。(你见过不使用RTTI和虚函数调用的类吗?但是却有一个值来跟踪它们是哪个实际派生类型吗?那只是伪装成RTTI再发明罢了。)

有一种普遍的思考多态性的方式:如果(选择)调用(某个内容)并使用(参数)。(抱歉,当我们忽略抽象时,编程就是这样)

设计时(概念)、编译时(基于模板推导)、运行时(基于继承和虚函数)或数据驱动(RTTI和切换)多态性的使用取决于在每个生产阶段决策有多少已知,以及在每个情境中它们有多么变量。

思想是:越能预测,就越有可能发现错误并避免影响最终用户的错误。

如果一切都是常量(包括数据),则可以使用模板元编程来完成所有工作。在实际化的常量上进行编译后,整个程序就归结为只有一个返回语句,输出结果。

如果有一些情况在编译时已知,但您不知道它们要进行的实际数据操作,则编译时多态性(主要是CRTP或类似方法)可以解决问题。
如果情况的选择取决于数据(而不是编译时已知的值),并且切换是单维的(只能将要做的事情简化为一个值),则需要基于虚函数的分发(或通用的“函数指针表”)。
如果切换是多维的,因为C ++中不存在原生的“多个运行时分派”,则必须执行以下操作之一:
- 通过Gödelization将其减少到一维:其中包括虚基类和多重继承,具有“菱形”和“堆叠平行四边形”,但这要求可能的组合数已知且相对较小。 - 将一个维度链接到另一个维度中(如组合访问者模式中所示,但这要求所有类都知道其其他兄弟姐妹,因此无法从其构思的地方“扩展”出来)。 - 基于多个值分配调用。 这正是RTTI的用途。
如果不仅是切换,甚至操作也不是编译时已知的,则需要脚本和解析:数据本身必须描述要对它们采取的操作。
现在,由于我列举的每种情况都可以视为其后面的特殊情况,因此您可以通过滥用最底层的解决方案来解决每个问题,即使是顶层也可以承受的问题。
这就是道德化实际上推动避免的事情。 但这并不意味着生活在最底层领域的问题不存在!
仅仅因为反对而反对RTTI,就像只是为了反对而反对goto一样。 对鹦鹉而言的东西,而不是程序员。

每种方法适用的层次有一个很好的说明。尽管我没有听说过“哥德尔化”,但它是否有其他名称?你可以添加一个链接或更多的解释吗?谢谢 :) - j_random_hacker
1
@j_random_hacker:我也对这种使用哥德尔化的方法很好奇。通常我们认为哥德尔化首先是将某些字符串映射到某些整数,其次使用这种技术在形式语言中产生自指语句。但是我对虚拟分派中使用这个术语并不熟悉,希望能够学习更多相关知识。 - Eric Lippert
1
事实上,我滥用了这个术语:根据Goedle的说法,由于每个整数对应一个整数n-ple(其质因数的幂),并且每个n-ple对应一个整数,因此每个离散n维索引问题都可以简化为单维问题。这并不意味着这是唯一的方法:这只是一种表达“可能性”的方式。你所需要的就是“分而治之”的机制。虚函数是“分”,多重继承是“治”。 - Emilio Garavaglia
当所有操作都在一个有限域(范围)内进行时,线性组合更为有效(经典的i = r*C+c用于获取矩阵单元格数组中的索引)。在这种情况下,除法是“访问者”,而征服则是“复合体”。由于涉及线性代数,因此在这种情况下使用的技术对应于“对角化”。 - Emilio Garavaglia
不要把这些都看作是技巧。它们只是类比 - Emilio Garavaglia
2
一个疯狂的事情是,为了避免使用某个特定功能,程序员编写了大量毫无意义的代码,实际上是在私下重新实现了该功能。[狂按点赞按钮] "仅仅为了抨击RTTI而抨击它,就像仅仅为了抨击goto而抨击它一样。这只适用于鹦鹉学舌的人,而不是程序员。"[继续狂按,徒劳无功] 除此之外,对于这种模式、其适用性以及替代方案的精彩总结。对于那些被教导去憎恨锤子的人来说,每个钉子都是......嗯,锤子......哦,你知道我是什么意思的。 - underscore_d

23

在一个小例子中看起来很整洁,但在现实生活中,你很快就会得到一组可以相互影响的类型,其中一些可能只有单向影响。

那么dark_orange_nodeblack_and_orange_striped_node或者dotted_node呢?它们的点能是不同的颜色吗?如果大多数点是橙色的,它也能被触发吗?

每次添加新规则时,你都需要重新审查所有poke_adjacent函数并添加更多的if语句。


像往常一样,要创建通用示例很难。

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

毫无疑问,这比比较 typeids 更加经济实惠。


3
你说“当然”,但是你怎么这么确定呢?这真的是我试图弄清楚的。假设我将orange_node重新命名为pokable_node,并且它们是我唯一可以调用poke()方法的节点。这意味着我的接口将需要实现一个poke()方法,例如抛出异常(“此节点不可poke”)。这似乎更加耗费资源。 - mbr0wn
2
他为什么需要抛出异常?如果你关心接口是否可操作,只需添加一个“isPokeable”函数并在调用poke函数之前首先调用它。或者就像他说的那样,在不可操作的类中“什么也不做”。 - Brandon
2
@NicolBolas 为什么你想让友好和敌对的怪物共享相同的基类,或者可聚焦和不可聚焦的UI元素,或者带数字键盘和不带数字键盘的键盘? - user253751
1
@mbr0wn 这听起来像是行为模式。基础接口有两个方法 supportsBehaviourinvokeBehaviour,每个类都可以拥有一组行为列表。其中一个行为可能是 Poke,并且可以被所有想要被 Poke 的类添加到支持的行为列表中。 - Falco
1
@BoPersson 是的,在大多数情况下,使用空实现就足够了。但正如已经写过的那样,它可能会更加复杂。也许一个算法需要5个函数,并且应该忽略不支持它的类型的对象。当然,你可以将所有函数添加到基类中。但是,由于你必须修改基类才能添加这样的算法,所以你会失去可扩展性。即使你只是搞砸了基类的接口,参考文档也更难理解。如果你部分实现一个接口,你将得不到编译时错误。 - JojOatXGME
显示剩余4条评论

20
一些编译器不使用它/RTTI并非总是启用 我认为你误解了这样的论点。 有许多C++编码场景不应使用RTTI。编译器开关被用于强制禁用RTTI。如果您在这样的范式中进行编码...那么您几乎肯定已经被告知了这个限制。 因此,问题出现在库中。也就是说,如果您正在编写依赖RTTI的库,则关闭RTTI的用户无法使用您的库。如果您希望这些人使用您的库,则即使您的库也由可以使用RTTI的人使用,它也不能使用RTTI。同样重要的是,如果您无法使用RTTI,则必须更加努力地寻找库,因为RTTI使用对您来说是一个交易终止者。 它会消耗额外的内存/可能会很慢 在热循环中有许多事情是您不做的。您不分配内存。您不会遍历链接列表。等等。当然,RTTI也可以成为那些“不要在这里做这件事”的东西之一。
然而,请考虑你的RTTI示例。在所有情况下,您都有一个或多个类型不确定的对象,并且您希望对它们执行一些操作,这些操作可能对其中某些对象不可能。

这是您必须在设计层面上解决的问题。您可以编写不分配内存的容器,符合“STL”范例。您可以避免使用链表数据结构,或限制其使用。您可以将结构体数组重新组织为数组的结构体或其他形式。这会改变一些东西,但您可以将其隔离起来。

将复杂的RTTI操作更改为常规虚函数调用?这是一个设计问题。如果您必须更改它,则需要更改每个派生类。它会改变许多代码与各种类交互的方式。这种更改的范围远远超出了性能关键代码的范围。

所以...为什么一开始要写错呢?

我不必在不需要的地方定义属性或方法,基本节点类可以保持简洁。

为了什么目的?

你说基类是“精简而高效的”,但实际上...它是不存在的。它实际上并不执行任何操作。
看看你的示例:node_base。它是什么?它似乎是一个具有相邻其他事物的东西。这是一个Java接口(甚至是没有泛型的Java):一个仅存在于用户可以将其强制转换为真正类型的类。也许您会添加一些基本功能,例如相邻性(Java添加ToString),但就是这样。
“精简而高效”和“透明”之间存在差异。
正如Yakk所说,这种编程风格在互操作性方面受到限制,因为如果所有功能都在派生类中,则没有访问该派生类的系统外部用户无法与该系统进行互操作。他们无法覆盖虚函数并添加新行为。他们甚至不能调用这些函数。
但是它们也使得在系统内实现新功能变得十分困难。考虑你的 poke_adjacent_oranges 函数。如果有人想要一个可以像 orange_node 一样被 poke 的 lime_node 类型怎么办呢?我们不能从 orange_node 派生 lime_node,这没有意义。

相反,我们必须添加一个新的从 node_base 派生的 lime_node。然后将 poke_adjacent_oranges 的名称更改为 poke_adjacent_pokables。然后,尝试将其转换为 orange_nodelime_node;无论哪种转换起作用,我们就 poke 哪个。

然而,lime_node 需要它自己的 poke_adjacent_pokables。这个函数需要进行相同的转换检查。

如果我们添加第三种类型,我们不仅需要添加它自己的函数,还必须更改其他两个类中的函数。

显然,现在你将poke_adjacent_pokables变成了一个自由函数,这样它就能为所有类型工作。但是如果有人添加了第四种类型并忘记将其添加到该函数中,会发生什么呢?
你好,静默的破坏。程序似乎工作得还不错,但实际上并不是这样。如果poke是一个真正的虚函数,当你没有重写node_base中的纯虚函数时,编译器会失败。
使用你的方法,你没有这样的编译器检查。当然,编译器不会检查非纯虚函数,但至少在可能的情况下(即:没有默认操作),你有保护措施。
使用带RTTI的透明基类会导致维护上的噩梦。事实上,大多数使用RTTI都会导致维护头痛。这并不意味着RTTI没有用处(例如,它对于使boost::any工作至关重要)。但它是一个非常专业化的工具,只适用于非常特殊的需求。
以这种方式,它与goto一样“有害”。它是一个有用的工具,不应该被淘汰。但是,在您的代码中,使用它应该是罕见的
因此,如果您无法使用透明基类和动态转换,那么如何避免肥大的接口?如何防止您可能想要在类型上调用的每个函数都向基类冒泡?
答案取决于基类的用途。
像node_base这样的透明基类只是在使用错误的工具解决问题。模板最适合处理链表。节点类型和邻接将由模板类型提供。如果您想将多态类型放入列表中,则可以。只需在模板参数中使用BaseClass *作为T。或者您喜欢的智能指针。
但是还有其他情况。其中之一是执行许多操作但具有某些可选部分的类型。特定实例可能会实现某些功能,而另一个实例则不会。但是,这种类型的设计通常提供了正确的答案。
“实体”类就是一个完美的例子。这个类长期以来一直困扰着游戏开发人员。从概念上讲,它具有巨大的接口,位于几乎十几个完全不同的系统的交集处。并且不同的实体具有不同的属性。某些实体没有任何视觉表示,因此它们的渲染函数什么也不做。而这全部是在运行时确定的。
现代的解决方案是组件式系统。 Entity 只是一组组件的容器,它们之间有一些粘合剂。某些组件是可选的;没有视觉表示的实体没有“图形”组件。没有 AI 的实体没有“控制器”组件。等等。
在这样的系统中,实体只是指向组件的指针,大部分接口是通过直接访问组件提供的。
开发这样的组件系统需要在设计阶段识别出某些功能在概念上被分组在一起,所有实现其中一个的类型都将实现它们全部。这允许您从潜在的基类中提取类并将其作为单独的组件。
这也有助于遵循单一职责原则。这样的组件化类只负责成为组件的持有者。

来自Matthew Walton:

我注意到很多答案没有提到你的例子表明node_base是库的一部分,用户将创建自己的节点类型。然后他们无法修改node_base以允许另一种解决方案,因此RTTI可能成为他们的最佳选择。

好的,让我们探讨一下。

要使这个有意义,您必须有这样一种情况:某个库L提供了一个数据容器或其他结构化的数据持有者。用户可以向该容器添加数据、迭代其内容等。但是,该库实际上并不处理这些数据;它只是管理其存在。

但它甚至不是管理其存在,而是管理其销毁。原因是,如果你期望为这些目的使用RTTI,那么你正在创建L不知道的类。这意味着你的代码分配对象并将其移交给L进行管理。

现在,有些情况下,这样的设计是合理的。事件信号/消息传递、线程安全的工作队列等。这里的一般模式是:有人在两个代码之间执行适用于任何类型的服务,但该服务不需要知道所涉及的具体类型。

在C语言中,这种模式被称为void*,使用时需要非常小心以避免出错。在C++中,这种模式被称为std::experimental::any(即将更名为std::any)。
理想情况下,L提供了一个node_base类,该类接受表示实际数据的any。当您接收消息、线程队列工作项或任何其他操作时,您将把该any转换为其适当的类型,发送方和接收方都知道该类型。
因此,不必从node_data派生orange_node,只需将orange放入node_dataany成员字段中即可。最终用户提取它并使用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
回复:性能成本 - 我在我们应用程序上使用 3GHz 处理器测试动态转换 dynamic_cast<>,成本约为 2 微秒,比检查枚举慢大约 1000 倍。(我们的应用程序有 11.1 毫秒的主循环截止时间,所以我们非常关注微秒级别的性能。) - Crashworks
6
不同实现的性能差异很大。GCC使用类型信息指针比较,速度较快;而MSVC使用字符串比较,速度不够快。但是,MSVC的方法可以与链接到不同版本库(静态或DLL)的代码一起使用,而GCC的指针方法认为静态库中的类与共享库中的类不同。 - Zan Lynx
1
@Crashworks 只是为了完整记录这里:使用的编译器是哪个(以及哪个版本)? - H. Guijt
@underscore_d: MSVC。 - Crashworks
@Crashworks 谢谢。有趣的是,我刚刚在这里找到了你关于这个问题的帖子 - https://dev59.com/4XRB5IYBdhLWcg3wl4EQ#4334493 - 这里显示GCC在那些测试中大致相同,是吗?如果你还记得,我很想知道它们的版本。 - underscore_d
显示剩余2条评论

9

C++是建立在静态类型检查的思想上。

[1] RTTI,即dynamic_casttype_id,是动态类型检查。

因此,基本上您正在询问为什么静态类型检查比动态类型检查更可取。简单的答案是,是否优先选择静态类型检查,取决于许多因素。但C++是围绕静态类型检查的思想设计的编程语言之一。这意味着例如开发过程,特别是测试,通常适应于静态类型检查,然后最好适配。


关于:

我不知道有没有一个使用模板或其他方法的干净方法来做到这一点

您可以通过访问者模式(visitor pattern)使用静态类型检查而无需转换来执行此过程-异构节点图,例如:

#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通常基于模板且静态类型检查是其核心思想,我认为您会发现其图形子库也基于静态类型检查。


[1] 运行时类型信息


1
需要注意一件“有趣的事”是,可以通过使用RTTI来“爬升”层次结构以减少访问者模式所需的代码量(在添加类型时所作出的更改)。我知道这被称为“非循环访问者模式”。 - Daniel Jour

3
当然,有一个场景是多态无法帮助的:名称。typeid 可以让你访问类型的名称,尽管这个名称的编码方式是由实现定义的。但通常这不是问题,因为你可以比较两个 typeid
if ( typeid(5) == "int" )
    // may be false

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

哈希也一样。
引用部分:
[...]RTTI被视为“有害的”。
“有害”肯定是言过其实了:RTTI有一些缺点,但它确实也有优点。
您并不真正需要使用RTTI。RTTI是解决面向对象编程问题的工具:如果您使用另一种范式,这些问题可能会消失。C没有RTTI,但仍然可以工作。相反,C++完全支持OOP,并为您提供多个工具来克服某些可能需要运行时信息的问题:其中一个就是RTTI,但是这种工具会带来代价。如果您负担不起该代价,请在进行安全性能分析后再做决定,还有“老派”的void*可供选择:它是免费的、成本为零的。但您无法获得类型安全。所以这一切都是关于交易的。
分割线
引用部分:
- 一些编译器不使用/ RTTI不总是启用 我真的不相信这个论点。这就好像说我不应该使用C++14特性,因为存在一些不支持它的编译器。然而,没有人会劝我不要使用C++14特性。
如果您编写(可能是严格)符合C++标准的代码,则可以期望在任何实现中都获得相同的行为。符合标准的实现应支持标准C++功能。
但请注意,在某些环境中,C++定义(“独立”定义)可能不会提供RTTI,也不会提供异常、虚拟等等。RTTI需要底层层次才能正常工作,这些层次处理底层细节,如ABI和实际类型信息。
分割线
在这种情况下,我同意Yakk关于RTTI的观点。是的,它可以使用;但是逻辑上是否正确呢?语言允许您绕过此检查并不意味着应该这样做。

0

如果您可以在编译时枚举参与图形的类型集合,则可以使用std::variant替换dynamic_cast()的使用。这应该更有效率。它还可以将未处理的节点类型变成编译时错误。当然,节点类型集合可能只在运行时知道,那么您几乎肯定需要某种形式的RTTI。https://godbolt.org/z/TPjxa1G6M

#include <unordered_map>
#include <variant>
#include <type_traits>
#include <cstdio>

template <class... Ts>
struct graph {
    using key = std::variant<Ts const*...>;
    using value = std::variant<Ts*...>;
    std::unordered_multimap<key, value> edge;

    template <class callable>
    void visit_adjacent(key n, callable f) const {
        auto [i, e] = edge.equal_range(n);

        for (; i != e; ++i) {
            std::visit(f, i->second);
        }
    }
};



struct red {};
struct yellow {};
struct orange : red, yellow {
    void poke() { std::printf("poke %p\n", (void*)this); }

    template <class graph>
    void poke_adjacent_oranges(graph const& g) const {
        g.visit_adjacent(this, []<class T>(T* other) {
            if constexpr (std::is_base_of_v<orange, T>) {
                other->poke();
            }
        });

    }
};

// ....
struct blue {};

int
main(){
    graph<red, yellow, orange, blue> g;

    orange o1;
    orange o2;
    red r;
    blue b;

    g.edge.emplace(&o1, &o2);
    g.edge.emplace(&o1, &r);
    g.edge.emplace(&o1, &b);

    std::printf("o2: %p\n", (void*)&o2);
    o1.poke_adjacent_oranges(g);


    return 0;
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接