C++多态

目录

1 多态的概念

2  多态的定义和实现

3 final 和 override

4 抽象类

5 多态的原理

6 关于多态和虚函数一些小问题


1 多态的概念

通俗来说,多态就是多种形态,具体一点就是去完成同一件事(调用函数),当不同的对象去完成时会产生不同的形态(不同的对象调用同一个函数,产生的过程和结果不一样)。其实我们是见过多态的,也就是前面讲过的函数重载,函数重载是一种静态的多态,静态的意思就是编译时就能确定。而我们这里要讲的是动态的多态,文章中说的多态没有特殊指明的都是指的动态的多态。

2  多态的定义和实现

多态的构成条件:多态是指一个继承关系中,不同类型的类对象,去调用同一个函数,产生了不同的行为。所以,继承是多态的必要前提。

同时,除了要在继承体系中,还需要以下条件:

1 虚函数的重写

2 必须是父类的指针或者引用来调用虚函数

虚函数是什么呢?

虚函数就是使用virtual修饰的成员函数,这里的virtual与前面的虚继承的virtual是没有任何关系的,只是用的同一个关键字而已。

那么虚函数的重写又是指的什么?我们前面讲过,父类和子类如果存在同名成员,父类的成员会被隐藏。但是,如果同名的是虚函数,那么就不一定是构成隐藏了,也有可能构成重写\覆盖。为什么说是有可能呢?因为虚函数的重写也是需要满足几个条件的,虚函数的重写需要满足以下三个条件:

1 分别是父类和子类的成员函数   2 必须是虚函数   3 函数名,参数(类型),返回值(类型)(返回值这点也有例外,后面讲)相同 

只有满足这三个条件,才能称得上是重写。

class A
{
	public:
		virtual int func()
		{
			return 1;
		}
};

class B : public A
{
public:
	virtual int func()
	{
		return 2;
	}
};

我们可以对比一下隐藏和重写的条件

隐藏/重定义的条件:父类和子类的成员函数+函数名相同

重写/覆盖的条件:父类和子类的成员函数+虚函数+函数名、参数、返回值相同

我们发现,隐藏和重写的条件有一部分是重叠的,只是重写的构成条件更加严格。重写的范围似乎是隐藏的范围中的一个更小的范围,像是一种包含关系。但是如果两个函数构成重写,那就不能称之为隐藏关系了。

父类和子类的两个同名函数必须满足重写的所有条件才能被称为重写,否则就是隐藏。

虚函数的重写是多态的构成条件之一。

第二个条件就是必须是父类的指针或者引用去调用虚函数。可以是父类对象的指针或者引用,也可以是从子类对象中切片出去的指针或者引用

当我们使用的是一个父类对象的指针或者引用时,它所指向的就是一个子类对象。而当我们传的是一个子类对象的切片的父类指针或者引用时,虽然指针或者引用类型就是父类的指针或者引用,但是实际上这个切片指针或者引用指向的却是一个子类的对象的父类那一部分,当然他还是指能够看到父类的那一部分。

如果是调用普通的同名函数的话,指针或者引用是什么类型,调用的就是哪个类域的函数,就算我们使用一个子类切片出来的父类类型的指针,虽然实际指向的是一个子类对象,但是调用的函数还是父类类域中的同名函数。
比如当我们将上面的函数的virtual都去掉,变成普通的同名函数的话

class A
{
public:
	int func()
	{
		return 1;
	}
};

class B : public A
{
public:
	int func()
	{
		return 2;
	}
};

    B b;
    A* pa = &b;
    A& ra = b;
    
    cout << b.func() << endl;
    cout << pa->func() << endl;
    cout<<ra.func()<<endl;

但是当我们完成虚函数的重写之后,再以上面方式来调用虚函数的时候,

我们就会发现,尽管使用的是父类类型的指针或者引用,但是我们调用的还是子类中的那个虚函数。也就是说,多态调用的情况下,调用哪个类域的虚函数并不取决于指针或者引用本身的类型,而是取决于指针或者引用所指向的对象的类型。

