【C++】多态的原理

[!Abstract]

  • 1.多态的概念
    • 1.1 概念
    • 1.2 C++中的多态
  • 2.多态的定义及实现
    • 2.1 多态的构成条件
    • 2.2 虚函数
    • 2.3 虚函数的重写
      • 两个例外:
        • 1.协变
        • 2.析构函数重写
    • 2.4 C++11 override 和 final 关键字
    • 2.5 重载、覆盖(重写)、隐藏(重定义)的对比
  • 3.纯虚函数的概念
  • 4.多态的原理
    • 4.1 虚函数表
    • 4.2 原理
  • 5.单继承和多继承关系中的虚函数表
    • 5.1 单继承中的虚函数表
    • 5.2 多继承中的虚函数表
    • 5.3 复杂的菱形继承、菱形虚拟继承
  • 6.继承和多态的经典例题(待续)

1. 多态的概念

1.1 概念

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

多态性是面向对象编程的三大特征之一,其他两个特征包括封装、继承。

多态性允许我们使用一个通用的接口来操作不同类型的对象,而无需在编写代码时明确知道对象的具体类型。这为代码的灵活性和可维护性提供了很大的好处。

1.2 C++中的多态

在C++中,多态性通过两种主要方式实现:

  1. 编译时多态性(Compile-time Polymorphism)
  2. 运行时多态性(Runtime Polymorphism)。
1.2.1 编译时多态性(静态多态性):
  • 函数重载(Function Overloading):允许我们定义多个具有相同名称但参数列表不同的函数。编译器根据调用的函数和提供的参数类型来决定调用哪个函数。
void print(int x) 
{
// 打印整数
}

void print(double y) 
{
// 打印浮点数
}
  • 运算符重载(Operator Overloading):允许我们重新定义类的成员函数,以便在特定操作中使用对象。
class Complex 
{
public:
Complex operator+(const Complex& other) 
{
    // 重载加法运算符
}
};
1.2.2 运行时多态性(动态多态性):
  • 虚函数(Virtual Functions)和动态绑定:通过将基类函数声明为虚函数,可以在派生类中重写这些函数。在运行时,系统会根据实际对象的类型来调用相应的函数,而不是根据指针或引用的类型。
class Shape 
{
public:
virtual void draw() 
{
    // 绘制形状
}
};

class Circle : public Shape 
{
public:
void draw() override 
{
    // 绘制圆形
}
};
  • 纯虚函数和抽象类:纯虚函数是在基类中声明的虚函数,但没有实现。包含纯虚函数的类称为抽象类,无法直接实例化,而是需要通过派生类来实现纯虚函数。
class AbstractShape 
{
public:
virtual void draw() = 0;  // 纯虚函数
};

class ConcreteCircle : public AbstractShape 
{
public:
void draw() override 
{
    // 绘制圆形
}
};

2. 多态的定义及实现

2.1 多态的构成条件

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

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;
	Func(ps);
	Func(st);
	return 0;
}

在继承中要构成多态有两个条件

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

2.2 虚函数

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

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

2.3虚函数的重写

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

虚函数重写的两个例外:

以下两个例外情况都能够构成虚函数重写,实现多态:

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

  • 例如:基类虚函数的返回类型是基类 B 的指针或引用,那么派生类中重写的函数可以有一个返回类型是 B 的派生类 D 的指针或引用。

class B {};
class D : public B {};

class Base 
{
public:
    virtual B* create() { return new B; }
};

class Derived : public Base 
{
public:
    virtual D* create() override { return new D; }  // 协变返回类型
};

在这里,Derived::create 重写了 Base::create,即使它们的返回类型不完全相同。

例外2. 析构函数的重写(基类与派生类析构函数的名字不同)

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

class Person
{
	public:
	virtual ~Person() {cout << "~Person()" << endl;}
};

class Student : public Person 
{
	public:
	virtual ~Student() { cout << "~Student()" << endl; }
};
// 只有派生类Student的析构函数重写了Person的析构函数,下面的delete对象调用析构函数,才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数。
int main()
{
	Person* p1 = new Person;
	Person* p2 = new Student; //将一个派生类对象指针赋值给一个基类指针
	delete p1;
	delete p2; //虽然是Person*指针,但是正确调用了Student的析构函数
	return 0;
}

注意:期望delete ptr调用析构函数是一个多态调用, 如果设计一个类,可能会作为基类,其次析构函数最好定义为虚函数

2.4 C++11 override 和 final

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

  1. final修饰虚函数时,表示该虚函数不能再被重写,如果派生类重写了它,那么编译报错。
class Base 
{
public:
    virtual void foo() final;  // 此虚函数不能在派生类中被重写
};

class Derived : public Base 
{
public:
    void foo();  // 错误:Base中的foo()是final的
};
  1. final修饰类时,该类不能被继承。
class Base final 
{};

class Derived : public Base // 错误:Base被声明为final
{};  
  1. override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。
class Car
{
	public:
	virtual void Drive(){}
};

