【C++】多态

目录

  • 1. 多态的概念
    • 1.1 概念
  • 2. 多态的定义及实现
    • 2.1 多态的构成条件
    • 2.2 虚函数
    • 2.3 虚函数的重写
      • 2.3.1 重写的一些特殊情况
    • 2.4 final和override
    • 2.5 重载、覆盖(重写)、隐藏(重定义)的对比
  • 3. 抽象类
    • 3.1 概念
    • 3.2 实现继承与接口继承
  • 4. 多态的原理
    • 4.1 虚函数表
    • 4.2 多态的原理
      • 4.2.1 为什么不能是子类的指针或引用?为什么不能是父类对象
    • 4.3 动态绑定与静态绑定
  • 5. 单继承和多继承关系中的虚函数表
    • 5.1 单继承中的虚函数表
    • 5.2 多继承中的虚函数表
    • 5.3 菱形继承与菱形虚拟继承
  • 6. 继承和多态性的常见问题

1. 多态的概念

1.1 概念

多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态

比如买高铁票,成人买票为原价,而学生买则会进行打折等诸如此类的行为都可以使用多态来实现。

2. 多态的定义及实现

2.1 多态的构成条件

多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了Person。Person对象买票全价,Student对象买票半价。

继承是多态的前提条件

那么在继承中要构成多态还有两个条件:

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

2.2 虚函数

被virtual关键字修饰的类成员函数即为虚函数:

class Person {
public:
	virtual void BuyTicket() {
		cout << "全价买票" << endl;
	}
};

必须是类中的成员函数,普通函数无法被virtual修饰

注意:虚函数与虚继承无关,只是用了同一个关键字

2.3 虚函数的重写

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

注意区别于继承中的重定义(隐藏),重定义是指父子类中只要出现同名的成员就构成重定义(隐藏)。

重写可以归为重定义的子集

class Person {
public:
	virtual void BuyTicket() {
		cout << "全价买票" << endl;
	}
};

class Student : public Person {
public:
	virtual void BuyTicket() {
		cout << "半价买票" << endl;
	}
};
//必须是父类的指针或者引用
void Func(Person& p) {
	p.BuyTicket();
}

int main() {
	Person p;
	Student s;
	//传递父类对象
	Func(p);
	//切割出子类中父类对象再传递
	Func(s);
	return 0;
}

输出结果:

在这里插入图片描述
通过多态调用便实现了传递不同的对象,调用了不同的函数最终产生了不同的结果。

【C++】多态_第1张图片
多态调用必须要严格遵循上面的两个条件,若一个不满足则为普通调用,普通调用只会看当前的对象类型,是什么类型就调用哪个类中的函数

若指定调用父类虚函数时也会使多态调用失效:

void Func(Person& p) {
	p.Person::BuyTicket();
}

2.3.1 重写的一些特殊情况

  1. 子类重写的虚函数前可以不加virtual来修饰,这是因为子类继承了父类的函数特性,包括虚函数属性,因此,子类中与父类的虚函数完全相同的函数天生就是虚函数,不过可以使用 virtual 关键字来重申这个函数是虚函数,但不是必需的。

为了可读性建议加上

  1. 协变,是指父子类虚函数的返回值可以不同,但必须是父子类关系的指针或者引用(同时),且不可颠倒顺序,并且只要是满足父子关系的类类型都可以。

【C++】多态_第2张图片
3. 析构函数的重写。
析构函数可以声明为虚函数,子类可以对析构函数进行重写,但这貌似不符合重写的条件,因为重写不仅要求为虚函数,还必须要求三同,而父子类的析构函数的名称并不相同,其实不然,在底层编译器会对所有的析构函数的名称进行统一处理为destructor,这样函数名也相同了,因此可以构成重写,如不构成重写在下列场景下会出问题:

