C++虚函数的底层实现原理详解

虚函数的底层实现原理详解

  • C/C++杂记:虚函数的底层实现原理
    • 虚函数的实现的基本原理
    • 深入虚表结构
    • 运行时类型识别(RTTI)与动态类型转换原理

C/C++杂记:虚函数的底层实现原理

虚函数的实现的基本原理

1. 概述

简单地说,每一个含有虚函数(无论是其本身的,还是继承而来的)的类都至少有一个与之对应的虚函数表,其中存放着该类所有的虚函数对应的函数指针。例:
C++虚函数的底层实现原理详解_第1张图片
其中:

B的虚函数表中存放着B::foo和B::bar两个函数指针。
D的虚函数表中存放的既有继承自B的虚函数B::foo,又有重写(override)了基类虚函数B::bar的D::bar,还有新增的虚函数D::quz。
提示:为了描述方便,本文在探讨对象内存布局时,将忽略内存对齐对布局的影响。

2. 虚函数表构造过程

从编译器的角度来说,B的虚函数表很好构造,D的虚函数表构造过程相对复杂。下面给出了构造D的虚函数表的一种方式(仅供参考):
C++虚函数的底层实现原理详解_第2张图片

提示:该过程是由编译器完成的,因此也可以说:虚函数替换过程发生在编译时。

3. 虚函数调用过程

以下面的程序为例:
C++虚函数的底层实现原理详解_第3张图片

编译器只知道pb是B*类型的指针,并不知道它指向的具体对象类型 :pb可能指向的是B的对象,也可能指向的是D的对象。

但对于“pb->bar()”,编译时能够确定的是:此处operator->的另一个参数是B::bar(因为pb是B*类型的,编译器认为bar是B::bar),而B::bar和D::bar在各自虚函数表中的偏移位置是相等的。

无论pb指向哪种类型的对象,只要能够确定被调函数在虚函数中的偏移值,待运行时,能够确定具体类型,并能找到相应vptr了,就能找出真正应该调用的函数。

提示:本人曾在“C/C++杂记:深入理解数据成员指针、函数成员指针”一文中提到:虚函数指针中的ptr部分为虚函数表中的偏移值(以字节为单位)加1。

B::bar是一个虚函数指针, 它的ptr部分内容为9,它在B的虚函数表中的偏移值为8(8+1=9)。

当程序执行到“pb->bar()”时,已经能够判断pb指向的具体类型了:

如果pb指向B的对象,可以获取到B对象的vptr,加上偏移值8((char*)vptr + 8),可以找到B::bar。
如果pb指向D的对象,可以获取到D对象的vptr,加上偏移值8((char*)vptr + 8) ,可以找到D::bar。
如果pb指向其它类型对象…同理…

4. 多重继承

当一个类继承多个类,且多个基类都有虚函数时,子类对象中将包含多个虚函数表的指针(即多个vptr),例:
C++虚函数的底层实现原理详解_第4张图片

其中:D自身的虚函数与B基类共用了同一个虚函数表,因此也称B为D的主基类(primary base class)。

虚函数替换过程与前面描述类似,只是多了一个虚函数表,多了一次拷贝和替换的过程。

虚函数的调用过程,与前面描述基本类似,区别在于基类指针指向的位置可能不是派生类对象的起始位置,以如下面的程序为例:
C++虚函数的底层实现原理详解_第5张图片

5. 菱形继承

本文不讨论菱形继承的情形,个人觉得:菱形继承的复杂度远大于它的使用价值,这也是C++让人又爱又恨的原因之一。

如果想要深入研究,可以参考:Itanium C++ ABI。

深入虚表结构

1. 虚表与“虚函数表”

在“C/C++杂记:虚函数的实现的基本原理”一文中曾提到“虚函数表”的概念,只是为了便于理解,事实是:虚函数表并不真的独立存在,它只是虚表(virtual table)中的一部分内容。例:
C++虚函数的底层实现原理详解_第6张图片

从图中可已看出,虚表除了包含虚函数指针,还包含其它一些信息(如:RTTI信息、偏移值等)。

