RTTI 简介

RTTI(Runtime Type Identification)是“运行时类型识别”的意思。C++引入这个机制是为了让程序在运行时能根据基类的指针或引用来获得该指针或引用所指的对象的实际类型。但是现在RTTI的类型识别已经不限于此了,它还能通过typeid操作符识别出所有的基本类型的变量对应的类型。为什么会出现RTTI这一机制呢?这和C++语言本身有关系,C++是一门静态类型语言,其数据类型是在编译期就确定的,不能在运行时更改。然而由于面向对象程序设计中多态性的要求,C++中的指针或引用本身的类型,可能与它实际代表的类型并不一致,有时我们需要将一个多态指针转换为其实际指向对象的类型,就需要知道运行时的类型信息,这就有了运行时类型识别需求。和Java、C#等拥有反射机制的语言相比,C++要想获得运行时类型信息,只能通过RTTI机制,并且C++最终生成的代码是直接与机器相关的。

C++ 通过以下两个关键字提供 RTTI 功能:

  • typeid:该运算符返回其表达式或类型名的实际类型
  • dynamic_cast:该运算符将基类的指针或引用安全地转换为派生类类型的指针或引用(也就是所谓的下行转换)

typeid

type_info

typeid 的返回值是 const type_info& 类型的数据,下面是 type_infogcc-8.1.0 中的简化版本定义(位于\include\c++\typeinfo文件中)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class type_info
{
public:
virtual ~type_info();

const char* name() const _GLIBCXX_NOEXCEPT
{ return __name[0] == '*' ? __name + 1 : __name; }

bool before(const type_info& __arg) const _GLIBCXX_NOEXCEPT;
bool operator==(const type_info& __arg) const _GLIBCXX_NOEXCEPT;

bool operator!=(const type_info& __arg) const _GLIBCXX_NOEXCEPT
{ return !operator==(__arg); }

// ...

// Return true if this is a pointer type of some kind
virtual bool __is_pointer_p() const;

// Return true if this is a function type
virtual bool __is_function_p() const;

protected:
const char *__name;
explicit type_info(const char *__n): __name(__n) { }

private:
/// Assigning type_info is not supported.
type_info& operator=(const type_info&);
type_info(const type_info&);
};

从源代码中可以看出以下几点内容:

  1. 有一个类成员 __name,类型是 const char*,这个指针最终会指向类型的名字

  2. 我们不能直接实例化类 type_info 的对象,因为该类的正常构造函数是保护的,要构造 type_info 对象的唯一方法就是使用 typeid 运算符。

  3. 由于重载的赋值运算符和拷贝构造函数也是私有的,因此我们不能自己去复制或分配类 type_info 的对象。

  4. 其余那些成员方法都是一些从名字就能看出用法的函数,比如 name() 返回类型名,__is_pointer_p()返回是否是指针类型等等

type_id 识别静态类型

当typeid中的操作数是以下任意一种时,typeid得出的是静态类型,即编译时就确定的类型:

  • 一个任意的类型名

  • 一个基本内置类型的变量,或指向基本内置类型的指针或引用

  • 一个任意类型的指针(指针就是指针,本身不体现多态,多指针解引用才有可能会体现多态)

  • 一个具体的对象实例,无论对应的类有没有多态都可以直接在编译器确定

  • 一个指向没有多态的类对象的指针的解引用

  • 一个指向没有多态的类对象的引用

由于静态类型在程序的运行过程中并不会改变,所以并不需要等到程序运行时再去推算其类型,在编译时期就能根据操作数的静态类型,从而推导出其具体类型信息。我们先来看如下一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int main()
{
std::cout << typeid(char).name() << std::endl;
std::cout << typeid(int).name() << std::endl;
std::cout << typeid(double).name() << std::endl;

std::cout << "----------" << std::endl;

std::cout << typeid(unsigned char).name() << std::endl;
std::cout << typeid(unsigned int).name() << std::endl;

std::cout << "----------" << std::endl;

std::cout << typeid(std::string).name() << std::endl;
std::cout << typeid(std::vector<float>).name() << std::endl;

return 0;
}

运行结果如下(gcc-9.1.0):

1
2
3
4
5
6
7
8
9
c
i
d
----------
h
j
----------
NSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE
St6vectorIfSaIfEE

如上,typeid 实际输出的是被编译器转换后的类名,并不是很直观,想要得到更直观的类型名,可以参考这篇文章 导入 cxxabi.h 头文件,使用 abi::__cxa_demangle 函数进行转换,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <cxxabi.h>

const char* TypeToName(const char* name)
{
const char* __name = abi::__cxa_demangle(name, nullptr, nullptr, nullptr);
return __name;
}

