为什么说:不要使用 dynamic_cast, 需要运行时确定类型信息, 说明设计有缺陷?

在<Google C++代码规范>中, google内部约定: 除单元测试外, 不要使用 dynamic_cast, 如果你需要在运行时确定类型信息, 说明设计有缺陷. 何解? 我们真的不需要dynamic_cast吗? PS: 网上的结论是因为dynamic_cast的转换并不会总是成功的, dynamic_cast转换符只能用于含有虚函数的类. 转换后的指针判空是很正常的啊, 网上的结论完全没有说服力. 为方便说明具个现在项目中真实的例子, 简单抽象了一下代码如下: void GameObjectKillTa…
关注者
97
被浏览
8966

10 个回答

这个问题好赞!
好久没遇到这么好的问题了。

首先,附上google C++ style guide关于dynamic_cast的讨论的链接:
google-styleguide.googlecode.com

google说的挺好的,我就不重复了。
我说一下我自己对这段文字的理解。

1. dynamic_cast可能会失败,可能不安全,可能会有效率问题,但是这不是主要原因。
2. 不使用dynamic_cast的主要原因是:代码混乱不好维护,判断分支过多不易读等。
3. 需要用到dynamic_cast的地方都可以通过别的(更好的)方式来解决。如下列出了几种常见的情况:
3.1 需要一个类似于switch的判断,对于不同的子类,做不同的事情。
此需求可以使用“多态”来实现。
3.2 需要一个类似于if的判断,对于某一个特殊的子类,做不同的事情。
此需求可以使用“函数重载”或者“模板偏特化”来实现。
3.3 确实需要一个子类的指针,需要调用一个子类才有的方法。
一开始就使用子类的指针就好了。
剩下的一些情况,其实都有更好的解决办法的,只需要稍微思考一下,就能想到。
这个问题其实一般都是学问家们来讨论的,但是挺有趣,所以咱们也可以探讨一下。

首先,C++ 的RTTI(包括了 dynamic_cast)肯定不是个很好的设计:
  • dynamic_cast 是有可能抛出 std::bad_cast 异常的,但大多数时候,我们不希望使用 C++ 异常系统,理由嘛,多种多样,我的原因是——我就根本没学会用异常这个技术。而且 C++ 异常系统是没有 finally 关键字的,很别扭。
  • C++ 的 RTTI 整体上比较弱(比如无法枚举成员函数),几乎就等于不能用,真要用类型信息的话,很多项目选择自己实现。
  • dynamic_cast 性能依赖于编译器,如果是通过字符串比较实现的,性能堪忧,尤其是继承树庞大的时候,dynamic_cast 可能需要执行若干次字符串比较。当然实际上我们很少需要如此关心性能。
  • 跟大多数语言不一样,由于多继承的存在,C++ 的类型转换可能会改变指针的值,你大可以想象这可能造成多么吊诡的错误。

进而,滥用 dynamic_cast 会带来一些问题,比如:
  • 假如你到处使用 dynamic_cast 确认具体类型,那么当你要增加一个子类的时候,你得修改多少地方?你不嫌麻烦吗?不怕漏下了吗?
  • 使用 dynamic_cast 的代码是难于测试的,你无法通过接口确认它到底依赖于哪些具体类,测试代码会比较复杂。并且增加了子类就要修改测试代码。

再进而,大多数时候都是滥用:
  • C++ 为了实现上层代码尽量不要关心具体类型,特意设计了重载、多态、模版等特性,你不用,非要自己写代码处理,那你为啥要用 C++ 呢?
  • 很多时候我们只是要知道对象的某种性质(比如常见的 xxx type)而不是全部类型信息,使用 dynamic_cast 获得了全部类型信息,继而要 include 具体类的头文件,不符合“最小”原则。

再再进而,大多数 dynamic_cast 都可以修改代码去掉,至少可以尽量下压到底层,或者集中到一起方便维护。比如:
  • 《设计模式》那本书里有一个工厂方法模式,可以用来解决一些问题,比如:
    // 带有 dynamic_cast
    void some(Base *p) {
        Derived *pd = dynamic_cast<Derived *>(p);
        Partner *ptnr = nullptr;
        if (pd) {
            ptnr = new Partner4Derived(pd);
        }
        // ...
    }
    // 不带 dynamic_cast
    void some(Base *p) {
        Partner *ptnr = p->getPartner(); // 返回Partner4Derived
        // ...
    }
    

  • 你那个例子,可以通过函数重载去掉 dynamic_cast,比如这样:
    class Life : public GameObject {
    public:
        // ...
        virtual void kill(Life *other) = 0;
        virtual void killedBy(Player *other) = 0;
        virtual void killedBy(Monster *other) = 0;
        virtual void killedBy(Elf *other) = 0;
        // ...
    };
    class Player : public Life {
    public:
        // ...
        virtual void kill(Life *other) {
            other->killedBy(this);
        }
    };
    class Elf : public Life {
        //...
    };
    class Monster : public Life {
    public:
        // ...
        // 实现你的 PlayerKillMonster
        virtual void killedBy(Player *other) {
            // ...
        }
        // ...
    };
    void gameObjectKillTarget(GameObject *killer, GameObject *target) {
        // ...
        killer->kill(target); // kill 是虚函数
    }
    
    这种实现有个挺洋气的名字叫做“double-dispatch”,其实也很别扭,但是确实可以去掉上层调用代码中的 dynamic_cast。

但是,你自己也做项目你知道的,拿着《设计模式》往项目里套,往往对不上号,C++ 既然打了 dynamic_cast 这个补丁,说明还是能用到的,我说几种个人认为比较适合用 dynamic_cast 处理的情况(注意,从这里开始我就不确定正确性了,只是个人看法,大家觉得不对可以在评论里留言纠正):
  • 处理参数协变问题。C++ 的虚函数返回值是可以跟着 this 协变的,但是参数不行。所以会有这样的写法(google 规范文档里的例子):
    bool Base::equal(Base *other) = 0;
    bool Derived::equal(Base *other) {
        Derived *p = dynamic_cast<Derived *>(other);
        if (!p) return false;
        // ...
    }
    
    这里要想去掉 dynamic_cast 当然是可以去掉的,但是 Derived::equal 这个函数,只是利用转型看看 other 和 this 的类型是不是一样,不依赖别的具体类,这样写似乎也没什么问题。
  • dynamic_cast 只是一个具体实现方式,本质上是一个“判断对象类型并做相应处理”的问题。一段代码完成的工作,总是要做的,不是放在 A 处做,就是放在 B 处做。假设我们想尽量保持 B 的单纯,那么这项工作就可以在 A 处,反之亦然。比如类似这样:
    bool Object::event(Event *e) {
        switch (e->type()) {
        case Event::Timer:
            timerEvent((TimerEvent*)e);
            break;
    
        case Event::ChildAdded:
        case Event::ChildPolished:
        case Event::ChildRemoved:
            childEvent((ChildEvent*)e);
            break;
        // ...
    }
    
    这里的强制转型和 dynamic_cast 其实是个差不多的东西,想要去掉当然是可以去掉的,方法类似于上面的方法二,但是那样会让 Event 变得比较复杂。当我们希望它只是一个简单的属性集,不依赖于 Object 及其任何子类,就会有这样的实现。Object 和 Event 都有大量的子类,并且和你给出的例子不同的是,子类随时可能大量增加,并且和基类不在一个模块(module)中,在这种情况下,所谓的“优雅解决”很可能是绣花枕头,能不用就别用。