首页 虚函数表
文章
取消

虚函数表

虚函数表

  • 每个包含了虚函数的类都包含一个虚函数表。虚函数表是一个指针数组,其元素是指向虚函数的指针,每个元素对应一个虚函数的函数指针。需要指出的是,普通的函数即非虚函数,其调用并不需要经过虚表,所以虚表的元素并不包括普通函数的函数指针

  • 虚表是属于类的,而不是属于某个具体的对象,一个类只需要一个虚表即可。同一个类的所有对象都使用同一个虚表。

  • 虚表内的条目,即指向虚函数的指针的赋值发生在编译器的编译阶段,也就是说在代码的编译阶段,虚函数表就可以构造出来了。(只要发现有virtual关键字就会塞进虚函数表)

  • _vfptr对象中指向虚函数表的这个指针是创建对象的时候才会出现,因为这个东西属于对象。因为编译期不会为对象分配内存(new是运行期的东西),所以自然对象的虚函数表指针只有运行期才有。

    • 所以无论你的对象是栈上还是堆上,无论你是否用了虚函数,无论是否满足多态调用的条件。只要这个类内部用有虚函数,那么一定会有虚函数表。那么每一个对象则一定会用有一个虚函数表指针。所以对象会变大。
  • 如果一个基类有虚函数,那么这个基类地址的头部(偏移量为0,最开始的地方)是一个指向虚函数表的指针。这个指向虚函数表的指针是在类对象创建时自动赋值的。会自动被设置为指向自己这个类的虚表。我们可以管这个指针叫做_vfptr。(具体的位置因平台而异)

  • 也就是说。在多态情况下,也就是父类有虚函数的情况下,每一个类(父类/子类)都有一个自己的虚函数表。子类是继承(复制)来的。子类在多重继承的时候可能会有多个虚函数表,和多重继承导致的多个_vfptr此句理解为:由于虚函数表和虚函数表指针是继承(复制)而来的,所以子类每继承一个父类,就会有一套其父类的虚函数表和对应的虚函数表指针。)子类中_vfptr虚函数表指针的数量是所有继承的父类的虚函数表指针的总和。

    • 只要父类有虚函数表,子类无论是否有虚函数,也会有一个自己的虚函数表。
  • 只要是虚函数,无论是否触发多态调用(比如对象调用),都会去查表。(最原始的情况)

    • 尽管编译器会优化此行为。这样如果不满足多态调用条件则不会查表。
  • 对象的虚表指针用来指向自己所属类的虚表,虚表中的指针会指向其继承的最近的一个类的虚函数。这里的要点是,假如C继承BB继承AB重写了A的虚函数,如果C没有重写,那么由于是C继承的B,所以C的虚函数表中的指针会指向B重写过的虚函数而不是A的。即 继承的最近一个类的虚函数。

    • 单继承中,无论继承链条有多长,无论子类是否有自己的虚函数,子类也只有一个虚函数表(假设基类有虚函数)。因为每个中间类的虚函数表都是继承自基类的。
  • C++中虚函数表位于只读数据段(.rodata),也就是C++内存模型中的常量区;而虚函数则位于代码段(.text),也就是C++内存模型中的代码区。

  • 当子类继承父类后,子类在编译期间就会继承(复制一份)父类的虚函数指针和虚函数表。但是如果子类重写了该方法(加virtual或者不加都可以,但是方法必须完全相同包括返回值),那么子类的虚函数表中的函数则会改变,也就是把自己的条目补全或替换。(在子类对象构造函数调用后)会将自己对象的虚函数指针指向自己的虚函数表。

  • 父类指针指向子类对象时,发生动态联编,如果虚函数表被重写,则调用父类虚函数会指向被重写的方法,否则父类指针指向自身的方法,是静态联编。

  • 虚基类表指针_vbptr的含义是:该指针指向的虚基类表里面记录了对应的虚继承的类在本类里的位置。也就是对应虚基类距离本类头部的偏移量。

    • 我们知道虚基类的内容都是在屁股的,所以相当于用一个指针塞在了头部,然后查表找到屁股的位置
      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
      
      class A {
          int a;
      };
          
      class B {
          int d;
      };
          
      class C : public virtual  B, public virtual A {
          int sss;
      };
      1>class C	size(24):
      1>	+---
      1> 0	| {vbptr}
      1> 8	| sss
      1>  	| <alignment member> (size=4)
      1>	+---
      1>	+--- (virtual base B)			//虚基类内容在屁股
      1>16	| d  
      1>	+---
      1>	+--- (virtual base A)			//虚基类内容在屁股
      1>20	| a
      1>	+---
      1>C::$vbtable@:
      1> 0	| 0
      1> 1	| 16 (Cd(C+0)B)				//虚基类B的起始地址是16。所以偏移量是16
      1> 2	| 20 (Cd(C+0)A)				//虚基类B的起始地址是20。所以偏移量是20
      1>vbi:	   class  offset o.vbptr  o.vbte fVtorDisp
      1>               B      16       0       4 0
      1>               A      20       0       8 0
      
  • 虚基类表和虚函数表都是编译期确定,每个类共享一个。虚函数表指针和虚基类表指针都是跟着对象走的。但是具体数量会因为是虚继承或不是虚继承而导致差异。
    • 虚基类表在多重非菱形继承的时候,无论该类虚继承了几个类,该类都只有一个虚基类表。(如上面代码)
    • 如果是菱形继承的情况下,继承了几个虚继承的类,就会有几个虚基类表
    • 如果是链式虚继承,则虚基类表指针数量会累加。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    
    class x {};
    class y: public virtual x{};
    class z: public virtual y{};
    1>class z	size(16):
    1>	+---
    1> 0	| {vbptr} //z的虚基类表指针
    1>	+---
    1>	+--- (virtual base x)
    1>	+---
    1>	+--- (virtual base y)
    1> 8	| {vbptr} //y的虚基类表指针
    1>	+---
    1>z::$vbtable@z@:
    1> 0	| 0
    1> 1	| 8 (zd(z+0)x)
    1> 2	| 8 (zd(z+0)y)
    1>z::$vbtable@y@:
    1> 0	| 0
    1> 1	| 0 (zd(y+0)x)
    1>vbi:	   class  offset o.vbptr  o.vbte fVtorDisp
    1>               x       8       0       4 0
    1>               y      
    
  • 普通非虚单继承下,虚函数表中的指针会指向其继承的最近的一个类的虚函数。比如B继承A,C继承B。则C会继承B的虚函数表。此时C的虚函数表会显示是自己的虚函数表(C::$vftable@:

  • 普通非虚多继承下,继承了几个含有虚函数的类就有几个虚函数指针和虚函数表。比如C继承A,C又继承B。则C会含有两个虚函数表,然后自己的虚函数放入第一个继承的类的虚函数表。(假如先继承的B,就放到B,先继承的A就放到A)此时C的两个虚函数表不会显示是自己的虚函数表,而是A和B的 (C::$vftable@B@: / C::$vftable@A@:)

    • 如果多继承有同名函数,则会有thunk。所有的虚函数都会被重定向至C重写后的函数。
  • VC下,虚函数表指针排在虚基类表指针前面。

  • 使用类作用域访问运算符显式调用虚函数会压制虚函数的调用方法(不会触发多态不查表),调用法会被处理成调用非静态成员函数。

  • 严格来说虚函数表指针的切换应该是在构造函数后,用户代码前。也就是构造函数的最后几行

举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Base
{
public:
    virtual void function1() {};
    virtual void function2() {};
};
 
class D1: public Base
{
public:
    virtual void function1() {};
};
 
class D2: public Base
{
public:
    virtual void function2() {};
};

这是一个最简单的例子。因为这里有三个类,所以编译器会创建三个虚函数表。

编译器会在使用了虚函数的最上层的基类中的定义我们提到过的指向虚函数表的指针_vfptr

加上之后长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Base
{
public:
    FunctionPointer *__vptr; //注意这里
    virtual void function1() {};
    virtual void function2() {};
};
 
class D1: public Base
{
public:
    virtual void function1() {};
};
 
class D2: public Base
{
public:
    virtual void function2() {};
};

这就是为什么说,这个_vfptr指针的偏移量是0,在最开始。因为类的内存分布是按照声明顺序的。

注意这里为什么D1和D2没有添加_vfptr,因为_vfptr属于基类部分。子类是包含基类部分的。所以说这里不能再写了。也就是说,子类虽然包含自己的_vfptr,但是它是继承自父类的。

_vfptr 在类对象创建的时候会设置成指向类的虚函数表。例如,类型 Base 被实例化的时候,_vfptr 就指向 Base 的虚函数表。类型 D1 或者 D2 被实例化的时候,_vfptr 就在子类调用父类构造期间,先指向 Base的虚函数表,再在执行子类构造期间指向D1 或者 D2 的自己的虚函数表。

不应在构造和析构函数中调用虚函数,但是构造函数中可以调用非虚的类成员函数

我们都知道,构造子类对象的时候,子类对象会先调用父类构造。调用父类构造的时候,我们可以理解为子类对象目前是父类类型。所以对象的_vfptr指针此时会指向父类的虚函数表。所以说这个时候虚函数不是虚函数。当父类构造完毕,调用子类的构造函数之时,子类对象会被认为是子类类型。这时这个_vfptr指针会指向子类自身的虚函数表,这时候虚函数才是虚函数(严格来说虚函数表指针的切换应该是在构造函数后,用户代码前。构造函数的最后几行)这就是为何不应在构造和析构函数中调用虚函数。因为此时的虚函数并不表现出虚函数的多态性质。举个例子,我们在父类的构造函数中使用了一个虚函数,因为这个虚函数被调用是在父类构造期间。所以即便是子类对象来调用,这个_vfptr也是指向的父类的虚函数表。

举例:

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
class A{
    public:
    A(){
        p();
    }
    virtual void p(){
        cout << "A" <<endl;
    }
};

class B: public A{
    public:
    B(){
        p();
    }
    void p(){
        cout << "B"<<endl;
    }
};

class C :public B{
    public:
    C(){
        p();
    }
    void p(){
        cout << "C" << endl;
    }
};

int main(){
    A* a = new C; //会输出 ABC
}

这个程序会输出ABC。因为构建子类对象会依次调用父类构造。表面上这里构造函数调用了虚函数,但是这个虚函数实际执行的是对应类的虚函数。这就是我们说的,可以在构造函数中调用虚函数,但是不应调用。我们会发现,因为是依次构造,所以在调用对应类的构造的时候,调用的是对应类的虚函数。(C类对象在执行A类部分构造期间,调用的虚函数执行的是A类自己的。在执行B类部分构造期间,执行的是B类自己的。在执行C类部分构造期间,执行的是C类自己的)所以说并没有多态特性。在实际应用中,这种在构造函数中调用虚函数的时候,不会有这种每一个类的虚函数都调用了对应虚函数的情形。 标准情况下,BC类的构造函数是不会调用函数P的。所以如果仅仅在父类A中调用了P,这个P也会是类AP,不是类CP

记住:在自己类的构造函数中调用了虚函数,将会是自己类的函数。即,父类构造函数中调用的虚函数依旧是父类的,子类构造函数中调用的虚函数依旧是子类的。顺序是父类构造->此时虚函数表指针指向父类虚函数表->子类构造->此时虚函数表指针指向子类虚函数表。(严格来说虚函数表指针的切换应该是在构造函数后,用户代码前。构造函数的最后几行)所以再次重申,父类指针指向子类对象,子类对象先调用了父类构造。父类构造中有虚函数,即便被子类重写过,但是此时子类对象仍在父类构造期间,虚函数表指针依旧指向父类的虚函数表。所以此时虚函数实际执行的不会具有多态性,会依旧执行父类虚函数而不是子类虚函数。

编译器会保证先调用所有基类的构造函数,然后再设置虚函数表指针,然后再调用成员的初始化操作。

  • 但是构造函数中可以调用非虚类成员函数

虚函数表长什么样

我们假设所有的函数都是虚函数

基类的虚函数表

QQ截图20220724044555

一般继承(无虚函数覆盖*)的子类虚函数表

QQ截图20220724044712

QQ截图20220724044722

此时:

1)虚函数按照其声明顺序放于表中。 2)父类的虚函数在子类的虚函数前面。

