【C++】多态

声明:该博客代码都是在vs2022下x86程序中,涉及的指针都是4bytes。如果是x64程序,则需要考虑指针是8bytes问题。

1.多态的概念

就是多种形态,具体就是去完成某个行为,当不同的对象 去完成时会产生出不同的状态

2.多态的定义及实现

多态构成条件

  1. 必须通过基类的指针或者引用调用虚函数
  2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写。只有这样才能指针指向父类时调父类,指向子类时调子类

重点:

满足多态以后的函数调用,不是在编译时确定的,是运行起来以后到对象的中取找的。不满足多态的函数调用时编译时确认好的。

虚函数

被virtual修饰的类成员函数称为虚函数

注意:只有成员函数才能变成虚函数,全局函数不行。

虚函数和虚继承只是公用"虚"关键字,一点关系也没有,相互独立

虚函数的重写

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

条件

虚函数(virtual修饰),三同(返回值,参数,函数名三者相同)

例外:

1、派生类的重写虚函数可以不加virtual(建议加上):只要基类是虚函数,被继承下去在派生类中依然保持虚函数属性。

2.协变,返回的值可以不同,但是要求返回值必须是父子关系指针和引用

cpp 复制代码
#include<iostream>
using namespace std;
class A
{};
class B : public A
{};
class Person {
public:
	virtual A* BuyTicket() const {
		cout << "买票-全价" << endl;
		return 0;
	}
};
class Student : public Person {
public:
	virtual B* BuyTicket() const { 
		cout << "买票-半价" << endl;
		return 0;
	}
};
void func(const Person * p)
{
	p->BuyTicket();
}
int main()
{
	Person pp;
	func(&pp);
	Student st;
	func(&st);
	return 0;
}

若基类没有virtual修饰,不构成虚函数重写

该函数为参数为普通对象,看当前者类型来确定一直调用的基类函数

virtual修饰,构成虚函数重写

析构函数的重写

子类析构完后会自动调用父类析构,然后父类再析构,因为子类中有一份父类的拷贝

析构函数加virtual,是不是虚函数重写?

是,因为类析构函数都被处理成destructor这个统一的名字,这么处理就是为了让它们构成重写

为什么要构成重写,因为如下场景

若不构成虚函数重写

析构函数只要基类加了virtual修饰就一定是虚函数重写,没有返回值与参数,函数名底层相同,可以说子类可以不加virtual主要是为析构设计的

override和final

函数名字母次序写反而无法构成重载,这种错误在编译期间是不会报出的,只有在程序运行时通过预期结果debug得出。

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

如何设计不想被继承的类?

方法1:基类构造函数私有 (C++98)

A类中将构造函数设为私有,从而派生类B无法继承A因为无法调用基类的构造函数。为了A类自己能创建对象,在公共区域用了static来封装构造函数,static 方法属于类本身,而不是类的实例。这意味着可以在不创建类实例的情况下调用 static 方法。

方法2:基类加一个final (C++11)

override使用场景:

在需要检测重写的函数参数名和括号后加上关键字

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

一道虚函数重写例题

答案B。

通过该题需明白,虚函数的意义就是重写,重写的是实现,框架用的是基类的(函数返回类型,参数,函数名),所以派生类可以不加virtual。这是一个语法问题,实际中通常不这么定义

3.抽象类

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

  • 测试代码
cpp 复制代码
class Car
{
public:
	virtual void Drive() = 0;
};
class Benz :public Car
{
public:
	inline virtual void Drive()
	{
		cout << "Benz-舒适" << endl;
	}
};
class BMW :public Car
{
public:
	inline virtual void Drive()
	{
		cout << "BMW-操控" << endl;
	}
};
class BYD :public Car
{
public:
	inline virtual void Drive()
	{
		cout << "BYD-build your dream" << endl;
	}
};
void Func(Car* p)
{
	p->Drive();
	p->Func();
}
int main()
{
	Func(new Benz);
	Func(new BMW);
	Func(new BYD);
	return 0;
}

抽象类不能实例化出对象,但可以定义指针或引用如Func函数的参数类型。