顺便介绍一下gcc的-fdump-class-hierarchy选项,它可以用于输出C++程序的虚表结构(在当前目录下生成一个.class文件),例:
C++虚函数的底层实现原理详解_第7张图片
2. 虚表结构

一个虚表包含以下几个部分:
C++虚函数的底层实现原理详解_第8张图片

其中:

橙色线框中的内容仅限于虚拟继承的情形(若无虚拟继承,则无此内容),虚拟继承的讨论已超过了本文的范围,暂且忽略。
“offset to top”是指到对象起始地址的偏移值,只有多重继承的情形才有可能不为0,单继承或无继承的情形都为0。
“RTTI information”是一个对象指针,它用于唯一地标识该类型。(注:本系列博文后续会有详细讨论。)
“virtual function pointers”也就是我们之前理解的虚函数表,其中存放着虚函数指针列表。
前一节的示例是单继承的示例,下面列出了一个多继承的示例:
C++虚函数的底层实现原理详解_第9张图片

从中可以看到:D的虚表中包含两个虚表结构,第一个也称之为“主虚表”(primary virtual table),另一个虚表又称之为“次虚表”(secondary virtual table)。

简单地概括一下:一个含有虚函数(无论是其本身的,还是继承而来的)的类,可以有一个主虚表和多个次虚表,主虚表和次虚表构成一个虚表组(virtual table group)。

3. 参考

Itanium C++ ABI

运行时类型识别(RTTI)与动态类型转换原理

运行时类型识别(RTTI)的引入有三个作用:

  1. 配合typeid操作符的实现;

  2. 实现异常处理中catch的匹配过程;

  3. 实现动态类型转换dynamic_cast。

typeid操作符的实现

1.1. 静态类型的情形

C++中支持使用typeid关键字获取对象类型信息,它的返回值类型是const std::type_info&,例:

#include 
#include 
struct B {} b, c;
struct D : B {} d;
void test() {
    const std::type_info& tb = typeid(b); 
    const std::type_info& tc = typeid(c); 
    const std::type_info& td = typeid(d);
    assert(tb == tc);   // b和c具有相同的类型
    assert(&tb == &tc); // tb和tc引用的是相同的对象
    assert(tb != td);   // 虽然D是B的子类,但是b和d的类型却不同
    assert(&tb != &td); // tb和td引用的是不同的对象
}

理论上讲,编译器会为每一种类型生成一个能唯一标识该类型的类型信息对象,typeid返回的就是该对象的引用。

通过查看clang编译器生成的LLVM汇编程序(LLVM汇编程序比本地汇编程序可读性较强),可以证明这一点。
使用clang编译上述源码:“clang -S -emit-llvm test.cpp -o -”,生成LLVM汇编程序包含以下信息(为了方便阅读,省略了部分无关内容):

@_ZTI1B = linkonce_odr constant { i8*, i8* } { ... }
@_ZTI1D = linkonce_odr constant { i8*, i8*, i8* } { ... }

define void @_Z4testv() #0 {
  %tb = alloca %"class.std::type_info"*, align 8
  %tc = alloca %"class.std::type_info"*, align 8
  %td = alloca %"class.std::type_info"*, align 8
  store bitcast ({ i8*, i8* }* @_ZTI1B to %"class.std::type_info"*), %tb, align 8
  store bitcast ({ i8*, i8* }* @_ZTI1B to %"class.std::type_info"*), %tc, align 8
  store bitcast ({ i8*, i8*, i8* }* @_ZTI1D to %"class.std::type_info"*), %td, align 8
  ...

其中:

@_ZTI1B 和@_ZTI1D 是两个全局变量,用以存储std::type_info(或者其子类)对象。
上述LLVM汇编程序中还列出了test()函数的起始部分内容,其中将@_ZTI1B 存储于%tb和%tc,将@_ZTI1D 存储于%td,正好对应原程序中的引用初始化语句。
附加说明:

LLVM汇编语言也称之为LLVM中间表示(IR, Intermediate Representation),其中全局变量以“@”开头。详细请参见:LLVM Language Reference Manual。
_ZTI1B和_ZTI1D是经过名字修饰(name mangling)修饰之后的变量名,linux下可以使用c++filt命令还原成可读形式(例如:c++filt _ZTI1B输出“typeinfo for B”,说明_ZTI1B是标识B类型的全局变量)。