一般继承(有虚函数覆盖*)的子类虚函数表

QQ截图20220724044950

QQ截图20220724044958

此时: 1)覆盖的f()函数被放到了虚表中原来父类虚函数的位置。 2)没有被覆盖的函数依旧。

所以当如下代码执行时

1
2
Base *b = new Derive();
b->f();

b所指的内存中的虚函数表(子类构造结束后,子类_vfptr指向自己的虚函数表)的f()的位置已经被Derive::f()函数地址所取代,于是在实际调用发生时,编译器检测到了f是一个虚函数,所以不会静态的将Base类的函数直接编译过来。,而是运行的时候,动态的根据b指向的对象,找到这个对象的自己的_vfptr指针找到这个对象(一个类的所有对象共用一个虚函数表)的虚函数表。然后调用虚函数表里面对应的函数。于是Derive::f()被调用了。这就实现了多态。

多重继承(无虚函数覆盖*)的子类虚函数表

假设有如下继承关系(无覆盖)

QQ截图20220724045219

QQ截图20220724045811

我们可以看到: 1) 每个父类都有自己的虚表。因为每一个继承,子类都会有一个对应的虚表。所以多继承,子类也会有多个虚表(虚表指针)。

2) 所以子类有多重继承而来的多个_vfptr以指向多个虚函数表

3)子类的成员函数被放到了第一个父类的表中。(所谓的第一个父类是按照声明顺序来判断的) 这样做就是为了解决不同的父类类型的指针指向同一个子类实例,而能够调用到实际的函数。

注意,多重继承有多个虚函数表,而每一个虚函数表的开头都会有一个偏移量和一个RTTI信息

多重继承(有虚函数覆盖*)的子类虚函数表

QQ截图20221228023004

QQ截图20220724050217

注:覆盖又称override 重载

参考自这里

虚函数表的其他内容

虚函数表在第一个指向虚函数指针的前面还会有两个东西:

  1. 一个指针,指向一个存有字符串的类(type_info)。也就是储存了RTTI信息。c++运行时类型识别信息。我们就是靠这个来知道在有虚函数的情况下,父类指针指向子类对象的时候,子类到底是什么类型 。我们知道只有带有虚函数的类才会生成虚函数表,因此动态类型强制转换只用于多态类型,在进行动态类型转换时只要取虚函数表中的第-1个元素得到type_info类对象判断其真正的类型再进行操作。这个指针是编译期设定的。

  2. 一个偏移量。指示类中这个_vfptr离类的头指针有多远。一般情况下都是0。因为_vfptr会在头部。但是在多继承的时候,如上图,会有多个_vfptr。所以会有偏移量。

RTTI 和虚函数的关系

https://www.jianshu.com/p/3b4a80adffa7

在解释这个问题之前我们先回顾一下向上和向下转型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Base{
    virtual void func(){
        //...虚函数有没有都行
    }
};
class Derive :public Base{
    
};
int main(){
    Base* b = new Derive(); //父类指针指向子类对象。这句话等同于下面这两行。也就是子转父。也就是向上转型。此行是对象切片
    Base* b = static_cast<Base*>(new Derive()); 
    Base* b = (Base*) new Derive(); //C风格
    
    Derive* d = new Base(); //错误,禁止隐式的向下转型。也就是子类指针指向父类对象。也就是父转子。下面这两行可以通过编译
    Derive* d = static_cast<Derive*>(new Base()); 
    Derive* d = (Derive*) new Base(); // C风格
    
    
    return 0;
}

static_cast强制类型转换不安全,因为没有类型检查。但是并不禁止编译。会产生未定义行为。我们上一行的Derive* d = static_cast<Derive*>(new Base());可能看得不够清楚。我们拆开来看

1
2
Base* baseptr = new Base();
Derive* d = static_cast<Derive*>(baseptr); //不安全

看到问题了吗?我们仅仅初始化了父类成员,但是转成了子类对象。这代表会多出一块未定义的空间。

但是如果

1
2
Base* baseptr = new Derive(); //注意这里
Derive* d = static_cast<Derive*>(baseptr); //安全

这样就是安全的,为什么?因为我们这里用的是指针。虽然子类对象隐式已经在第一行被转换成了父类类型,但是我们可以安全的手动转回来。因为我们第一行new的时候初始化了全部的子类成员,隐式转换成父类后会被隐藏(此处使用的是指针。切记,指针类型表示编译器可以合法读取多大区域的内存。我分配了整个子类,但是我只让指针读取父类的部分,这是可以的,数据没有被清除,仅仅是指针访问不到而已。),手动转回来不会出现未定义空间。

在没有多态的时候,由于无法使用dynamic cast导致没有类型检查导致风险较大。因为你必须要手动保证转型的指针new的是子类对象而不是父类对象。但是dynamic cast就帮助我们进行了一个类型检查。

