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

你可能感兴趣的:(C++虚函数和虚继承探秘)