【C++】多态_第3张图片
p是一个父类指针,因此既可以指向父类对象也可以指向子类对象,本来期望的是delete子类去调用子类的析构函数,可实际情况是delete子类也是去调用父类的析构,若子类对象中动态开辟的资源时则会造成内存泄漏。
这是因为delete操作符首先会先去调用该类对象的析构函数,p->operator delete()此时的析构函数是普通函数,因此是普通调用,而普通调用看的是调用它的类型,p是Person类型,所以自然会去调用Person类的析构函数,即使p实际指向的是Student类对象。
因此若要解决上述情况,就需要进行多态调用而非普通调用。此时实现多态的两个条件已经满足,p为父类的指针和引用,且可以将析构函数声明为虚函数。

【C++】多态_第4张图片
此时通过多态调用,指向父类去调用父类的析构,指向子类去调用子类的析构即可解决上述情况。

建议父类的析构函数都声明为虚函数

2.4 final和override

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

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

【C++】多态_第5张图片
若不想让一个类被继承时,也可以在类名后加上final来修饰。

  1. override:检查子类虚函数是否重写了父类某个虚函数,如果没有重写编译报错
    【C++】多态_第6张图片

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

  1. 重载:两个函数在同一个作用域,函数名相同但参数不同(个数、类型、类型顺序)。
  2. 覆盖(重写):两个函数分别在父子类域中,且函数名、参数列表和返回值全都相同(协变除外),两个函数还都得是虚函数。
  3. 覆盖(重写):两个函数(或其它成员)分别在父子类域中,且函数名(成员名)相同。

父子类同名的函数不构成重写就构成重定义。

3. 抽象类

3.1 概念

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

class Car
{
public:
	virtual void Drive() = 0;
};
class Benz :public Car
{
public:
	virtual void Drive()
	{
		cout << "Benz-舒适" << endl;
	}
};
class BMW :public Car
{
public:
	virtual void Drive()
	{
		cout << "BMW-操控" << endl;
	}
};

void Func(Car* p) {
	p->Drive();
}

int main() {
	Benz b;
	Func(&b);
 	return 0;
}

3.2 实现继承与接口继承

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

4. 多态的原理

4.1 虚函数表

一道笔试题:

// sizeof(Base)是多少?
class Base
{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}
private:
	int _b = 1;
};

结果是8字节,这是因为除了存放_b成员以外还要存放一个虚函数表指针:

【C++】多态_第7张图片
vfptr是虚函数表指针,指向的是代码段中虚函数的地址,一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。

虚函数表本质是一个函数指针数组

4.2 多态的原理

当子类继承时没有重写虚函数,那么子类对象虚函数表中保存的虚函数地址是相同的(虚函数表地址不同),若子类重写了虚函数时会发生什么?

【C++】多态_第8张图片
可以发现子类对象的虚表中保存的虚函数地址与父类对象虚表中的地址不同,此时子类的虚表中保存的是重写后的虚函数地址,因此重写也叫覆盖(覆盖了父类的虚函数地址)。

多态调用本质就是通过父类的指针或者引用去对应对象的虚表中查找对应的虚函数地址然后调用它,以此来完成指向父类调父类指向子类调子类的行为。

多态调用是在运行时通过查找对应的虚函数表才能确定函数地址,而普通调用则是在编译时就可以直接确定对应函数的地址。

注意:虚函数的重写,重写的是函数的实现,函数首部的属性使用的还是父类虚函数的首部

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

4.2.1 为什么不能是子类的指针或引用?为什么不能是父类对象

  1. 因为子类的指针和引用只能指向子类,因此子类无法调用父类中的虚函数始终只能调用子类的函数,这也就无法实现多态
  2. 将子类赋值给父类对象时,并不会拷贝虚表,因此调用时始终是会去调用父类的函数,若拷贝虚表,那么父类对象中的虚表是父类的还是子类的就不确定了,这样便会引起混乱,因此不能是父类对象。

4.3 动态绑定与静态绑定

  1. 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载。
  2. 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。

5. 单继承和多继承关系中的虚函数表

5.1 单继承中的虚函数表

typedef void (*fptr)();
void PRINTVFT(fptr* table) {
//vs下最一个位置放的是空指针,其平台不一定
	for (int i = 0; table[i] != nullptr; ++i) {
		table[i]();
	}
}
class Base {
public:
	virtual void Func1() {
		cout << "Base::Func1()" << endl;
	}
	virtual void Func2() {
		cout << "Base::Func2()" << endl;
	}
	virtual void Func3() {
		cout << "Base::Func3()" << endl;
	}
private:
	int _a = 1;
};

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