class Benz :public Car 
{
	public:
	virtual void Drive() override {cout << "Benz-舒适" << endl;}
};

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

函数重载(Overloading)
  1. 两个或多个函数必须在同一作用域。
  2. 函数名相同,但参数列表不同。可以是参数个数不同或者参数类型不同。返回值类型不影响函数重载。
函数重写(覆盖)
  1. 两个函数分别在基类和派生类的作用域。
  2. 函数名相同。
  3. 参数列表也必须完全相同。
  4. 在基类中的函数必须是虚函数。
  5. 返回值类型也必须相同,但C++允许协变返回类型。
  6. 访问权限不能更严格(例如,如果基类函数是public,派生类中重写的函数也必须是public)。
函数重定义(隐藏)
  1. 当派生类有一个与基类同名的函数时,不论参数是否相同,都会隐藏基类中所有同名的函数。
  2. 如果派生类的函数与基类的函数同名但参数不同,并且基类的函数没有被声明为虚函数,那么就是重定义(也叫做隐藏)。
  3. 隐藏是不受访问权限限制的。也就是说,即使基类的函数是private,派生类中也会隐藏它。
  4. 如果想在派生类中调用基类的被隐藏函数,需要使用基类名和作用解析运算符(::)。

3. 纯虚函数概念

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

virtual void Func() = 0;

[!Abstract] 接口继承和实现继承

  • 普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。
  • 虚函数的继承是一种接口继承(即继承了函数的返回值类型、函数名、参数列表的类型和顺序),派生类继承的是基类虚函数的接口,目的是为了重写,以达成多态。

4. 多态的原理

4.1 虚函数表

虚函数表(也称为vtable,在vs中虚函数表的指针名字叫_vfptr)是C++编译器用于实现运行时多态的一种机制。对于含有虚函数的类,编译器会为该类生成一个虚函数表(存放在数据段)。这个表是一个函数指针数组,其中包含指向该类及其基类中所有虚函数的指针。
请添加图片描述

每一个含有虚函数的类的对象中都至少都有一个虚函数表指针,因为这个指针的存在,我们在使用sizeof(类名)的时候,要把虚表指针的大小算进去,如下面的例题:

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

int main()
{
	Base b;
	cout << sizeof(Base) << endl; 
	// sizeof(Base)是多少?
	// 答:32位平台下8字节(4字节虚表指针 + 4字节int)
	//     64位平台下16字节(8字节虚表指针 + 4字节int)
	//     因为最后要结构体内存对齐,所以是16字节
	
	cout << sizeof(int) << endl;  //4Byte
	return 0;
}

[!Attention] 注意:

  1. 虚函数和普通函数的实现都被存放在代码段中,没有什么区别(下面会验证)。
  2. 虚函数表通常存放在程序的常量区(下面会验证)。
  3. 每一个含有虚函数的类对象都有一个隐藏的指针,这个指针指向该类的虚函数表。通常,这个隐藏的指针是对象在内存中的第一个成员,尽管这一点并没有在C++标准中明确规定。所以这个虚函数表指针被存放在每个对象里,对象定义在何处,虚函数表指针就存放在何处。
  • C/C++内存分布简图:
+------------------+     高地址
|                  |
|       栈         | <- 本地变量,函数调用信息等
|                  |
+------------------+
|                  |
|       堆         | <- 动态分配的变量(malloc/new)
|                  |
+------------------+
|                  |
|   未初始化数据段  |
|    (BSS 段)     | <- 未初始化的静态和全局变量
|                  |
+------------------+
|                  |
|   已初始化数据段   | <- 已初始化的静态和全局变量
|     (数据段)      |
|                  |
+------------------+
|                  |
|    常量数据段     | <- 字面量,常量字符串等 (虚函数表在这里存放)
|    (rodata)      |
|                  |
+------------------+
|                  |
|      代码段       | <- 程序代码(虚函数和普通函数)
|                  |
+------------------+     低地址
  • 验证虚函数和虚函数表存放的位置:
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(*VFPTR) ();
void PrintVTable(VFPTR vTable[])
{
	cout << " 虚函数表地址(_vfptr) :" << vTable << endl;
	for (int i = 0; vTable[i] != nullptr; ++i)
	{
		printf(" 第%d个虚函数地址 :%x		调用结果:", i, vTable[i]);
		VFPTR f = vTable[i];
		f();
	}
	cout << endl;
}

int main()
{
	int a = 0;
	static int b = 1;
	static int c;
	const char* str = "hello world";
	int* p = new int[10];
	
	cout << "---高地址---" << endl;
	printf("栈:%p\n", &a);
	printf("堆:%p\n", &p);
	printf("静态区/数据段(未初始化):%p\n", &c);
	printf("数据段(已初始化):%p\n", &b);
	printf("常量区:%p\n", str);
	printf("代码段:%p\n", &PrintVTable);
	cout << "---低地址---" << endl << endl;
	
	Derive d;
	Derive2 d2;
	
	VFPTR* vTableb1 = (VFPTR*)(*(int*)&d);
	PrintVTable(vTableb1);
	
	VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1)));
	PrintVTable(vTableb2);
	
	return 0;
}
  • 运行结果:
    请添加图片描述

