跳至主要內容

继承_“虚”

LincZero大约 18 分钟

继承_“虚”

(需结合 “深度剖析指针指针” 笔记一起使用)

区分虚基类、纯虚基类、虚函数、纯虚函数

比较

不是同一个概念

翻译前概念定义方法目的和作用
Virtual base class虚基类class A : virtual public APerent解决多个类同祖先问题
通过把基类继承声明为虚拟的,就只能继承基类的一份拷贝
其实名字叫做 “虚拟继承” 会不会好一点?
Pure virtual base class纯虚基类包含纯虚函数的类
Virtual function虚函数virtual double Area();
Pure virtual function纯虚函数virtual double Area() const = 0;---------------------------------------------------------------

各自原理深度剖析

虚函数原理详解(简练模型)

参考:

虚函数表_概述

虚函数表

  • 创建者:编译器
  • 数量:简单地说,每一个含有虚函数(无论是其本身的,还是继承而来的)的类都至少有一个与之对应的虚函数表,该虚函数表将被该类的所有对象共享
  • 内容:其中存放着该类所有的虚函数对应的函数指针
  • 位置:只读数据段编译器将虚函数表存放在了目标文件或者可执行文件的常量段,即代码区(不是代码区,下一节 "内存分布" 图中说的是在只读数据段)

虚函数表指针(vptr):通常用vptr表示

  • 数量:每个对象成员都会多一个vptr(除非 多重继承会不止一个)
  • 内容:指向虚函数表
  • 位置:堆区。如果1个类中存在一个虚函数,那么第一个地址永远都是指向虚函数列表的指针。 子类没有vptr,子类的虚函数存放在第一个父类的虚函数表的最后,如果有覆盖,则覆盖掉相应父类的虚函数。

img

总结:简单来讲就是

  • 基类和派生类都有【虚函数表】、但他们的内容不同。 派生类的表是基函数的表的基础上 增加 或 修改 得来的,长度上肯定更长或一样
  • 基类和派生类的对象内存(sizeof)会多一个【虚函数表指针】(vptr),即每个生成的对象大小会大上一个指针(多重继承不止一个)

一般继承

提示:为了描述方便,本文在探讨对象内存布局时,将忽略内存对齐对布局的影响

img

虚函数表构造过程(编译器编译时负责)

从编译器的角度来说,B的虚函数表很好构造,D的虚函数表构造过程相对复杂。下面给出了构造D的虚函数表的一种方式(仅供参考)

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

img

虚函数调用过程

img

大概原理:

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

编译时能够确定的是:此处operator->的另一个参数是B::bar(因为pb是B*类型的,编译器认为bar是B::bar), 而B::bar和D::bar在各自虚函数表中的偏移位置是相等的(这是一个比较重要的特性)

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

所以其实说是说虚函数带来了额外的消耗,但其实消耗很少 —— 只需要加一个偏移值寻址即可,而并非 if-else 那样的。

详细举例:

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指向其它类型对象...同理...

多重继承

当一个类继承多个类,且多个基类都有虚函数时,子类对象中将包含多个虚函数表的指针(即多个vptr)

虚函数表构造过程

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

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

img

虚函数调用

虚函数的调用过程,与前面描述基本类似 区别在于基类指针指向的位置可能不是派生类对象的起始位置。以如下面的程序为例,pc指针不在起始位置:

img

菱形继承

不讨论菱形继承的情形,个人觉得:菱形继承的复杂度远大于它的使用价值

虚函数原理详解(内存分布)

参考

虚拟存储器结构

class A {
  public:
    virtual void v_a(){}
    virtual ~A(){}
    int64_t _m_a;
};

int main(){
    A* a = new A();
    return 0;
}

如以上代码所示,在C++中定义一个对象 A,那么在内存中的分布大概是如下图这个样子。

虚拟存储器结构

  • stack区
    • 首先在主函数的栈帧上有一个 A 类型的指针指向堆里面分配好的对象 A 实例。
  • heap区
    • 对象 A 实例的头部是一个 vtable 指针,紧接着是 A 对象按照声明顺序排列的成员变量。 (当我们创建一个对象时,便可以通过实例对象的地址,得到该实例的虚函数表,从而获取其函数指针。)
  • vtable区(详见 “虚表结构”)
    • vtable 指针指向的是代码段中的 A 类型的虚函数表中的第一个虚函数起始地址
    • 虚函数表的结构其实是有一个头部的,叫做 vtable_prefix ,紧接着是按照声明顺序排列的虚函数。
    • 注意到这里有两个虚析构函数。因为对象有两种构造方式,栈构造和堆构造。所以对应的,对象会有两种析构方式,其中堆上对象的析构和栈上对象的析构不同之处在于,栈内存的析构不需要执行 delete 函数,会自动被回收。
  • typeinfo区
    • typeinfo 存储着 A 的类基础信息,包括父类与类名称,C++关键字 typeid 返回的就是这个对象
    • typeinfo 也是一个类,对于没有父类的 A 来说,当前 tinfo 是 class_type_info 类型的,从虚函数指针指向的vtable 起始位置可以看出。

