C++多态(上)
目录
一、多态的概念
二、多态的定义及实现
1. 多态的构成条件
2. 虚函数
3. 虚函数的重写
4. C++11 override 和 final
4.1 final 关键字
4.2 override 关键字
5. 重载、覆盖(重写)、隐藏(重定义)的对比
三、抽象类
1. 概念
2. 接口继承和实现继承
2.1 接口继承
2.2 实现继承
2.3 两者的对比
四、多态的原理
1. 虚函数表
2. 多态的原理
3. 动态绑定与静态绑定
一、多态的概念
多态,顾名思义,就是函数调用的多种形态。它允许不同的对象去完成同一件事时,产生不同的动作和结果。其核心在于:相同的接口调用能根据对象类型产生不同的行为。
例如:
-
支付场景:扫码支付时,微信/支付宝/云闪付调用相同的
Pay()
接口但执行不同流程。 -
绘图软件:
Shape
基类的Draw()
方法,圆形/矩形/三角形各自实现不同绘制逻辑。 -
游戏开发:
Character
类的Attack()
方法,战士/法师/射手各自攻击方式不同。
二、多态的定义及实现
1. 多态的构成条件
多态是一种非常灵活且强大的面向对象编程特性。它的基本思想是:允许不同继承关系的类对象调用同一名称的函数,从而产生不同的行为。例如,Person 类和 Student 类(Student 继承 Person),当它们的对象调用买票函数时,Person 对象买全价票,Student 对象买半价票。
在继承体系中,要实现多态,必须满足以下两个条件:
1. 通过基类的指针或者引用调用虚函数
- 这是因为基类指针或引用可以向上转型,指向派生类对象。通过这种方式,编译器能够根据实际对象类型来决定调用哪个类的函数。例如,用 Person 类的引用去调用 buyTicket() 函数,如果该引用实际指向 Student 对象,就会调用 Student 类重写的 buyTicket() 函数,实现不同的买票行为。
2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
- 虚函数是实现多态的关键。它是用
virtual
关键字修饰的类成员函数。只有当派生类重写了基类的虚函数后,才能根据对象的实际类型来调用相应的函数,从而实现多态行为。
2. 虚函数
虚函数就是用virtual
修饰的类成员函数。其定义方式如下:
class Animal
{
public:virtual void Speak() { cout << "动物叫声" << endl; // 虚函数声明}
};
注意:
只有类的非静态成员函数前可以加
virtual
,普通函数前不能加virtual
。这是因为虚函数是与类的对象紧密相关的,用于实现多态行为,而非静态成员函数可以访问类的成员变量和成员函数,适合这种动态绑定的场景。虽然虚函数和虚继承都使用
virtual
关键字,但它们的作用完全不同。虚函数是为了实现多态,而虚继承是为了解决菱形继承中的数据冗余和二义性问题。
3. 虚函数的重写
虚函数的重写是指派生类中有一个与基类完全相同的虚函数(返回值类型、函数名字、参数列表完全相同)。例如:
class Person
{
public:virtual void BuyTicket() { cout << "买票 - 全价" << endl; }
};class Student : public Person {
public:virtual void BuyTicket() { cout << "买票 - 半价" << endl; }
};
这里有一些需要注意的细节:
-
在派生类的虚函数前不加
virtual
关键字也可以构成重写。因为基类的虚函数被继承后,派生类中的该函数仍然保持虚函数属性。但为了代码的可读性和规范性,建议在派生类的虚函数前也加上virtual
关键字。 -
通过一个函数来演示多态的效果:
void Func(Person& p)
{ p.BuyTicket();
}int main()
{Person ps;Student st;Func(ps); // 输出 “买票 - 全价”Func(st); // 输出 “买票 - 半价”return 0;
}
🌵虚函数重写的两个例外
1. 协变(基类与派生类虚函数返回值类型不同)
当基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,这种情况也被称为协变。例如:
class Base {};
class Derived : public Base {};class Factory
{
public:virtual Base* Create() { return new Base; }
};class ImprovedFactory : public Factory
{
public:virtual Derived* Create() override { // 协变返回return new Derived;}
};
在这种情况下,虽然返回值类型不同,但派生类的函数仍然重写了基类的虚函数。这主要是因为派生类的指针或引用可以隐式转换为基类的指针或引用,从而保证了函数的兼容性。
2. 析构函数的重写(基类与派生类析构函数的名字不同)
如果基类的析构函数为虚函数,那么派生类析构函数只要定义,无论是否加virtual
关键字,都与基类的析构函数构成重写。例如:
class Person
{
public:virtual ~Person() { cout << "~Person()" << endl; }
};class Student : public Person
{
public:virtual ~Student() { cout << "~Student()" << endl; }
};
这是因为编译器对析构函数的名称做了特殊处理,将它们统一处理成
destructor
。这种重写机制在内存管理中非常重要。例如,当用基类指针指向派生类对象,并用
delete
释放对象时:
int main()
{Person* p1 = new Person;Person* p2 = new Student;delete p1; // 调用 Person 的析构函数delete p2; // 调用 Student 的析构函数,然后调用 Person 的析构函数return 0;
}
如果没有将基类的析构函数定义为虚函数,delete p2 只会调用基类的析构函数,而不会调用派生类的析构函数,这样可能会导致内存泄漏等资源管理问题。因此,为了确保在多态情况下正确地释放资源,建议将基类的析构函数定义为虚函数。
4. C++11 override 和 final
C++11引入了
override
和final
关键字,用于增强对虚函数重写的控制。
4.1 final 关键字
final
修饰虚函数,表示该虚函数不能再被重写。例如:
class Vehicle
{
public:virtual void Start() final { // 禁止重写cout << "启动引擎" << endl;}
};class Car : public Vehicle
{
public:void Start() override { // 编译错误!cout << "电子打火" << endl;}
};
这意味着,Car
类无法重写Vehicle
类的Start()
函数,因为Vehicle
类的Start()
函数已经被final
修饰,表示这是虚函数的最终版本,不允许在派生类中进一步重写。
4.2 override 关键字
override
用于检查派生类虚函数是否重写了基类某个虚函数。如果没有重写,编译报错。例如:
class Shape
{
public:virtual void Draw(int color) {cout << "绘制图形" << endl;}
};class Circle : public Shape
{
public:void Draw(float color) override { // 错误!参数类型不匹配cout << "绘制圆形" << endl;}
};
这有助于开发者在编写代码时,确保派生类虚函数正确地重写了基类的虚函数,避免因函数名称拼写错误或其他因素导致无法构成重写的情况,从而提高代码的健壮性和可维护性。
5. 重载、覆盖(重写)、隐藏(重定义)的对比
特性 | 重载(Overload) | 重写(Override) | 隐藏(Hide) |
---|---|---|---|
作用域 | 同一作用域 | 父子类之间 | 父子类之间 |
函数签名 | 参数列表不同 | 完全相同 | 名称相同即可 |
virtual | 无关 | 必须使用 | 无关 |
访问权限 | 可不同 | 可不同 | 可不同 |
目的 | 扩展功能 | 实现多态 | 名称遮盖 |
三、抽象类
1. 概念
抽象类是一种特殊的类,它包含纯虚函数。纯虚函数是在虚函数后面加上 =0
,例如:
class Car {
public:virtual void Drive() = 0;
};
当一个类包含了纯虚函数,这个类就被称为抽象类。抽象类不能用来实例化对象,也就是说,你不能直接创建抽象类的对象。例如,对于Car
类,下面的代码是不允许的:
Car myCar; // 错误,不能实例化抽象类
这使得抽象类更像是一个接口规范,它定义了某些操作的接口,但没有提供具体的实现。派生类继承抽象类后,也不能直接实例化对象,除非它重写了所有的纯虚函数。例如:
class Benz : public Car
{
public:virtual void Drive() override { // 重写纯虚函数cout << "Benz - 舒适" << endl;}
};class BMW : public Car
{
public:virtual void Drive() override { // 重写纯虚函数cout << "BMW - 操控" << endl;}
};
只有当派生类(如Benz
和BMW
)重写了抽象类中的纯虚函数后,这些派生类才能被用来创建对象。例如:
void Test()
{Car* pBenz = new Benz; // 只有 Benz 重写了 Drive(),才能实例化pBenz->Drive(); // 输出 "Benz - 舒适"Car* pBMW = new BMW; // 只有 BMW 重写了 Drive(),才能实例化pBMW->Drive(); // 输出 "BMW - 操控"
}
这种机制确保了派生类在使用之前必须实现抽象类所规定的所有接口,从而保证了代码的规范性和一致性。
2. 接口继承和实现继承
2.1 接口继承
接口继承主要和虚函数相关。当派生类继承基类的虚函数时,它继承的是基类虚函数的接口。这种继承的目的是为了重写,以实现多态行为。例如,Car
类中的Drive()
是一个虚函数,Benz
和BMW
类继承了这个接口,并根据自己的特性重写了实现。这样,通过基类指针或引用调用Drive()
函数时,会根据实际对象类型来决定调用哪个类的实现。
接口继承强调的是规范和协议,它规定了派生类必须实现某些功能,但不关心具体的实现细节。这种继承方式使得代码具有良好的扩展性和灵活性,因为新的派生类可以按照相同的接口规范添加新的实现,而无需修改现有的代码逻辑。
2.2 实现继承
实现继承则是指派生类继承了基类函数的具体实现。例如,如果基类有一个普通函数(非虚函数),派生类会直接继承这个函数的实现代码。派生类可以直接使用这个函数,而无需自己重新实现。
实现继承适用于那些在基类中已经有一个通用实现,派生类可以直接使用或者稍作修改的情况。这种继承方式可以减少代码重复,提高代码的复用性。
2.3 两者的对比
(1) 继承的目的不同
-
接口继承主要是为了实现多态,通过统一的接口规范,让不同的派生类可以以不同的方式实现相同的操作。
-
实现继承主要是为了代码复用,直接利用基类中已经实现的功能。
(2) 对函数重写的要求不同
-
在接口继承中,虚函数通常需要被重写,以实现不同的行为。例如,
Car
类的Drive()
虚函数,Benz
和BMW
类根据自己的特点重写了这个函数。 -
在实现继承中,派生类可以直接使用基类的函数实现,无需重写,除非需要修改或扩展基类的功能。
(3) 多态支持不同
-
接口继承通过虚函数支持多态,使得可以通过基类指针或引用来调用派生类的函数。
-
实现继承中的普通函数调用是在编译时期就确定的,不支持多态。
(4) 适用场景不同
-
接口继承适用于需要定义一组规范,让不同的类按照这些规范实现自己的功能,并且在运行时根据对象类型动态调用相应的函数的场景。例如,定义一个图形接口,不同的图形类(如圆形、矩形等)按照这个接口实现自己的绘制方法。
-
实现继承适用于基类已经提供了一个通用的实现,派生类可以直接使用或者进行少量修改的场景。例如,一个通用的文件读取类,派生类可以继承其基本的读取功能,并添加一些特定的处理逻辑。
四、多态的原理
1. 虚函数表
让我们通过一个经典面试题切入:sizeof(Base)
的值是多少?
#include <iostream>
using namespace std;class Base
{
public:virtual void Func1(){cout << "Func1()" << endl;}
private:int _b = 1;
};
通过观察测试,我们发现 b
对象是 8 字节。这是因为除了 _b
成员变量占用 4 字节外,对象中还包含一个虚函数表指针(__vfptr),虚函数表指针始终位于对象起始位置(不同编译器可能有差异,有些平台可能会放到对象的最后面)。
这个虚函数表指针指向虚函数表,虚函数表中存放着虚函数的地址。含有虚函数的类都至少有一个虚函数表指针。
再来看一个改造后的例子:
#include <iostream>
using namespace std;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;
}
🌵从这个例子中,我们发现了以下几点:
派生类对象
d
中也有一个虚表指针。d
对象由两部分构成,一部分是继承自父类Base
的成员,包括父类的虚表指针,另一部分是派生类自己的成员变量_d
。基类对象
b
和派生类对象d
的虚表是不一样的。因为Derive
重写了Func1
,所以d
的虚表中存放的是重写的Derive::Func1
。虚函数的重写也叫作覆盖,覆盖是指虚表中虚函数的覆盖。
Func2
继承自基类Base
,且是虚函数,所以被放进虚表。而Func3
也是从基类继承来的,但不是虚函数,因此不会放进虚表。虚函数表本质上是一个存储虚函数指针的指针数组,一般情况下,这个数组最后会放一个 nullptr。
🍇总结一下派生类的虚表生成过程:
a. 先将基类中的虚表内容拷贝一份到派生类虚表中。
b. 如果派生类重写了基类中某个虚函数,就用派生类自己的虚函数覆盖虚表中基类的虚函数。
c. 派生类自己新增的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
🌻虚表是什么阶段初始化的?虚函数存在哪里?虚表存在哪里?
虚表实际上是在构造函数初始化列表阶段进行初始化的。虚表中存放的是虚函数的地址,这些虚函数和普通函数一样,都存在于代码段中,只是它们的地址被存到了虚表当中。对象中存储的不是虚表本身,而是指向虚表的指针。
通过以下代码我们可以判断出虚表的存在位置:
int i = 0;
int main()
{Base b;Derive d;int* j = new int;// 通过内存地址分析存储位置cout << "虚表地址:" << (void*)*(int*)&b << endl;cout << "栈变量地址:" << (void*)&b << endl;cout << "数据段地址:" << (void*)&i << endl;cout << "堆上地址:" << (void*)j << endl;cout << "代码段地址:" << (void*)"Hello" << endl;return 0;
}
从打印结果可以看出,虚表地址与代码段的地址非常接近,由此可知虚表实际上是存在代码段的。
2. 多态的原理
上面分析了这么多,那么多态的原理到底是什么?还记得这里Func函数传Person调用的 Person::BuyTicket,传Student调用的是Student::BuyTicket。
#include <iostream>
using namespace std;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 Mike;Func(Mike);Student Johnson;Func(Johnson);return 0;
}
在这个例子中,当 p
指向 Mike
对象时,p.BuyTicket()
会在 Mike
的虚表中找到 Person::BuyTicket
并调用;当 p
指向 Johnson
对象时,p.BuyTicket()
会在 Johnson
的虚表中找到 Student::BuyTicket
并调用。这就实现了不同对象在执行同一行为时展现出不同的形态,即多态。
我们可以反过来思考一下要构成多态需要满足两个条件:一是完成虚函数的重写,二是必须使用父类的指针或者引用去调用虚函数。为什么呢?
如果使用父类对象去调用虚函数,则无法构成多态。因为当使用父类对象时,会发生切片行为,切片后父类对象只会包含父类的部分成员变量,并且会调用父类的拷贝构造函数对那部分成员变量进行拷贝构造。由于同类型的对象共享一张虚表,所以父类对象中的虚表指针指向的是父类的虚表。后续用这个父类对象调用虚函数时,都是通过虚表指针找到父类的虚表,进而调用父类的虚函数。
❌错误示范:
Person p1 = Mike; // 对象切片,丢失派生类信息 Person p2 = Johnson; // 对象切片,丢失派生类信息 p1.BuyTicket(); // 调用 Person::BuyTicket() p2.BuyTicket(); // 调用 Person::BuyTicket()
而使用父类指针或者引用时,不会发生切片行为。父类指针或引用可以指向子类对象,此时通过父类指针或引用来调用虚函数,会根据实际指向的对象的虚表来找到对应的虚函数。例如在上面的例子中,
p
是Person
引用,当它指向Mike
时调用BuyTicket()
,会调用Person::BuyTicket()
;当它指向Johnson
时调用BuyTicket()
,会调用Student::BuyTicket()
。这就实现了多态。总结来说,构成多态时,指向谁就调用谁的虚函数,跟对象的实际类型有关。而不构成多态时,对象类型是什么就调用谁的虚函数,跟编译时的类型有关。
3. 动态绑定与静态绑定
静态绑定 :静态绑定又称为前期绑定或早绑定,在程序编译期间就能确定程序的行为,例如函数重载。编译器在编译时就能确定调用哪个函数。
动态绑定 :动态绑定又称为后期绑定或晚绑定,在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也就是动态多态。
我们可以通过查看汇编代码来进一步理解静态绑定和动态绑定的区别,以下面代码为例:
#include <iostream>
using namespace std;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 mike;Func(&mike);mike.BuyTicket();return 0;
}
对于 Func(&mike)
中的 p->BuyTicket()
,其对应的汇编代码如下:
从这段汇编代码可以看出,当满足多态条件时,函数调用是在运行时确定的,需要先到对象的虚表中查找对应的虚函数地址,再进行调用。
而 mike.BuyTicket()
对应的汇编代码为:
这说明不满足多态条件时,函数调用是在编译时就确定的。
再来看一个对比例子:
#include <iostream>
using namespace std;class Person
{
public:virtual void BuyTicket(){cout << "买票 - 全价" << endl;}
};class Student : public Person
{
public:virtual void BuyTicket(){cout << "买票 - 半价" << endl;}
};int main()
{Student Johnson;Person p = Johnson; // 不构成多态p.BuyTicket();return 0;
}
这里将 Student
对象 Johnson
赋值给 Person
对象 p
,发生了切片,不构成多态。调用 p.BuyTicket()
时,其对应的汇编代码只有两条指令,直接调用 Person::BuyTicket
函数,函数地址在编译时就已确定。
而若改为以下代码构成多态:
int main()
{Student Johnson;Person& p = Johnson; // 构成多态p.BuyTicket();return 0;
}
此时调用 p.BuyTicket()
对应的汇编代码就会像前面所说的那样,需要在运行时通过虚表查找函数地址,体现出动态绑定的特点。这也很好地体现了静态绑定是在编译时确定的,而动态绑定是在运行时确定的。