【C++基础】第一百零七课:[特殊工具与技术]运行时类型识别

RTTI,dynamic_cast,typeid,type_info类

Posted by x-jeff on August 28, 2024

【C++基础】系列博客为参考《C++ Primer中文版(第5版)》C++11标准)一书,自己所做的读书笔记。
本文为原创文章,未经本人允许,禁止转载。转载请注明出处。

1.运行时类型识别

运行时类型识别(run-time type identification,RTTI)的功能由两个运算符实现:

  • typeid运算符,用于返回表达式的类型。
  • dynamic_cast运算符,用于将基类的指针或引用安全地转换成派生类的指针或引用。

当我们将这两个运算符用于某种类型的指针或引用,并且该类型含有虚函数时,运算符将使用指针或引用所绑定对象的动态类型(参见:类型转换与继承)。

2.dynamic_cast运算符

dynamic_cast运算符(dynamic_cast operator)的使用形式如下所示:

1
2
3
dynamic_cast<type*>(e)
dynamic_cast<type&>(e)
dynamic_cast<type&&>(e)

其中,type必须是一个类类型,并且通常情况下该类型应该含有虚函数。在第一种形式中,e必须是一个有效的指针;在第二种形式中,e必须是一个左值;在第三种形式中,e不能是左值。

在上面的所有形式中,e的类型必须符合以下三个条件中的任意一个:e的类型是目标type的公有派生类、e的类型是目标type的公有基类或者e的类型就是目标type的类型。如果符合,则类型转换可以成功。否则,转换失败。如果一条dynamic_cast语句的转换目标是指针类型并且失败了,则结果为0。如果转换目标是引用类型并且失败了,则dynamic_cast运算符将抛出一个bad_cast异常。

2.1.指针类型的dynamic_cast

假定Base类至少含有一个虚函数,Derived是Base的公有派生类。如果有一个指向Base的指针bp,则我们可以在运行时将它转换成指向Derived的指针,具体代码如下:

1
2
3
4
5
6
if (Derived *dp = dynamic_cast<Derived*>(bp))
{
    //使用dp指向的Derived对象
} else { //bp指向一个Base对象
    //使用bp指向的Base对象
}

如果bp指向Derived对象,则上述的类型转换初始化dp并令其指向bp所指的Derived对象。此时,if语句内部使用Derived操作的代码是安全的。否则,类型转换的结果为0,dp为0意味着if语句的条件失败,此时else子句执行相应的Base操作。

我们可以对一个空指针执行dynamic_cast,结果是所需类型的空指针。

2.2.引用类型的dynamic_cast

引用类型的dynamic_cast与指针类型的dynamic_cast在表示错误发生的方式上略有不同。因为不存在所谓的空引用,所以对于引用类型来说无法使用与指针类型完全相同的错误报告策略。当对引用的类型转换失败时,程序抛出一个名为std::bad_cast的异常,该异常定义在typeinfo标准库头文件中。

我们可以按照如下的形式改写之前的程序,令其使用引用类型:

1
2
3
4
5
6
7
8
9
void f(const Base &b)
{
    try {
        const Derived &d = dynamic_cast<const Derived&>(b);
        //使用b引用的Derived对象
    } catch (bad_cast) {
        //处理类型转换失败的情况
    }
}

3.typeid运算符

为RTTI提供的第二个运算符是typeid运算符(typeid operator),它允许程序向表达式提问:你的对象是什么类型?

typeid表达式的形式是typeid(e),其中e可以是任意表达式或类型的名字。typeid操作的结果是一个常量对象的引用,该对象的类型是标准库类型type_info或者type_info的公有派生类型。type_info类定义在typeinfo头文件中。

typeid运算符可以作用于任意类型的表达式。和往常一样,顶层const被忽略,如果表达式是一个引用,则typeid返回该引用所引对象的类型。不过当typeid作用于数组或函数时,并不会执行向指针的标准类型转换(参见:数组转换成指针)。也就是说,如果我们对数组a执行typeid(a),则所得的结果是数组类型而非指针类型。

当运算对象不属于类类型或者是一个不包含任何虚函数的类时,typeid运算符指示的是运算对象的静态类型。而当运算对象是定义了至少一个虚函数的类的左值时,typeid的结果直到运行时才会求得。

3.1.使用typeid运算符

通常情况下,我们使用typeid比较两条表达式的类型是否相同,或者比较一条表达式的类型是否与指定类型相同:

1
2
3
4
5
6
7
8
9
10
Derived *dp = new Derived;
Base *bp = dp; //两个指针都指向Derived对象
//在运行时比较两个对象的类型
if (typeid(*bp) == typeid(*dp)) {
    //bp和dp指向同一类型的对象
}
//检查运行时类型是否是某种指定的类型
if (typeid(*bp) == typeid(Derived)) {
    //bp实际指向Derived对象
}

个人注解:两个if判定条件都是true。

注意,typeid应该作用于对象,因此我们使用*bp而非bp:

1
2
3
4
//下面的检查永远是失败的:bp的类型是指向Base的指针
if (typeid(bp) == typeid(Derived)) {
    //此处的代码永远不会执行
}

当typeid作用于指针时(而非指针所指的对象),返回的结果是该指针的静态编译时类型。