由此我们验证了在vs2022下的两个事实:

  1. 虚函数和普通函数一样,函数的实现都被存放在代码段中。
  2. 虚函数表通常存放在程序的常量区。

4.2 原理

多态允许我们通过基类的指针或引用来操作派生类对象,而具体要执行哪个版本的虚函数则是在运行时决定的。这是通过以下几个步骤实现的:

  1. 编译阶段: 对于含有虚函数的类,编译器会生成一个虚函数表。
  2. 对象创建: 当一个这样的对象被创建时,它的虚函数表指针会被初始化,使其指向相应类的虚函数表。
  3. 运行时调用: 当通过基类指针或引用调用虚函数时,程序会使用这个隐藏的虚函数表指针来查找实际需要调用的函数的地址,然后进行调用。这一切都在运行时发生。

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

5.1 单继承中的虚函数表

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

class Derive :public Base {
public :
	virtual void func1() {cout<<"Derive::func1" <<endl;}
	virtual void func3() {cout<<"Derive::func3" <<endl;}
	virtual void func4() {cout<<"Derive::func4" <<endl;}
private :
	int b;
};

观察下图中的监视窗口中我们发现看不见func3和func4。这里是编译器的监视窗口故意隐藏了这两个函数,也可以认为是他的一个小bug。
请添加图片描述

那么我们如何查看d的虚表呢?下面我们使用代码打印出虚表中的函数。

typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[])
{
	// 依次取虚表中的虚函数指针打印并调用。调用就可以看出存的是哪个函数
	cout << " 虚表地址>" << vTable << endl;
	for (int i = 0; vTable[i] != nullptr; ++i)
	{
		printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]);
		VFPTR f = vTable[i];
		f();
	}
	cout << endl;
}

int main()
{
	Base b;
	Derive d;
	// 思路:取出b、d对象的头4bytes,就是虚表的指针,前面我们说了虚函数表本质是一个存虚函数指针的指针数组,这个数组最后面放了一个nullptr
	// 1.先取b的地址,强转成一个int*的指针
	// 2.再解引用取值,就取到了b对象头4bytes的值,这个值就是指向虚表的指针
	// 3.再强转成VFPTR*,因为虚表就是一个存VFPTR类型(虚函数指针类型)的数组。
	// 4.虚表指针传递给PrintVTable进行打印虚表
	// 5.需要说明的是这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处理不干净,虚表最后面没有放nullptr,导致越界,这是编译器的问题。我们只需要点目录栏的-生成-清理解决方案,再编译就好了。
	VFPTR* vTableb = (VFPTR*)(*(int*)&b);
	PrintVTable(vTableb);
	VFPTR* vTabled = (VFPTR*)(*(int*)&d);
	PrintVTable(vTabled);
	return 0;
}

请添加图片描述

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;
};

typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[])
{
	cout << " 虚表地址>" << vTable << endl;
	for (int i = 0; vTable[i] != nullptr; ++i)
	{
		printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]);
		VFPTR f = vTable[i];
		f();
	}
	cout << endl;
}

int main()
{
	Derive d;
	VFPTR* vTableb1 = (VFPTR*)(*(int*)&d);
	PrintVTable(vTableb1);
	VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d+sizeof(Base1)));
	PrintVTable(vTableb2);
	return 0;
}

观察下图可以看出:多继承派生类的未重写的虚函数放在第一个继承基类部分的虚函数表中
请添加图片描述

5.3. 复杂的菱形继承、菱形虚拟继承

实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的模型,访问基类成员有一定得性能损耗。如果想了解,可参考下文:
C++的对象内存布局

6. 继承和多态的经典例题(待续)

  1. 以下程序输出结果是什么()
    A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确
class A
{
public:
	virtual void func(int val = 1)
	{ 
		std::cout << "A->" << val << std::endl; 
	}
	
	virtual void test() 
	{ 
		func(); 
	}
};
class B : public A
{
public:
	void func(int val = 0)
	{
		std::cout << "B->" << val << std::endl; 
	}
};
int main(int argc, char* argv[])
{
	B* p = new B;
	p->test();
	return 0;
}

答案:D

解析:
在这个例子中,当 p->test() 被调用时,this 指针的动态类型是 B*,因为 p 是一个指向 B 对象的指针。

A 类的 test 函数中,this 指针指向的是调用对象,即 B 对象。当 this->func() 被执行时,由于 func 是一个虚函数,编译器会使用this
动态绑定的对象类型来确定调用的实际版本。

因为 this 指针的动态类型是 B*,所以编译器会选择调用 B 类中重写的 func 版本。因此,在 test 函数内部调用的是 B::func,而不是 A::func

总结:
this 是一个指向调用对象的指针,其静态类型与成员函数所属的类相同,但是动态类型可能与其所属的类不同。调用哪个类中重写的虚函数,取决于 this 实际动态指向的类型。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

_宁清

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值