C++进阶----多态

Posted 4nc414g0n

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C++进阶----多态相关的知识,希望对你有一定的参考价值。

C++进阶----多态

2)多态

①多态实现

多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为

比如:


多态的构成条件:

  1. 必须通过基类的指针或者引用调用虚函数
  2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写

虚函数重写

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


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

Ⅰ.特例1–协变

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

class A
;
class B : public A 
;
class father 
public:
	virtual A* f() 
		return new A;
	
;
class son : public father 
public:
	virtual B* f() 
		return new B;
	
;

简而言之,父类的返回的A和子类返回的B必须要构成父子关系

Ⅱ.特例2–析构函数重写

如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写
编译器对析构函数的名称做了特殊处理,所有类的析构函数都会被处理为destructor()函数名


在普通场景下,析构函数是否重写都是可以的,参考C++进阶----继承中的子类析构函数
但特殊情况如下,如果不重写:

class A 
public:
	//virtual ~A()
	~A()
	
		cout << "~A()" << endl;
	
;
class B : public A
public:
	//virtual ~B()
	~B()
	
		cout << "~B()" << endl;
		//delete somethingelse
	
;
A* p1 = new A;
A* p2 = new B;
delete p1;
delete p2;

如果在B的析构函数中有释放其他资源的操作,不调用~B()就会造成资源泄漏

override final

C++11提供了overridefinal两个关键字,可以帮助用户检测是否重写

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

class Alphabet
public:
	virtual void print()
;
class A :public Alphabet 
public:
	virtual void print() override cout << "I am A" << endl;
;

在C++98中,我们要定义一个不能被继承的类只能将这个类的构造函数设为私有的(private),因为C++规定永远是先构造父类再是子类


在C++11中提供了关键字:
final: 修饰虚函数,表示该虚函数不能再被重写

class Alphabet

public:
	virtual void Print() final 
;
class A :public Alphabet

public:
virtual void Print() cout << "I am A" << endl;
;

final: 修饰类,表示该类不能被继承

class A final;
class B : public A;//直接报错

重载 / 重写 / 隐藏

三个对比:

  1. 重载:
    两个函数在同一作用域
    函数名相同,参数不同
  2. 重写(覆盖)
    两个函数分别在基类和派生类的作用域
    函数名/参数/返回值都必须相同(协变例外)
    两个函数必须是虚函数
  3. 重定义(隐藏)
    两个函数分别在基类和派生类的作用域
    函数名相同

    两个基类和派生类的同名函数不构成重写就是重定义

②抽象类(纯虚函数)

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


例如:Person,Animal等,一种概括可以设为抽象类

class Animal
public:
	virtual void habits()=0;//不需要实现
;
class Felidae : public Animal
public:
	virtual void habits()
		cout<<"lazy"<<endl;
	
;

接口继承与实现继承:

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

③虚函数表

问,在x86下 sizeof(A)为多少?

class A

public:
	virtual void Func()
	
		cout << "Func()" << endl;
	
private:
	int _b = 1;
	char _ch = 'a';
;

sizeof(A)=12, 多出来的4个字节是一个指针,在x86下一个指针4个字节,这个指针就是虚函数表指针_vfptr(virtual function pointer),当类里面有虚函数时才会创建

创建一个A的派生类B

class B :public A 
public:
	void Func1()
	
		cout << "A::Func1()" << endl;
	
private:
	int _a = 1;

断点调试,可以发现:

  1. 父类有虚表指针,继承子类一定有
  2. 父子类无论是否完成虚函数重写,都有各自的独立的虚表
  3. 一个类的所有对象,共享一张虚表



可以看到_vfptr指向的其实时一个函数指针数组(数组每个元素是函数指针),也叫做虚函数表

形象如下图表示:

总结:

  1. 派生类对象由两部分构成,一部分是父类继承下来的成员,虚表指针也就是存在这一部分,另一部分是自己的成员
  2. 不是虚函数的函数继承下来不会放入虚表
  3. 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr
  4. 派生类的虚表生成:
    ----先将基类中的虚表内容拷贝一份到派生类虚表中
    ----如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数
    ----派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后
  5. 注意虚函数表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中 另外对象中存的不是虚表,存的是虚表指针,虚表存在代码段,Linux 虚表也存在代码段

④其他虚函数表

单继承虚函数表(vs省略的派生类虚函数表内容)

观察下面的代码:

class A 
public:
	virtual void print()
	
		cout << "I am A_print()" << endl;
	
;
class B :public A 
public:
	virtual void print()
	
		cout << "overwrite,I am B_print()" << endl;
	
	virtual void Func()
	
		cout << "I am Func()" << endl;
	
;

按理说派生类B应该不仅继承了A的print虚函数,还有自己的虚函数Func,但是监视窗口里虚函数表中只有一个元素

编写下面代码验证:
需要用到的知识:

  1. C语言----指针进阶中的两段复杂代码–出自《C陷阱和缺陷》,强转的时候可以用typedef
  2. 虚函数表最后以nullptr结尾
//typedef void(*VFPTR)();
//打印虚表
void PrintVFT(void* vft[])

	printf("%p\\n", vft);
	for (size_t i = 0; vft[i] != nullptr; ++i)
	
		printf("vft[%d]:%p->", i, vft[i]);
		//VFPTR f = (VFPTR)vft[i];
		//f();
		((void(*)())vft[i])();//将函数指针强转为void(*)()后再调用
	
	printf("\\n");

调用:

A a;
B b;
PrintVFT((void**)(*((int*)&a)));//(int*)而不是(int),不是对象的前四个字节而是,而是对象前四个字节指向的地址
PrintVFT((void**)(*((int*)&b)));