为了避免这种不安全的转换,所以我们需要dynamic_cast来在运行时检查是否可以安全转换。这就用到了RTTI。

只有包含虚函数的类(多态类)才可以使用dynamic_cast因为RTTI 依赖于虚表,所以用dynamic_cast 对应的类一定要有虚函数。

多态类就是有虚函数的类(直接声明或继承)

  • 没有虚函数的前提下,如果父类和子类定义了相同名称的成员函数,那么通过对象指针调用成员函数时,到底调用那个函数要根据指针的原型来确定,而不是根据指针实际指向的对象类型确定。
1
2
3
4
5
6
7
8
9
10
11
class Base{
  	//...没有虚函数  
};
class Derive:public Base{
    
};
int main{
    Base* ptr = new Derive(); //静态绑定。ptr实际上指向的类型是ptr的类型即父类类型
    cout << typeid(*ptr).name() << endl; //输出base
    return 0
}
  • 有虚函数的前提下,如果父类和子类定义了相同名称的成员函数(重写,动态绑定),那么通过对象指针调用成员函数时,与当前指向类实例的父类指针类型无关,仅和类实例对象本身有关。
1
2
3
4
5
6
7
8
9
10
11
12
13
class Base{
  	virtual void p(){
        //...
    }
};
class Derive:public Base{
    //...子类无论是否定义了相同名称的成员函数(重写),都会使父类指针指向的类型变为子类类型
};
int main{
    Base* ptr = new Derive(); //动态绑定。ptr实际上指向的类型是Derive的类型即子类类型。指针的静态类型和目标类型不同
    cout << typeid(*ptr).name() << endl; //输出Derive
    return 0
}

虽然此时ptr可以被认为是指向了子类类型,但是我们不要忘记了,指针类型其实是告诉编译器应该正确解析多大的内存空间。所以此时依旧无法访问子类自己的部分 如变量和非虚函数。

所以如果此时我们想要访问子类函数或变量应该怎么办?我们需要dynamic_cast来在运行时检查是否可以安全转换。这就用到了RTTI。

dynamic_cast 如果执行成功,会返回子类地址。如果转的是指针,失败会返回nullptr, 如果转的是指针,失败会抛出bad_cast因为引用无法为空

通过运行时类型信息(RTTI)能够使用基类的指针或引用来检查这些指针或引用所指的对象的实际派生类型

RTTI是一种概念,是一种机制。在虚函数表表头放置type_info是实现RTTI的一种方式。

我们说过,有虚函数表的开头是一个指向当前类对应的 type_info 对象。当程序在运行阶段获取类型信息时,可以通过对象指针找到对象自己的指向虚函数表的指针 _vfptr,再通过 _vfptr找到 type_info 对象的指针,进而取得类型信息。

static_cast转型的话只要是有点关系的两个类型就可以转,转了会不会出问题他不管,用户自己负责。

dynamic_cast的话会根据虚表里信息去比对,这个基类指针指向的对象真的是派生类才会转换,否则就返回空指针,提醒用户不能转换。

dynamic_cast的向下转换效率很低。因为需要使用一个类似于字符串比较的函数对两个类型描述器type_info进行比较。但是这样却足够安全。

一段话总结

父类指针指向子类对象,限制了父类指针读取的内存区域,只可以读取父类部分。所以我们认为这时候这个东西是父类类型。所以在没有虚函数的时候,父类指针是无法使用子类独有资源(成员变量,成员函数)的。

当多态发生的时候,我们父类指针指向子类对象依旧会被编译器认为是父类类型(也可以理解为指向对象被隐式转换或者是切片)。所以只能访问子类对象的父类部分,无法访问子类独有的数据(变量,非虚函数和独有的虚函数)。但是我们说过,指向虚函数表的指针_vfptr是在子类的父类部分中的。所以这个指针是可以访问的。当我们调用虚函数的时候,由于我们指向的是子类对象,也就是实例化的是子类对象,(Base* ptr = new Derive()这句话我们看到new了一个Derive,所以还是会进行正常的构造过程。也就是先执行父类构造,再执行子类构造。我们说过,父类构造期间,对象的_vfptr会指向父类的虚函数表,然后执行子类构造期间,_vfptr会指向子类虚函数表。所以子类构造完毕后,_vfptr一定会指向自己的虚函数表)。所以_vfptr指向的是子类的虚函数表。所以可以通过这个指向了子类虚函数表的_vfptr指针找到对应的虚函数表,然后对虚函数进行调用。但是这个时候我们还是不可以通过父类指针访问子类的非虚函数和子类的成员变量。因为他还是被认为是父类类型。所以这时候我们想访问子类独有资源就可以使用dynamic_cast来进行安全的转换。

这个dynamic_cast是怎么用的呢?我们父类指针指向子类对象的时候,限制了指针仅可以读取子类对象的父类部分。我们进行dynamic_cast的时候,会使用RTTI信息。这个信息是储存在虚函数表的头部。也就是编译器找到了虚函数表指针(子类对象的虚函数表指针一定指向自己的虚函数表,所以RTTI信息类型也是子类),然后问RTTI:”这人想要让我转换成子类,也就是多读点儿子类的东西,你帮我看看我指向的对象到底是不是子类,多读的那部分被正确初始化了没有?合法不合法?“。RTTI看了一眼回答:”哦,你指向的部分实际类型确实是一个子类的类型,因为他new的时候new的是一个子类对象。我检查了,都初始化了,也合法,那你就多读点儿吧“。这就是dynamic_cast和RTTI的联系。

所以虚函数表的RTTI应该就是进行一个安全检查。

多态中 this指针的切换

这里有了虚函数表指针就比较好理解。在一开始子类对象构造的时候,先构造父类部分,此时this指针的类型是父类,虚函数表指针也指向父类=虚函数表,因为此时这个对象是父类类型。等到构造到了子类部分,子类进行构造的时候,this指针切换成子类类型,虚函数表指针切换到子类虚函数表。此时对象才是子类类型。

注意,this指针的切换是在执行期。(废话)

还有,this指针的类型是要看具体在哪个类里面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Base{
    public:
    void func(){
        cout <<"base func" << endl;
    }
};

class Derive:public Base{
    public:
    void call(){
        func();
    }
};
int main() {
    Base* ptr = new Derive();
    Derive* ptr1 = new Derive();

    ptr->func();        //1
    ptr1->call();       //2
    ptr1->func();       //3

    return 0;
}

在这段代码内,func的this指针类型为base,call的this指针为derive

静态类型 动态类型和 为什么多态只能使用指针或者引用?

引用可以实现多态的原因是引用的底层也是指针。

c++确定类型分两种,一种是对象,一种是指针或者是引用。如果是对象,那就是我们编译时确定它是什么玩意 即静态绑定。即便此时使用的是子类对象给父类对象赋值,但是对象没有动态类型。所以肯定是使用其静态类型。

如果是指针或引用,在设计层面他们就被允许指向声明类型或者是声明类型的子类的对象。也就是进行了动态类型判定的动态绑定。而对象永远不应该改变它的类型。这就是对象模型的工作方式。所以只有在用指针和引用调用的时候才发生了这个动态类型确定(RTTI)和虚函数表查询并且执行正确的函数。

而通过对象调用则不会有这种情况。所以在直接使用对象赋值的时候,拷贝赋值这种东西会忽略掉_vfptr这种只有动态绑定才会激活的东西,只会拷贝赋值普通成员变量。所以哪怕子类的_vfptr和虚函数表继承了父类的,也确实是父类的,但是因为他是个_vfptr,规定只有在指针或者引用的情况才会被激活,所以会在拷贝赋值的时候忽略。

深度探索C++对象模型中提到:

  “一个pointer或一个reference之所以支持多态,是因为它们并不引发内存任何“与类型有关的内存委托操作; 会受到改变的。只有它们所指向内存的大小和解释方式 而已”

对这句话解释就是:

  • 指针和引用类型只是要求了基地址和这种指针所指对象的内存大小,与对象的类型无关。指针和引用并不涉及内存中对象的类型转换,只是改变了解释方式。
  • 而把一个派生类对象直接赋值给基类对象,就牵扯到对象的类型问题(比如切割),编译器就会回避之前的的虚机制。从而无法实现多态。(对象直接调用调用赋值=会发生转型)。