typeid是否需要运行时检查决定了表达式是否会被求值。只有当类型含有虚函数时,编译器才会对表达式求值。反之,如果类型不含有虚函数,则typeid返回表达式的静态类型;编译器无须对表达式求值也能知道表达式的静态类型。

如果表达式的动态类型可能与静态类型不同,则必须在运行时对表达式求值以确定返回的类型。这条规则适用于typeid(*p)的情况。如果指针p所指的类型不含有虚函数,则p不必非得是一个有效的指针。否则,*p将在运行时求值,此时p必须是一个有效的指针。如果p是一个空指针,则typeid(*p)将抛出一个名为bad_typeid的异常。

4.使用RTTI

在某些情况下RTTI非常有用,比如当我们想为具有继承关系的类实现相等运算符时(参见:相等运算符)。对于两个对象来说,如果它们的类型相同并且对应的数据成员取值相同,则我们说这两个对象是相等的。在类的继承体系中,每个派生类负责添加自己的数据成员,因此派生类的相等运算符必须把派生类的新成员考虑进来。

一种容易想到的解决方案是定义一套虚函数,令其在继承体系的各个层次上分别执行相等性判断。此时,我们可以为基类的引用定义一个相等运算符,该运算符将它的工作委托给虚函数equal,由equal负责实际的操作。

遗憾的是,上述方案很难奏效。虚函数的基类版本和派生类版本必须具有相同的形参类型(参见:虚函数)。如果我们想定义一个虚函数equal,则该函数的形参必须是基类的引用。此时,equal函数将只能使用基类的成员,而不能比较派生类独有的成员。

要想实现真正有效的相等比较操作,我们需要首先清楚一个事实:即如果参与比较的两个对象类型不同,则比较结果为false。例如,如果我们试图比较一个基类对象和一个派生类对象,则==运算符应该返回false。

基于上述推论,我们就可以使用RTTI解决问题了。我们定义的相等运算符的形参是基类的引用,然后使用typeid检查两个运算对象的类型是否一致。如果运算对象的类型不一致,则==返回false;类型一致才调用equal函数。每个类定义的equal函数负责比较类型自己的成员。这些运算符接受Base&形参,但是在进行比较操作前先把运算对象转换成运算符所属的类类型。

4.1.类的层次关系

为了更好地解释上述概念,我们定义两个示例类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Base {
    friend bool operator==(const Base&, const Base&);
public:
    //Base的接口成员
protected:
    virtual bool equal(const Base&) const;
    //Base的数据成员和其他用于实现的成员
};
class Derived: public Base {
public:
    //Derived的其他接口成员
protected:
    bool equal(const Base&) const;
    //Derived的数据成员和其他用于实现的成员
};

4.2.类型敏感的相等运算符

接下来介绍我们是如何定义整体的相等运算符的:

1
2
3
4
5
bool operator==(const Base &lhs, const Base &rhs)
{
    //如果typeid不相同,返回false;否则虚调用equal
    return typeid(lhs) == typeid(rhs) && lhs.equal(rhs);
}

在这个运算符中,如果运算对象的类型不同则返回false。否则,如果运算对象的类型相同,则运算符将其工作委托给虚函数equal。当运算对象是Base的对象时,调用Base::equal;当运算对象是Derived的对象时,调用Derived::equal

4.3.虚equal函数

继承体系中的每个类必须定义自己的equal函数。派生类的所有函数要做的第一件事都是相同的,那就是将实参的类型转换为派生类类型:

1
2
3
4
5
6
bool Derived::equal(const Base &rhs) const
{
    //我们清楚这两个类型是相等的,所以转换过程不会抛出异常
    auto r = dynamic_cast<const Derived&>(rhs);
    //执行比较两个Derived对象的操作并返回结果
}

4.4.基类equal函数

1
2
3
4
bool Base::equal(const Base &rhs) const
{
    //执行比较Base对象的操作
}

5.type_info类

type_info类的精确定义随着编译器的不同而略有差异。不过,C++标准规定type_info类必须定义在typeinfo头文件中,并且至少提供表19.1所列的操作。

除此之外,因为type_info类一般是作为一个基类出现,所以它还应该提供一个公有的虚析构函数。当编译器希望提供额外的类型信息时,通常在type_info的派生类中完成。

type_info类没有默认构造函数,而且它的拷贝和移动构造函数以及赋值运算符都被定义成删除的(参见:阻止拷贝)。因此,我们无法定义或拷贝type_info类型的对象,也不能为type_info类型的对象赋值。创建type_info对象的唯一途径是使用typeid运算符。

type_info类的name成员函数返回一个C风格字符串,表示对象的类型名字。对于某种给定的类型来说,name的返回值因编译器而异并且不一定与在程序中使用的名字一致。对于name返回值的唯一要求是,类型不同则返回的字符串必须有所区别。例如:

1
2
3
4
5
6
7
8
9
10
int arr[10];
Derived d;
Base *p = &d;

cout << typeid(42).name() << ", "
     << typeid(arr).name() << ", "
     << typeid(Sales_data).name() << ", "
     << typeid(std::string).name() << ", "
     << typeid(p).name() << ", "
     << typeid(*p).name() << endl;

输出结果可能如下:

1
i, A10_i, 10Sales_data, Ss, P4Base, 7Derived