int main() {
	Derive d;
	PRINTVFT((fptr*)(*(int*)&d));
	return 0;
}

打印出子类虚表中的虚函数:

【C++】多态_第9张图片
可以发现子类若重写了父类的虚函数时,子类虚表中的虚函数地址会替换为重写后的虚函数地址,没有重写的则依旧是父类的虚函数地址,而子类自己的虚函数将会依次放到这张虚表最后的位置。

5.2 多继承中的虚函数表

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;
	PRINTVFT((fptr*)(*(int*)&d));
	cout << endl;
	//打印出d对象中base2对象中的虚函数
	// PRINTVFT((fptr*)(*(char*)&d + sizeof(Base1)));
	Base2* p = &d;
	PRINTVFT((fptr*)(*(int*)p));

	return 0;
}

输出:

【C++】多态_第10张图片
多继承的情况与单继承是类似的,只不过虚表的个数会增加,若子类也有自己的虚函数,那么它会依次放到第一个继承下来的父类的虚表的最后的位置。

上面子类重写了两个父类的虚函数,那么两个虚表中存放func1函数的地址应该是一样的,可实际上两个地址并不相同:

【C++】多态_第11张图片
事实上Base1的虚表中保存的才是func1函数真正的地址,将d对象切片给Base1的指针或者引用来调用func1时需要传递this指针,而切出来的Base1对象的地址就是d对象的起始地址,两个地址是重合的,因此可以直接传递this,通过this指针去该对象的虚表中找到对应的函数,若把d对象切片给Base2的指针或引用来调用func1时,由于切出来的Base2对象的地址与d对象的起始地址并不在一个位置,因此传递的this指针是错误的,所以在调用前需要去修正this,让其指向d对象的起始位置然后再去调用func1函数。

int main() {
	Derive d;
	d.func1();
	Base1* p1 = &d;
	p1->func1();
	Base2* p2 = &d;
	p2->func1();
	return 0;
}

Base1指针调用func1函数:

【C++】多态_第12张图片
eax存放的是this指针,由于p1中保存的地址就是d对象的起始地址,因此可以跳转到func1函数的地址处开始调用:

在这里插入图片描述

Base2指针调用func1:

【C++】多态_第13张图片
与前者步骤一致。

在这里插入图片描述
但这句jmp指令执行完后并没有像前者一样直接进跳转到func1函数的起始位置,而是跳转到了另一个地址处:

在这里插入图片描述
这里让ecx中保存的this指针-8,然后再跳转到func1函数的地址处开始调用:

在这里插入图片描述
-8是因为Base1对象占8个字节,减去后正好指向d对象的起始地址。

减多少取决于前面对象的大小

以上就是修正this指针的过程,本质就是为了不管是哪个父类对象的指针或者引用指向子类,都能在传递this指针时让其指向子类对象的起始地址处然后调用对应重写后的虚函数。

若是普通调用则不会发生上述行为

5.3 菱形继承与菱形虚拟继承

class A {
public:
	virtual void fun1() { cout << "A::fun1()" << endl; }
	int _a = 1;
};
class B : public A {
public:
	int _b = 2;
};
class C : public A {
public:
	int _c = 3;
};
class D : public B, public C {
public:
	int _d = 4;
};

int main() {
	D d;
	return 0;
}

菱形继承只有A类有虚函数时,内存中D类型对象布局如下:

【C++】多态_第14张图片
可以发现与多继承的布局没有区别,但是会有数据冗余和二义性问题。

菱形虚拟继承且只有A类中有虚函数时,内存布局如下:

【C++】多态_第15张图片
此时d对象中只有一份公共的A类对象在下方,同时B和C类对象中增加了虚基表指针。

若B和C类重写了A类的虚函数时会报错,因为此时整个d对象中只有一份公共的虚表 ,如果都重写那虚表中该放哪个呢?因此正确的做法是让最终子类D类也重写虚函数,此时虚表中存放的是D类重写的虚函数,B和C类重写的对于d对象而言就没有意义了,但是若要单独定义B或C类对象时则是有意义的。

