1.前言
多态 ---- 是 面向对象 三大基本特征中的最后一个,多态 可以实现 “一个接口,多种方法”,比如 父类 和 子类 中的同名方法,在增加了多态后,调用同名函数时,可以根据不同的对象(父类对象或子类对象)调用属于自己的函数,实现不同的方法,因此 多态 的实现依赖于 继承。
如果大家还不太了解 继承 可以先看看这篇文章:[c++高阶] 继承深度剖析-优快云博客
本章重点:
本章着重讲解多态的原理,多态的实现与定义,什么是多态,多态的两个例外条件,以及抽象类。
2.什么是多态
多态通俗来讲就是不同的对象使用相同的函数来处理数据,会产生不同的结果。
举个简单的例子:学生和老师都去买火车票
但是因为学生有学生证,所以学生能够享受半价优惠,而老师却只能付全款。这就是一种典型的多态的体现,不同的对象(老师,学生)都是调用买票这个函数,从而产生了不同的结果。
举例2:
为了争夺在线支付市场,支付宝年底经常会做诱人的 扫红包-支付-给奖励金 的活动。
那么大家想想为什么有人扫的红包又大又新鲜 8块、10 块...而有人扫的红包都是1毛,5...。其实这背后也是一个多态行为。
总结一下:同样是扫码动作(看作一件事情),不同的用户扫了,得到的不-样的红包(不同的对象,产生了不同的结果),这也是一种多态行为。
这里直接给出结论:
构成多态的条件是:1.必须是父类的指针或者引用来进行调用
2.被调用的函数必须是虚函数,且子类的虚函数要被重写。
到这里很多小伙伴肯定有疑问了?什么是多态,什么是虚函数?什么又是重写呢?不要着急,听我娓娓道来。
3.多态的定义和实现
实现多态需要借助虚表(虚函数表),而构成虚表又需要虚函数,即
virtual
修饰的函数,除此之外还需要使用虚表指针来进行函数定位、调用 。
在讲什么是多态之前,需要先带大家了解一下什么是虚函数
关键字virtual加在成员函数前
这个成员函数就是虚函数!
而虚函数的重写也叫覆盖就是指:派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的,返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。
示例如下:
class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "买票-半价" << endl; }
};
在上述代码中,BuyTicket这个函数就被重写了。
当通过父类的指针或者引用去调用虚函数时,他就会根据对象去调用函数,从而实现通过不同的对象调用函数来实现不同的结果。
4.多态的实例调用
在写这个例子之前,再次强调一下,构成多态的条件就只有两个,1.通过父类的指针或者引用来调用虚函数 2.子类必须在继承父类的基础上,对父类的虚函数进行重写。
假设你在家里有一只宠物。宠物可以是 猫、狗 或 鸟。每种宠物都会发出叫声,但每种宠物的叫声不同。我们可以定义一个基类
Pet
,然后让Cat
、Dog
和Bird
继承自这个基类。
class Pet {
public:
virtual void makeSound() {
cout << "Some generic pet sound" << endl;
}
};
class Cat : public Pet {
public:
void makeSound() override {
cout << "Meow" << endl;
}
};
class Dog : public Pet {
public:
void makeSound() override {
cout << "Woof" << endl;
}
};
class Bird : public Pet {
public:
void makeSound() override {
cout << "Tweet" << endl;
}
};
你现在有一个Pet类的指针类型,它可以指向任何一种具体宠物的对象
Pet* myPet = new Dog();
myPet->makeSound(); // 输出 "Woof"
尽管 myPet
是 Pet
类型的指针,但它指向的是一个 Dog
对象。因此,当你调用 makeSound()
方法时,会调用 Dog
类的 makeSound()
方法,而不是 Pet
类的。(这就是多态)
如果你把
myPet
指向一个Cat
对象:
myPet = new Cat();
myPet->makeSound(); // 输出 "Meow"
这里为什么可以这样进行赋值呢?不懂得小伙伴可以参考我上篇文章:继承的深度剖析
5.构成多态的两个例外
5.1 协变
概念:返回值可以不同,但是却必须是父子关系的指针或者引用
示例如下:
class Base {
public:
virtual Base* clone() const {
return new Base(*this);
}
virtual void print() const {
cout << "This is Base" << endl;
}
};
class Derived : public Base {
public:
Derived* clone() const override {
return new Derived(*this);
}
void print() const override {
cout << "This is Derived" << endl;
}
};
使用协变的优点:
1️⃣:提高代码的类型安全性
2️⃣:提高代码的可读性和可维护性
3️⃣:支持面向对象编程中的深拷贝
协变返回类型在实现深拷贝时尤为有用。在基类中定义一个虚函数 clon
e
并在派生类中重写,返回派生类对象,可以确保拷贝的对象具有正确的动态类型。
class Shape {
public:
virtual Shape* clone() const {
return new Shape(*this);
}
virtual void draw() const {
cout << "Drawing a shape" << endl;
}
};
class Circle : public Shape {
public:
Circle* clone() const override {
return new Circle(*this);
}
void draw() const override {
cout << "Drawing a circle" << endl;
}
};
int main() {
Shape* shape = new Circle();
Shape* clonedShape = shape->clone();
shape->draw(); // 输出 "Drawing a circle"
clonedShape->draw(); // 输出 "Drawing a circle"
delete shape;
delete clonedShape;
return 0;
}
5.2 析构函数的重写
在C++中,析构函数是一个特殊的成员函数,用于在对象被销毁时执行清理操作。关于析构函数的重写(override),实际上并不是一个准确的术语,因为析构函数在派生类中并不能像普通的虚函数那样被重写。然而,我们可以通过让基类的析构函数成为虚函数,从而实现多态性和正确的资源释放。
为什么析构函数需要重写?
当使用基类指针或引用指向派生类对象时,如果基类的析构函数不是虚函数,那么在删除对象时,只会调用基类的析构函数,而不会调用派生类的析构函数。这可能会导致资源泄漏,因为派生类的清理工作未被执行。
示例如下:
没有虚函数重写
#include <iostream>
using namespace std;
class Base {
public:
~Base() {
cout << "Base destructor called" << endl;
}
};
class Derived : public Base {
public:
~Derived() {
cout << "Derived destructor called" << endl;
}
};
int main() {
Base* b = new Derived();
delete b; // 只会调用 Base 的析构函数
return 0;
}
输出:
Base destructor called
由于基类没有虚函数,所以无法构成重写。就导致资源泄露
使用虚函数重写:
#include <iostream>
using namespace std;
class Base {
public:
virtual ~Base() {
cout << "Base destructor called" << endl;
}
};
class Derived : public Base {
public:
~Derived() {
cout << "Derived destructor called" << endl;
}
};
int main() {
Base* b = new Derived();
delete b; // 会调用 Derived 和 Base 的析构函数
return 0;
}
PS:子类的虚函数不写virtual依旧构成多态
输出:
Derived destructor called
Base destructor called
在这个例子中,由于 Base
的析构函数是虚函数,删除 b
时先调用了 Derived
的析构函数,然后调用了 Base
的析构函数。
重写析构函数的作用
虚析构函数确保了在使用基类指针或引用时,派生类的析构函数会被正确调用,从而防止资源泄漏和其他潜在问题。
6.c++11的 override和final函数
6.1.override函数
override
是C++11引入的一种功能,用来明确表示派生类中的函数是覆盖基类中的虚函数。
示例如下:
class Pet {
public:
virtual void makeSound() {
cout << "Some generic pet sound" << endl;
}
};
class Cat : public Pet {
public:
void makeSound() override {
cout << "Meow" << endl;
}
};
当你在派生类的虚函数进行重写时,如果你有拼写错误,当你使用了override之后,编译器会进行检查。编译器会检查这个函数是否确实覆盖了基类中的虚函数。如果没有(例如函数签名不匹配),编译器会报错。这可以帮助我们捕捉错误。
着重讲解一下这里为什么派生类没有加Virtual也构成虚函数的重写。
为什么只需要在基类中声明虚函数 ?
在C++中,只有基类需要声明虚函数,因为这是多态性工作的基础。虚函数表(Virtual Table)是由基类维护的,当派生类重写这个虚函数时,它会自动被记录在虚函数表中。
基类声明虚函数:当你在基类中声明一个函数为虚函数时,编译器会在对象的虚函数表(VTable)中为这个函数创建一个入口。虚函数表是一个指针数组,每个对象都有一个指向它所属类的虚函数表的指针。
派生类覆盖虚函数:当派生类覆盖这个虚函数时,它会在虚函数表中替换基类的函数指针。因此,通过基类指针调用虚函数时,实际调用的是派生类的函数。
调用虚函数:当你通过基类指针调用虚函数时,程序会查找虚函数表,并调用实际指向的函数。这就是多态性的工作原理。
派生类不需要显式声明函数为虚函数,因为它继承了基类的虚函数机制。
总结上面的话就是:虚函数表是基类来维护的,并且派生类中函数的调用是通过基类的指针或者引用来进行调用的,因此派生类即使没有声明函数为虚函数,它也能够有多态的性质。
6.2 final函数
final函数主要作用就是修饰虚函数,表示该虚函数不能被重写。
final也可以用来修饰类,这样做的目的就是为了防止类被继承
class Base final {
public:
void display() const {
cout << "Base display" << endl;
}
};
// 下面的代码将导致编译错误,因为 Base 类被标记为 final,不能被继承
class Derived : public Base {
};
int main() {
Base b;
b.display();
return 0;
}
6.3 重载、覆盖(重写)、隐藏(重定义)的对比
重载:重载是指在同一个作用域中定义多个同名函数,但这些函数的参数列表(参数的数量、类型或顺序)不同。重载主要用于提高代码的灵活性和可读性。
示例如下:
#include <iostream>
using namespace std;
class Printer {
public:
void print(int i) {
cout << "Printing int: " << i << endl;
}
void print(double d) {
cout << "Printing double: " << d << endl;
}
void print(const string& s) {
cout << "Printing string: " << s << endl;
}
};
int main() {
Printer p;
p.print(42); // 调用 print(int)
p.print(3.14); // 调用 print(double)
p.print("Hello"); // 调用 print(const string&)
return 0;
}
覆盖:覆盖是指派生类重新定义基类中已经存在的虚函数,以实现特定的行为。覆盖是多态性的重要机制,允许派生类根据需要修改基类的行为。覆盖函数必须与被覆盖函数具有相同的参数列表和返回类型。
示例如下:
#include <iostream>
using namespace std;
class Base {
public:
virtual void show() {
cout << "Base show" << endl;
}
};
class Derived : public Base {
public:
void show() override {
cout << "Derived show" << endl;
}
};
int main() {
Base* b = new Derived();
b->show(); // 调用 Derived 的 show()
delete b;
return 0;
}
隐藏:派生类中定义一个与基类中同名但参数列表不同的函数,构成重定义或隐藏。(重点分别在基类和派生类当中。)
示例如下:
#include <iostream>
using namespace std;
class Base {
public:
void display() {
cout << "Base display" << endl;
}
void display(int i) {
cout << "Base display with int: " << i << endl;
}
};
class Derived : public Base {
public:
void display() {
cout << "Derived display" << endl;
}
};
int main() {
Derived d;
d.display(); // 调用 Derived 的 display()
// d.display(42); // 错误:Derived 中没有匹配的函数
d.Base::display(42); // 调用 Base 的 display(int)
return 0;
}
总结
重载(Overloading):同一个作用域内同名函数的参数列表不同,构成重载。
覆盖(重写)(Overriding):派生类重新定义基类中的虚函数,函数名必须相同,构成覆盖。
重定义(隐藏)(Hiding):派生类中定义一个与基类中同名但参数列表不同的函数,构成重定义或隐藏。
7.抽象类
抽象类(Abstract Class)是面向对象编程中的一个重要概念,尤其在实现多态性方面非常有用。抽象类通常作为基类,定义接口供派生类实现,而不能实例化。
抽象类的特点
- 包含纯虚函数:一个类至少包含一个纯虚函数(pure virtual function)就是抽象类。纯虚函数在声明时使用
= 0
来标识。 - 不能实例化:抽象类不能创建对象。只能通过派生类来实例化对象。
- 提供接口:抽象类通常用来定义接口,而具体的实现由派生类提供。
纯虚函数
纯虚函数是在基类中声明但没有定义的函数,需要派生类提供具体实现。纯虚函数的语法如下:
virtual void functionName() = 0;
示例如下:
定义一个抽象的动物,其他具体的动物继承这个类。
抽象类:Animal
#include <iostream>
using namespace std;
class Animal {
public:
// 纯虚函数,表示动物发出的声音
virtual void makeSound() const = 0;
// 虚析构函数,保证正确删除派生类对象
virtual ~Animal() {}
};
派生类:Cat和Dog
class Dog : public Animal {
public:
void makeSound() const override {
cout << "Woof!" << endl;
}
};
class Cat : public Animal {
public:
void makeSound() const override {
cout << "Meow!" << endl;
}
};
使用抽象类和派生类:
int main() {
Animal* animals[2];
animals[0] = new Dog();
animals[1] = new Cat();
for (int i = 0; i < 2; ++i) {
animals[i]->makeSound(); // 调用派生类的 makeSound 方法
}
for (int i = 0; i < 2; ++i) {
delete animals[i]; // 删除对象,调用虚析构函数
}
return 0;
}
运行结果:
Woof!
Meow!
关键点
抽象类定义接口:抽象类 Animal 定义了动物发出声音的方法接口。
派生类实现接口:Dog 和 Cat 类实现了这些接口,并提供了具体的行为。
多态性:通过基类指针调用派生类的方法,实现了多态性。
虚析构函数:保证了通过基类指针删除派生类对象时,正确调用派生类的析构函数。
抽象类的用途
抽象类适合用于描述无法拥有实体的类,比如 人、动物、植物,毕竟这些都是不能直接使用的,需要经过 继承 赋予特殊属性后,才能作为一个独立存在的个体(对象)
8.多态的原理
多态的基本原理
在C++中,多态性是通过虚函数来实现的,而虚函数的机制依赖于虚函数表(vtable)和虚指针(vptr)。以下是这两个概念及其相互关系的详细解释。
虚函数就不做过多的解释了,前面都讲的很清楚。
- 虚函数表(Virtual Table, vtable):编译器为每个含有虚函数的类生成一个虚函数表,其中存储了该类的虚函数指针。(简单来说虚函数表就是一个函数指针数组,里面存放的都是一些指针)
实现机制
虚函数表(vtable):虚函数表是编译器为每个包含虚函数的类生成的一个隐藏的表。这个表中存储了该类的所有虚函数的指针(也可以理解为存储了所有虚函数的地址)。
·每个含有虚函数的类都有一个虚函数表,表中存储了该类的所有虚函数的指针。
虚函数表存储了类的虚函数的地址。对于基类中的虚函数,如果在派生类中被重写,那么虚函数表中相应的条目会指向派生类的实现。
虚指针(vptr):虚指针是编译器在每个对象实例中添加的一个隐藏指针。这个指针指向该对象所属类的虚函数表。
每个对象实例都有一个隐藏的指针,指向该类的虚函数表 ------ vptr。
虚指针在对象实例化时被初始化,指向正确的虚函数表。
当通过基类指针或引用调用虚函数时,程序通过 vptr 找到对应的 vtable,然后从 vtable 中找到实际调用的函数。
虚函数表与虚指针的关系
- 虚指针指向虚函数表。
- 当通过基类指针或引用调用虚函数时,程序会通过虚指针找到虚函数表,然后从虚函数表中找到实际需要调用的函数。
光看概念太抽象了,直接上代码理解。
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对象中没有重写Func2的虚函数,所以D中直接继承了B中的Func2函数。而对于Func1来说,派生类中对Func1进行了重写,所以D对象中Func1函数的地址进行了替换。
通过观察和测试,我们发现了以下几点问题:
派生类对象 d 中也有一个虚表指针,d 对象由两部分构成,一部分是父类继承下来的成员,另一部分是自己的成员。
基类b 对象和派生类 d对象虚表是不一样的,这里我们发现Func1完成了重写,所以d 的虚表中存的是重写的 Derive::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
另外Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但是不是虚函数,所以不会放进虚表。
虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了nullptr。
派生类中的续表是如何生成的呢?
- 先将基类中的虚表内容拷贝一份到派生类虚表中;
- 如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数;
- 派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
为什么一定要使用父类的指针或者引用来调用,才能构成多态呢?
在解答这个问题之前,先了解一下下面的几个概念。
动态绑定:也称之为晚期绑定,是指在程序运行时才确定函数调用的确切版本。C++中的多态就是通过动态绑定实现的。
静态类型和动态类型:静态类型是指变量声明时的类型,而动态类型是指变量实际指向的对象类型。在C++中,指针或引用的静态类型决定了它能够调用哪些函数,而动态类型则决定了实际调用哪个版本的函数。
有了上述两个概念之后,就可以解释为什么要使用父类的指针或者引用来调用,才能够成多态了。
-
使用父类指针或引用:当你使用父类的指针或引用指向派生类的对象时,你可以调用父类中声明为虚函数的成员函数。由于父类指针或引用的静态类型是父类,编译器会在运行时根据对象的实际类型(动态类型)来决定调用哪个版本的函数,这就是动态绑定。因此,使用父类指针或引用可以触发多态行为。(这里也就是说由于对象是子类的,但是确用的是父类的指针或者引用来指向这个对象,这样在编译的时候无法确定到底调用哪一个函数,所以这个时候就出现了动态绑定,根据运行时对象的实际类型来确定调用哪个版本的函数)。
-
使用子类指针或引用:如果你直接使用子类的指针或引用,编译器会根据静态类型来决定调用哪个版本的函数。这意味着编译器在编译时就已经确定了函数调用的目标,因此不会发生动态绑定,也就无法实现多态。
9.共勉
上述就是我对c++中多态的理解,如果有不懂和发现问题的小伙伴,欢迎在评论区留言或者私信哟。