int main()
{
std::cout << TypeToName(typeid(char).name()) << std::endl;
std::cout << TypeToName(typeid(int).name()) << std::endl;
std::cout << TypeToName(typeid(double).name()) << std::endl;

std::cout << "----------" << std::endl;

std::cout << TypeToName(typeid(unsigned char).name()) << std::endl;
std::cout << TypeToName(typeid(unsigned int).name()) << std::endl;

std::cout << "----------" << std::endl;

std::cout << TypeToName(typeid(std::string).name()) << std::endl;
std::cout << TypeToName(typeid(std::vector<float>).name()) << std::endl;

return 0;
}

运行结果如下:

1
2
3
4
5
6
7
8
9
char
int
double
----------
unsigned char
unsigned int
----------
std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >
std::vector<float, std::allocator<float> >

输出了相对直观的类型名,同时可以看到 std 容器中模板的各个参数都被展开作为了类型名的一部分。

type_id 识别动态类型

typeid 中的操作数是以下任意一种时,typeid 需要在程序运行时推算类型,因为其操作数的类型在编译时期是不能被确定的:

  • 一个指向含有多态的类对象的指针的解引用

  • 一个指向含有多态的类对象的引用

注意,由于 C++ 中的动态多态主要就是通过使用基类的指针或引用指向派生类对象,之后通过这个指针或引用调用虚函数来实现的,因此上述的 “含有多态” 指的就是含有虚函数的类对象。当类中没有虚函数时,即使是基类指针指向派生类对象,也是无法体现多态性的。此时 typeid 自然也就会得到错误的结果。

因此,想要使用 typeid 来识别动态类型,必须要保证类中含有虚函数

使用一个简单的例子来说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class A
{
public:
void print()
{
std::cout << " A " << std::endl;
}

int a;
};

class B : virtual public A
{
public:
void print()
{
std::cout << " B " << std::endl;
}

int b;
};

class C : virtual public A
{
public:
void print()
{
std::cout << " C " << std::endl;
}

int c;
};

class D : public B, public C
{
public:
void print()
{
std::cout << " D " << std::endl;
}

int d;
};

先建立起一个菱形继承的类体系,然后使用 typeid 来获取动态类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int main()
{
D d;
A* a_ptr = &d;
B* b_ptr = &d;
C* c_ptr = &d;

std::cout << TypeToName(typeid(d).name()) << std::endl; // D
d.print(); // D
std::cout << TypeToName(typeid(*a_ptr).name()) << std::endl; // A
a_ptr->print(); // A
std::cout << TypeToName(typeid(*b_ptr).name()) << std::endl; // B
b_ptr->print(); // B
std::cout << TypeToName(typeid(*c_ptr).name()) << std::endl; // C
c_ptr->print(); // C

return 0;
}

可以看出,由于类中没有虚函数,因此 typeid 会得到错误的结果。为类中添加虚函数再进行测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class A
{
public:
virtual void print()
{
std::cout << " A " << std::endl;
}

int a;
};

int main()
{
D d;
A* a_ptr = &d;
B* b_ptr = &d;
C* c_ptr = &d;

std::cout << TypeToName(typeid(d).name()) << std::endl; // D
d.print(); // D
std::cout << TypeToName(typeid(*a_ptr).name()) << std::endl; // D
a_ptr->print(); // D
std::cout << TypeToName(typeid(*b_ptr).name()) << std::endl; // D
b_ptr->print(); // D
std::cout << TypeToName(typeid(*c_ptr).name()) << std::endl; // D
c_ptr->print(); // D

return 0;
}

可以看到,typeid 此时得到了正确的结果,并且调用虚函数也展现出了多态性。使用引用也可以表现出相同的效果,这里就不再赘述。

typeid 实现原理

由上述分析我们知道了识别动态类型时 type_info 主要是跟随虚函数表来一同生成的,且 typeid 返回的就是一个 type_info 对象的引用。那么,type_info 与虚函数表之间的关系是怎样的呢?

首先给出结论,一个类型的 type_info 对象(实际上是该对象的指针)存放在该类型的虚函数表起始位置的前一个位置,与虚函数表一同生成。

例如我们有一个 base 类,该类的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Base
{
public:
virtual void f()
{
std::cout << "Base::f()" << std::endl;
}

virtual void g()
{
std::cout << "Base::g()" << std::endl;
}

virtual void h()
{
std::cout << "Base::h()" << std::endl;
}
};

则该类的内存布局及虚函数表的内存布局如下所示:

image.png