inline函数可以是虚函数吗

可以,不过编译器就忽略inline属性,这个函数就不再是inline,因为虚函数要放到虚表中去

红方框代表放进虚表,虽然Func函数直接展开但无法避免调用开销,因为虚函数的调用需要动态绑定。在反汇编代码中,内联函数 Func 的调用会被直接展开,没有 call 指令


如果在类中声明和定义分离,就不构成内联

接口继承和实现继承

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

4.多态的原理

虚函数表

vs2022x86程序中,涉及的指针都是4bytes,char_b占用一字节,为了内存对齐,编译器可能会在 _b 后面插入3字节的填充,使得类的总大小为8字节。多出来的空间即为虚函数表指针的大小

除了_b成员,还多一个__vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)

针对上面的代码我们做出以下改造,进一步观察

1.我们增加一个派生类Derive去继承Base

2.Derive中重写Func1

cpp 复制代码
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;
}

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

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

3.Func3也继承下来了,但不是虚函数,不进虚表

4.

虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr。如果没有重新生成一下解决方案

总结一下派生类的虚表生成:

1.先将基类中的虚表内容拷贝一份到派生类虚表中

2.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数

3.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。

虚函数表在编译器就生成好,虚函数指针在执行初始化列表时确认·

虚函数表存在哪里的?

同类型的对象共用虚表

通过上图可得,虚表指针是类对象地址中前四个字节,我们可以通过将类对象地址强转成int*指针就刚好取到虚表指针地址,借此来观察虚表在内存中的存储位置

  • 测试代码
cpp 复制代码
class Person {
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:
	virtual void BuyTicket() { cout << "买票-半价" << endl; }
};
void Func(Person& p)
{
	p.BuyTicket();
}

观察得出虚表地址与常量区(代码段)地址最为接近,相差不到100字节,相比其他存储区域可以认为最有可能存储在常量区

子类虚函数在监视窗口和内存中的情况(单继承)

  • 测试代码
cpp 复制代码
class Person
{
public:
	virtual void Func1()
	{
		cout << "Base::Func1()" << endl;
	}
	virtual void Func2()
	{
		cout << "Base::Func2()" << endl;
	}
protected:
	int _b = 1;
};
class Student: public Person
{
public:
	virtual void Func3()
	{
		cout << "Base::Func3()" << endl;
	}
protected:
	int _d = 2;
};


监视窗口中没有看到派生类的虚函数func3,只能看到父类的虚函数表,但在内存窗口中多出一个地址,怀疑是func3,如何验证?编译器的监视窗口故意隐藏了这个函数,也可以认为是他的一个小bug

FUNC_PTR* table:指向函数指针数组的指针。这个数组存储了一系列函数指针。

FUNC_PTR f = table[i]:将当前函数指针赋值给局部变量 f。

f():通过函数指针调用对应的函数。

注意:FUNC_PTR table[] 和 FUNC_PTR* table 是等价的。它们都表示一个指向函数指针的指针。

FUNC_PTR table[] 表示一个函数指针数组。table 是一个数组,其元素类型是 FUNC_PTR,在C++中,数组名作为参数时,会退化为指向数组第一个元素的指针。因此,FUNC_PTR table[] 实际上等价于 FUNC_PTR* table

证明我们猜测正确,监视窗口中只能看父类虚表情况,内存中存储按照基类和派生类的定义顺序来

再深入理解多态构成条件

多态的条件:

1.父类的指针和引用

2.虚函数的重写

测试代码:

cpp 复制代码
class Base
{
public:
	virtual void Func1()
	{
		cout << "Base::Func1()" << endl;
	}
	virtual void Func2()
	{
		cout << "Base::Func2()" << endl;
	}
	void Func3()
	{
		cout << "Base::Func3()" << endl;
	}
//protected:
	int _b = 1;
};
class Derive : public Base
{
public:
	virtual void Func1()
	{
		cout << "Derive::Func1()" << endl;
	}
protected:
	int _d = 2;
};

提出以下问题:

1.中为什么不能是子类指针或引用?

