c++中的多态

提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档

文章目录

  • 前言
  • 一、多态
    • 1、多态的概念及定义
    • 2、虚函数重写的两个例外
      • [2.1 协变(基类与派生类虚函数返回值类型不同)](#2.1 协变(基类与派生类虚函数返回值类型不同))
      • [2.2 析构函数的重写(基类与派生类析构函数的名字不同)](#2.2 析构函数的重写(基类与派生类析构函数的名字不同))
    • 3、接口继承和实现继承
    • [4、C++11 override 和 final](#4、C++11 override 和 final)
      • [4.1 final:修饰虚函数,表示该虚函数不能再被重写](#4.1 final:修饰虚函数,表示该虚函数不能再被重写)
      • [4.2 override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。](#4.2 override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。)
    • 5、重载、覆盖(重写)、隐藏(重定义)的对比
    • 6、抽象类
    • 7、多态的原理
      • [7.1 虚函数表](#7.1 虚函数表)
      • [7.2 多态的原理](#7.2 多态的原理)
    • 8、多继承中的虚函数表
    • 9、动态绑定与静态绑定
    • 10、菱形虚拟继承下的多态
    • 11、继承和多态的常见问答题
      • [11.1 什么是多态?](#11.1 什么是多态?)
      • [11.2 什么是重载、重写(覆盖)、重定义(隐藏)?](#11.2 什么是重载、重写(覆盖)、重定义(隐藏)?)
      • [11.3 多态的实现原理?](#11.3 多态的实现原理?)
      • [11.4 inline函数可以是虚函数吗?](#11.4 inline函数可以是虚函数吗?)
      • [11.5 静态成员可以是虚函数吗?](#11.5 静态成员可以是虚函数吗?)
      • [11.6 构造函数可以是虚函数吗?](#11.6 构造函数可以是虚函数吗?)
      • [11.7 析构函数可以是虚函数吗?](#11.7 析构函数可以是虚函数吗?)
      • [11.8 对象访问普通函数快还是虚函数更快?](#11.8 对象访问普通函数快还是虚函数更快?)
      • [11.9 虚函数表是在什么阶段生成的,存在哪里?](#11.9 虚函数表是在什么阶段生成的,存在哪里?)
      • [11.10 C++菱形继承的问题?虚继承的原理?](#11.10 C++菱形继承的问题?虚继承的原理?)
      • [11.11 什么是抽象类?抽象类的作用?](#11.11 什么是抽象类?抽象类的作用?)

前言


一、多态

1、多态的概念及定义

多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。例如买票,学生买票是半价,成年人买票是全价,老人和小孩买票是半价。

多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了Person。Person对象买票全价,Student对象买票半价。
在继承中要构成多态还有两个条件:
1.必须通过基类的指针或者引用调用虚函数。
2.被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写。

下面的代码中就形成了多态,因为满足多态的两个条件。

当我们将基类的virtual去掉时,此时基类的BuyTicket函数就不是虚函数了,所以就不满足多态的两个条件了,所以此时就不构成多态了。

此时在Func函数中调用BuyTicket函数:

(1). 不满足多态时 -- 看调用者的类型,调用这个类型的成员函数。因为不满足多态时people为Person类类型对象,所以会调用Person类中的BuyTicket函数。

(2). 满足多态时 -- 看指向的对象的类型,调用这个类型的成员函数。因为满足多态时,people指向的是Student类类型对象里面的Person类部分的数据,所以people指向的对象类型是Student类型,所以会调用Student类的BuyTicket函数。

虚函数

虚函数:即被virtual修饰的类成员函数称为虚函数
虚函数的重写

虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称派生类的虚函数重写了基类的虚函数。

注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用。

2、虚函数重写的两个例外

2.1 协变(基类与派生类虚函数返回值类型不同)

派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。

当我们向下面这样写时,会报出错误,因为此时既不构成多态,也不构成协变。

当我们将基类的虚函数返回基类的引用或指针,派生类的虚函数返回派生类的引用或指针时,此时构成了协变,所以也构成了多态。

值得注意的是:并不是只能返回当前的两个父子类的引用或指针才构成协变,返回另一对父子类的引用或指针也可以构成协变。即返回其他的父子类的引用或指针也是可以的,都叫做协变。

如果虚函数返回的不是父子类的指针或引用,而是父子类的对象时,会出错,此时就不构成协变了。

2.2 析构函数的重写(基类与派生类析构函数的名字不同)

如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor。

下面的代码中调用的析构函数我们发现是不对的,我们创建了一个Person对象和一个Student对象,但是调用析构函数时调用了两次Person类的析构函数,而没有调用Student类的析构函数,这是肯定不行的,会出现内存泄漏。出现这个情况的原因其实是因为此时Person类和Student类的析构函数不构成多态,所以调用析构函数看调用者的类型,而在Func函数中people为Person类类型的对象,所以调用的都是Person类的析构函数。

c 复制代码
class Person
{
public:
	virtual void BuyTicket()
	{
		cout << "买票全价" << endl;
	}
	~Person()
	{
		cout << "~Person()" << endl;
	}
};
class Student :public Person
{
public:
	virtual void BuyTicket()
	{
		cout << "买票半价" << endl;
	}
	~Student()
	{
		cout << "~Student()" << endl;
	}
};
void Func(Person* people)
{
	people->BuyTicket();
	delete people;
}
int main()
{
	Func(new Person);
	Func(new Student);
	return 0;
}

我们想要实现通过指向的对象的类型调用这个类型的析构函数时,我们就需要将Person类和Student类的析构函数构成多态,但是我们发现基类的析构函数和派生类的析构函数名字不同,这违背了构成多态的规则,但是这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor,这样就满足多态的条件了。此时我们将基类的析构函数使用virtual关键字置为虚函数。此时我们就可以看到析构函数的调用就对了,不会出现内存泄漏了。即只有派生类Student的析构函数重写了Person的析构函数,Func函数中delete对象时调用析构函数,才能构成多态,才能保证people指向的对象正确的调用析构函数。

3、接口继承和实现继承

普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。

下面通过一个练习题来体会接口继承。

以下的程序输出结果是什么( )。

(A)A->0 (B)B->1 (C)A-1 (D)B->0 (E)编译出错 (F)以上都不正确

答案为B。通过下面的图片我们看到func函数是构成多态的,p为一个派生类B的指针,所以在test函数中会调用派生类B的func函数,而因为虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,所以在派生类B的func函数中继承了基类A的func函数的接口,即继承了形参val的缺省值为1,所以在调用B类的func函数时,会打印B->1。

然后我们再来看这一题的改编版本。

此时的答案就为D,此时test函数的参数为派生类指针,所以在test函数中调用func函数并没有构成多态,故会调用B类的func函数,而因为没有构成多态,所以B类的func函数也就没有继承A类的func函数的接口,所以B类的func函数的val缺省值为0。故会打印B->0。

下面我们再次将这一题进行改编。

此时答案为B,我们可以看到此时也构成了多态,虽然p是一个A * 类型的指针,但是p指向的是一个B类类型的对象,所以在test函数中,还是会调用B类的func函数,而因为B类的func函数继承了A类的func函数的接口,所以此时B类的func函数的val缺省值为1,所以会打印B->1。

4、C++11 override 和 final

从上面可以看出,C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果时才可以通过debug找到,这样会得不偿失,因此:C++11提供了override和final两个关键字,可以帮助用户检测是否重写。

4.1 final:修饰虚函数,表示该虚函数不能再被重写

可以看到子类重写Drive函数时会报错。 因为父类中使用final修饰了Drive函数。

4.2 override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。

如果父类Drive中没有写virtual,则子类中就会报错。即如果使用override修饰了之后,如果没有重写虚函数就会报错。

如果重写了虚函数,就不会报错了。

5、重载、覆盖(重写)、隐藏(重定义)的对比

6、抽象类

在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。

一个类型在现实中没有对应的实体时,我们就可以将这个类定义为抽象类。

当一个子类继承了抽象类后,这个子类也不能实例化出对象。因为这个子类继承了抽象类后,也继承了抽象类中的纯序函数,所以此时这个子类也包含了纯虚函数,所以此时这个子类也是抽象类。

当子类中重写了纯虚函数后,子类就不是抽象类了,此时子类就可以实例化出对象了。纯虚函数强制了子类去重写这个函数。

7、多态的原理

7.1 虚函数表

下面我们来看一道笔试题:sizeof(Base)是多少?

答案为:12。

通过观察测试我们发现b对象是12bytes,除了_b成员,还多一个__vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表,。那么派生类中这个表放了些什么呢?我们接着往下分析。

下面的代码中我们在Derive类中重写了Func1函数,然后没有重写Func2函数。我们看到在b对象和d对象中都有自己的虚函数表指针,并且这两个虚函数表指针指向不同的虚函数表,我们看到在Derive中重写了Func1函数,所以Base类和Derive类的两个Func1函数是不同的函数。在Base类类型对象b中的虚函数表中Func1函数的地址和Derive类类型对象d中的虚函数表中Func1函数的地址不同,这是因为Derive中重写了Func1函数,而因为Derive中没有重写Func2虚函数,所以可以看到Base类类型对象b中的虚函数表中Func2函数的地址和Derive类类型对象d中的虚函数表中Func2函数的地址相同。虽然Derive也继承了Base类的Func3函数,但是因为Func3函数不是虚函数,所以Func3函数的地址不会被放到虚函数表中。

通过观察和测试,我们发现了以下几点问题:

1.派生类对象d中也有一个虚表指针,d对象由两部分构成,一部分是父类继承下来的成员,虚表指针也就是存在部分的另一部分是自己的成员。

2.基类b对象和派生类d对象虚表是不一样的,这里我们发现Func1完成了重写,所以d的虚表中存的是重写的Derive::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。

3.另外Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但是不是虚函数,所以不会放进虚表。

4.虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr。

5.总结一下派生类的虚表生成:a.先将基类中的虚表内容拷贝一份到派生类虚表中 b.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数 c.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。

这里我们还有一个容易混淆的问题,虚函数存在哪的?虚表存在哪的?

我们要知道虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。我们去验证一下会发现vs下虚表是存在代码段的。

c 复制代码
class Base
{
public:
	virtual void Func1()
	{
		cout << "Base::Func1()" << endl;
	}
	virtual void Func2()
	{
		cout << "Base::Func2()" << endl;
	}
	void Func3()
	{
		cout << "Base::Func3()" << endl;
	}
private:
	int _b = 1;
};

class Derive : public Base
{
public:
	virtual void Func1()
	{
		cout << "Derive::Func1()" << endl;
	}
private:
	int _d = 2;
};

int main()
{
	Base b;
	Derive d;

	return 0;
}

7.2 多态的原理

我们看到在Func函数中传Person调用的Person::BuyTicket,传Student调用的是Student::BuyTicket。这其实是因为Student类中对BuyTicket函数进行了覆盖,即在Student类类型对象的虚函数表中重写写入了BuyTicket函数的地址。所以在传入Student类类型对象时,此时这个对象的虚函数表指针中存的BuyTicket函数的地址为Student类重写的BuyTicket函数的地址。虚函数表在编译时就准备好了,如果这个虚函数在子类中被重写了,就改变这个虚函数的地址。

1.观察下图的橙色箭头我们看到,p是指向mike对象时,p->BuyTicket在mike的虚表中找到虚函数是Person::BuyTicket。

2.观察下图的绿色箭头我们看到,p是指向johnson对象时,p->BuyTicket在johson的虚表中找到虚函数是Student::BuyTicket。

3.这样就实现出了不同对象去完成同一行为时,展现出不同的形态。

4.反过来思考我们要达到多态,有两个条件,一个是虚函数覆盖,一个是对象的指针或引用调用虚函数。反思一下为什么?

因为如果多态的第二个条件是为基类的对象调用虚函数也可以的话,那么当基类拷贝派生类时,则只会拷贝成员,而不拷贝虚表,因为可能会乱套,即如果拷贝虚表的话,就会出现父类成员有子类虚表的情况产生,而如果不拷贝虚表的话,那么就只会调用父类的函数了,就不会实现不同对象去完成同一行为时,展现出不同的形态。

我们看到两次进入Func函数中p对象的虚函数表中都是Person类中的BuyTicket函数的地址,这就是因为基类拷贝派生类时,则只会拷贝成员,而不拷贝虚表。所以才造成了两次执行BuyTicket函数都是执行的Person类中的。所以多态的条件才规定只能是基类的引用或指针才能构成多态。


再通过下面的汇编代码分析,看出满足多态以后的函数调用,不是在编译时确定的,是运行起来以后到对象的中取找的。不满足多态的函数调用时编译时确认好的。


通过下面的虚函数表内存模型我们看到虚函数表本质是一个虚函数指针数组,这个函数指针数组里面存放了虚函数的地址。

通过下面我们看到同类类型的对象共用一张虚函数表。

我们将Derive中定义一个虚函数Func4,我们知道Derive派生类从基类Base中继承的虚函数都放在了Derive对象的虚函数表中,那么Derive类中自己写的虚函数是不是也存在自己的虚函数表中呢?

我们可以看到在编译器的监视窗口中,我们只在Derive类类型对象d的虚函数表中看到Func1函数和Func2函数,但是我们查看虚函数表中的内容时看到在Func1函数和Func2函数地址的好像还有一个类似的函数地址,那么这个地址是不是就是Derive类中的虚函数Func4的地址呢?并且我们看到在这个地址后面的4个字节为0。

下面我们写一个程序来打印对象的虚函数表。

因为虚函数表其实就是一个函数指针数组,即虚函数表内部存的都是虚函数的地址,所以我们可以写一个函数来遍历这个函数指针数组。我们将这个函数的形参定义为函数指针数组(本质就是一个函数指针数组指针)。

虽然遍历虚函数表的函数好写,但是当我们调用这个函数时实参不好传,因为PrintVFTable函数要求要传一个函数指针数组,即传一个函数指针数组指针。并且我们通过上面的分析知道了一个对象的虚函数表指针存在这个对象的前4(32位下)个字节的空间中。所以我们需要先取地址对象,然后将对象的地址强制类型转换为int * 类型,这样解引用时就只解引用对象的地址中前4个字节的内容,然后这4个字节的内容就是虚函数表的地址,我们又需要将这4个字节的地址强制类型转换为VF_PTR * 类型的指针,这样才能当作实参传入PrintVFTable函数中。我们在PrintVFTable函数中遍历访问了虚函数表中虚函数的地址,并且取出这些虚函数的地址,进行了调用。我们看到对象d中的虚函数表中打印了三个虚函数的地址,并且虚函数Func4也运行了,所以我们证明了上面的猜想是对的,即派生类的虚函数表中存了所有的虚函数。并且我们知道了编译器的监视窗口有时候是不准确的。

调用PrintVFTable函数的时候,也可以这样写。

即先将对象地址转为VF_PTR ** 类型的指针,然后解引用这个地址,此时就得到了VF_PTR * 类型的指针,这个指针的内容就是虚函数表的地址。并且这样写就不需要考虑32位和64位计算机了,而前面的方法需要考虑32位和64位计算机。

那么为什么不能使用PrintVFTable((VF_PTR*)&b)调用呢,这是因为因为&b是1地方的地址,即存虚表指针的地址,而我们需要的是虚表指针的内容,虚表指针的内容在&b的前4个字节中,所以我们一定需要一次解引用,将&b的前4个字节的内容解引用出来,而又因为PrintVFTable函数的参数类型为VF_PTR * ,所以我们将&b强转为一个VF_PTR * *。这样解引用一次获得了&b的前4个字节的内容,而此时还是VF_PTR * 的类型,所以还可以当作实参传入PrintVFTable函数中。

我们知道虚函数也是函数,和普通函数一样,都是存在代码段的,所以虚函数表是在编译阶段生成的,即在程序编译时就生成了虚函数表,并且向虚函数表中填入了相应虚函数的地址。那么对象中的虚表指针是什么时候初始化的呢?

通过下面的过程我们看到虚函数表指针是在构造函数的初始化列表中初始化的。

那么虚函数表存在哪里呢?

通过下面的代码我们看到虚函数表存在常量区中,即在代码端中。

然后我们查看虚函数的地址,可以看到虚函数其实也存在代码段中,因为编译好的虚函数就是一堆指令了,而指令就存在代码段中,所以虚函数也存在代码段中。

8、多继承中的虚函数表

上面我们分析了单继承中派生类的虚函数表只有一个,并且这个虚函数表中存了派生类继承基类的虚函数,如果派生类重写了基类中的虚函数,那么派生类的虚函数表中就将这个虚函数的地址改写,而如果派生类没有重写基类的虚函数,那么派生类的虚函数表这个虚函数的地址和基类的这个虚函数地址相同。并且派生类中自己新添加的虚函数其实也存到了自己的虚函数表中。那么当多继承时,派生类中的虚函数表是怎样的呢?

c 复制代码
class Base1 {
public:
	virtual void func1()
	{
		cout << "Base1::func1" << endl;
	}
	virtual void func2()
	{
		cout << "Base1::func2" << endl;
	}
private:
	int _b1;
};

class Base2 {
public:
	virtual void func1()
	{
		cout << "Base2::func1" << endl;
	}
	virtual void func2()
	{
		cout << "Base2::func2" << endl;
	}
private:
	int _b2;
};

class Derive :public Base1, public Base2
{
public:
	virtual void func1()
	{
		cout << "Derive::func1" << endl;
	}
	virtual void func3()
	{
		cout << "Derive::func3" << endl;
	}
private:
	int _d1;
};

int main()
{
	Derive d;

	return 0;
}

我们看到派生类Derive类类型对象d中有两个虚函数表指针,这也表明了多继承中有多个虚函数表。

那么派生类Derive中新加的虚函数会存到哪个虚函数表中呢?下面我们再通过上面写的PrintVFTable函数打印派生类Derive中基类Base1和Base2的虚函数表中的内容。我们知道Derive派生类中第一个虚函数表的指针在对象的地址的前4个字节的空间中,而第二个虚函数表的指针在对象的地址的中间,这两个虚函数表指针的中间隔了一个基类Base1的大小,所以我们查看第二个虚函数表时需要将地址向后移一个Base1类的大小。

除了上面的写法查看第二个虚函数表的地址外,我们还可以用下面的方法。因为Base2 * ptr2 = &d时会直接指向派生类对象d中的Base2基类中的内容,所以我们直接通过ptr2就得到了第二个虚函数指针所在的地址。

然后我们发现派生类Derive中新加的虚函数存在了第一张虚函数表中。并且我们看到多继承以后,派生类的两个虚表中重写的func1函数的地址不一样。但是按照上面分析单继承的结论来说,如果派生类重写了基类的虚函数,那么派生类的虚函数表中存的应该是重写后的虚函数的地址,而Derive派生类中重写了Base1和Base2基类的func1函数,但是Derive对象的两个虚函数表中func1函数的地址却不一样,这是为什么呢?

我们在下面的代码中将ptr1指向Derive类类型对象d的起始位置,即存第一个虚函数表的地址,让ptr2指向Derive类类型对象中Base2类的数据存的地址,即存第二个虚函数表的地址。然后我们分别通过第一个虚函数表运行fun1函数,第二个虚函数表运行func1函数。

我们看到ptr1指向的虚函数表中func1函数跳转了两次就到了func1函数的位置。

然后我们看到运行ptr2指向的第二个虚函数表中的func1函数时,虽然跳转了4下,但是最后还是来到了第一张虚函数表中记录的func1函数的位置执行func1函数了。那么为什么执行第二个虚函数表中的func1函数时需要跳转那么多下呢?

这是因为ptr1指向的&d的开头,而第一张虚函数表的地址就存在&d开头的位置,所以调func1函数时直接通过ptr1就可以得到虚函数表的地址,然后通过虚函数表就可以得到func1函数的地址。但是ptr2指向的是&d的中间,当调用func1函数时,需要先将ptr2修正到指向&d开头,即指向第一个虚函数表的指针的位置,所以通过ptr2调用func1函数时在指令的中途我们看到有一个指令是sub ecx 8,即减去Base1的大小,到此就将ptr2指向的位置修正到指向&d的位置了。然后此时ptr2指向的也是存第一个虚函数表地址的位置,此时就可以通过虚函数表地址找到虚函数表,然后通过虚函数表得到func1函数的地址,然后再去跳转到执行func1函数。

如果我们让Derive派生类先继承Base2,然后再继承Base1的话,那么当ptr1调用func1时就会先进行修正,然后再去找func1函数的地址了。

9、动态绑定与静态绑定

1.静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载

2.动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。

10、菱形虚拟继承下的多态

下面的代码中我们将B类和C类都虚继承A类,并且B类和C类中都重写了A类的虚函数,然后让D类继承B类和C类。

c 复制代码
class A
{
public:
	virtual void func()
	{

	}
public:
	int _a;
};

class B :virtual public A
{
public:
	virtual void func()
	{

	}
public:
	int _b;
};

class C :virtual public A
{
public:
	virtual void func()
	{

	}
public:
	int _c;
};

class D :public B, public C
{
public:
	int _d;
};

int main()
{
	D d;
	d.B::_a = 1;
	d.C::_a = 2;
	d._b = 3;
	d._c = 4;
	d._d = 5;
}

那么此时就会出现一个问题,即因为是菱形虚拟继承,所以在D类的内存模型中只有一个A类的数据,但是在B类和C类中都重写了A类中的虚函数,那么内存中的A类中应该放B类还是C类重写的虚函数呢?所以这就会产生歧义,所以此时D类中就必须重写func函数,然后A类中放D类重写的func虚函数。

当我们在D类中重写了func函数后,在D类的内存模型中,A类的位置刚开始的4个字节的内容就是虚函数表的地址,然后通过这个地址找到虚函数表,可以看到虚函数表中有一个函数的地址,这个地址就是func函数的地址。

然后我们再向B类和C类中分别加一个虚函数,此时再看D类的内存模型就可以看到现在D类的内存模型和刚刚上面分析的不一样了,即B类和C类的虚函数都存到了自己的虚函数表之中,此时D类中有两个虚基表指针,三个虚函数表指针。

11、继承和多态的常见问答题

11.1 什么是多态?

C++中多态按实现的时机分为两种:

编译时多态:又称静态多态,程序在编译时就可确定(函数的)调用地址。通过重载机制实现(包括函数重载和运算符重载),而重载机制又是通过函数名修饰规则来实现。

函数重载:就是函数名相同但参数的类型、顺序、个数不同,功能相近的多个函数。不同函数的调用地址在编译时即可确定。

运算符重载:(其本质是函数重载)就是对已有的运算符赋予多重含义,一个运算符作用于不同类型的数据就会导致不同的行为。

运行时多态:又称为动态多态,必须在运行中才能确定函数的调用地址。通过继承和虚函数实现,在基类与派生类中存在同名函数并且函数原型相同,这时可声明(基类中函数)为虚函数(派生类中同名函数自动成为虚函数),在编译时无法确定调用哪个函数, 只有在程序运行时,才能确定调用基类还是派生类的同名函数。运行时多态的实现是通过虚函数表来实现。

11.2 什么是重载、重写(覆盖)、重定义(隐藏)?

11.3 多态的实现原理?

多态的实现靠虚函数表,用virtual关键字声明的函数叫做虚函数,包含虚函数的类的对象中都会有一个虚函数指针,这个虚函数指针指向这个对象的虚函数表,虚函数表中存储了该对象的虚函数地址。这个虚函数表是在编译阶段由编译器生成的,在编译阶段如果这个类继承并重写了父类的虚函数,那么编译器就会将虚函数表中虚函数的地址改为重写的虚函数的地址。对象的虚函数表的指针是在该类的构造函数的初始化列表中初始化的。这样就形成了每一个类型的对象都会有一个虚函数表,并且虚函数表里面记录了该类自己的所有虚函数地址。所以当一个基类引用或者指针指向一个派生类对象时,会拿到这个派生类对象的虚函数指针,然后通过这个虚函数指针找到这个对象的虚函数,然后调用虚函数时就从这个对象的虚函数表中找该虚函数的地址,这样就实现了不同的类调用同一个函数会有不同的状态。

11.4 inline函数可以是虚函数吗?

inline函数可以是虚函数,不过编译器就忽略了inline属性。内联函数是在编译阶段进行展开,所以内联函数是没有地址的,而虚函数是运行时才能确定函数的地址,即运行时去虚函数表中查找虚函数的具体地址。如果将内联函数设置为虚函数,那么因为内联函数没有地址,所以虚函数表中就不会有内联函数的地址,所以这样看内联函数是不能为虚函数的。但是inline关键字是作为提示符告诉编译器此函数作为内联函数希望在编译阶段展开,但是,编译器并不一定要展开。所以可以将内联函数声明为虚函数,而在c++语言中也可以使用inline virtual来修饰同一个函数。内联函数和虚函数是函数的两个不同的属性,甚至是冲突的属性,所以一个函数不可能即是内联函数又是虚函数,编译器会忽略其中的一个属性。

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

静态成员不能是虚函数,因为静态成员函数没有this指针,使用类型::成员函数的调用方式无法访问虚函数表,即无法拿到虚函数表指针,所以静态成员函数无法放进虚函数表。

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

构造函数不能是虚函数,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的,如果构造函数是虚函数,就要通过虚函数表来调用,可是对象空间还没有实例化,也就是内存空间还没有,那么虚函数表指针就还没有,没有虚函数表指针就找不到虚函数表,没有虚函数表就无法调用构造函数,所以构造函数不能是虚函数。而且不能在构造函数中调用虚函数,因为此时对象空间还没有实例化。

11.7 析构函数可以是虚函数吗?

析构函数可以为虚函数,并且一般情况下基类析构函数要定义为虚函数。只有在基类析构函数定义为虚函数时,调用操作符delete销毁指向对象的基类指针时,才能准确调用派生类的析构函数(从该级向上按序调用虚函数),并且在派生类的析构函数执行完毕后会调用基类的析构函数执行,这样才能准确销毁数据。虽然基类和派生类的函数名不相同,看起来违背了虚函数重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor。

11.8 对象访问普通函数快还是虚函数更快?

首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。

下面虽然调用的虚函数,但是因为是普通调用,所以不会去虚函数表中查找,而是和普通函数执行一样的指令进行函数调用。

ptr->func1是虚函数的调用,因为虽然这里也没有构成多态,可以按普通函数的调用方法去调用func1函数,但是那样还需要编译器判断是否为多态函数调用,增加了编译器的成本,所以编译器直接会按多态函数来调用func1函数,即去虚函数表中找func1函数的地址来调用。这样编译器就省去了判断多态的条件。

11.9 虚函数表是在什么阶段生成的,存在哪里?

虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)的。

11.10 C++菱形继承的问题?虚继承的原理?

菱形继承有数据冗余和二义性的问题。即在派生类中会有两份数据,这两份数据还有可能不同,并且这两份数据都有各自的内容空间。使用虚继承可以解决菱形继承数据冗余和二义性的问题,虚继承会在D类对象中存储B类和C类数据的地方中存一个虚基表指针,通过虚基表指针可以找到B类和C类的虚基表,虚基表里面存的是该类的地址距离D类对象中A类数据存储地址的偏移量。这样B类和C类就可以通过虚基表访问同一个A类了,而且D类对象中就只有一个A类的数据,这样就解决了数据冗余和二义性的问题。

11.11 什么是抽象类?抽象类的作用?

在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。

相关推荐
酒鬼猿3 分钟前
C++初阶(十五)--STL--list 的深度解析与全面应用
开发语言·c++
gma9994 分钟前
JSONCPP 数据解析与序列化
开发语言·前端·javascript·c++
以卿a23 分钟前
C++ 日期计算器的实现(运算符重载)
java·开发语言·c++
2401_8407597641 分钟前
2062:【例1.3】电影票(http://ybt.ssoier.cn:8088/problem_show.php?pid=2062)
c++
程序员与背包客_CoderZ42 分钟前
C++设计模式——Singleton单例模式
c语言·开发语言·c++·单例模式·设计模式
落笔映浮华丶1 小时前
C++(进阶) 第1章 继承
开发语言·c++
阿史大杯茶1 小时前
CodeTON Round 9 (Div. 1 + Div. 2, Rated, Prizes! ABCDE题) 视频讲解
数据结构·c++·算法
nikoni232 小时前
【模电】整流稳压电源
笔记·其他·硬件工程
一叶知秋h2 小时前
(笔记,自己可见_1)简单了解ZYNQ
笔记
红色的山茶花2 小时前
YOLOv8-ultralytics-8.2.103部分代码阅读笔记-autobackend.py
笔记·yolo