每一个实体(对象,指针或引用)都有静态类型和动态类型两种。它们可能不一致。

  • 静态类型指的是不需要考虑表达式的执行期语义,仅分析程序文本而决定的表达式类型。静态类型仅依赖于包含表达式的程序文本的形式,而在程序运行时不会改变。通俗的讲,就是上下文无关,在编译时就可以确定其类型也就是声明时的类型。
    • 对象、引用或指针的静态类型决定了该对象的哪些成员是可见的
  • 动态类型指的是变量或者表达式表示的内存中的对象的类型,动态类型直到运行时才可知。只有指针或引用才有动态类型。

在编译期间,所有变量(对象)的静态类型都是其声明时类型。如果是指针或者是引用,其静态类型依旧是其声明时类型。但是其动态类型会被推迟至运行时确定。

那么如何确定一个变量在运行时到底使用了其静态类型还是其动态类型呢?

  1. 首先确定该对象(变量)确实有动态类型。也就是使用了指针或引用形态。(因为只有这种情况才有动态类型)
  2. 应用场景为带虚函数的继承体系结构。(只有虚函数表里才有type_info)
    • 必须子类重写了父类的虚函数。因为父类指针无法访问子类独有的成员(变量,非虚函数和独有的虚函数)
  3. 检查静态类型和动态类型是否相等。(运行时通过RTTI形式, 访问虚函数表的type_info确定其类型)
  4. 如果该对象的静态类型和动态类型不相等,并且调用了(重写的)虚函数,采用其动态类型。否则采用静态类型(废话)。

所以如果

  • 使用对象调用,不满足条件1,使用静态类型。

  • 如果没有虚函数或没有重写,不满足条件2,使用静态类型

  • 如果类型一致,比如父类指针指向父类对象,不满足条件3,使用静态类型。

  • 如果使用对象或引用调用(满足条件1)虚函数(满足条件2),并且发生了父类指针指向子类对象(满足条件3)的时候才确认使用其动态类型。这样就可以触发多态。但是具体调用父类还是子类虚函数依旧要看表内的具体内容,也就是子类重写了父类虚函数。否则查表查到的依旧是父类的虚函数。

假设有以下继承结构:class Drived : public Base,然后有表达式p->mem()obj.mem(),其中mem()是一个类成员函数,但具体是基类还是子类,现在可以不用管,而且我们也不用管p或者obj是指向哪个类。当程序的编译期,当编译器遇到表达式p->mem()obj.mem(),执行以下步骤:

  • 首先确定p(或obj)的静态类型,即声明类型;

  • 然后在p(或obj)的静态类型中查找mem()函数,如果没有找到,按照作用域规则,这时编译器会到其直接基类中寻找,并依次向上,直到达到继承链的顶端,如果在这个过程中找到了,则继续下一步,如果没有找到,则报错;
  • 一旦找到名字匹配的mem()函数,则编译器会根据mem()是否是虚函数,以及我们是通过指针或引用(如p)来调用mem()函数,还是通过类对象(如obj)来调用mem()函数,生成不同的代码。
    • 如果mem()是虚函数且是通过指针或引用调用(如p->mem()),编译器生成的代码会检查其动态类型,这就是动态绑定的过程,也就是说编译器生成的代码会直到运行期确定了动态类型才决定具体调用基类还是子类的虚函数mem()
    • 如果mem()不是虚函数或是通过对象调用(如obj.mem()),则编译器会产生一个常规的函数调用代码,可以直接确定调用哪一个mem()函数。

其实可以直接理解为:动态类型就是为动态绑定(C++继承的多态)准备的。只有当上述3个条件都满足了,动态类型才能发挥其威力(也就是采用对象的动态类型,即很好的支持虚函数动态绑定机制)。如果条件不全部满足,则依旧采用对象的静态类型。

注意,这里的动态类型并不能代表我们可以通过父类指针调用子类独有的数据成员(变量,非虚函数和独有的虚函数)。如果需要调用独有的数据成员,依旧需要dynamic_cast进行操作。

对于虚函数,执行时实际调用该函数的对象类型为该指针或引用所指对象的实际类型。也就是在此时,如果满足了使用动态类型的几个条件,则调用方(父类指针)在调用时可以被解释为所指对象的实际类型(子类类型)。也就是p->mem()的时候,p的类型在调用时(执行时)被解释为子类类型。但是p的静态类型依旧是父类类型。

第二点,我们指针指向的对象没法互相赋值,赋值只能是交换指针。指针的拷贝赋值是指针类型自己的,和指向的对象类型无关。所以这一层面的拷贝赋值使用的是对象的拷贝赋值,而不是指针的拷贝赋值。所以自然忽略掉了_vfptr对应的内容。

我们梳理一下,假设我们有下面代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class A  
{  
public:  
    virtual void T(){}  
    virtual void print()  
    cout<<"A"<<endl;
};  
class B:public A  
{  
public:  
    void print()  
    cout<<"B"<<endl;  
};  
int main()  
{  
  A a;  
  B b;  
  a = b;  
  a.print();                         //通过对象直接访问  
  b.print();  
  return 0;  
}

我们的ab已经被构造完毕,所以此时_vfptr已经指向了自己的。哪怕我们没有使用多态调用,但是由于类内存在虚函数,则一定存在虚函数表。对象也一定存在_vfptr指针。(我可以不用,但不能没有)

QQ截图20220823022745

我们可以看到,对象b的虚函数表指针和虚函数表被认为是在A里面的,也就是继承来的。但是继承是复制。所以才有那句话:每一个类都有一个自己的虚函数表,每一个对象都有一个自己的虚函数表指针_vfptr

当我们执行到a = b的时候,虚函数表的内容并没有被替换。所以我们可以认为,拷贝赋值或者拷贝构造的时候会忽略掉任何和虚函数相关的东西,只针对可以拷贝的东西进行操作。默认的赋值运算符并不会操作虚函数表。一个类对象里面的_vfptr永远不会变,永远都会指向所属类型的虚函数表,因为对象的语义就是类型永远不会改变。

QQ截图20220823024407

  • 一句话总结:虽然子类的虚表指针和虚函数表是继承自父类,并且认为是父类的,但是在把子类对象拷贝给父类对象的时候,编译器会忽略掉所有虚机制的部分。

  • 记住:无论是否使用多态调用,只要这个对象的类里面有虚函数,他一定会有虚函数表并且会把虚函数放到虚函数表。只要是虚函数,我无论是否触发多态调用,他都会去查表。对象调用不会触发多态的原因是对象赋值忽略虚指针的部分,所以等号左手边的对象依旧会用自己类类型的虚函数表。会查到自己类的虚函数。所以不会有多态行为。
    • 注意,这句“无论是否触发多态调用,都会去查表。”不准确。会有编译器优化,如上一段说的,如果是非虚函数或通过对象调用,直接不使用虚函数表。编译器会产生一个常规的函数调用代码,可以直接确定调用哪一个函数。
  • 多态调用无法访问子类独有的虚函数。因为我们记得,子类独有的虚函数是放到虚函数表的后面的。也就是说,我们通过对象的虚函数表指针查表的时候,由于子类独有的数据成员不属于父类部分,也就是处于父类指针解释不到的位置。所以父类无法访问。假如父类的虚函数表是50字节,子类添加了独有的所以是80字节。但是父类通过虚函数表指针访问的时候,依旧是只能看到前50字节。

  • 又及,父类指针指向子类对象的时候,访问子类独有数据/子类独有函数/子类独有虚函数的时候都不满足多态调用,所以指针在调用时依旧解释为其静态类型。而且父类指针不含有子类独有数据,所以并不能满足要求所以无法调用。

更多可以看深度探索c++对象模型的1.3和虚函数章节。包括笔记的2.2拷贝构造函数部分。

QQ图片20230108101950

从此图我们可以看到,由于是虚函数所以并不能直接call。需要首先拿到对象地址(6988),然后拿出对象的虚函数表地址(698B),然后找到虚函数在虚函数表的地址放入eax(6990)[这里因为这个函数是第二个虚函数,所以+4了。图里是32位所以+4, 64位正常+8]。然后调用函数(6993)

对一个指针解引用是否具备多态性?

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
class father{
    public:
        father()= default;

        virtual void func(){
            cout <<"father" << endl;
        }
};

class child:public father{
    public:
        child() = default;
        void func(){
            cout << "child" << endl;
        }
};

int main(){

    father* fptr = new child; //父类指针指向子类对象,注意指针自己是父类。也就是fptr的静态类型是父类
    auto s = *fptr; //解引用依靠的是静态类型。所以此时s是father类型。
    (*fptr).func();//输出child
    s.func(); //输出father。
    
    auto& ss = *fptr;
    ss.func(); //正确输出child
    return 0;
}