1.2. 动态类型的情形

当typeid的操作数引用的是一个动态类(含有虚函数的类) 类型时,它的返回值是被引用对象对应类型的类型信息对象,例:

#include 
#include 
struct B { virtual void foo() {} };
struct C { virtual void bar() {} };
struct D : B, C {};
void test() {
    D d;
    B& rb = d;
    C& rc = d;
    assert(typeid(rb) == typeid(d));  // rb引用的类型与d相同
    assert(typeid(rb) == typeid(rc)); // rb引用的类型与rc引用的类型相同
}

编译时可能还不知道rb或rc引用的类型,运行时怎么能判断该返回基类还是派生类对应的类型信息对象呢?

还记得“C/C++杂记:深入虚表结构”一文中讲过的-fdump-class-hierarchy选项吧,用它将D的虚表打印出来如下:
C++虚函数的底层实现原理详解_第10张图片

可见,无论是“主虚表”还是“次虚表”,其中的RTTI信息位置都是&_ZTI1D(即D类型对应的类型信息对象)。

正是利用了这一点,运行时便可以通过vptr找到“虚函数表”,而“虚函数表”之前的一个位置存放了需要的类型信息对象,typeid可以直接返回这里的类型信息对象引用即可。
下面的图示描述了这一过程:
C++虚函数的底层实现原理详解_第11张图片

2. 实现异常处理中catch的匹配过程
catch的匹配过程也可利用与typeid相似的原理进行类型匹配判断,此不再赘述。

3. 动态类型转换(dynamic_cast)

说明:本节不考虑虚拟继承的情形。

先上一个例子:
C++虚函数的底层实现原理详解_第12张图片

转换过程:
(1) 对#2来说最为简单,首先获取RTTI对象,RTTI对象与目标类型信息对象一致,而偏移值也为0,所以只用返回源地址(pb)即可。
(2) 对#1和#3来说,RTTI对象与目标类型信息对象一致,但是有偏移值-8,所以返回值为“(char*)pa + (-8)”或“(char*)pc + (-8)”。
(3) 对#4来说,RTTI对象与目标类型信息对象不一致,但是目标类型C 是RTTI对象表示类型(D)是基类(后面会讨论如何判断继承关系),因此转换也是可行的。
用clang编译上述源码,生成LLVM汇编程序如下(已作简化):

@_ZTI1A= linkonce_odr constant { i8*, i8* } { ... }
@_ZTI1B= linkonce_odr constant { i8*, i8* } { ... }
@_ZTI1C= linkonce_odr constant { i8*, i8*, i8* } {..., i8* bitcast ({ i8*, i8* }* @_ZTI1A to i8*) }
@_ZTI1D= linkonce_odr constant { i8*, i8*, i32, i32, i8*, i64, i8*, i64 } { ...,
        i8* bitcast ({ i8*, i8* }* @_ZTI1B to i8*), i64 2,
        i8* bitcast ({ i8*, i8*, i8* }* @_ZTI1C to i8*), i64 2050
    }
    ```
从中可以看出,RTTI对象中存放的内容还包括基类的RTTI对象指针,成树状结构:

![在这里插入图片描述](https://img-blog.csdnimg.cn/dc02c427114d484199897db0d7e11792.png)

因此继承关系可以通过此树状结构判断,有了继承关系,再递归从虚表中查找基类子对象在派生类中的偏移值,便可以确定最终返回地址。

**4. 参考**

(1) [Itanium C++ ABI](http://mentorembedded.github.io/cxx-abi/abi.html)
(2) [LLVM Language Reference Manual](http://llvm.org/docs/LangRef.html)
(3) [libc++abi源码](http://libcxxabi.llvm.org/)([private_typeinfo.h](http://llvm.org/svn/llvm-project/libcxxabi/trunk/src/private_typeinfo.h)文件)

你可能感兴趣的:(C\C++杂记,c++,c语言)