C++动态类型转换:深入理解dynamic_cast与多态编程

早在《显式类型转换(强制类型转换)与 static_cast》中,我们已介绍过强制类型转换的概念,以及如何使用 static_cast 将变量从一种类型转换为另一种类型。
本课将继续探讨另一种强制类型转换运算符:dynamic_cast。

dynamic_cast 的需求

在多态编程中,经常遇到“持有基类指针,却想访问仅派生类才拥有的信息”的场景。

考虑以下(略作人为设计的)程序:

#include <iostream>
#include <string>
#include <string_view>

class Base
{
protected:
    int m_value{};

public:
    Base(int value)
        : m_value{value}
    {
    }

    virtual ~Base() = default;
};

class Derived : public Base
{
protected:
    std::string m_name{};

public:
    Derived(int value, std::string_view name)
        : Base{value}, m_name{name}
    {
    }

    const std::string& getName() const { return m_name; }
};

Base* getObject(bool returnDerived)
{
    if (returnDerived)
        return new Derived{1, "Apple"};
    else
        return new Base{2};
}

int main()
{
    Base* b{ getObject(true) };

    // 仅持有一个 Base*,如何打印 Derived 对象的名称?

    delete b;
    return 0;
}

本例中,函数 getObject() 始终返回 Base*,但该指针可能指向 Base 对象,也可能指向 Derived 对象。当 Base* 实际指向 Derived 对象时,如何调用 Derived::getName()

一种做法是在 Base 中添加虚函数 getName(),从而可通过 Base*/Base& 动态决议到 Derived::getName()。然而,若实际指向 Base 对象,该函数应返回何值?没有合适的返回值;而且把本应仅由 Derived 关心的事物塞进 Base,会污染基类接口。

我们知道 C++ 允许隐式地将 Derived* 转换为 Base*getObject() 正是如此)。这一过程常称为“向上转型”(upcasting)。那么,如果存在一种方法,能将 Base* 再转换回 Derived*,即可直接调用 Derived::getName(),无需依赖虚函数决议。

dynamic_cast

C++ 提供了名为 dynamic_cast 的强制类型转换运算符,恰好满足此需求。尽管 dynamic_cast 具备若干能力,其最常见用途便是将基类指针向下转换为派生类指针,此过程称为“向下转型”(downcasting)。

dynamic_cast 的语法与 static_cast 相同。下面用 dynamic_cast 改写上述 main()

int main()
{
    Base* b{ getObject(true) };

    Derived* d{ dynamic_cast<Derived*>(b) }; // 将 Base* 向下转型为 Derived*

    std::cout << "The name of the Derived is: " << d->getName() << '\n';

    delete b;
    return 0;
}

输出:

The name of the Derived is: Apple

dynamic_cast 失败的情况

上例之所以成功,是因为 b 确实指向 Derived 对象。然而,我们做了一个危险的假设:相信 b 一定指向 Derived。若把 getObject() 的参数从 true 改为 falsegetObject() 将返回指向 Base 对象的 Base*。此时尝试 dynamic_cast 会失败,因为无法完成转换。

dynamic_cast 失败时,转换结果为 nullptr

由于未检查空指针,后续访问 d->getName() 将解引用空指针,导致未定义行为(通常为崩溃)。

为使程序安全,必须确认 dynamic_cast 成功:

int main()
{
    Base* b{ getObject(true) };

    Derived* d{ dynamic_cast<Derived*>(b) };

    if (d) // 确保 d 非空
        std::cout << "The name of the Derived is: " << d->getName() << '\n';

    delete b;
    return 0;
}

规则

务必通过检查空指针来确保 dynamic_cast 成功。

注意:由于 dynamic_cast 在运行时进行一致性检查(确保转换可行),会产生性能开销。

另请注意,以下几种情形无法使用 dynamic_cast 进行向下转型:

  • 受保护或私有继承时;
  • 类未声明或继承任何虚函数(因而无 vtable)时;
  • 涉及虚基类的某些特殊情形(参见该页面示例及解决方案)。

使用 static_cast 进行向下转型