对一个指针解引用,返回的是其静态类型的引用。

image-20230120024852905

  • 所以说,我们fptr静态类型是father。它一直是father。因为类型需要编译时解析。我们上面提到了,在编译期间,所有变量(对象)的静态类型都是其声明时类型。如果是指针或者是引用,其静态类型依旧是其声明时类型。但是其动态类型会被推迟至运行时确定。所以我们对它解引用得到的是一个father&。这样做如果赋值,因为使用一个引用给一个变量赋值的时候会调用其拷贝构造(杂记2提到过)。那么等号左侧的auto自然是father类型。
  • 如果直接调用,因为解引用返回father&,它是一个引用,那么基于这个引用自然可以正确施加多态。

  • 最后,如果我们使用引用去接,则没有问题。正常多态。

https://stackoverflow.com/questions/75181450/why-dereference-an-polymorphism-pointer-will-get-the-pointers-type-rather-than#75181533

符合条件的函数入参依旧可以触发多态

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 base{
    public:
    void func1(){
        cout << "base1" << endl;
    }
    virtual void func2(){
        cout << "base2" << endl;
    }
};

class derive:public base{
    public:
    void func1(){
        cout << "derived1" << endl;
    }
    void func2(){
        cout << "derived2" << endl;
    }
};

void func(base& b){
    b.func1();
    b.func2();
}

int main(){
    derive obj;
    func(obj);
    //输出Base1, derived2
    return 0;
}

我们的函数签名是传入一个父类的引用(指针也可以)。然而我们其实传入的是子类的对象(指针也可以),这样是因为函数的入参其实就是一个拷贝过程。意思就是一个derive类型的对象初始化了base类型的引用(符合引用多态)或者是一个derive类型的指针赋值给了base类型的指针(符合指针多态)

多重继承下的thunk 深度探索c++4.2

在了解什么是thunk之前,我们先明确三个事情。

  • this指针永远指向调用方。
  • 函数形参的this指针的类型永远是类类型。
  • this指针的类型判定依靠的是静态类型而不是动态类型
  • 注意这个和虚函数表指针不一样。虚函数表指针在指针赋值的时候不会改变。只有对象赋值的时候会有额外操作即忽略掉虚机制的赋值。(虚函数表指针不会被赋值)

什么叫this指针的类型判定依靠的是静态类型而不是动态类型

这句话解释出来就是Base* ptr = new Derive()这行代码,父类指针指向子类对象。但是此时这个对象被认为是父类类型。又因为this指针依靠静态类型判断,所以说此时子类对象的this指针会被认为是父类类型。

所以这时我们想要进行多态或非多态函数调用,就必须将this调整为对应类型。

这句话的意思是,假如我们父类有一个函数是func,那么我们调用这个函数的时候,this指针类型必须被转换为父类类型。如果此时子类有一个函数是func1,那么调用这个函数的时候,this指针类型必须被转换为子类类型。

多重继承

QQ截图20220829081138

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
class Base1 {
public:
    Base1() {};
    virtual ~Base1() {}; 				//虚析构
    void test() {
        cout << "Base1" << endl;
    }
    virtual void func() {				//同名虚函数

    }
    int a;
};
class Base2 {
public:
    Base2() {};
    virtual ~Base2() {};				//虚析构
    virtual void Base2func(){}			//子类没有重写该函数。
    virtual void func() {				//同名虚函数

    }
    int b;
};

class Derive: public Base1, public Base2 {
public:
    Derive(){}
    virtual ~Derive(){}					//虚析构
    void test() {
        cout << "Derive" << endl;
    }
    virtual void func() {				//同名虚函数重写

    }
    int c;
};
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
1>class Derive	size(40):
1>	+---
1> 0	| +--- (base class Base1)
1> 0	| | {vfptr}
1> 8	| | a
1>  	| | <alignment member> (size=4)
1>	| +---
1>16	| +--- (base class Base2)
1>16	| | {vfptr}
1>24	| | b
1>  	| | <alignment member> (size=4)
1>	| +---
1>32	| c
1>  	| <alignment member> (size=4)
1>	+---
1>Derive::$vftable@Base1@:
1>	| &Derive_meta
1>	|  0
1> 0	| &Derive::{dtor}
1> 1	| &Derive::func
1> 2	| &Derive::func1
1>Derive::$vftable@Base2@:
1>	| -16
1> 0	| &thunk: this-=16; goto Derive::{dtor}		//thunk 出现在非第一基类的剩余的虚函数表内。且必须是发生同名虚函数重写
1> 1	| &Base2::Base2func						//子类没有重写且不是重名,不触发thunk
1> 2	| &thunk: this-=16; goto Derive::func		//thunk
1>Derive::{dtor} this adjustor: 0
1>Derive::func this adjustor: 0
1>Derive::func1 this adjustor: 0
1>Derive::__delDtor this adjustor: 0
1>Derive::__vecDelDtor this adjustor: 0

我们可以非常清楚地看到三件事情:

  • Base1和Base2互相不包含。因为是多继承。
  • 虚函数被重写。虚函数被编译器认为是同名虚函数
  • 同名虚函数func被重写。

这三件事情隐含的信息有如下几条:

  • 因为是多继承,而且都有虚函数,所以Derive会有两张虚函数表。一张是Base1 的 一张是Base2 的
  • 因为是非虚多继承,所以子类自己的虚函数会被添加至第一基类的虚函数表上。
  • 子类同名虚函数重写会重写掉所有类的同名虚函数。此时这个类的两张虚函数表里的同名函数如果被子类重写了,则这两张虚函数表对应的同名虚函数都会被替换。也就是此时可以看做只有一个func。

还记得我们说过,调用成员函数时,this指针会被转为对应类型的成员吗?

我们来看看多继承会有什么问题。

假设有如下代码:

1
Base2* ptr = new Derive();

我们不用想都知道,这个时候这个ptr的this指针应该指向的是上图黄色框的部分。如果此时我们多态调用了子类虚函数,我们如何转型?

我们需要把指针往上面推,推到子类的起始点,也就是推到头。黑色框是整个子类对象。

我们发现,第一基类的this指针位置一定和子类的this指针位置一样。

所以此时我们要有一个偏移量,这个偏移量就是Base1的大小。

但是不仅仅多继承的函数调用需要调整this指针,只要是继承都需要。那么什么是thunk呢?

thunk是 调整this指针的位置 + 跳转至对应虚函数进行执行的整合体

我们既然需要一个地方保存this指针的偏移量,我们还要虚函数表保存对应的虚函数地址。那么就意味着要存两个东西。

但是,我们知道,子类的this指针一定会被推到开头。因为子类对象是整个黑框。而且我们还知道,子类重写同名虚函数,所有虚函数执行哪个都一样。那么我们为什么不直接让其余基类的虚函数表里面,同名的虚函数部分只存一个this偏移量,然后直接回头从第一基类的虚函数表找到对应函数来执行呢?

对 可以这样。这就是thunk。具体的跳转是汇编层面的优化。所以我们看到了这样的东西:

1
2
3
4
1>Derive::$vftable@Base2@:
1>	| -16
1> 0	| &thunk: this-=16; goto Derive::{dtor}		
1> 1	| &thunk: this-=16; goto Derive::func		

这里面不仅有一个this指针的偏移量,还有一个goto语句。这就是跳转到了要执行的目标函数。也就是子类的虚函数。

这里-16是因为,第一基类现在有一个虚函数表指针,地址是+8,还有一个int,地址是+8+4=+12。然后补齐到8的倍数,也就是+4 = +16。所以Base2*指向的位置是+16。要推到头就是-16。

thunk伪代码会长得像这样:

1
2
3
base2_dtor_thunk:
	this = this - sizeof(base1);
	Derive::~Derive(this);

为什么单继承没有thunk

最简单的理由:单继承只有一张虚函数表。无论这个继承链有多长

所以什么时候会有thunk 和 thunk的规则

  • 子类一定要重写多继承中的同名虚函数(此处包括虚析构的重写)。
  • 重写的函数理论上应该被覆盖至所有的继承的父类的虚函数表内。但是除第一基类外,其余基类的虚函数表的对应位置被优化了,优化为这个thunk。

在上面两条的基础上,三种情况会导致触发thunk 而调整this指针

1
2
Base2* ptr = new Derive();
delete ptr;
  • 这里需要调整的原因是,本来this指针指向黄色框部分,但是调用了子类重写的同名虚函数(析构函数)则需要把this转换为子类类型传入。所以this需要推到头部。也就是-16