因为只有父类才可以既指向基类又指向派生类,满足多态条件

2.(1.)中为什么不能传父类对象

可观察得到,对象d中修改_b后拷贝到对象b中去,但虚函数表没有拷贝过去。若拷贝,会发生对象切片。这意味着父类对象只会包含派生类对象中继承自父类的部分,派生类中新增的成员变量和方法将被忽略。那么调用父类对象中的虚表就不是父类而变成子类的了,乱套了。所以子类赋值给父类对象切片,不会拷贝虚表。这就是不能用父类对象的原因。

基类的指针和引用

通过虚函数表和虚函数指针,允许运行时决定调用哪个类的实现。在运行时根据对象的实际类型调用相应的方法。

基类指针或引用同样不会复制派生类中新增的成员变量和方法,但是可以通过引用或指针访问派生类对象的完整内容。这就是与父类对象的区别

多态原理

  • 测试代码
cpp 复制代码
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 ps;
	Student st;
	Student* ptr = &st;
	Func(&st);
	ptr->BuyTicket();
	ps.BuyTicket();
	return 0;
}

Func函数参数是基类的指针或引用,里面是虚函数,构成多态。接下来通过在返回编具体观察多态的实现


Func函数虽然运行结果构成多态,但其在反汇编中就是普通函数的调用,都不是类的成员函数。需要跳转到p->BuyTicket();才能观察到多态的实现

出满足多态以后的函数调用,不是在编译时确定的,是运行起来以后到对象的中取找的。不满足多态的函数调用时编译时确认好的。

先BuyTicket虽然是虚函数,但是ps是对象,不满足多态的条件,所以这里是普通函数的调用转换成地址时,是在编译时已经从符号表确认了函数的地址,直接call 地址

动态绑定与静态绑定

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

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

5.多继承关系的虚表

  • 测试代码
cpp 复制代码
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;
};
typedef void(*FUNC_PTR) ();
//打印函数指针数组
//void PrintfVFT(FUNC_PTR table[])
void PrintfVFT(FUNC_PTR* table)
{//Linux下函数指针数组最后不放nullptr条件要写死
	for (size_t i = 0; table[i] != nullptr; i++)
	{
		printf("[%d]:%p->", i, table[i]);
		FUNC_PTR f = table[i];
		f();
	}
	printf("\n");
}

20是一个Derive类对象占用的空间大小,其中Base1和Base2中各有一个虚函数表指针占用4字节和int类型成员变量占用4字节,各占用8字节,总计16字节,再加上Derive类本身int类型成员变量4字节大小。

可以发现虽然重写了func1,但是Base1和Base2中func1不一样。

观察发现,多继承派生类的未重写虚函数放在第一个继承基类部分的虚函数表中

通过反汇编观察

区别在于Base1在调用时直接call func1的地址,而func2在调用时需要修正this指针再call地址。

原因在于内存中没有类型的概念,类型是代码中的概念,取决我们用什么样的方式去解释它。Base1和Derive类指针的起始位置相同,可以直接调用,而Base2指针的位置在起始位置+sizeof(Base1)的位置,所以需要修正到起始位置,可以认为Base1中func1地址为真地址,而Base2中的地址是被封装后的。

这里的封装是调用函数之后再进行修正,也可以在调用之前就进行修正然后直接调用

6.菱形虚拟继承(了解)

实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的模型,访问基类成员有一定得性能损耗。

  • 测试代码
cpp 复制代码
class A
{
public:
	virtual void func1()
	{
		cout << "A::func1" << endl;
	}
public:
	int _a;
};
//class B : public A
class B : virtual public A
{
public:
	virtual void func1()
	{
		cout << "B::func1" << endl;
	}
	virtual void func2()
	{
		cout << "B::func2" << endl;
	}
public:
	int _b;
};
//class C : public A
class C : virtual public A
{
public:
	virtual void func1()
	{
		cout << "C::func1" << endl;
	}
	virtual void func2()
	{
		cout << "C::func2" << endl;
	}
public:
	int _c;
};
class D : public B, public C
{
public:
	virtual void func1()
	{
		cout << "D::func1" << endl;
	}
	virtual void func3()
	{
		cout << "D::func3" << endl;
	}
public:
	int _d;
};
int main()
{
	D d;
	d.B::_a = 1;
	d.C::_a = 2;
	d._b = 3;
	d._c = 4;
	d._d = 5;
	return 0;
}

