C++虚函数和虚继承探秘

什么是继承?
什么是多重继承?
多重继承存在变量和函数名冲突怎么办?
子类对象和父类对象的内存模型是什么样的?
虚继承如何解决多重继承冲突问题?

本文将深入C++的底层实现,从内存结构、汇编语言的层面分析这些问题的答案。

一、单继承

继承是面向对象编程的核心,它可以使两个类具有所谓的“父子”关系,子类继承父类所有的可见成员。我们先考虑单继承的情况,即子类只有一个“父亲”。

// File: VirtualInherit.cpp

class Animal {
public:
    int age;
    virtual void speak() {}
};

class Cat : public Animal {
public:
    virtual void speak() {}
};

int main()
{
        Animal* animal = new Cat();
        animal->speak();
}

这里,父类是Animal,子类Cat继承自Animal,这在逻辑上是合理的。父类有一个虚函数speak,但实现为空。子类重写该函数的实现,使得对speak的调用呈现出多态的特性(多态是指Animal* animal = new Cat(); animal->speak();这段代码实际调用的是Cat类的speak方法,父类指针调用虚函数的时候会自动找到对象的实际类型对应的实现)。

看到这里,我猜大家已经觉得无聊了,下面我们深入单继承的内存模型,看看所谓的虚函数表和虚函数表指针都在哪里。

我们使用Microsoft Visual C++中的cl编译器,它提供了一个-d1reportAllClassLayout参数,可以在编译时打印出所有类的内存模型。在命令行中执行如下命令

cl -d1reportAllClassLayout VirtualInherit.cpp

打印出的结果就是Animal类和Cat类的内存结构和虚函数表的结构(其它无关的类结构就不贴出来了)。

class Animal    size(8):
        +---
 0      | {vfptr}
 4      | age
        +---

Animal::$vftable@:
        | &Animal_meta
        |  0
 0      | &Animal::speak

Animal::speak this adjustor: 0


class Cat       size(8):
        +---
        | +--- (base class Animal)
 0      | | {vfptr}
 4      | | age
        | +---
        +---

Cat::$vftable@:
        | &Cat_meta
        |  0
 0      | &Cat::speak

Cat::speak this adjustor: 0

可以看到,Animal类占用8个字节,前4个字节是虚函数表指针(这里用的是32位编译器,所以指针占4个字节),后4个字节是数据成员。Animal类的虚函数表中只有一个函数指针&Animal::speak

同样地,Cat类也占用了8个字节,不过这8个字节都继承自父类AnimalCat类的虚函数表也继承自Animal,但由于重写了speak函数,所以虚函数表中的函数指针从&Animal::speak变成了&Cat::speak

细心的朋友会发现在虚函数表后面还打印出了一句话Animal::speak this adjustor: 0,这个数表示的是Animal::speak函数所在的虚函数表对应的虚函数表指针相对于this的偏移量。可能不太容易理解,因为这个例子中每个类只有一个虚函数表,对应的虚函数表指针也都处于对象内存空间的起始位置,因此是0。后面我们介绍到多重继承的时候,类可能拥有多个虚函数表,adjustor就不一定是0了。

另外,请读者不要误认为adjustor是对象内存模型的一部分。事实上,对象中不包含adjustor,虚函数表中也不包含adjustor。它只是编译器编译过程的中间产物,体现在汇编代码就就是一个立即数而已。

在进一步介绍多重继承之前,我认为有必要从汇编的层面上彻底分析一下虚函数的调用过程。

二、从汇编码分析虚函数调用

如果你电脑上装有Visual Studio,建议你也按照下面的步骤把C++代码编译成汇编码试试。

执行命令

cl VirtualInherit.cpp /FAs /Od

其中,/FAs参数用于生成.asm汇编码文件,/Od用于禁止编译器优化。

由于编译出来的汇编码比较长,为了方便大家理解,我把它拆成几部分,并舍去不重要的内容。

1.函数声明和虚函数表

在汇编代码的开头,声明了许多将在后面定义的函数,类似于C++中“先声明后定义”。之后分别定义了Animal类和Cat类的虚函数表。

    TITLE   D:\c++Projects\VirtualInheritDemo\VirtualInheritDemo\VirtualInherit.cpp
    .686P                             ; 686P指令集
    .XMM                              ; XMM指令集
    include listing.inc               ; 包含文件
    .model  flat                      ; Flat存储模型