Derive* ptr = new Derive();
ptr->Base2func();
  • 此处是子类调用了一个没有重写且不同名的父类函数。此时因为该函数在Base2内,所以this指针必须转换为Base2。所以这里this需要往下推到黄色框也就是Base2部分。

  • 第三种是协变,过于复杂 不看了。

虚继承:

在VC下面,有如下规则:

  • 虚继承的类元素一般会被放到类对象内存分布的最后尾。这里指的是直接虚继承。
    • 为啥放到屁股?因为我们B虚继承A,C虚继承A。这时候B和C分别拥有一个虚基类表指针,虚基类表和A的部分。然后我们有D继承B和C的时候,我们依旧会继承B和C的虚基类表指针和虚基类表。然后这时候会有一个vib表记录基类A的偏移量之类的信息。通过这些vbi表和虚基类表我们可以做到将基类A只留存一份在D中。

    • 另外一个原因是,我们把虚继承的类分割成了两部分。非虚继承的部分做为不变区域,虚继承的部分是共享(动态区域)因为虚继承会导致偏移量的变化。所以要在开头放一个虚基类表指针来正确指向虚基类的内容。

    • 使用这种虚基类表的好处在于虚基类表属于类,整个类共享。无论有多少个对象都只有一次开销。而且虚基类表指针是一定要有的。

    • 为啥不放到头?放到头了我指针放哪?

    • 当子类虚继承了父类的时候,子类包含有父类的全部数据和自己的虚基类表。直到当有一个类继承了两个类,这两个类都虚继承了同一个类的时候,这部分相同的数据才会被合并。

      • 1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        
        class A{
            //A数据
        };
        class B: virtual public A{
            //B数据
            //此时B类包含A类全部数据和自己的虚基类表
        };
        class C: virtual public A{
            //C数据
            //此时C类包含A类全部数据和自己的虚基类表    
        };
              
        class D: public B, public C{
            //D数据
            //此时,D继承了两个类,这两个类虚继承自同一个类,这个时候相同的部分也就是A类数据才会被合并。
        };
        
    • 这是不同编译器不一样。编译器决定的。

  • 在一个类虚继承多个类的时候(多重虚继承),无论虚继承了多少类,该类只会有一个虚基类表指针_vbptr和一个虚基类表。因为此类是虚继承的开始类。

  • 在一个类虚继承了一个虚继承过的类的时候,会有两个虚基类表。一个是自己的,因为自己在虚继承。另一个是继承来的。

    • 所以只有在本类虚继承了其他类的时候,本类才会生成一个虚基类表和虚基类表指针。
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
class Z {
    int v;
};

class X {
    int v;
};

class Y : public virtual Z, public virtual X {
    int k;
};
1>class Y	size(24):
1>	+---
1> 0	| {vbptr}						//仅有一个虚基类表指针
1> 8	| k
1>  	| <alignment member> (size=4)
1>	+---
1>	+--- (virtual base Z)
1>16	| v
1>	+---
1>	+--- (virtual base X)
1>20	| v
1>	+---
1>Y::$vbtable@:
1> 0	| 0
1> 1	| 16 (Yd(Y+0)Z)
1> 2	| 20 (Yd(Y+0)X)
1>vbi:	   class  offset o.vbptr  o.vbte fVtorDisp
1>               Z      16       0       4 0
1>               X      20       0       8 0
  • 在链式虚继承中,虚基类表指针数量会累加。也就是查看继承链上发生了几次虚继承。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class x {};
class y: public virtual x{};
class z: public virtual y{};
1>class z	size(16):
1>	+---
1> 0	| {vbptr} //z的虚基类表指针
1>	+---
1>	+--- (virtual base x)
1>	+---
1>	+--- (virtual base y)
1> 8	| {vbptr} //y的虚基类表指针
1>	+---
1>z::$vbtable@z@:
1> 0	| 0
1> 1	| 8 (zd(z+0)x)
1> 2	| 8 (zd(z+0)y)
1>z::$vbtable@y@:
1> 0	| 0
1> 1	| 0 (zd(y+0)x)
1>vbi:	   class  offset o.vbptr  o.vbte fVtorDisp
1>               x       8       0       4 0
1>               y      
  • 在虚继承中,无论是否是多重虚继承,如果是只重写了对应虚继承的类的虚函数,则继承了多少个含有虚函数的类,就有多少个虚函数表指针_vfptr和对应数量的虚函数表。
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
44
45
46
class Z {
    int v;
    virtual void func(){}
};

class X {
    int v;
    virtual void func1() {}
};

class Y : public virtual Z, public virtual X {
    int k;
    virtual void func() {}			//重写
    virtual void func1() {}			//重写
};
1>class Y	size(48):
1>	+---
1> 0	| {vbptr}							//虚基类表指针,只有一个
1> 8	| k
1>  	| <alignment member> (size=4)
1>	+---
1>	+--- (virtual base Z)
1>16	| {vfptr}							//Z的虚函数表指针
1>24	| v
1>  	| <alignment member> (size=4)
1>	+---
1>	+--- (virtual base X)
1>32	| {vfptr}							//X的虚函数表指针
1>40	| v
1>  	| <alignment member> (size=4)
1>	+---
1>Y::$vbtable@:
1> 0	| 0
1> 1	| 16 (Yd(Y+0)Z)
1> 2	| 32 (Yd(Y+0)X)
1>Y::$vftable@Z@:
1>	| -16
1> 0	| &Y::func
1>Y::$vftable@X@:
1>	| -32
1> 0	| &Y::func1
1>Y::func this adjustor: 16
1>Y::func1 this adjustor: 32
1>vbi:	   class  offset o.vbptr  o.vbte fVtorDisp
1>               Z      16       0       4 0
1>               X      32       0       8 0
  • 在虚继承中,无论是否是多重虚继承,如果是不仅仅重写了对应虚继承的类的虚函数,而且增加了自己的虚函数,则会在上面一条的基础上再增加一个自己的虚函数表和虚函数表指针
    • 注意这里是虚继承和非虚继承的区别。非虚继承会在第一个继承的虚函数表后面新增。虚继承则会创建新的虚函数表。
    • 注意这里Y是直接虚继承了Z和X。所以Z和X会被按照顺序排在Y的后面。
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
44
45
46
47
48
49
50
51
52
53
54
55
class Z {
    int v;
    virtual void func(){}
};

class X {
    int v;
    virtual void func1() {}
};

class Y : public virtual Z, public virtual X {
    int k;
    virtual void func() {}			//重写
    virtual void func1() {}			//重写
    virtual void func2() {}			//!新增

};
1>class Y	size(56):
1>	+---
1> 0	| {vfptr}								//自己的虚函数表指针
1> 8	| {vbptr}								//虚基类表指针,只有一个
1>16	| k
1>  	| <alignment member> (size=4)
1>	+---
1>	+--- (virtual base Z)
1>24	| {vfptr}								//Z的虚函数表指针
1>32	| v
1>  	| <alignment member> (size=4)
1>	+---
1>	+--- (virtual base X)
1>40	| {vfptr}								//X的虚函数表指针
1>48	| v
1>  	| <alignment member> (size=4)
1>	+---
1>Y::$vftable@Y@:
1>	| &Y_meta
1>	|  0
1> 0	| &Y::func2
1>Y::$vbtable@:
1> 0	| -8
1> 1	| 16 (Yd(Y+8)Z)		//此处的Y+8意思是这个虚基类表指针距离该类头部的偏移量。因为虚基类表指针在虚函数表指针后面,所以+8了
1> 2	| 32 (Yd(Y+8)X)
1>Y::$vftable@Z@:
1>	| -24
1> 0	| &Y::func
1>Y::$vftable@X@:
1>	| -40
1> 0	| &Y::func1
1>Y::func this adjustor: 24
1>Y::func1 this adjustor: 40
1>Y::func2 this adjustor: 0
1>vbi:	   class  offset o.vbptr  o.vbte fVtorDisp
1>               Z      24       8       4 0
1>               X      40       8       8 0

  • 在菱形虚继承中,继承了几个虚继承的类,就会有几个虚基类表。因为此时中间类已经含有了虚基类表,此处是继承下来。而且,无论本类是否有新增虚函数,虚函数表的数量和普通多继承的算法一致,也就是继承了几个虚函数的类就有几个虚函数表。自己新增的虚函数会被写在第一个继承的虚函数表内。
    • 注意,这里类Y没有直接虚继承X和W 所以X和W依旧按照正常继承顺序排列在Y的前面。直到X和W直接虚继承了Z,Z排在最后尾。所以此时X和W的虚函数表和虚基类表和变量都是正常顺序。整体顺序是X, W, Y, Z。
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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
class Z {
    int Zzz;
    virtual void func(){}				//虚函数,Z有自己的虚函数表。
};