imgopen in new window

虚表结构(vtable)

通过之前的分析可以知道其实传统认为的虚函数表并不是单独存在而是虚表的一部分,如下图所示

imgopen in new window

  • 紫色线框中的内容:仅限于虚拟继承的情形(若无虚拟继承,则无此内容)
  • “offset to top”:是指到对象起始地址的偏移值,只有多重继承的情形才有可能不为0,单继承或无继承的情形都为0。
  • “RTTI information”:是一个对象指针,它用于唯一地标识该类型。
  • “virtual function pointers”:也就是我们之前理解的虚函数表,其中存放着虚函数指针列表。

虚函数的实现原理

  • 一般继承时,子类的虚函数表中先将父类虚函数放在前,再放自己的虚函数指针。
  • 如果子类覆盖了父类的虚函数,将被放到了虚表中原来父类虚函数的位置。
  • 在多继承的情况下,每个父类都有自己的虚表,子类的成员函数被放到了第一个父类的表中。,也就是说当类在多重继承中时,其实例对象的内存结构并不只记录一个虚函数表指针。基类中有几个存在虚函数,则子类就会保存几个虚函数表指针
class A{
private:
    uint64_t a;
public:
    virtual void A_a(){std::cout << __func__;}
};
class C{
private:
    uint64_t c;
public:
    virtual void C_a(){std::cout << __func__;}
};

class D:public A,public C{
private:
    uint64_t d;
public:
    virtual void D_a(){std::cout << __func__;}
};

虚函数原理详解(代码与地址)

参考:

成员函数指针通常是具有非平凡内部结构的对象,这就是为什么你不能使用打印原始类型的工具打印它

测试准备

