【C++】多态(详解)

文章目录

  • 上文链接
  • 一、多态的概念
  • 二、多态的实现
    • [1. 虚函数](#1. 虚函数)
    • [2. 虚函数的重写/覆盖](#2. 虚函数的重写/覆盖)
    • [3. 实现多态的两个重要条件](#3. 实现多态的两个重要条件)
    • [4. 多态场景的一个选择题](#4. 多态场景的一个选择题)
    • [5. 协变](#5. 协变)
    • [6. 析构函数的重写](#6. 析构函数的重写)
    • [7. override 和 final](#7. override 和 final)
    • [8. 纯虚函数与抽象类](#8. 纯虚函数与抽象类)
    • [9. 重载/重写/隐藏的对比](#9. 重载/重写/隐藏的对比)
  • 三、多态的原理
    • [1. 虚函数表指针](#1. 虚函数表指针)
    • [2. 虚函数表](#2. 虚函数表)
      • [(1) 性质](#(1) 性质)
      • [(2) 如何取到虚函数表的地址](#(2) 如何取到虚函数表的地址)
    • [3. 多态是如何实现的](#3. 多态是如何实现的)
    • [4. 静态绑定与动态绑定](#4. 静态绑定与动态绑定)

上文链接

一、多态的概念

多态 (polymorphism) 的概念:通俗来说,就是多种形态。多态分为编译时多态 (静态多态) 和 运行时多态 (动态多态)。

编译时多态 (静态多态) 主要就是我们熟悉的的函数重载和函数模板,他们传不同类型的参数就可以调用不同的函数,通过参数不同达到多种形态,之所以叫编译时多态,是因为他们实参传给形参的参数匹配是在编译时完成的,我们把编译时一般归为静态,运行时归为动态。

运行时多态,具体点就是去完成某个行为 (函数),可以传不同的对象就会完成不同的行为,就达到多种形态。比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是优惠买票 (5折或75折);军人买票时是优先买票。再比如,同样是动物叫的一个行为 (函数),传猫对象过去,就是 "(>ω<) 喵",传狗对象过去,就是 "汪汪"。


二、多态的实现

1. 虚函数

类成员函数前面加 virtual 修饰,那么这个成员函数被称为虚函数。注意非成员函数不能加 virtual 修饰。

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

虚函数是 C++ 中实现运行时多态的核心机制。它允许派生类重写基类的实现,并在运行时根据对象的实际类型决定调用哪个函数版本。


2. 虚函数的重写/覆盖

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

注意:在重写基类虚函数时,派生类的虚函数在不加 virtual 关键字时,也可以构成重写 (因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用,不过在考试选择题中,经常会故意埋这个坑,让你判断是否构成多态。


3. 实现多态的两个重要条件

多态是一个继承关系下的类对象去调用同一函数,产生了不同的行为。比如 Student 继承了 Person。Person 对象买票全价,Student 对象优惠买票。调用函数时,必须满足下面两个条件:

  • 被调用的函数必须是虚函数,并且完成了虚函数重写 / 覆盖。

  • 必须是基类的指针或者引用调用虚函数

【说明】要实现多态效果,第一派生类必须对基类的虚函数完成重写 / 覆盖,重写或者覆盖了,基类和派生类之间才能有不同的函数,多态的不同形态效果才能达到;第二必须是基类的指针或引用,因为只有基类的指针或引用才能既指向基类对象又指向派生类对象。

cpp 复制代码
#include<iostream>

using namespace std;

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

class Student : public Person 
{
public:
    // 重写了基类 BuyTicket 函数的实现
	virtual void BuyTicket() { cout << "买票-打折" << endl; }
};

void Func(Person* ptr)
{
	// 这里可以看到虽然都是 Person 指针 Ptr 在调用 BuyTicket 
	// 但是跟 ptr 没关系,⽽是由 ptr 指向的对象决定的。 
	ptr->BuyTicket();
}

int main()
{
	Person ps;
	Student st;
	Func(&ps);
	Func(&st);

	return 0;
}
  • 输出
cpp 复制代码
买票-全价
买票-打折

4. 多态场景的一个选择题

以下程序的输出结果是什么?

A. A->0

B. B->1

C. A->1

D. B->0

E. 以上结果都不是

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

首先分析代码我们可以初步确定:类 B 公有继承自类 A,并重写了虚函数 func 的实现。A::test() 函数中调用了 func(),由于 AB 中的 func 都是虚函数,且 p 为基类的指针,构成多态,实际调用哪个版本取决于对象的类型。

p->test() 调用继承自 Atest() 函数,test() 内部调用 func()。这里实际上是 this 指针在调用 func(),即 A::func(),相当于参数列表调用的是 A::func(),于是有 val = 1。但是由于 p 指针指向的对象是 B,即 func() 函数的实现部分调用的是 B::func(),所以最终有 B->1,因此选 B。


5. 协变

派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。协变的实际意义并不大,所以了解即可。

cpp 复制代码
#include<iostream>

using namespace std;

class A {};
class B : public A {};

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

class Student : public Person
{
public:
	virtual B* BuyTicket()
	{
		cout << "买票-打折" << endl;
		return nullptr;
	}
};

void Func(Person* ptr)
{
	ptr->BuyTicket();
}

int main()
{
	Person ps;
	Student st;
	Func(&ps);
	Func(&st);

	return 0;
}
  • 输出
cpp 复制代码
买票-全价
买票-打折

6. 析构函数的重写

我们先来看这样一个问题:

cpp 复制代码
class A
{
public:
	virtual ~A()
	{
		cout << "~A()" << endl;
	}
};

class B : public A {
public:
	~B()
	{
		cout << "~B()->delete:" << _p << endl;
		delete _p;
	}

protected:
	int* _p = new int[10];
};

int main()
{
	A* p1 = new A;
	A* p2 = new B;
	delete p1;
    cout << "---" << endl;
	delete p2;

	return 0;
}

上面程序的输出结果如下:

cpp 复制代码
~A()
---
~B()->delete:000001FB4F546B70
~A()

可以看到的是,在 delete p2 的时候,先调用了 B 的析构函数,再调用的 A 的析构函数。说明这两个析构函数构成多态,但是它们的函数名并不一致,这是为什么?这是由于在多态析构函数需要构成重写,而重写的条件之一是函数名相同,所以编译器会对析构函数名进行特殊处理,处理成 destructor(),即 ~A()~B() 都统一被处理成了 destructor(),这样一来就形成了多态。

如果 ~A() 前面不加 virtual,那么 delete p2 时只调用的 A 的析构函数,不会调用 B的析构函数,因为不构成多态。这样就会导致内存泄漏问题,因为我们需要在 ~B() 中释放资源。

注意:这个问题面试中经常考察,即为什么基类中的析构函数建议设计为虚函数。


7. override 和 final

从上面可以看出,C++ 对虚函数重写的要求比较严格,但是有些情况下可能由于疏忽而无法构成重写,比如函数名写错、参数写错等。这种错误在编译期间是不会报出的,因此 C++11 提供了 override 关键字,可以帮助用户检测虚函数是否重写。

cpp 复制代码
class Car 
{
public:
	virtual void drive()
	{}
};

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

// error C3668: "Benz::Drive": 包含重写说明符"override"的方法没有重写任何基类方法

如果我们不想让派生类重写这个虚函数,那么可以用 final 去修饰该虚函数。

cpp 复制代码
class Car
{
public:
	virtual void Drive() final {}
};

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

// error C3248: "Car::Drive": 声明为"final"的函数无法被"Benz::Drive"重写

8. 纯虚函数与抽象类

在虚函数的后面写上 =0,则这个函数为纯虚函数,纯虚函数不需要定义实现 (实现了也没什么意义因为要被派生类重写,但是语法上可以实现),只要声明即可。

包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象,如果派生类继承后不重写纯虚函数,那么派生类也是抽象类。纯虚函数某种程度上强制了派生类必须重写虚函数,因为不重写实例化不出对象。

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

int main()
{
	// 编译报错:error C2259: "Car": 无法实例化抽象类 
	Car car;

	Car* pBenz = new Benz;
	pBenz->Drive();

	Car* pBMW = new BMW;
	pBMW->Drive();

	return 0;
}

9. 重载/重写/隐藏的对比


三、多态的原理

1. 虚函数表指针

下面编译为 32 位程序的运行结果是什么?

A. 编译报错

B. 运行报错

C. 8

D. 12

cpp 复制代码
class Base
{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}

protected:
	int _b = 1;
	char _ch = 'x';
};

int main()
{
	Base b;
	cout << sizeof(b) << endl;
    
	return 0;
}

Base 实例化出的 b 对象中含有两个成员变量 _b_ch,通过内存对齐规则计算得它们所占得大小是 8 个字节。那这道题选 C 吗?答案是否定的,正确的结果是 12,因为除了_b_ch成员,还会多一个叫 __vfptr 的指针存放在对象的前面 (注意有些平台可能会放到对象的最后面,这个跟平台有关)。对象中的这个指针我们叫做虚函数表指针 (v 代表 virtualf 代表 function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为一个类所有虚函数的地址都要被放到这个类对象的虚函数表中,虚函数表也简称虚表。


2. 虚函数表

(1) 性质

  • 基类对象的虚函数表中存放基类所有虚函数的地址。同类型的对象共用同一张虚表,不同类型的对象各自有独立的虚表,所以基类和派生类有各自独立的虚表。

  • 派生类由两部分构成,继承下来的基类和自己的成员。一般情况下,继承下来的基类中有虚函数表指针,自己就不会再生成虚函数表指针。但是要注意的这里继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同一个,就像基类对象的成员和派生类对象中的基类对象成员也独立的。

  • 派生类中重写的基类的虚函数,派生类的虚函数表中对应的虚函数就会被覆盖成派生类重写的虚函数地址。

  • 派生类的虚函数表中包含:

    (1) 基类的虚函数地址;(如果基类的虚函数没有被重写)

    (2) 派生类重写的虚函数地址;

    (3) 派生类自己的虚函数地址。

  • 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个 0x00000000 标记。(这个 C++ 并没有进行规定,各个编译器自行定义的,vs 系列编译器会在后面放个 0x00000000 标记,g++系列编译则不会放)。

  • 虚函数存在哪?虚函数和普通函数一样,编译好后是一段指令,都是存在代码段 (常量区) 的,只是虚函数的地址又存到了虚表中。

  • 虚函数表存在哪的?这个问题严格说并没有标准答案因为 C++ 标准并没有规定,我们写下面的代码可以对比验证一下,vs 下是存在代码段 (常量区)。


(2) 如何取到虚函数表的地址

假设有一个基类 Base,内部有虚函数,由它定义出了一个对象为 b 以及指向 b 的指针 pb,那么如何获取到 b 中虚表的地址?我们希望获取到的是 __vfptr 所指向的内容,但是我们不能直接访问它,这个时候我们可以对 pb 进行强转,转换成 int*,即 (int*)pb。这个时候我们对它解引用的话获取到的就是前 4 个字节,也就正好是 __vfptr 的那一段。即 *(int*)pb 为虚表的地址。

cpp 复制代码
class Base 
{
public:
	virtual void func1() { cout << "Base::func1" << endl; }
	virtual void func2() { cout << "Base::func2" << endl; }
	void func5() { cout << "Base::func5" << endl; }

protected:
	int a = 1;
};

class Derive : public Base
{
public:
	// 重写基类的func1 
	virtual void func1() { cout << "Derive::func1" << endl; }
	virtual void func3() { cout << "Derive::func1" << endl; }
	void func4() { cout << "Derive::func4" << endl; }

protected:
	int b = 2;
};

int main()
{
	int i = 0;
	static int j = 1;
	int* p1 = new int;
	const char* p2 = "xxxxxxxx";
	printf("栈:%p\n", &i);
	printf("静态区:%p\n", &j);
	printf("堆:%p\n", p1);
	printf("常量区:%p\n", p2);

	Base b;
	Derive d;
	Base* p3 = &b;
	Derive* p4 = &d;

	printf("Person虚表地址:%p\n", *(int*)p3);
	printf("Student虚表地址:%p\n", *(int*)p4);
	printf("虚函数地址:%p\n", &Base::func1);
	printf("普通函数地址:%p\n", &Base::func5);

	return 0;
}
  • 输出
cpp 复制代码
栈:010FF954
静态区:0071D000
堆:0126D740
常量区:0071ABA4
Person虚表地址:0071AB44
Student虚表地址:0071AB84
虚函数地址:00711488
普通函数地址:007114BF

由上面的代码可以发现,虚函数表和虚函数的地址与常量区很接近,这也验证了它们在 vs 下确实是存放在常量区的。


3. 多态是如何实现的

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

private:
	string _name;
};

class Student : public Person 
{
public:
	virtual void BuyTicket() { cout << "买票-打折" << endl; }

private:
	string _id;
};

class Soldier : public Person 
{
public:
	virtual void BuyTicket() { cout << "买票-优先" << endl; }

private:
	string _codename;
};

void Func(Person* ptr)
{
	// 这⾥可以看到虽然都是 Person 指针 Ptr 在调⽤ BuyTicket 
	// 但是跟 ptr 没关系,⽽是由 ptr 指向的对象决定的
	ptr->BuyTicket();
}

int main()
{
	Person ps;
	Student st;
	Soldier sr;
	Func(&ps);
	Func(&st);
	Func(&sr);

	return 0;
}
  • 输出
cpp 复制代码
买票-全价
买票-打折
买票-优先

从底层的角度 Func 函数中 ptr->BuyTicket(),是如何既可以调用 Person::BuyTicket(),又可以调用 Student::BuyTicket() 的呢?

通过下图我们可以看到,满足多态条件后,底层不再是编译时通过调用对象确定函数的地址,而是运行时到指向的对象的虚表中确定对应的虚函数的地址,这样就实现了指针或引用指向基类就调用基类的虚函数,指向派生类就调用派生类对应的虚函数。

ptr 指向的 Person 对象,调用的是 Person 的虚函数

ptr 指向的 Student 对象,调用的是 Student 的虚函数。


4. 静态绑定与动态绑定

对于不满足多态条件的函数调用是在编译时就绑定的,也就是编译时确定调用函数的地址,叫做静态绑定。

而对于满足多态条件的函数调用是在运行时绑定的,也就是在运行时才到指向对象的虚函数表中找到调用函数的地址,也就做动态绑定。

cpp 复制代码
// ptr是指针 + BuyTicket是虚函数,则满足多态条件 
// 下面是动态绑定情况下的汇编代码,在运行时到 ptr 指向对象的虚函数表中确定调用函数地址 
     ptr->BuyTicket();
00EF2001 mov eax,dword ptr [ptr] 
00EF2004 mov edx,dword ptr [eax] 
00EF2006 mov esi,esp 
00EF2008 mov ecx,dword ptr [ptr] 
00EF200B mov eax,dword ptr [edx] 
00EF200D call eax 
    
// BuyTicket 不是虚函数,则不满⾜多态条件。 
// 下面就是静态绑定时的汇编代码,编译器直接确定调用的函数地址 
     ptr->BuyTicket();
00EA2C91 mov ecx,dword ptr [ptr] 
00EA2C94 call Student::Student (0EA153Ch)
相关推荐
risc-v@cn3 小时前
【在ubuntu下使用vscode打开c++的make项目及编译调试】
c++·vscode·ubuntu
草莓熊Lotso3 小时前
【C++】--函数参数传递:传值与传引用的深度解析
c语言·开发语言·c++·其他·算法
zylyehuo4 小时前
C++提高编程
c++
scx201310044 小时前
20250822 组题总结
c++·算法
困鲲鲲5 小时前
CMake2: CMakeLists.txt的常用命令
c++·cmake·常用命令
云边有个稻草人5 小时前
【C++】第二十五节—C++11 (上) | 详解列表初始化+右值引用和移动语义
c++·c++11·右值引用·移动语义·列表初始化·移动构造·移动赋值
源代码•宸6 小时前
网络流量分析——基础知识(二)(Tcpdump 基础知识)
运维·开发语言·网络·c++·经验分享·tcpdump
johnZhangqi13 小时前
深圳大学-计算机信息管理课程实验 C++ 自考模拟题
java·开发语言·c++
StudyWinter14 小时前
【C++】仿函数和回调函数
开发语言·c++·回调函数·仿函数