class X:public virtual Z {				//虚继承。X有自己的虚基类表
    int Xxx;
    virtual void func1() {}				//此处有重名函数。虚函数,X有自己的虚函数表。
};
class W :public virtual Z {				//虚继承。W有自己的虚基类表
    int Www;
    virtual void func1() {}				//此处有重名函数。虚函数,W有自己的虚函数表。
};
class Y : public X, public W {			//菱形继承
    int Yyy;
    virtual void func() {}				//重写
    virtual void func1() {}				//重写
    virtual void func2() {}				//新增!!

};

1>class Y	size(72):
1>	+---
1> 0	| +--- (base class X)
1> 0	| | {vfptr}							//X自己的虚函数表指针
1> 8	| | {vbptr}							//X自己的虚基类表指针
1>16	| | Xxx
1>  	| | <alignment member> (size=4)
1>	| +---
1>24	| +--- (base class W)
1>24	| | {vfptr}							//W自己的虚函数表指针
1>32	| | {vbptr}							//W自己的虚基类表指针
1>40	| | Www
1>  	| | <alignment member> (size=4)
1>	| +---
1>48	| Yyy
1>  	| <alignment member> (size=4)
1>	+---
1>	+--- (virtual base Z)					//虚基类元素放在最后!!
1>56	| {vfptr}							//Z自己的虚函数表指针
1>64	| Zzz
1>  	| <alignment member> (size=4)
1>	+---
1>Y::$vftable@X@:
1>	| &Y_meta
1>	|  0
1> 0	| &Y::func1
1> 1	| &Y::func2								//Y先继承的X,所以新增的虚函数放入了X的虚函数表	
1>Y::$vftable@W@:
1>	| -24
1> 0	| &thunk: this-=24; goto Y::func1		//重名函数导致的thunk
1>Y::$vbtable@X@:
1> 0	| -8
1> 1	| 48 (Yd(X+8)Z)	//此处的X+8意思是这个虚基类表指针距离该类(X)头部的偏移量。因为虚基类表指针在虚函数表指针后面,所以+8了
1>Y::$vbtable@W@:
1> 0	| -8
1> 1	| 24 (Yd(W+8)Z)	//此处的W+8意思是这个虚基类表指针距离该类(W)头部的偏移量。因为虚基类表指针在虚函数表指针后面,所以+8了
1>Y::$vftable@Z@:
1>	| -56
1> 0	| &Y::func
1>Y::func this adjustor: 56
1>Y::func1 this adjustor: 0
1>Y::func2 this adjustor: 0
1>vbi:	   class  offset o.vbptr  o.vbte fVtorDisp
1>               Z      56       8       4 0

此处继承了两个虚继承的类,所以两个虚基类表。总计继承了三个有虚函数的类,所以有三个虚函数表。

待补充 可以看文章:https://blog.csdn.net/xiaxzhou/article/details/76576516

解决菱形继承问题。

哪些函数不可被声明为虚函数:

普通函数(非成员函数)

非成员函数只能被重载(overload),不能被继承(override),而虚函数主要的作用是在继承中实现动态多态,非成员函数早在编译期间就已经绑定函数了,无法实现动态多态,那声明成虚函数还有什么意义呢?

构造函数(注意析构函数不仅可以,而且必须把析构函数声明为虚函数)

要想调用虚函数必须要通过“虚函数表”来进行的,但虚函数表是要在对象实例化之后才能够进行调用。而在构造函数运行期间,还没有为虚函数表分配空间,自然就没法调用虚函数了。一句话概括:构造函数用来实例化对象,虚函数必须要在对象实例化后才可调用。

静态成员函数

静态成员函数对于每个类来说只有一份,所有的对象都共享这一份代码,它是属于类的而不是属于对象。虚函数必须根据对象类型才能知道调用哪一个虚函数,故虚函数是一定要在对象的基础上才可以的,两者一个是与实例相关,一个是与类相关。而且因为只有一份,没有动态绑定的必要。

内联成员函数

内联函数是为了在代码中直接展开,减少函数调用花费的代价,虚函数是为了在继承后对象能够实施多态性。但是inline函数在编译时被展开,而虚函数在运行时才能动态地绑定函数。

友元函数

C++不支持友元函数的继承。对于没有继承特性的函数没有虚函数的说法。友元函数不属于类的成员函数,不能被继承。

更清晰的继承状态下的虚函数表例子:

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 A {
public:
    virtual void vfunc1();
    virtual void vfunc2();
    void func1();
    void func2();
private:
    int m_data1, m_data2;
};
class B : public A {
public:
    virtual void vfunc1();
    void func1();
private:
    int m_data3;
};
class C: public B {
public:
    virtual void vfunc2();
    void func2();
private:
    int m_data1, m_data4;
};


int main() 
{
    B bObject;
    A *p = & bObject;
    p->vfunc1();
}

QQ截图20220724172506

程序在执行p->vfunc1()时,会发现p是个指针,且调用的函数是虚函数,接下来便会进行以下的步骤。

首先,根据虚表指针p->_vfptr来访问对象bObject对应的虚表。虽然指针p是基类A*类型,但是_vfptr也是基类的一部分,所以可以通过p->_vfptr可以访问到对象对应的虚表。即便是父类指针指向子类对象,也是查找子类对象的虚函数表。因为子类调用父类构造函数完毕后,调用子类构造期间会将自己的_vfptr指向子类自身的虚函数表

然后,在虚表中查找所调用的函数对应的条目。由于虚表在编译阶段就可以构造出来了,所以可以根据所调用的函数定位到虚表中的对应条目。对于p->vfunc1()的调用,B 的虚函数表的第一项即是vfunc1对应的条目。

最后,根据虚表中找到的函数指针,调用函数。可以看到,B 的虚函数表的第一项指向B::vfunc1(),所以p->vfunc1()实质会调用B::vfunc1()函数。

注意重载 重写 重定义的区别

1. 重载(overload)

指函数名相同,但是它的参数表列个数或顺序,类型不同。但是不能靠返回类型来判断。 特征是:

  1. 相同的范围(在同一个作用域中)
  2. 函数名字相同
  3. 参数不同
  4. 返回值可以不同。但要注意仅通过返回值不能进行重载。
  5. virtual 关键字可有可无

仅根据函数返回值类型不能进行重载。如果两个函数仅仅返回值类型不同,则不可以进行重载。因为调用时不能指定类型信息,编译器不知道你要调用哪个函数。

2. 重写(也称为覆盖 override)

指派生类重新定义基类的虚函数

特征是:

  1. 不在同一个作用域(分别位于派生类与基类) ;
  2. 函数名字相同(析构函数除外,但是析构函数会被理解为同名。所以会触发thunk);
  3. 参数相同;
  4. 基类函数必须有 virtual 关键字,不能有 static
  5. 返回值相同(或是协变),否则报错
  6. 重写函数的访问修饰符可以不同。尽管 virtual 是 private 的,派生类中重写改写为 public,protected 也是可以的

3. 重定义(也称为隐藏)

特征是:

  1. 不在同一个作用域(分别位于派生类与基类) ;
  2. 函数名字相同;
  3. 返回值可以不同;
  4. 参数不同时不论有无 virtual 关键字,基类的函数将被隐藏(注意别与重载以及覆盖混淆)
  5. 参数相同时基类函数没有 virtual关键字。此时,基类的函数被隐藏(注意别与覆盖混淆) 。
  6. 避免无意间重定义,可以使用override关键字。

特别注意:当使用基类指针指向子类对象,并且发生重定义,无论基类函数有还是没有virtual,都只执行基类函数。没有virtual的时候不发生多态,自然只执行基类。有virtual但是参数不同的时候,尽管可能发生多态,但是函数签名不一致,基类指针指向子类对象,他只能访问子类的基类部分。所以说他也就是基类类型,自然只能访问到基类函数。

  • 隐藏可以使用using来引入父类的对应函数来解决。–effective C++ 条款34
    • 因为默认情况下,在子类对象中查找对应的函数,因为是逐个作用域进行查找,如果子类实现了,则函数找到后立刻就不找了,不会查看父类作用域。
    • 使用using引入后,这个父类函数会被放置在子类作用域里,因为都在一个作用域内,所以都能看得到。