当然了,如果不满足多态调用的条件,那么就还是普通调用的策略,也就是取决于调用函数的对象/指针/引用的类型。比如我们使用的是一个从子类中切片出来的父类对象来调用虚函数,那么它就不满足多态调用的条件,那么调用的就是父类中的那个函数。

多态的特殊情况:

1 子类的对父类的虚函数的重写也可以不加 virtual 。因为就算我们没有写子类中定义或者重写这个虚函数,子类中也是有这个虚函数的,他会把父类的那个虚函数继承下来。不管子类对不对父类的那个虚函数重写,默认也是先把父类的那个虚函数继承下来,如果子类要重写的话,重写的是函数的实现,并不会改变他的虚函数的属性,所以他还是构成虚函数的重写。

但是我们还是建议在子类的虚函数也加上virtual,一般还是不玩这种特殊情况。 所以构不构成虚函数的重写其实主要还是取决于父类有没有将该函数设置为虚函数,如果父类写了,子类没有写,也还是构成重写的,如果父类没有写,子类写了,那么只有子类的那个同名函数是虚函数,父类的那个还是普通函数,不构成重写。

2 协变 :构成虚函数重写的条件中,其实两个虚函数的返回值是可以不同的,虽然可以不同,但也是有要求的,必须返回当前继承关系中的父/子类的指针或者引用。 当然协变就是要一起变,如果这两个虚函数中的其中一个是继承关系中的父/子类的指针或者引用,那么另外一个也需要是继承关系关系中的一个指针或者引用

如果你像这样写的话,vs编译器会认为你是想要进行重写,但是你重写的条件没有满足,会进行报错。 但是也有极少数的编译器会认为这里构成重定义也就是隐藏,不会报错,因为这里是满足隐藏的条件的,取决于编译器。但是大多数编译器还是会认为这是想要完成重写,只是语法上不对。