可以看出,由于 base 类没有任何的成员变量,因此 base 类对象中只含有一个虚函数表指针,如 泛型与多态 中所述。虚函数表(不考虑前置项的情况下)实际上是一个指针数组,其中的每一项都指向一个类中的虚函数地址。

  • _vptr.Base - 2:这里存储的是 offset_to_top,这个表示的是当前的虚表指针距离类开头的距离,可以看到对于 _vptr.Base 来说这个值就是 0,因为_vptr.Base就存在于类 Base 的起始位置。后续的例子中会有该值不是0的情况出现的。

    在多继承中,由于不同基类的起点可能处于不同的位置,因此当需要将它们转化为实际类型时,this 指针的偏移量也不相同。由于实际类型在编译时是未知的,这要求偏移量必须能够在运行时获取。实体 offset_to_top 表示的就是实际类型起始地址到当前这个形式类型起始地址的偏移量。在向上动态转换到实际类型时(即基类转派生类),让this指针加上这个偏移量即可得到实际类型的地址。需要注意的是,由于一个类型即可以被单继承,也可以被多继承,因此即使只有单继承,实体 offset_to_top 也会存在于每一个多态类型之中。

  • _vptr.Base - 1:这里存储的是 typeinfo for Base,里面的内容其实也是一个指针,指向的是类 Base 的运行时信息。

在了解了内存布局后,结合 C++ 的各种神奇操作,我们可以轻松验证以上的结论:

1
2
3
4
5
6
7
const char* TypeToName(const char* name)
{
const char* __name = abi::__cxa_demangle(name, nullptr, nullptr, nullptr);
return __name;
}

typedef void (*Fun)(void);

首先还是定义两个辅助工具,TypeToName 用于将编译器中的类型名称转换为实际名称;Fun 是一个函数指针,没有参数,返回值为 void,我们将用这个函数指针来间接调用虚函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
int main()
{
Base base;
Fun fun = nullptr;
long long* vtbp_addr = (long long*)(&base);
long long* vtb_addr = (long long*)*vtbp_addr;

std::cout << "&vtbp: " << vtbp_addr << std::endl; // &vtbp: 0x61fdf0
std::cout << "&vtb: " << vtb_addr << std::endl; // &vtb: 0x405570

std::cout << "offset_to_top: " << *(vtb_addr - 2) << std::endl; // offset_to_top: 0
std::cout << "&typeinfo: " << (long long*)*(vtb_addr - 1) << std::endl; // &typeinfo: 0x405540

std::type_info* typeinfo = (std::type_info*)*(vtb_addr - 1);
std::cout << "typeinfo: " << TypeToName(typeinfo->name()) << std::endl; // typeinfo: Base

fun = (Fun)*(vtb_addr);
std::cout << "&f(): " << (long long*)fun << std::endl; // &f(): 0x402f8
std::cout << "call f(): ";
fun(); // call f(): Base::f()

fun = (Fun)*(vtb_addr + 1);
std::cout << "&g(): " << (long long*)fun << std::endl; // &g(): 0x402fc0
std::cout << "call g(): "; // call g(): Base::g()
fun();

fun = (Fun)*(vtb_addr + 2);
std::cout << "&h(): " << (long long*)fun << std::endl; // &h(): 0x403000
std::cout << "call h(): "; // call h(): Base::h()
fun();
}

由于 base 类对象中只含有一个虚函数表指针,因此 vtbp 的地址就是 base 对象的地址,且 vtbp 中存储的就是 vtb 的地址。因此可以通过简单的指针操作直接获取到 vtb 的地址。需要注意的是,在进行地址操作时,需要将地址转换为 long long* 类型(64位系统下)。

1
2
long long* vtbp_addr = (long long*)(&base);                             
long long* vtb_addr = (long long*)*vtbp_addr;

接着便可以查看 vtb 起始位置的前两个位置的内容:

1
2
std::cout << "offset_to_top: " << *(vtb_addr - 2) << std::endl;          // offset_to_top: 0
std::cout << "&typeinfo: " << (long long*)*(vtb_addr - 1) << std::endl; // &typeinfo: 0x405540

可以看出在 -2 位置存储的确实是 offset_to_top,代表的是当前的虚表指针距离类开头的距离,这里是 0;在 -1 位置存储的是 type_info 对象的地址。

可以直接通过类型转换的方式将 type_info 对象的地址指向的内容转换为 type_info 对象,然后通过 name() 方法获取到类型名:

1
2
std::type_info* typeinfo = (std::type_info*)*(vtb_addr - 1);
std::cout << "typeinfo: " << TypeToName(typeinfo->name()) << std::endl; // typeinfo: Base

完美符合预期。接着我们可以通过 vtb 中的函数指针来调用虚函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fun = (Fun)*(vtb_addr);
std::cout << "&f(): " << (long long*)fun << std::endl; // &f(): 0x402f8
std::cout << "call f(): ";
fun(); // call f(): Base::f()

fun = (Fun)*(vtb_addr + 1);
std::cout << "&g(): " << (long long*)fun << std::endl; // &g(): 0x402fc0
std::cout << "call g(): "; // call g(): Base::g()
fun();