https://stackoverflow.com/questions/1896830/why-should-i-use-the-using-keyword-to-access-my-base-class-method

语法重载重定义(隐藏重写(覆盖)
函数作用域相同不在同一个作用域(分别位于派生类与基类)不在同一个作用域(分别位于派生类与基类)
函数名相同相同相同(析构函数除外)
参数列表不同无要求相同
返回值相同无要求相同(协变除外)
是否必须为虚函数/无要求两个函数必须是虚函数
  • 重载是编译期的多态。根据正确的数据类型匹配正确的方法。

  • 重写是运行期的多态。根据正确的对象匹配正确的方法。

父类指针指向子类对象,仅可访问子类的父类部分。因为不知道子类的内存布局,所以没法访问子类的部分。也就是说,父类指针指向子类对象会发生隐式转换。所以说它还是个父类类型。

隐藏也会发生在父类子类中有同名变量的时候。子类中会隐藏父类的变量,我们可以用作用域访问运算符去访问。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class test{
    public:
    int val;

    test(int x):val(x){}
    virtual void print(){
        cout <<"test" << endl;
    }
};
class test1 : public test{
    public: 
    int val = 10;               //子类父类同名变量 这个val是test1::val 所以值是10.
    int vala;
    test1(int x, int y):test(x), vala(y){};     //调用父类构造函数赋值,这里是给子类的父类变量赋值,也就是给test::val赋值
    void print(){
        cout <<"test1" << endl;
    }
};

1
2
3
4
5
6
7
8
9
10
11
12
13
int main()
{
    test t(4);
    cout << t.val << endl;      //此时输出4
    test1 t1(43,5);
    cout << t1.val << endl;     //此时输出10。因为t1是子类对象,访问的是子类的val,也就是test1::val
    t = t1;                     //这样赋值的时候只会赋值43。因为我们的构造函数是给test::val赋值而不是test1::val赋值。而且这样的拷贝赋值只会操作子类父类共有的部分,也就是test::val。
    //而test1::val是子类自己的。所以无法赋值。
    cout << t.val << endl;      //此时输出43。
    test* tptr = new test1(43,5);   //多态调用,输出43。因为父类指针指向子类对象也是父类类型。父类无法访问子类独有部分。所以这里访问的是test::val而不是test1::val
    cout << tptr->val <<endl;       //输出43。
	return 0;
}

注意不能靠返回值类型来进行函数重载

  • 因为重载是编译期决定,函数要么是可以调用要么是不能调用,不存在有可能调用,所以有歧义。(静态多态)
  • 我们调用函数的时候没有指定函数返回值类型,也不能指定函数返回值类型,所以有歧义。

如果硬要返回值重载,那么就要重新设计语言。设计成函数调用的时候需要指定返回值类型,而且函数签名也要包含返回值。

C语言没有函数重载。因为C语言的函数签名只有名称没有参数。C++可以重载是因为函数签名包括函数名字和参数类型,但是不包括返回值。

只要是指针或者是引用,无论指向的对象在栈上还是堆上,都可以实现多态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class A{
    public:
    virtual void pr(){
        cout <<"a" << endl;
    }
};


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


int main(){
    B bb;
    A* ptra = &bb;
    A& refa = bb;
    ptra->pr();
    refa.pr();
}

上面代码中,对象bb在栈上。父类指针指向子类对象的条件满足了,无论是堆或者栈都可以。可以多态。

引用也可以。

协变 (Covariance )

协变的意思是 如果父类虚函数返回的是父类的指针或者引用,那么子类重写父类虚函数的时候,返回值可以不同,可以换成子类的指针或者引用。注意不可以是对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class A {
public:
    int a = 5;
    int b = 10;
    virtual A* getitem(){	//注意返回必须是指针或者引用,对象不行。
        return this;
    }
};

class B:public A {
public:
    int a = 5;
    int b = 10;
    virtual B* getitem(){ //注意返回必须是指针或者引用,对象不行。
        return this;
    }
};
  • shared_ptr不能直接协变,需要转换一下。参考这篇文章

纯虚类

  • 纯虚类必须包含至少一个纯虚函数。
  • 纯虚类不可被实例化。但是可以使用其指针或引用。
    • 这个包含了声明对象,值传递和值返回
  • 纯虚类可以有成员变量
  • 纯虚类可以有普通的成员函数
  • 纯虚类可可以有其他虚函数
  • 纯虚类可以有带有参数的构造函数
  • 可以在纯虚类的派生类的构造函数中显式调用纯虚类的带参数构造函数
  • 派生类不会自动继承纯虚函数的定义,只要子类没有全部实现父类的虚函数,那么这个类依旧是个抽象类
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 Base {
public:
    int val; //可以有数据成员
    Base() {
        cout << "base default const" << endl;
    }
    Base(int a) { //可以有带参构造
        val = a;
        cout << "base param const" << endl;
    }
    virtual ~Base() { //非纯虚析构

    }
    virtual void func() = 0; //纯虚函数
    void anotherfunc() { //可以有其它函数
        cout << "base another func" << endl;
    }
};

void Base::func() { //纯虚函数必须类外实现
    cout << "Base pure func" << endl;
}

class Derive :public Base {
public:
    Derive() = default;
    Derive(int x) :Base(x) {}; //子类可以使用纯虚类的带参构造。和平常的子类调用父类有参遵循一个规则。
    virtual ~Derive() {

    }
    virtual void func() {
        cout << "derive pure func" << endl;
    }
};



int main(){
    Derive obj(10);
    obj.func(); //调用子类实现的纯虚函数。正常调用
    obj.Base::func(); //显式调用父类的纯虚函数。
    return 0;
}

纯虚函数

纯虚函数 目前没有办法写出来的东西,而且也没办法写默认的就把它设计为纯虚函数

比如一个类叫牛子。里面有个尿尿的行为。你没办法在父类定义尿尿具体要尿多少,也没办法给尿尿设计默认值。但是牛子必须能尿尿。所以你就要设计为纯虚函数这样让子类必须能尿尿而且根据子类自己来决定什么时候尿多少 如果设计不是纯虚函数,而设计了一个空函数作为默认值,那么子类有可能忘了写牛子能尿尿,这样就麻烦了。 纯虚函数的优点类似于关键字overwrite。强制子类进行某种操作。否则无法通过编译。 纯虚函数也促成了接口 抽象类的实现

  • 纯虚函数只可以类内声明,类外定义。
  • 纯虚析构函数必须有函数定义(默认实现)。也就是必须要有函数体。用来做一些基类的清理工作,防止基类出现内存泄漏(当然啥也不干也行,但是必须要有)。 而且。纯虚析构函数,必须要在类外定义。否则,派生类的析构函数由于编译器的扩展而显式的调用基类的析构函数时会找不到定义。同时编译器也无法为已经声明为纯虚的析构函数生成一个默认的实现。
  • 纯虚函数通常没有定义体。但是也可以拥有。所以:
    • 基类的纯虚函数的默认实现必须由派生类显式的要求调用。这句话翻译过来就是如果子类想使用纯虚函数的默认实现,就需要加作用域访问运算符去显式调用。举个例子: ```c++ class test{ public: virtual void getval() = 0; //纯虚函数类内声明 };

    void test::getval(){ //类外定义默认实现 cout «“pure func” « endl; }

    class test1: public test{ public: virtual void getval(){ //子类必须要实现纯虚函数。 test::getval(); //如果我就想用默认实现,就得加作用域访问运算符。 } };

    int main(){ test1 T1; T1.getval(); } ```

  • 派生类不会自动继承这个纯虚函数的定义,如果子类 test1未定义 getval(),那么 子类 test1 依然是一个抽象类型。这句话翻译过来就是,只要子类没有全部实现父类的虚函数,那么这个类依旧是个抽象类
  • 这种 纯虚 函数还提供实现的方案比较好的应用场景为:基类提供了一个默认的实现,但是不希望自动的继承给派生类使用,除非派生类明确的要求。
  • 纯虚函数理论上不会占据虚函数表。但是一旦纯虚函数被子类实现,他就变成了正常的虚函数,还是会进入虚函数表。所以结论是纯虚函数也会占据一个虚函数表位置。可能依靠实现。

more effective c++ 条款24

QQ截图20230228212906

本文由作者按照 CC BY 4.0 进行授权

王道操作系统笔记-内存内存(27)-- 内存管理9

C++杂记 - 2