协变的情况下,对于父类的虚函数,你可以返回父类自己的指针或者引用  this/*this 或者构建一个来返回,但是不能够使用 this/*this来返回子类的指针或者引用,因为父类的 this/*this 转换不出来子类的指针或者引用。 如果是子类的虚函数的话,那么就可以用this/*this来返回所在的这个继承中的父类或者子类的指针或者引用。比如我们可以向下面这样写

要注意的就是 父类和子类的声明顺序,因为编译器是向上搜索的,如果在父类对象中返回子类的引用或者指针,编译器是不认识的,因为子类声明在父类的后面。所以其实一般来说,父类的虚函数在协变的情况下也是只能返回父类的指针或者引用,而子类则有更多的选择,既可以返回子类对象切片出来的父类类型指针或者引用,也可以返回子类类型的指针或者引用,还可以返回父类对象的父类类型的指针或者引用。

协变在实际应用中也不多。

关于继承关系中的析构函数

我们说过子类的析构函数不需要显示调用父类的析构函数,因为编译器为了保证先析构子类再析构父类,会在子类析构完之后自动调用一次父类的析构函数来对父类的那一部分进行析构。在继承的章节中,我们都是将父类和子类的析构函数写成普通函数的,但是其实很多地方都建议将这继承关系中的父类和子类的析构函数都写成虚函数,或者只在父类中加上 virtual ,这样一来子类的析构函数也会继承他的虚函数属性,会自动构成重写,因为析构函数都是无参无返回值的,同时所有的析构函数名都会被处理成 destructor ,是满足重写的条件的。那么为什么要这样做呢?难道不重写就不能正确释放了吗?我们以前好像没见过这里出问题啊。

要想知道为什么,我们就需要看特殊的场景,比如下面这种情况:        

能看出来,我们new了一个子类对象出来,但是使用一个父类类型的指针来接收,这时候delete会调用谁的析构函数呢?

当然是调用父类的析构函数,因为这里不构成多态调用,只是普通调用的场景。传给delete的是一个父类类型的指针,那么delete首先就会使用这个指针去调用析构函数,普通调用就取决于this指针的类型了,而不是this指针所指向的对象的类型了,那么调用的就是父类的析构函数。但是这里调用父类的析构函数会有问题,他只会去析构子类对象中父类的那一部分,而如果子类的成员中有资源需要释放呢??这时候就会造成内存泄露了。而如果将父类和子类的析构函数定义成虚函数,那么这里就是多态调用了,调用哪个类的析构函数就取决于this指针所指向的类型。

那么在析构函数的场景下,我们就可以利用到上面所说的特殊情况,也就是子类可以省略virtual,我们只需要保证父类的析构函数是虚函数,那么它的所有子类的析构函数就都是虚函数了,默认就构成重写了,调用的时候就满足多态调用。

所以我们以后再写继承关系的时候,无脑把父类的析构函数写成虚函数,这样无论我们子类的析构函数有没有写virtual,都会构成重写,能够保证析构函数能够正确调用。唯一的坏处就是定义成虚函数之后会进虚函数表,进行多态调用的时候可能会对效率有一点影响,但是总的来说还是利大于弊。

3 final 和 override

final和override是C++11新增的两个关键字。

我们有没有想过,如何设计一个不能被继承的类?目前我们所能想到的方法就是将该类的构造函数设为私有,这样一来,就算被继承下去,子类也无法调用到该类的构造函数,那么子类设计出来也没法用了,所以能够防止被继承。

当然也有人会说可以将析构函数设置撑死有,虽然这个方法确实是可行的,但是危害很大,可能会有人利用这个漏洞来一直制造内存泄漏,所以,这种父类的析构函数私有的这种方法,只能够限制程序员在栈上创建子类对象,因为栈上的对象出作用域会被编译器自动析构。但是却无法限制程序员在堆上申请对象,因为在堆上申请的空间是由程序员自己控制释放的,如果程序员不使用 delete来释放子类对象的话,就不会通过子类调用父类的析构函数,自然也就无法限制该类不能继承,相反,别人还能利用这一点来制造内存泄漏。

但是我们这里还有一种方法来禁止一个类被继承,就是在类声明的时候加上 final ,这个类就叫做最终类,不能被继承,当然这个类本身还是可以继承别的类的。

同时,final也可以用来修饰虚函数,表示该虚函数不能被重写。但是这种用法真的很奇怪,因为虚函数实现出来就是为了重写实现多态的。当然可能确实有的场景会禁止虚函数被重写。

总结:final修饰类,该类不能被继承,final修饰虚函数,该虚函数不能被重写。

override 是用来修饰子类的虚函数的,但是它的功能是用来检查子类是否完成了对父类的虚函数的重写,其实吧,主要还是确保我们写的函数满足重写的条件,必须参数和函数名是否相同,相当于加了一个assert类似的判断,只不过它是在编译时就报错了。

4 抽象类

概念:在虚函数的声明后面加上 =0 ,这个虚函数就是纯虚函数,而包含纯虚函数的类就叫做抽象类(也叫做接口类),抽象类不能实例化出对象。抽象类的派生类如果不重写这个纯虚函数,那么派生类还是继承的父类的纯虚函数,还是一个抽象类,所以还是不能实例化出对象。所以,只有他的派生类重写了这个纯虚函数,派生类才能实例化出对象。

纯虚函数规范了派生类必须重写这个纯虚函数,另外纯虚函数更体现了接口继承

抽象类可以说是强迫子类去完成重写纯虚函数,override是检查是否完成重写。

接口继承与实现继承:

普通函数的继承都是实现继承,派生类继承了基类的函数,可以使用基类的函数,继承的是函数的实现。而虚函数尤其是纯虚函数体现的是一种接口继承,派生类继承的是积累虚函数的接口(接口就是函数的声明,不包括函数的实现),接口继承的目的是为了重写,达成多态,继承的是接口。 所以如果成员函数不实现多态,就不要把函数定义成虚函数。虽然从结果上来说,虚函数如果不在子类中重写的话他就会直接继承父类的接口和实现。

那么这里还有一个问题:父类和子类的虚函数的参数列表一样,但是参数的缺省值不一样,还构成重写吗?        

class A
{
public:
	virtual void func(int a = 1) { cout << a <<endl; }
};

class B :public A
{
public:
	virtual void func(int a = 2) { cout << a << endl; }
};

这当然是构成重写的,参数相同指的是参数类型相同,并没有规定参数的缺省值也要相同。

那怎么验证他构成重写呢?我们只需要看能否进行多态调用就行了。

但是这里为什么打印出来的是 1 呢?难道不构成重写调用的是A的func吗?

并不是,我们在函数体中再加一点东西来看一下

这时候我们就更奇怪了,为什么参数缺省值用的是A的,但是函数体确实执行的B的。

这就体现出来了我们所说的接口继承,在多态调用的情况下,子类的虚函数的重写的是父类虚函数的实现,但是使用的还是父类的函数接口,也就是父类的函数声明。

那么是不是意味着子类的虚函数的函数声明就没有意义了?当然不是,接口继承是体现在多态调用的时候,那么在普通调用的情况下,还是用的自己的接口的。至于为什么,下面的多态的原理能解释。

5 多态的原理

我们可以来看一下这样一个问题

在x86环境下,A和B的大小分别是多少?如果这是普通类的话,那毫无疑问A的大小就是四个字节,B的大小就是8个字节。但是这两个类是包含了虚函数的特殊类,我们可以打印出来看一下

我们发现A和B都比正常的普通类的大小多了四个字节,当我们换成64位环境时,我们就会发现比正常类多了 8 个字节,我们可以知道就是多了一个指针变量,我们可以打开监视窗口来看一下对象模型。

我们发现,在父类对象和子类对象中的父类对象中,都存了一个 void** 的二级指针变量__vfptr, vfptr其实是 virtual function (table) ptr 的缩写,也就是虚函数表的指针,虚函数表也被称为虚表,注意这里的虚表要和前面的虚基表进行区分,虚基表存的是共享的基类的偏移量以及一个我们还没有填充的东西。虚函数表存的则是虚函数的地址,一个类不管有几个虚函数,他的地址都会被放到虚函数表中,然后在该类的对象中保存一个虚表的指针,所以我们其实可以理解位虚表就是一个函数指针数组,虚函数的地址都强制转换为 void* 放在一个数组里面,同时,不管虚表中存了多少个函数地址,在对象中都只会存这个虚表的地址。虚表指针是存在对象的前4/8个字节的。

如果一个类有多个虚函数,那么函数地址在虚表中的顺序就是虚函数的声明顺序。

一个类的所有实例共用一份虚表,他们都只有读取的权限,虚表的创建写入是编译期间就完成的。

有了这个准备工作,我们就可以来探讨多态的原理了。首先我们从最简单的单继承开始看

从下图我们可以看到,在父类对象和子类对象中的父类对象中都会存储虚表的地址。

当子类没有对父类的虚函数完成重写时,子类中的父类对象中的 __pfptr 存储的地址和普通父类对象中存储的虚表地址也不一样,也就是说,当子类并未对父类的虚函数进行重写时,子类会将父类的虚函数表直接继承下来,也就是拷贝一份父类的虚表自己用,但是虚表的内容却没有发生变化,也就是说子类的虚表中还是保存父类的虚函数的地址。因为我们知道,函数的虚拟地址在编译时就已经确定了,也就是说,虚函数表其实也是在编译时就已经形成了,创建父类对象时只需要把虚函数表的地址放到父类对象中,而不是说要在创建父类对象的时候才去建表。我们可以看一下虚表中的虚函数地址。

我们能发现,其实虚函数和普通的成员函数是没有什么不同的,函数的实现都是保存在类域中的,虽然虚函数有可能会被普通调用,也有可能被多态调用,但是调用的都是同一份函数实现,唯一不同的就是只要该函数是虚函数,该函数的地址就会额外被保存到虚函数表中,这个虚函数表是用来进行多态调用的。

上面是子类没有对父类虚函数进行重写的情况,那么如果子类对父类的虚函数进行重写了呢?

这时候我们发现,虚表中的虚函数的地址也变了,因为子类对父类的虚函数进行了重写,所以子类里的父类对象的虚表中填入的是子类中重写的函数的地址。我们看着这两个虚表好像地址差的不大,难道这里面存在一些不可言喻的关系?

其实没有我们想象的那么复杂,他们都是独立的虚表,我们可以看到他们都已经以nullptr结束了,所以他们在内存上其实是没有任何关系的,离得近只是因为他们都是存在代码段的,而且我们的代码很少,所以离得近。

那么子类的这个虚表和父类的虚表有什么关系呢?

首先,如果我们在子类中没有额外定义虚函数的时候,按理来说,子类是没有自己的虚表的,他会继承父类的虚表,而这里有有两种情况,就是子类是否对父类的虚函数进行重写,如果子类没有重写父类的任何虚函数,那么意味着子类就是单纯地继承父类的虚表,所以这时候子类中的父类对象用的会直接用父类的虚表,这个虚表相当于被父子类公用,这样能够节省出空间。 但是如果子类对父类地虚函数进行了重写,那么就意味着子类中的父类的虚表和原生父类的虚表是不一样的,所以编译器会为子类中的父类单独开一个虚表,因为这时候已经无法共享一个虚表节省内存了。那么这个子类中的父类对象虚表是怎么来的呢? 我们把重写也叫做覆盖,他不是没有原因的,首先编译器会拷贝一份父类的虚表给子类,然后子类如果对父类的虚函数进行了重写,那么会在这张拷贝过来的表中找到对应的表项进行覆盖填入,编译完之后,在子类中的父类对象用的虚表中,没有被重写的虚函数在需表中还是父类虚表的地址,被重写的虚函数就替换为了子类重写之后的函数实现。怎么证明呢?我们只需要在父类中多加一个虚函数就行了。

虚表地址填入对象是在初始化之前就已经填入的,实例化一个对象我们一般把他分为两步,首先药费配对象所需的空间,然后调用构造函数进行初始化。 但实际上,在构造函数之前,也就是初始化列表之前,虚表指针就已经填入到对象中了,也就是其实分三步,分配空间,填入虚表指针,然后才是构造函数的初始化。 而对于一个没有虚函数子类对象的实例化来说,首先第一步也是申请空间(包括父类部分以及虚表指针),然后调用父类的构造函数对父类部分进行初始化,最后调用子类的构造函数进行初始化。 但是对于有虚函数重写的子类来说,第一部还是申请空间,然后构造一个父类对象部分,构造父类对象的过程还是先填入虚表指针,然后调用构造函数,最后初始化子类部分,子类部分如果有自己的虚函数,也会先填入虚表指针,然后调用子类的构造函数进行初始化。

但是目前我们只是了解了虚表的规则,并没有讲清楚多态调用的原理。

我们可以看一下普通调用和多态调用的过程,直接看汇编代码,看一下它们有什么区别

首先我们可以看到,对于普通调用,普通调用的函数是静态(编译时)绑定,也就是编译的时候就将函数的地址已经填入到了要调用函数的地方。

而对于多态调用,我们发现它的汇编代码比普通调用要多,他首先进行了一系列的操作,最终将函数的地址保存到了寄存器 eax 中,然后调用函数。多态调用是动态(运行时)绑定,也就是编译器在编译期间是不能确定要调用的函数是哪一个的,而是需要在运行时去调用函数的对象中的虚函数表中去找到幺调用的函数的地址。然后进行调用

为什么多态调用的时候无法静态绑定函数地址呢?首先,多态调用是用父类类型的指针或者引用去调用重写的虚函数,那么这时候调用的哪个类的函数不取决于指针或者引用的类型,而是取决于指针或者引用实际指向的对象类型,而编译期间是不会指针解引用等操作的,就好比空指针的解引用也只会在运行时报错,而不会在编译时就发现错误。那么既然无法进行解引用,那么编译器也不知道指针或者引用所指向的是父类对象还是子类对象的父类的那一部分,所以无法进行静态绑定。而动态绑定则是在多态调用的时候,编译器会去指针或者引用所指向的对象的虚表中去找到要调用的函数的地址,然后用寄存器保存,再进行调用。

这时候就体现出多态调用的不同之处了,比如上面的例子,我们使用B对象的父类部分的指针 pa 去调用 func ,那么在运行时编译器会去找到 pa 所指向的对象(也就是B对象中的父类的那部分)中的虚表,而这个虚表其实是 子类 中 拷贝父类过来然后由于重写进行覆盖了的,所以取到的函数的地址就是 子类中进行了重写之后的虚函数的地址 ,这也就是为什么多态调用时调用的函数的取决于指针或者引用指向的对象。

其实多态的调用的原理不难,主要是理解子类中的父类虚表的覆盖。

同时,到了这里我们也能理解为什么多态体现的是接口继承了,我们知道,函数的传参和函数的调用其实是分开的,在调用函数之前,上一层栈帧就会用函数参数进行压栈,压完栈之后才会跳转到函数的实现,进行栈帧的开辟等。而参数的压栈的过程是不需要访问虚表的,压栈也是在编译时就能够确定下来的,那么压栈使用的函数声明就是指针或者引用的类域也就是父类类域的函数的声明,参数压完栈之后才回去虚表中找函数的实现,我们说过虚函数的重写必须要满足三重才是重写,这也是接口继承的构成条件,参数列表类型必须是一样的。

那么虚表是存哪个内存区域的呢?代码段,我们可以将虚表的地址打印出来,然后对比一下其他几个区域的变量的地址 。

那么如何将虚表的地址打印出来呢?虚表地址是存在对象的起始4/8个字节的,所以我们只需要取出子类的父类切片或者直接取出父类对象的前四个字节就行了,前四个字节要取出来也不难,我们可以先拿到起始地址,然后强转成 int* ,这样一来解引用就能得到前四个字节的内容,最后再将解引用出来的内容转换为一个 void* 的指针变量,就能以指针形式是打印出来了

class A
{
public:
	virtual void func() { int a = 0; };
};

int a = 0; //全局变量,静态区

int main()
{
	static int b = 0; //静态变量,静态区

	int c = 0; //局部变量,栈区

	int* pa = new int(); //堆区

	const char* str = "abc";//常量,代码区(常量区)

	cout << "栈区:" << &c << endl;
	cout << "静态区:" << &a <<"--" <<&b<< endl;
	cout << "堆区:" << pa << endl;
	cout << "常量区,代码段:" << (void*)str << endl;

	A a1;
	A* pa1 = &a1;
	cout << "虚表地址:" << (void*)(*(int*)pa1) << endl;

	return 0;
}

虚表就是存在代码区的,这还是很好看出来的。

总结一下:如果父类的虚函数在子类并没有完成重写,那么子类中的父类对象用的就是父类的虚表。同一个类型的所有对象的虚表用的是同一张虚表。

那么为什么子类对象切片出去的父类对象无法实现多态呢? 因为切片来进行对象赋值的时候,只会拷贝子类中的父类声明的成员变量,不会拷贝虚表指针,虚表是在构造函数初始化列表之前就被填入了父类的虚表指针。

子类中如果没有对父类的虚函数进行重写,但是子类又额外定义了虚函数,这时候虚标实什么样的?

class A
{
public:
	virtual void func1() { cout << "A::func1" << endl; }
	virtual void func2() { cout << "A::func2" << endl; }
	int _a = 0;
};

class B:public A
{
public :
	virtual void func3() { cout << "B::func3" << endl; }
	int _b = 1;
};

对于A类的对象的虚表我们是很清楚的

但是B的对象的虚表实什么样的呢?对于B中多出来的一个虚函数,他是会再开一张虚表呢?还是将A类的虚表拷贝过来,即使没有进行虚函数重写,然后再将新增加的虚函数增加到表中。

 但是我们打开 B 的对象的对象模型,我们发现,他好像既没有创建新表,也没有将地址放到拷贝过来的 A 的表中,我们能够确定的就是 B 肯定拷贝了一份 A 的虚表,因为我们看到 a 和 b 的虚表指针的值不一样了。那么B的虚函数 func3 的地址放哪去了呢?难道没有进虚表?首先只要是虚函数就要进虚表,这没有什么好说的。 其实 func3  的地址是放进了拷贝过来的 A 的虚表中的,只是vs的监视窗口并没有给我们显示出来,这还是由于vs做的一些自以为的优化,我们可以打开内存窗口看一下最真实的情况        

我们发现B对象中的A的对象的虚表中是保存了三个函数的地址的,只是我们不知道这个地址到底是不是B的func3,不过我们可以用指针来调用一下。我们学习C语言函数指针的时候就已经知道了函数名其实就是一个函数指针,我们可以直接使用函数的指针来调用对应的函数。

那么我们只需要将 B 对象中的 子类A 那一部分的虚表指针拿出来,然后对其解引用,我们就能得到虚表的其实地址。

	void* start = (*(void**)(&b));
	cout << "虚表起始地址" << start << endl;
	func* ptable = (func*)start; //强转成 函数指针数组 的 指针

这里我们取到虚表地址是将 b 的地址强制转换成了一个 void** 的二级指针,然后对其解引用,就能取到一个 void* 的大小的空间的值,这样一来,如果我们使用的是64位平台,void*的大小就是 8 个字节,我们就能取到前八个字节的数据,如果是 32 位平台,我们就能取到前 4 个字节的数据。如果我们继续强转 int* 的话,那就只能再32位平台下跑。 得到虚表起始地址之后,我们将其强转为一个函数指针的二级指针,也就是这样就能够以下标的方式来访问虚表的内容。

所以在B里面A对象的虚表中,确实存了B定义的虚函数的地址。

还有另外一种情况,就是B对A的部分虚函数进行了重写,同时B定义了一个新的虚函数,这时候的情况还是和上面的一样,对重写的虚函数的地址进行覆盖,然后将新定义的虚函数的地址增加到覆盖之后的虚表中,当然这两个操作的顺序还是按照声明的顺序来。

接下来再来谈一下多继承场景下的虚表

class A
{
public:
	virtual void func1() { cout << "A::func1" << endl; }
	int _a;
};
class B
{
public:
	virtual void func2() { cout << "B::func2" << endl; }
	int _b;
};
class C :public A, public B
{
public:
	virtual void func3() { cout << "C::func3" << endl; }
	int _c;
};

对于子类 C ,首先我们要知道的是,他肯定是要继承两个父类 A 和 B 的虚表的(我们这里讨论的是A和B都有虚表的情况), 但是C自己定义的虚函数要进哪个虚表呢?首先说结论,他是进第一个虚表,也就是A的虚表。确切的说C的虚函数进的是他声明的父类之中第一个有虚表的类的对象的虚表。也就是说,如果A没有虚表,那他进的当然就是B的虚表了。

那么怎么证明呢?同时,怎么证明C的虚函数就没有进B部分的虚表呢?其实很简单,还是去除他们两个的虚表,然后执行虚表的函数就行了。

	C c;

	void* start = (*(void**)(&c));  
	cout << "A 虚表地址:" << start << endl; 
	func* ptable = (func*)start; 
	int i = 0;
	while (ptable[i]) //c中A对象的虚表
	{
		ptable[i++]();
	}

	B* pb = &c;
	cout << "B 虚表地址:" << (void*) * (void**)pb << endl;
	start = (*(void**)pb);
	ptable = (func*)start;
	i = 0;
	while (ptable[i]) //c中B对象的虚表
	{
		ptable[i++]();
	}

到这里我们就确定了C的虚函数会进A部分的虚表,而不会进B部分的虚表。

同时C里面的B的虚表也是对B 的虚表的拷贝。

我们还可以看一下多继承情况下,子类既不对父类的虚函数进行重写,也不增加新的虚函数的情况

不管怎么样,子类首先都会将父类的虚表拷贝一份继承下来,然后再看声明决定要不要覆盖或者增加。

到了这里,想必我们也能知道子类对父类进行重写的情况了,无非就是上面的情况下,对C中的A和B的虚表进行覆盖写入。

那么菱形继承下虚表是什么样的呢?

都不进行重写的情况下我们还是很容易就能想到的,无非就是D中有两份A的虚表,分别在B和C中,他们的虚表都增加了自己的虚函数的地址。

但是我们考虑一种特殊情况,就是B和C都对A的虚函数进行重写了,然后子类再对func1进行重写,重写的是谁的虚函数呢?B的还是C的?

其实这一点并没有什么好说的,因为不管重写的是谁的,对于D来讲调用 func1都是调用自己重写之后的func1,如果是多态调用的话,还是指向谁就调用谁的函数,并不会有什么影响。反而是要注意D没有对func1重写的情况下的菱形继承的二义性问题。

最后讲一下菱形虚拟继承下的虚表的情况,首先我们看一下 B和C 都不对A的虚表进行重写,以及B和C都没有定义虚函数的场景。虚拟继承的情况下,B和C是共享一个A的,也共享一份A的虚表,那么B和C如何找到共享的A以及虚表呢?其实在这种请路况下,因为B和C都没不需要修改虚表,所以找到了A就找到了虚表,所以其实B和C的虚基表中还是只需要存一个自己的起始地址到A的起始地址的偏移量就行了

但是如果B和C中都定义了自己的虚函数呢?

在这种情况下,由于A是BC共享的,那么他们新增的虚函数就不能增加到A的虚表中,所以B和C各自会再创建一个虚表用来保存自己定义的虚函数。而共享的A则不会被他们影响

那么这时候B和C的虚基表会有发生变化吗

首先我们看到虚表指针是存在虚基表指针的前面的。而虚基表的前四个字节终于有值了,前四个字节填的是虚表指针的偏移量(相对于虚基表指针),而后四个字节还是共享的A相对于虚基表指针的偏移量。

最后还有一种情况就是,B和C都对A的虚函数进行了重写,那么这时候D继承的是B的重写呢还是C的重写呢?又或者说是直接继承最原始的A的函数?首先排除直接继承A,因为D只能看到B和C的东西。

直接说结论,这时候继承B或者C的重写都不恰当,所以D需要对A的虚函数再次进行重写,明确A的虚函数而当最终形态。

如果D再有自己的虚函数,他的虚函数进的还是第一张表,也就是B的表,记住,不会进B和C共享的A的表,因为B的表在A的表的前面。

6 关于多态和虚函数一些小问题

下面讲的虚函数都是指的进行多态调用的虚函数

内联函数可以是虚函数吗(可以进行多态调用吗)? 

首先,内联函数是要直接在调用的地方展开的,而多态调用则是动态绑定,理论上来说是不能同时存在内敛属性和虚函数属性的。但是我们不要忘了inline是一个建议选项,并不是说你家了inline就一定会成为内联函数。如果你把他写成虚函数,完成了重写等,满足多态调用的场景,那么在满足多态调用的场景下内敛属性就没有了,而是只有虚函数属性。但是如果不是多态调用的场景那就是内联函数,需要展开。 但是现在的编译器为了防止出现这种情况,直接禁止将联函数设置为虚函数了,所以这个问题其实不好回答。

静态成员函数可以是虚函数吗?

不能,因为静态函数没有this指针,没有this指针怎么看到虚表呢?没有虚表就不能进行多态调用,既然不能进行多态调用,那么虚函数就没意义了,所以静态成员函数不能是虚函数。

构造函数可以是虚函数吗?

构造函数是不能动态绑定的。因为一个对象初始化的顺序是 : 分配内存 ->初始化虚表指针 ->初始化列表 ->构造函数函数体 ,虚表指针虽然在构造函数之前就填好了,但是这时候对象还没有完全被实例化,他的内存还没有确定下来 ,内部的变量还没有确定内存,此时无法通过该对象找到虚表进行多态调用,既然不能进行多态调用,所以构造函数不能是虚函数

普通函数和虚函数的调用速度谁快?

如果是普通调用的场景,那么是一样的。如果是多态调用的场景,由于普通函数是静态绑定的,而虚函数则需要动态绑定,会有效率的损耗。

抽象类是什么以及它的作用?

抽象类就是有纯虚函数的类。

他的作用就是强制子类重写了虚函数,同时抽象类体现了接口继承。

以上就是C++多态的知识点,如有错误或者争议可以联系作者或者在评论区进行指正。

  • 12
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值