实际上,向下转型也可用 static_cast 完成。区别是:static_cast 不做运行时类型检查,因此更快,也更危险。若将并非指向 DerivedBase* 强制转换为 Derived*,转换会“成功”,但随后解引用该指针时将产生未定义行为。

若你百分百确定向下转型必然成功,可用 static_cast。一种确保类型正确的做法是利用虚函数。以下示例(并非最佳方案)演示了这一点:

#include <iostream>
#include <string>
#include <string_view>

// 类标识枚举
enum class ClassID
{
    base,
    derived
    // 后续可继续添加
};

class Base
{
protected:
    int m_value{};

public:
    Base(int value)
        : m_value{value}
    {
    }

    virtual ~Base() = default;
    virtual ClassID getClassID() const { return ClassID::base; }
};

class Derived : public Base
{
protected:
    std::string m_name{};

public:
    Derived(int value, std::string_view name)
        : Base{value}, m_name{name}
    {
    }

    const std::string& getName() const { return m_name; }
    ClassID getClassID() const override { return ClassID::derived; }
};

Base* getObject(bool bReturnDerived)
{
    if (bReturnDerived)
        return new Derived{1, "Apple"};
    else
        return new Base{2};
}

int main()
{
    Base* b{ getObject(true) };

    if (b->getClassID() == ClassID::derived)
    {
        // 已证明 b 指向 Derived,转型应成功
        Derived* d{ static_cast<Derived*>(b) };
        std::cout << "The name of the Derived is: " << d->getName() << '\n';
    }

    delete b;
    return 0;
}

然而,若你已大费周章实现上述检查(并承担虚函数调用开销),不如直接使用 dynamic_cast

再考虑一种情形:若对象实际类型是继承自 DerivedD2,上述 b->getClassID() == ClassID::derived 会失败,因为 getClassID() 返回 ClassID::D2,不等于 ClassID::derived;而 dynamic_cast<D2*>Derived 却可成功,因为 D2 是一种 Derived

dynamic_cast 与引用

尽管以上示例均使用指针(更为常见),dynamic_cast 也可用于引用,其机理与指针情形类似。

#include <iostream>
#include <string>
#include <string_view>

class Base
{
protected:
    int m_value{};

public:
    Base(int value)
        : m_value{value}
    {
    }

    virtual ~Base() = default;
};

class Derived : public Base
{
protected:
    std::string m_name{};

public:
    Derived(int value, std::string_view name)
        : Base{value}, m_name{name}
    {
    }

    const std::string& getName() const { return m_name; }
};

int main()
{
    Derived apple{1, "Apple"};   // 创建 apple
    Base& b{ apple };            // 基类引用绑定到对象
    Derived& d{ dynamic_cast<Derived&>(b) }; // 用引用进行 dynamic_cast

    std::cout << "The name of the Derived is: " << d.getName() << '\n'; // 通过 d 访问 Derived::getName

    return 0;
}

由于 C++ 不存在“空引用”,当 dynamic_cast 应用于引用且失败时,不会返回空引用,而是抛出类型为 std::bad_cast 的异常。本教程后续章节将讨论异常。

dynamic_cast 与 static_cast 的选择

新手常困惑何时用 static_cast,何时用 dynamic_cast。答案简单:除非向下转型,否则用 static_cast;若需向下转型,通常 dynamic_cast 更佳。不过,也应考虑完全避免转型,改用虚函数。

向下转型 vs 虚函数

部分开发者认为 dynamic_cast 是“邪恶”的,象征设计不良;应使用虚函数取代。一般而言,应优先使用虚函数,而非向下转型。然而,以下场景向下转型更为合适:

  • 无法修改基类以添加虚函数(例如基类属于标准库);
  • 需要访问仅派生类具备的成员(如仅派生类提供的访问函数);
  • 向基类添加虚函数不合理(例如基类无合适返回值)。若无需实例化基类,可考虑使用纯虚函数。

关于 dynamic_cast 与 RTTI 的警告

运行时类型信息(RTTI)是 C++ 的一项特性,可在运行时暴露对象的数据类型信息,dynamic_cast 正依赖于它。由于 RTTI 带来显著的时空开销,部分编译器允许关闭 RTTI 以优化。显而易见,若关闭 RTTI,dynamic_cast 将无法正确工作。

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"