单纯菱形继承的情况:

本质和多继承一样,有两张虚表,_a在B和C中各有一份

菱形虚拟继承:

当基类有一个虚函数,中间两个派生类对其完成重写就会语法报错?

原因是基类的虚函数属于中间两个派生类共享的,只有一张虚函数表不知道重写算谁的。

解决办法让最后派生类再重写一次,虚表中就是它覆盖的地址。中间派生类的重写只有在单独重写时会有意义。所以中间派生类会各自建立一张虚基表来存储到覆盖位置的偏移量

更复杂的场景,中间派生类添加一些不重写的虚函数:

不仅基类有虚函数表,中间派生类各自也会有一张虚函数表,若有重写基类的虚函数,还会套有虚基表。虚基表中第二个位置存偏移量,可以存虚函数表的偏移量

7.常考问答题总结

  1. 什么是多态?

静态多态:函数重写

动态多态:继承中虚函数重+父类指针调用

目的是为了更方便和灵活的进行多种形态调用。

  1. 什么是重载、重写(覆盖)、重定义(隐藏)?
  1. 多态的实现原理?

函数名修饰规则+虚函数表,详见本章

  1. inline函数可以是虚函数吗?

可以,不过编译器就忽略inline属性,这个函数就不再是inline,因为虚函数要放到虚表中去。详见本章

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

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

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

不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。这里就像先有鸡还是先有蛋的问题

虚函数表尚未初始化:在构造函数执行时,虚函数表指针还未完全初始化,因此无法正确调用虚函数。

构造顺序问题:基类构造函数先于派生类构造函数执行。如果构造函数是虚函数,基类构造函数可能会调用派生类的函数,导致未定义行为。

  1. 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?

可以将析构函数声明为虚函数通常用于多态场景,特别是在通过基类指针或引用删除派生类对象时,确保正确调用派生类的析构函数。在多态场景中,如果基类的析构函数不是虚函数,通过基类指针删除派生类对象时,只会调用基类的析构函数,而不会调用派生类的析构函数,这可能导致资源泄漏或未定义行为。

良好习惯:如果基类包含虚函数,通常应该将析构函数声明为虚函数。

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

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

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

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

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

数据冗余和二义性。

引入虚基类指针,确保基类只被继承一次,从而避免菱形继承的问题。虚基类的初始化由最派生类负责,访问虚基类成员时需要经过两次解引用操作(从派生类对象到虚基类指针。通过虚基类指针访问成员。)。

  1. 什么是抽象类?抽象类的作用?

在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类)。

抽象类强制重写了虚函数,另外抽象类体现出了接口继承关系。

相关推荐
xx155802862xx1 分钟前
matlab中进行海浪模型仿真
开发语言·matlab
2401_8582861122 分钟前
CD27.【C++ Dev】类和对象(18)友元和内部类
开发语言·c++·类和对象
(王子变青蛙)24 分钟前
C++初始
开发语言·c++·程序人生
莫有杯子的龙潭峡谷25 分钟前
4.15 代码随想录第四十四天打卡
c++·算法
极客先躯28 分钟前
高级java每日一道面试题-2025年4月06日-微服务篇[Nacos篇]-如何诊断和解决Nacos中的常见问题?
java·开发语言·微服务
灋✘逞_兇41 分钟前
快速幂+公共父节点
数据结构·c++·算法·leetcode
格里姆肖1 小时前
LVGL源码(7):渲染
c语言·stm32·单片机
code菜只因1 小时前
初始C语言(2)
c语言
胎粉仔1 小时前
Swift —— delegate 设计模式
开发语言·设计模式·swift
ᖰ・◡・ᖳ1 小时前
Web APIs阶段
开发语言·前端·javascript·学习