代码(菜鸟在线C++工具open in new window

class Base {
public:
	static  void s1() { cout << "Base::s1" << endl; }
	static  void s2() { cout << "Base::s2" << endl; }
	void         f1() { cout << "Base::f1" << endl; }
    void         f2() { cout << "Base::f2" << endl; }
    virtual void v1() { cout << "Base::v1" << endl; }
    virtual void v2() { cout << "Base::v2" << endl; }
};

void fi(){}
int i = 50;

测试

普通指针测试

int main()
{
	// 普通指针实验
	// "Pi",表示int指针
    // "FvvE",表示void返回void输入的Function(左边返回右边参数)
	// "PFvvE",表示void返回void输入的Function指针
	int* pi = &i;
    cout << typeid( *pi).name() << sizeof( *pi) << endl;	// " i    4"
	cout << typeid(  pi).name() << sizeof(  pi) << endl;	// "Pi    8"
	cout << pi << endl;										// "0x7ffe1398c3d4"
    
	void (*pfi)() = fi;
    cout << typeid(*pfi).name() << sizeof(*pfi) << endl;	// " FvvE 1"
	cout << typeid( pfi).name() << sizeof( pfi) << endl;	// "PFvvE 8"
	cout << pfi << endl;									// "1"
    cout << "——————————" << endl;

类函数指针测试

    // 类成员指针
    Base b;
    Base c;
	void       (*ptrS1)() = &Base::s1;
	void       (*ptrS2)() = &Base::s2;
	void (Base::*ptrF1)() = &Base::f1;
	void (Base::*ptrF2)() = &Base::f2;
	void (Base::*ptrV1)() = &Base::v1;
	void (Base::*ptrV2)() = &Base::v2;
    
	// 调用测试_静态成员
	pS1();													// "Base::s1"
	(*pS1)();												// "Base::s1"
	cout << typeid(*pS1).name() << sizeof(*pS1) << endl;	// " FvvE 1"
	cout << typeid( pS1).name() << sizeof( pS1) << endl;	// "PFvvE 8"
	cout << pS1 << *pS1 << endl;							// "1 1"
	cout << pS2 << *pS2 << endl;							// "1 1"
	cout << "——————————" << endl;
	
	// 调用测试_非静态成员
	// (*(b.*pF1))();										// error: 没有这种用法,可以理解为非静态函数不能直接取地址
	// (&(b.*pF1))();										// error: 没有这种用法,可以理解为非静态函数不能直接取地址
	(b.*pF1)();												// "Base::f1"
	((&b)->*pF1)();											// "Base::f1"
	cout << typeid( pF1).name() << sizeof( pF1) << endl;	// " M4BaseFvvE 16"
	cout << typeid(&pF1).name() << sizeof(&pF1) << endl;	// "PM4BaseFvvE  8"
	cout << typeid(b.*pF1).name() << endl;					// "FvvE"
	cout << "——————————" << endl;
	
	// 调用测试_虚函数成员
	// (*(b.*pV1))();										// error: 没有这种用法,可以理解为非静态函数不能直接取地址
	// (&(b.*pV1))();										// error: 没有这种用法,可以理解为非静态函数不能直接取地址
	(b.*pV1)();												// "Base::v1"
	((&b)->*pV1)();											// "Base::v1"
	cout << typeid( pV1).name() << sizeof( pV1) << endl;	// " M4BaseFvvE 16"
	cout << typeid(&pV1).name() << sizeof(&pV1) << endl;	// "PM4BaseFvvE  8"
	cout << typeid(b.*pV1).name() << endl;					// "FvvE"
	cout << "——————————" << endl;
    

虚函数表测试2

    // 虚函数表测试1
	// 注意:在虚函数表的最后有一个结点,这是虚函数表的结束结点,就像字符串的结束符“\0”一样,其标志了虚函数表的结束
	cout << &b << (int*)(&b) << endl;	// 虚函数表地址,在类的第一个地址处
												// 多重继承有多个虚函数表时,主基类外的其他表的不一定在起始位置
        										// 0x7fff4dab7668  0x7fff4dab7668
    (int*)*(int*)(&b); 					// f()地址,即虚函数表的第一个函数地址
    											// 0x4202560(整数转地址?)
    (int*)*(int*)(&b)+1;				// g()地址
    											// 0x4202561(整数转地址?)
    (int*)*(int*)(&b)+2;				// h()地址
    											// 0x4202562(整数转地址?)
	cout << "——————————" << endl;

虚函数表测试1

    // 虚函数表测试2
	// - 普通成员函数
	//     - c++实现成员函数的时候,会在成员函数中默认的第一个参数为本类的地址,所以就相当于fun(nullptr)。
	// - 虚方法
	//     - 而实现虚函数的时候,是需要与【对象的内存】相关的。
	//     - 发生虚函数调用时,首先在本对象的首地址去找虚函数表,找到相应的虚函数才会调用。
	//     - 空指针没有虚函数表,自然就报错了。
	pb_null->f1();								// "Base::f1"
	// pb_null->v1();							// error: 报错
	cout << "——————————" << endl;

结尾

    return 0;
}

虚析构函数:为什么析构函数必须要定义成虚函数?

有三个要点:

  • 一般来说,一个类如果定义了虚函数,则最好将析构函数也定义成虚函数
  • 只要基类的析构函数是虚函数,那么派生类的析构函数不论是否用virtual关键字声明,都自动成为虚析构函数。
  • 析构函数可以是虚函数,但是构造函数不能是虚函数。

接下来解释原因:前提是 一个类如果定义了虚函数,那么意味着这个类会被继承,方法会被重写。 将基类的析构函数定义成虚析构函数,派生类就能正确地重写析构函数,保证派生类的析构函数是用来析构派生类的,防止内存泄漏。

其他

参考

性能分析

调用性能

从前面虚函数的调用过程可知。当调用虚函数时过程如下(引自More Effective C++):

  • 通过对象的 vptr 找到类的 vtbl。 这是一个简单的操作,因为编译器知道在对象内 哪里能找到 vptr(毕竟是由编译器放置的它们)。因此这个代价只是一个偏移调整(以得到 vptr)和一个指针的间接寻址(以得到 vtbl)。
  • 找到对应 vtbl 内的指向被调用函数的指针。 这也是很简单的, 因为编译器为每个虚函数在 vtbl 内分配了一个唯一的索引。这步的代价只是在 vtbl 数组内的一个偏移。
  • 调用第二步找到的的指针所指向的函数。
    • 在单继承的情况下 调用虚函数所需的代价基本上和非虚函数效率一样,在大多数计算机上它多执行了很少的一些指令所以有很多人一概而论说虚函数性能不行是不太科学的。
    • 在多继承的情况 由于会根据多个父类生成多个vptr,在对象里为寻找 vptr 而进行的偏移量计算会变得复杂一些,但这些并不是虚函数的性能瓶颈。**虚函数运行时所需的代价主要是虚函数不能是内联函数。**这也是非常好理解的,是因为内联函数是指在编译期间用被调用的函数体本身来代替函数调用的指令。 但是虚函数的 “虚” 或者说 "运行时多态特性" 是指 “直到运行时才能知道要调用的是哪一个虚函数”,所以没法在编译时进行内联函数展开。当然如果通过对象直接调用虚函数它是可以被内联,但是大多数虚函数是通过对象的指针或引用被调用的,这种调用不能被内联。 因为这种调用是标准的调用方式,所以虚函数实际上不能被内联。

空间占用

在上面的虚函数实现原理部分,可以看到为了实现运行时多态机制,编译器会给每一个包含虚函数或继承了虚函数的类自动建立一个虚函数表,所以虚函数的一个代价就是会增加类的体积。在虚函数接口较少的类中这个代价并不明显,虚函数表vtbl的体积相当于几个函数指针的体积,如果你有大量的类或者在每个类中有大量的虚函数,你会发现 vtbl 会占用大量的地址空间。

但这并不是最主要的代价,主要的代价是发生在类的继承过程中,在上面的分析中,可以看到,当子类继承父类的虚函数时,子类会有自己的vtbl,如果子类只覆盖父类的一两个虚函数接口,子类vtbl的其余部分内容会与父类重复。**如果存在大量的子类继承,且重写父类的虚函数接口只占总数的一小部分的情况下,会造成大量地址空间浪费。**在一些GUI库上这种大量子类继承自同一父类且只覆盖其中一两个虚函数的情况是经常有的,这样就导致UI库的占用内存明显变大。 由于虚函数指针vptr的存在,虚函数也会增加该类的每个对象的体积。在单继承或没有继承的情况下,类的每个对象会多一个vptr指针的体积,也就是4个字节;在多继承的情况下,类的每个对象会多N个(N=包含虚函数的父类个数)vptr的体积,也就是4N个字节。

当一个类的对象体积较大时,这个代价不是很明显,但当一个类的对象很轻量的时候,如成员变量只有4个字节,那么再加上4(或4N)个字节的vptr,对象的体积相当于翻了1(或N)倍,这个代价是非常大的

虚函数的应用注意事项

  • 内联函数 (inline) 虚函数用于实现运行时的多态,或者称为晚绑定或动态绑定。而内联函数用于提高效率。内联函数的原理是,在编译期间,对调用内联函数的地方的代码替换成函数代码。内联函数对于程序中需要频繁使用和调用的小函数非常有用。默认地,类中定义的所有函数,除了虚函数之外,会隐式地或自动地当成内联函数(注意:内联只是对于编译器的一个建议,编译器可以自己决定是否进行内联). 无论何时,使用基类指针或引用来调用虚函数,它都不能为内联函数(因为调用发生在运行时)。但是,无论何时,使用类的对象(不是指针或引用)来调用时,可以当做是内联,因为编译器在编译时确切知道对象是哪个类的。
  • 静态成员函数 (static) static成员不属于任何类对象或类实例,所以即使给此函数加上virutal也是没有任何意义的。此外静态与非静态成员函数之间有一个主要的区别,那就是静态成员函数没有this指针,从而导致两者调用方式不同。虚函数依靠vptr和vtable来处理。vptr是一个指针,在类的构造函数中创建生成,并且只能用this指针来访问它,因为它是类的一个成员,并且vptr指向保存虚函数地址的vtable。虚函数的调用关系:this -> vptr -> vtable ->virtual function,对于静态成员函数,它没有this指针,所以无法访问vptr. 这就是为何static函数不能为virtual
  • 构造函数 (constructor) 虚函数基于虚表vtable(内存空间),构造函数 (constructor) 如果是virtual的,调用时也需要根据vtable寻找,但是constructor是virtual的情况下是找不到的,因为constructor自己本身都不存在了,创建不到class的实例,没有实例class的成员(除了public static/protected static for friend class/functions,其余无论是否virtual)都不能被访问了。此外构造函数不仅不能是虚函数。而且在构造函数中调用虚函数,实际执行的是父类的对应函数,因为自己还没有构造好,多态是被disable的。
  • 析构函数 (deconstructor) 对于可能作为基类的类的析构函数要求就是virtual的。因为如果不是virtual的,派生类析构的时候调用的是基类的析构函数,而基类的析构函数只要对基类部分进行析构,从而可能导致派生类部分出现内存泄漏问题。
  • 纯虚函数 析构函数可以是纯虚的,但纯虚析构函数必须有定义体,因为析构函数的调用是在子类中隐含的。