结果显示vs的确省略了Func

多继承虚函数表

如下代码:

class A 
public:
	virtual void func1()  cout << "A::func1" << endl; 
	virtual void func2()  cout << "A::func2" << endl; 
private:
	int b1;
;
class B 
public:
	virtual void func1()  cout << "B::func1" << endl; 
	virtual void func2()  cout << "B::func2" << endl; 
private:
	int b2;
;
class C : public A, public B 
public:
	virtual void func1()  cout << "C::func1" << endl; 
	virtual void func3()  cout << "C::func3" << endl; 
private:
	int d1;
;
//typedef void(*VFPTR)();
//打印虚表
void PrintVFT(void* vft[])

	printf("%p\\n", vft);
	for (size_t i = 0; vft[i] != nullptr; ++i)
	
		printf("vft[%d]:%p->", i, vft[i]);
		/*VFPTR f = (VFPTR)vft[i];
		f();*/
		((void(*)())vft[i])();	
	
	printf("\\n");

调用:

C c;
PrintVFT((void* *)(*(int*)&c));
PrintVFT((void* *)(*(int*)((char*)&c+sizeof(A))));
//相对第一个虚表指针偏移sizeof(A)个字节

多继承中派生类会产生不同的多个虚表,对应每一个父类

打印虚表验证可以发现派生类自己的虚函数func3会追加在继承第一个父类的虚表里
可以注意到第一个vft[0]和第二个vft[0]调用的同一个函数,但是地址不同,这里应该是编译器套娃加了第二层地址

菱形(虚拟)继承虚函数表

实际中我们不建议设计出菱形继承及菱形虚拟继承,虚基表+虚函数表过于复杂
参考:
C++ 虚函数表解析
C++ 对象的内存布局


⑤多态原理

回到构成多态的那两个条件:

  1. 为什么必须通过基类的指针或者引用调用虚函数?
  2. 为什么被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写?

class A;
void Func(A& alphabet)

	alphabet.print();

传指针或引用他不会识别是父类还是子类,如果是父类直接去头上4字节的虚表指针进行操作,子类就先进性切片(这里的切片是形象的说法,不是物理上的切割)再找头上4字节的虚表指针进行操作,而条件之一重写就是覆盖,建立自己的虚表,才能达到多态的效果
如果不是指针或引用,函数的参数是A alphabet,浅拷贝(拷贝构造),和父类A共用一样的虚表(一个类的所又对象共用一个虚表),使用虚表指针时不能达到多态效果


⑥动态绑定/静态绑定

静态绑定: 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载具体参考:C++初阶—C++基础入门概览函数重载部分
动态绑定:动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态
编译器在判断当前满足多态的条件时就会采用动态绑定 (运行时通过虚函数表确定地址) ,反之就是静态绑定 (编译链接时确定地址)


汇编代码证明

  1. 不传引用的时候:void Func(A alphabet)
  2. 传引用的时候:void Func(A& alphabet)

⑦继承与多态题目

题目1:下面程序输出什么? B
A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确

解释:

  1. 普通函数是实现继承,虚函数是接口继承,所以缺省值也一起继承下来
  2. 虚函数重写,调用的B类里的func

题目2:下面程序输出什么? A
A:class A class B class C class D B:class D class B class C class A
C:class D class C class B class A D:class A class C class B class D

解释:

  1. 初始化列表按照声明顺序来初始化
  2. 先构造父类,在构造子类

题目3:多继承中指针偏移问题,下面说法正确的是 C
A:p1 == p2 == p3 B:p1 < p2 < p3 C:p1 == p3 != p2 D:p1 != p2 != p3

解释:

题目4:p1->func() p2->test() 运行结果分别是什么 BC
A 编译报错 B运行崩溃 C 正常运行

解释:

  1. 调用虚函数要通过虚表指针去找,所以要解引用p1,但p1是nullptr,崩溃
  2. 普通函数调用不用解引用空指针,正常运行

⑧总结

  1. inline函数可以是虚函数吗?
    :可以,不过编译器就忽略inline属性,这个函数就不再是inline,因为虚函数要放到虚表中去
    (注意:VS下是这样的,Linux下也编译通过,inline函数没有地址,虚函数需要放到虚函数表,这两个矛盾)
  2. 静态成员可以是虚函数吗?
    :不能,因为静态成员函数没有this指针,使用类型::成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表
  3. 构造函数可以是虚函数吗?
    :不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。
    --------构造函数写为虚函数没有意义,因为子类中要调用父类构造函数初始化 写为虚函数目的是多态,构造函数不需要多态的方式
    --------对象的虚函数表指针,是在构造函数初始化列表阶段初始化的 如果构造函数是虚函数,那么调用构造函数时对象中虚表指针都没有初始化
  4. 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?
    :可以,并且最好把基类的析构函数定义成虚函数。参考本节课件内容
  5. 对象访问普通函数快还是虚函数更快?
    :首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找
  6. 虚函数表是在什么阶段生成的,存在哪的?
    :虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)的

以上是关于C++进阶----多态的主要内容,如果未能解决你的问题,请参考以下文章

C++进阶多态

C++进阶:多态

C++进阶:多态

C++进阶:多态多态的构成条件 | 虚函数的重写 | 抽象类 | 多态的原理 | 多继承的虚函数表

C++进阶:多态多态的构成条件 | 虚函数的重写 | 抽象类 | 多态的原理 | 多继承的虚函数表

C++进阶:多态多态的构成条件 | 虚函数的重写 | 抽象类 | 多态的原理 | 多继承的虚函数表