更为复杂情况是若B和C有单独的虚函数时,d对象中的B和C类对象会分别增加一张虚表存放各自的虚函数:

【C++】多态_第16张图片

菱形继承时由于B和C类对象中都有一份独立的A类对象,因此各自的虚函数可以直接放到自己继承下来的A类对象的虚表中,互不影响,但此时是虚拟继承,只有一份共享的A类对象,因此各自的虚函数再放到这个共享虚表中就不合理了,因此需要各自单独开辟一个虚表

B和C对象中虚基表和虚函数表的位置:

【C++】多态_第17张图片

需要注意的是,在继承章节那里虚基表的第一个位置存放的是全0,第二个位置是距离公共父类对象的偏移量,而在这里第一个位置存放的是fffffffc即-4,那这个值本质是也是一个偏移量,它是用来找自己虚函数表的位置,在该布局中,虚基表的地址-4就是虚函数表的位置。

若D类也有自己的虚函数时,它是放在B类的虚函数表中的:
【C++】多态_第18张图片

6. 继承和多态性的常见问题

  1. 什么是多态性?

多态性即多种形式,不同的对象在做同样的事或调用同样的函数时,会得到不同的结果。多态分为静态多态和动态多态,静态多态体现在函数重载上,不同的对象类型看似调用同一个函数,实际上编译器根据不同的类型实例化不同的函数,本质上是调用不同的函数。
动态多态体现在继承上,子类覆盖父类的虚函数,当通过父类的指针或引用调用同一函数时,实际上调用的是不同的函数,运行时通过对象中的虚函数表在表中动态查找对应的函数然后调用。
多态性的本质是更方便和灵活地进行各种形式的调用。

  1. 多态的实现原理?

静态多态:通过函数名装饰规则,相同名称的函数被修饰后得到的符号是不同的,不同的符号将被视为不同的函数。

动态多态性:继承+子类覆盖(重写)父类的虚函数,在运行时通过父类的指针或引用在对象的虚表中找到对应的函数,以此来实现指向父类调父类指向子类调子类。

3.内联函数可以是虚函数吗?

可以,但是编译器会忽略内联属性,因为内联函数会在调用它的地方直接展开,并且不会有地址,但是虚函数有地址因为是要进虚函数表,所以内联函数不能是虚函数,即使声明为内联编译器也会直接忽略内联请求。

  1. 静态函数可以是虚函数吗?

不能,静态成员函数可以直接通过类名调用,不需要对象即可调用,但是虚函数不行,虚函数必须通过对象中的虚表指针才能找到,没有对象就不能调用这个虚函数,所以静态成员函数不能成为虚函数。

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

不能,构造函数的作用是初始化新创建的对象,虚表指针也是在构造函数的初始化列表中初始化的,即先有构造函数,再有虚表指针。
如果构造函数是虚函数,调用它需要通过虚表指针去找,但是虚表指针需要先在构造函数的初始化列表中进行初始化,这就导致了先有鸡还是先有蛋的哲学问题,所以构造函数不能是虚函数,因为行为不符合构造函数的特征。

  1. 析构函数可以是虚函数吗?

可以,上面提到了一种析构函数必须是虚函数的情况。

  1. 对象访问普通成员函数还是虚函数的速度更快?

首先,如果它是一个普通对象就一样快,因为函数地址可以在编译时直接确定。如果是对象指针或引用,调用普通函数速度快,因为构成多态,得在运行时动态地去对象的虚表中查找对应函数然后调用。

  1. 虚函数表是在什么阶段生成的?存在于哪里?

虚函数表是在编译阶段生成的,存在于代码段中(在常量区中)。

  1. 什么是抽象类?抽象类做什么?

抽象类是指包含纯虚函数的类,不能实例化对象,被子类继承后也不能实例化对象,除非子类重写了纯虚函数,其效果是强制重写虚函数形成多态性,抽象类反映接口继承的关系。

你可能感兴趣的:(c++)