INCLUDELIB LIBCMT                     ; 包含库文件
INCLUDELIB OLDNAMES                   ; 包含库文件

PUBLIC  ?speak@Animal@@UAEXXZ         ; Animal::speak
PUBLIC  ??0Animal@@QAE@XZ             ; Animal::Animal
PUBLIC  ?speak@Cat@@UAEXXZ            ; Cat::speak
PUBLIC  ??0Cat@@QAE@XZ                ; Cat::Cat
PUBLIC  _main
PUBLIC  ??_7Animal@@6B@               ; Animal::`vftable'
PUBLIC  ??_7Cat@@6B@                  ; Cat::`vftable'
EXTRN   ??2@YAPAXI@Z:PROC             ; operator new
EXTRN   ??_7type_info@@6B@:QWORD      ; type_info::`vftable'

;  COMDAT ??_7Cat@@6B@
CONST   SEGMENT
    DD  FLAT:??_R4Cat@@6B@
??_7Cat@@6B@                          ; Cat::`vftable'
    DD  FLAT:?speak@Cat@@UAEXXZ
CONST   ENDS

;  COMDAT ??_7Animal@@6B@
CONST   SEGMENT
    DD  FLAT:??_R4Animal@@6B@
??_7Animal@@6B@                       ; Animal::`vftable'
    DD  FLAT:?speak@Animal@@UAEXXZ
CONST   ENDS

这里没有太多值得关注的。唯一让我们不太习惯的是,编译器把函数名后面加了很多奇怪的字符,这是编译器为了方便区分各种函数的类型和参数,对函数名做了调整,习惯就好。

此外,两个虚函数表都由CONST SEGMENT包围,表示它们属于常量数据段。

2.Cat::Cat构造函数

在查看Cat类的构造函数之前,我们要了解一些寄存器使用惯例,才能更轻松的理解程序。

  • ebp为帧指针,指向栈底(栈底是高地址端,因为栈是向低地址方向扩展的)。
  • esp为栈指针,指向栈顶(低地址端)。
  • 在栈动态变化的过程中,通常ebp不变,esp变化,push操作使esp自动减4,pop操作使esp自动加4。
  • ecx用来传递this指针。
  • 函数返回结果保存在eax寄存器中。

如果你无法理解上面的叙述,推荐看我的另一篇文章《函数调用栈》

下面我们来看Cat::Cat构造函数的代码,这里涉及到虚函数表指针的设置,因此值得关注。

; Function compile flags: /Odtp
;   COMDAT ??0Cat@@QAE@XZ
_TEXT   SEGMENT
_this$ = -4                           ; size = 4
??0Cat@@QAE@XZ PROC                   ; Cat::Cat, COMDAT
; _this$ = ecx
    push    ebp
    mov ebp, esp
    push    ecx                       ; 保护现场
    mov DWORD PTR _this$[ebp], ecx    ; 把ecx寄存器的值复制到地址ebp+_this$处,即保存this指针的副本
    mov ecx, DWORD PTR _this$[ebp]    ; 把this指针赋值给ecx寄存器
    call    ??0Animal@@QAE@XZ         ; 调用基类Animal的构造函数
    mov eax, DWORD PTR _this$[ebp]    ; 把this指针赋值给eax寄存器
    mov DWORD PTR [eax], OFFSET ??_7Cat@@6B@  ; 把虚函数表的地址复制到地址eax处,即Cat对象的首地址处
    mov eax, DWORD PTR _this$[ebp]    ; 把this指针赋值给eax寄存器
    mov esp, ebp
    pop ebp
    ret 0
??0Cat@@QAE@XZ ENDP                 ; Cat::Cat
_TEXT   ENDS

这里的关键操作是把虚函数表的地址,即虚函数表指针放到了Cat对象的首地址处,也就是this指针指向的位置。后面我们将看到编译器这样做的良苦用心。

3.主函数

主函数部分的汇编代码如下。

; Function compile flags: /Odtp
; File d:\c++projects\virtualinheritdemo\virtualinheritdemo\virtualinherit.cpp
_TEXT   SEGMENT
_animal$ = -12                        ; size = 4,在栈中申请的空间大小,下同
tv75 = -8                             ; size = 4
$T1 = -4                              ; size = 4
_main   PROC

; 23   : {

    push    ebp
    mov ebp, esp
    sub esp, 12                       ; 0000000cH

; 24   :    Animal* animal = new Cat;

    push    8                         ; 为operator new函数准备参数8
    call    ??2@YAPAXI@Z              ; 调用operator new函数,参数为申请的空间大小
    add esp, 4                        ; 栈缩小4字节
    mov DWORD PTR $T1[ebp], eax       ; 将operator new函数的返回值复制到地址ebp+$T1处
                                      ; operator new的返回值是申请的内存空间的首地址
    cmp DWORD PTR $T1[ebp], 0         ; 判断值是否为0
    je  SHORT $LN3@main               ; 为0则跳转到$LN3处,说明申请空间失败
    mov ecx, DWORD PTR $T1[ebp]       ; 否则把该值赋值给ecx寄存器,按照惯例,ecx用于传递this指针
    call    ??0Cat@@QAE@XZ            ; 调用Cat::Cat构造函数,参数为刚才申请的空间的首地址
                                      ; 也就是this指针
    mov DWORD PTR tv75[ebp], eax      ; 将构造函数返回值复制到地址ebp+tv75处
                                      ; 注意,C++语义中构造函数没有返回值,但在汇编层面上有返回值,返回值是this指针
    jmp SHORT $LN4@main               ; 跳转到$LN4处
$LN3@main:
    mov DWORD PTR tv75[ebp], 0        ; 地址ebp+tv75处赋值0
$LN4@main:
    mov eax, DWORD PTR tv75[ebp]      ; 把地址ebp+tv75处的值赋值给eax寄存器
    mov DWORD PTR _animal$[ebp], eax  ; 把eax寄存器的值复制到地址ebp+_animal$处(仍然是this指针)

; 25   :    animal->speak();

    mov ecx, DWORD PTR _animal$[ebp]  ; 把this指针赋值给ecx寄存器
    mov edx, DWORD PTR [ecx]          ; 把this指针指向的值赋值给edx寄存器,即虚函数表指针
    mov ecx, DWORD PTR _animal$[ebp]  ; 把this指针赋值给ecx寄存器
    mov eax, DWORD PTR [edx]          ; 把虚函数表指针指向的值赋值给eax寄存器,即虚函数的地址
                                      ; 这里由于只有一个虚函数,因此是[edx],若调用第二个虚函数,则为[edx+4]
    call    eax                       ; 调用虚函数

; 26   : }

    xor eax, eax
    mov esp, ebp
    pop ebp
    ret 0
_main   ENDP
_TEXT   ENDS

这段代码比较长,但其实我们只需要关注最后调用虚函数Cat::speak的部分。可以发现,并没有直接通过Cat::speak的常量地址?speak@Cat@@UAEXXZ来调用,而是从this指针找到虚函数表,再找到虚函数表的第一项,把该项的值作为函数指针来调用。这就是虚函数调用的秘密!

三、多重继承中的变量冲突

现在,我们增加两个类DogCatDog,来构成一个钻石型的继承结构。CatDog都继承自AnimalCatDog多重继承CatDog(你可以把它当做猫和狗的杂交...)。

class Animal {
public:
    int age;
    virtual void speak() {}
};

class Cat : public Animal {
public:
    virtual void speak() {}
    virtual void scratch() {}
};

class Dog : public Animal {
public:
    virtual void speak() {}
    virtual void bite() {}
};

class CatDog : public Cat, Dog {
};

在这种继承结构中,下面的调用会出错。

CatDog catDog;
int age = catDog.age;          // 错误:"CatDog::age"不明确
catDog.speak();                // 错误:"CatDog::speak"不明确

因为CatDog类会把所有父类的成员各保存一份,两个父类又包含了相同的Animal类的成员,此时Animal类的成员变量age和成员函数speakCatDog中都保存了两份。我们把此时的内存模型打印出来看一下。

class Animal    size(8):
        +---
 0      | {vfptr}
 4      | age
        +---

Animal::$vftable@:
        | &Animal_meta
        |  0
 0      | &Animal::speak

Animal::speak this adjustor: 0


class Cat       size(8):
        +---
        | +--- (base class Animal)
 0      | | {vfptr}
 4      | | age
        | +---
        +---

Cat::$vftable@:
        | &Cat_meta
        |  0
 0      | &Cat::speak
 1      | &Cat::scratch

Cat::speak this adjustor: 0
Cat::scratch this adjustor: 0


class Dog       size(8):
        +---
        | +--- (base class Animal)
 0      | | {vfptr}
 4      | | age
        | +---
        +---

Dog::$vftable@:
        | &Dog_meta
        |  0
 0      | &Dog::speak
 1      | &Dog::bite

Dog::speak this adjustor: 0
Dog::bite this adjustor: 0


class CatDog    size(16):
        +---
        | +--- (base class Cat)
        | | +--- (base class Animal)
 0      | | | {vfptr}
 4      | | | age
        | | +---
        | +---
        | +--- (base class Dog)
        | | +--- (base class Animal)
 8      | | | {vfptr}
12      | | | age
        | | +---
        | +---
        +---

CatDog::$vftable@Cat@:
        | &CatDog_meta
        |  0
 0      | &Cat::speak
 1      | &Cat::scratch

CatDog::$vftable@Dog@:
        | -8
 0      | &Dog::speak
 1      | &Dog::bite

重点查看CatDog类的内存结构,可以发现,首先存放的是base class Cat的内容,里面嵌套了Animal的虚函数表和成员变量,之后是base class Dog的内容,里面也嵌套了同样的Animal的虚函数表和成员变量。所以我们直接调用age或者speak的时候,编译器无法确定我们到底想调用哪个成员变量或是哪个虚函数表中的speak

解决方案当然是传说中的虚继承。

四、虚继承

接着上面的例子,只需要将CatDog继承自Animal的方式改为虚继承即可,其它部分不变。写法如下。

class Cat : virtual public Animal {
public:
    virtual void speak() {}
    virtual void scratch() {}
};

class Dog : virtual public Animal {
public:
    virtual void speak() {}
    virtual void bite() {}
};

虽然只加了两个virtual关键字,但内存结构却发生了翻天覆地的变化,让我们再查看一下内存模型。

class Animal    size(8):
        +---
 0      | {vfptr}
 4      | age
        +---

Animal::$vftable@:
        | &Animal_meta
        |  0
 0      | &Animal::speak

Animal::speak this adjustor: 0


class Cat       size(16):
        +---
 0      | {vfptr}
 4      | {vbptr}
        +---
        +--- (virtual base Animal)
 8      | {vfptr}
12      | age
        +---

Cat::$vftable@Cat@:
        | &Cat_meta
        |  0
 0      | &Cat::scratch

Cat::$vbtable@:
 0      | -4
 1      | 4 (Catd(Cat+4)Animal)

Cat::$vftable@Animal@:
        | -8
 0      | &Cat::speak

Cat::speak this adjustor: 8
Cat::scratch this adjustor: 0

vbi:       class  offset o.vbptr  o.vbte fVtorDisp
          Animal       8       4       4 0


class Dog       size(16):
        +---
 0      | {vfptr}
 4      | {vbptr}
        +---
        +--- (virtual base Animal)
 8      | {vfptr}
12      | age
        +---

Dog::$vftable@Dog@:
        | &Dog_meta
        |  0
 0      | &Dog::bite

Dog::$vbtable@:
 0      | -4
 1      | 4 (Dogd(Dog+4)Animal)

Dog::$vftable@Animal@:
        | -8
 0      | &Dog::speak

Dog::speak this adjustor: 8
Dog::bite this adjustor: 0

vbi:       class  offset o.vbptr  o.vbte fVtorDisp
          Animal       8       4       4 0


class CatDog    size(24):
        +---
        | +--- (base class Cat)
 0      | | {vfptr}
 4      | | {vbptr}
        | +---
        | +--- (base class Dog)
 8      | | {vfptr}
12      | | {vbptr}
        | +---
        +---
        +--- (virtual base Animal)
16      | {vfptr}
20      | age
        +---

CatDog::$vftable@Cat@:
        | &CatDog_meta
        |  0
 0      | &Cat::scratch

CatDog::$vftable@Dog@:
        | -8
 0      | &Dog::bite

CatDog::$vbtable@Cat@:
 0      | -4
 1      | 12 (CatDogd(Cat+4)Animal)

CatDog::$vbtable@Dog@:
 0      | -4
 1      | 4 (CatDogd(Dog+4)Animal)

CatDog::$vftable@Animal@:
        | -16
 0      | &CatDog::speak

CatDog::speak this adjustor: 16

vbi:       class  offset o.vbptr  o.vbte fVtorDisp
          Animal      16       4       4 0

有没有发现,虚继承的类及其子类在虚函数表指针后面又多了一个vbptr,叫做虚基类表指针。该指针指向的虚基类表中包含了一个偏移量,正是虚基类表指针到虚基类成员的偏移量。

我们以CatDog为例分析。它的内存模型为:

class CatDog    size(24):
        +---
        | +--- (base class Cat)
 0      | | {vfptr}
 4      | | {vbptr}
        | +---
        | +--- (base class Dog)
 8      | | {vfptr}
12      | | {vbptr}
        | +---
        +---
        +--- (virtual base Animal)
16      | {vfptr}
20      | age
        +---

base class Cat中的虚基类表指针指向的虚基类表为

CatDog::$vbtable@Cat@:
 0      | -4
 1      | 12 (CatDogd(Cat+4)Animal)

里面有两项,第一项是该虚基类表指针到虚函数表指针的偏移量,通常为-4,因为vfptrvbptr一般是紧挨着放置的。第二项是该虚基类表指针到虚基类成员的偏移量,我们对照上面的完整内存模型,可以找到虚基类Animal的成员首地址是16,而该虚基类表指针的地址是4,相差正好12。对base class Dog可以做同样的分析,这里就不赘述了。

现在,使用了虚继承之后,Animal类在CatDog中只有一份,对agespeak的调用不会再产生歧义。

不过,事情可能会比想象中复杂一些,如果你按照我的写法亲自尝试一下,可能会发现编译器提示CatDog类无法编译,提示错误:虚拟函数 函数"Animal::speak"的重写不明确。这是因为Cat类和Dog类都重写了speak函数,然而CatDog类中只能有一份speak函数,编译器不知道该保留哪个,所以要求CatDog类也重写speak函数。如果CatDog中没有重写speak或只有一个重写了speak,就不会提示错误。其实,只要我们站在编译器设计者的角度思考,就能理清这些纷繁复杂的规则。

另外,虚继承使用的时候还需要考虑虚基类的初始化。本文就不展开讲了,可以参考百度百科中的解释。

本文对虚继承的讨论只算是浅尝辄止,我们并没有深入到汇编层面查看指令到底是如何运行的。如果你感兴趣,完全可以像本文第二部分一样,把代码编译成汇编码,一行一行查看在虚继承体系中虚函数的调用过程,相信一定会很有收获。

本文在分析汇编码时遇到了一些困难,在此感谢StackOverFlow中热心网友的解答。如果你自行尝试的过程中遇到了问题,请查看文末参考资料中的链接,或在评论区提问,我会尽快答复。

参考资料

虚函数与虚继承寻踪 范志东
用汇编分析C++程序 牧秦丶
虚继承 百度百科
Reversing Microsoft Visual C++ Part II: Classes, Methods and RTTI igorsk
函数调用栈 金戈大王
汇编伪指令 strikeshine
汇编写函数:关于PUBLIC和EXTRN的区别 襄坤在线
Is vftable[0] stores the first virtual function or RTTI Complete Object Locator? StackOverFlow

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335

推荐阅读更多精彩内容

  • C++虚函数 C++虚函数是多态性实现的重要方式,当某个虚函数通过指针或者引用调用时,编译器产生的代码直到运行时才...
    小白将阅读 1,723评论 4 19
  • 转至元数据结尾创建: 董潇伟,最新修改于: 十二月 23, 2016 转至元数据起始第一章:isa和Class一....
    40c0490e5268阅读 1,670评论 0 9
  • 1. 结构体和共同体的区别。 定义: 结构体struct:把不同类型的数据组合成一个整体,自定义类型。共同体uni...
    breakfy阅读 2,099评论 0 22
  • 一个博客,这个博客记录了他读这本书的笔记,总结得不错。《深度探索C++对象模型》笔记汇总 1. C++对象模型与内...
    Mr希灵阅读 5,540评论 0 13
  • 南禅无古寺, 西天彩霞宁。 香火日日升, 众信求善行。 我佛若有...
    曹无伤阅读 201评论 0 4