fun = (Fun)*(vtb_addr + 2);
std::cout << "&h(): " << (long long*)fun << std::endl; // &h(): 0x403000
std::cout << "call h(): "; // call h(): Base::h()
fun();

dynamic_cast

dynamic_cast 是 C++ 中的一个运算符,用于将基类的指针或引用安全地转换为派生类类型的指针或引用。dynamic_cast 主要用于多态类型的转换,即在多态类型之间进行转换时,dynamic_cast 会检查转换是否合法,如果合法则进行转换,否则返回空指针。

注意事项:

  • dynamic_cast 是运行时处理的,运行时会进行类型检查(这点和 static_cast 差异较大)

  • dynamic_cast 不能用于内置基本数据类型的强制转换,并且 dynamic_cast 只能对指针或引用进行强制转换

  • dynamic_cast 如果转换成功的话返回的是指向类的指针或引用,转换失败的话则会返回 nullptr 或者抛出异常(如果是引用的话)

  • 使用 dynamic_cast 进行上行转换时,与 static_cast 的效果是完全一样的

  • 使用 dynamic_cast 进行下行转换时,dynamic_cast 具有类型检查的功能,比 static_cast 更安全。并且这种情况下 dynamic_cast 会要求进行转换的类必须具有多态性,否则编译不通过

dynamic_cast 就是利用虚表中的 type_info 来执行运行时类型检查和安全类型转换。

以下是 dynamic_cast 的工作原理的简化描述:

  • 首先,dynamic_cast 通过查询对象的 vptr 来获取其 type_info(这也是为什么 dynamic_cast 要求对象有虚函数)
  • 然后,dynamic_cast 比较请求的目标类型与从 type_info 获得的实际类型。如果目标类型是实际类型或其基类,则转换成功。
  • 如果目标类型是派生类,dynamic_cast 会检查类层次结构,以确定转换是否合法。如果在类层次结构中找到了目标类型,则转换成功;否则,转换失败。
  • 当转换成功时,dynamic_cast 返回转换后的指针或引用。
  • 如果转换失败,对于指针类型,dynamic_cast 返回空指针;对于引用类型,它会抛出一个 std::bad_cast 异常。

继续看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct A
{
int a;
void print()
{
std::cout << "A" << std::endl;
}

virtual ~A() = default;
};

struct B : public A
{
int b;
void print()
{
std::cout << "B" << std::endl;
}
};

为了使用 dynamic_cast ,此处将 A 类的析构函数声明为虚函数,以确保多态性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int main()
{
B b;
A* a = &b;

std::cout << TypeToName(typeid(b).name()) << std::endl; // B
std::cout << TypeToName(typeid(*a).name()) << std::endl; // B

A* pa = dynamic_cast<A*>(&b); // upcast
pa->print(); // A
A* aa = new B();
B* bb = dynamic_cast<B*>(aa); // downcast
bb->print(); // B
}

可以看到,upcast 与 downcast 都正常起作用,接下来将 A 类的析构函数声明为非虚函数:

1
2
3
.\test.cpp: In function 'int main()':
.\test.cpp:48:32: error: cannot dynamic_cast 'aa' (of type 'struct A*') to type 'struct B*' (source type is not polymorphic)
B* bb = dynamic_cast<B*>(aa);

可以看到,编译器报错,因为 A 类的析构函数不是虚函数,因此 A 类不具有多态性,dynamic_cast 无法进行类型转换。

我们可以进一步验证 dynamic_cast 与 RTTI 之间的关系:

在使用 g++ 编译时,可以通过 -fno-rtti 选项来关闭 RTTI 功能。

关闭之后再次编译上述代码,可以看到编译器报错:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int main()
{
B b;
A* a = &b;

std::cout << TypeToName(typeid(b).name()) << std::endl; // error: cannot use 'typeid' with -fno-rtti
std::cout << TypeToName(typeid(*a).name()) << std::endl; // error: cannot use 'typeid' with -fno-rtti

A* pa = dynamic_cast<A*>(&b); // 没有 RTTI 也能进行正常的 upcast
pa->print();
A* aa = new B();
B* bb = dynamic_cast<B*>(aa); // error: 'dynamic_cast' not permitted with -fno-rtti
bb->print();
}

可以看出,typeiddynamic_cast 都依赖于 RTTI 机制,关闭 RTTI 之后,typeiddynamic_cast 都无法使用,但使用 dynamic_cast 进行 upcast 仍然是可以的,并不需要 RTTI 的支持。

在 C++ 中,指针向父类(基类)转换始终是合法的,因为派生类对象总是包含基类对象的部分。因此,dynamic_cast 在进行向上转换时,不需要 RTTI 的支持。