一、什么是多态
“多态”英文是:
polymorphism
直译就是:
多种形态。
在程序中,可以理解成:
同一个行为,面对不同对象时,产生不同结果。
例如买票:
普通人买票 -> 全价
学生买票 -> 打折
军人买票 -> 优先
它们执行的行为都叫:
BuyTicket()
但是不同对象执行之后产生了不同结果。
再例如动物叫:
Dog -> 汪汪
Cat -> 喵喵
我们希望程序能够写成:
animal->Talk();
至于最终是“汪汪”还是“喵喵”,由这个 animal 实际指向什么对象决定。
这就是运行时多态最核心的思想。
1.1 编译时多态
所谓编译时多态,就是:
编译器在程序运行之前,就已经确定调用哪个函数。
最常见的就是函数重载。
#include <iostream>
using namespace std;
void Func(int x)
{
cout << "Func(int)" << endl;
}
void Func(double x)
{
cout << "Func(double)" << endl;
}
int main()
{
Func(10);
Func(3.14);
return 0;
}
编译器看到:
Func(10);
就知道应该调用:
Func(int)
看到:
Func(3.14);
就知道调用:
Func(double)
因此函数调用关系在编译阶段已经确定。
函数模板也是类似的思想,所以一般将:
函数重载
函数模板
归为:
静态多态 / 编译时多态。
1.2 运行时多态
运行时多态就不一样了。
来看一个例子:
#include <iostream>
using namespace std;
class Person
{
public:
virtual void BuyTicket()
{
cout << "普通人买票:全价" << endl;
}
};
class Student : public Person
{
public:
void BuyTicket() override
{
cout << "学生买票:打折" << endl;
}
};
void Func(Person* ptr)
{
ptr->BuyTicket();
}
int main()
{
Person p;
Student s;
Func(&p);
Func(&s);
return 0;
}
运行结果:
普通人买票:全价
学生买票:打折
注意:
void Func(Person* ptr)
这里两次调用的参数类型都是:
Person*
第一次:
Func(&p);
ptr 实际指向 Person 对象,因此调用:
Person::BuyTicket()
第二次:
Func(&s);
虽然 ptr 仍然是:
Person*
但它实际指向的是 Student 对象,因此调用:
Student::BuyTicket()
虽然都是 Person* 调用 BuyTicket(),最终调用哪个函数由指针实际指向的对象决定。
这就是:
运行时多态。
二、运行时多态的构成条件
C++ 中要真正形成运行时多态,必须满足两个条件:
基类的指针或者引用调用虚函数,并且派生类对该虚函数完成了重写。
这是讲义明确给出的两个核心条件。
也就是说,仅仅有继承是不够的:
class Student : public Person
这只能说明存在继承关系。
真正的多态还需要:
基类指针 / 引用
+
虚函数
+
派生类重写
2.1 为什么一定要用基类指针或者引用
先看:
Student s;
Person* ptr = &s;
为什么 Person* 可以指向 Student?
因为派生类对象内部包含继承得到的基类部分。
可以粗略理解为:
Student 对象
低地址
↓
┌────────────────────┐
│ Person 基类部分 │
├────────────────────┤
│ Student自己的成员 │
└────────────────────┘
因此:
Person* ptr = &s;
本质上就是让:
ptr
指向 Student 对象中的:
Person 基类部分
这也是继承中:
派生类对象可以赋值给基类指针。
最重要的意义。
2.2 基类指针可以统一接收不同对象
假设:
class Person
{
public:
virtual void BuyTicket()
{
cout << "普通人:全价" << endl;
}
};
class Student : public Person
{
public:
void BuyTicket() override
{
cout << "学生:打折" << endl;
}
};
class Soldier : public Person
{
public:
void BuyTicket() override
{
cout << "军人:优先" << endl;
}
};
我们可以统一写:
void Func(Person* ptr)
{
ptr->BuyTicket();
}
然后:
int main()
{
Person p;
Student s;
Soldier so;
Func(&p);
Func(&s);
Func(&so);
return 0;
}
结果分别是:
普通人:全价
学生:打折
军人:优先
这才是基类指针真正有价值的地方:
它把多个不同的派生类统一成了一种接口。
2.3 基类引用也能实现多态
不仅指针可以,引用也可以。
例如:
#include <iostream>
using namespace std;
class Animal
{
public:
virtual void Talk() const
{
cout << "Animal" << endl;
}
};
class Dog : public Animal
{
public:
void Talk() const override
{
cout << "汪汪" << endl;
}
};
class Cat : public Animal
{
public:
void Talk() const override
{
cout << "喵喵" << endl;
}
};
void LetAnimalTalk(const Animal& animal)
{
animal.Talk();
}
int main()
{
Dog dog;
Cat cat;
LetAnimalTalk(dog);
LetAnimalTalk(cat);
return 0;
}
结果:
汪汪
喵喵
因此判断多态时可以直接记:
基类指针
或者
基类引用
三、virtual 与虚函数重写
多态真正的关键字就是:
virtual
类的成员函数前加上 virtual,这个函数就称为:
虚函数。
例如:
class Person
{
public:
virtual void BuyTicket()
{
cout << "全价" << endl;
}
};
3.1 没有 virtual 会发生什么
来看:
#include <iostream>
using namespace std;
class Base
{
public:
void Func()
{
cout << "Base::Func()" << endl;
}
};
class Derive : public Base
{
public:
void Func()
{
cout << "Derive::Func()" << endl;
}
};
int main()
{
Derive d;
Base* ptr = &d;
ptr->Func();
return 0;
}
输出:
Base::Func()
很多初学者第一次看到这里会疑惑:
ptr 明明指向 Derive,为什么不调用 Derive::Func()?
因为:
Base::Func()
不是虚函数。
编译器看到:
Base* ptr;
就按照 ptr 的类型直接确定调用:
Base::Func()
此时不会发生运行时多态。
3.2 加上 virtual 后发生什么
把代码改成:
class Base
{
public:
virtual void Func()
{
cout << "Base::Func()" << endl;
}
};
派生类:
class Derive : public Base
{
public:
void Func() override
{
cout << "Derive::Func()" << endl;
}
};
仍然:
Derive d;
Base* ptr = &d;
ptr->Func();
结果变成:
Derive::Func()
区别就在:
virtual
所以我们目前可以先把 virtual 理解为:
告诉编译器,这个函数以后允许根据对象实际类型,在运行阶段决定最终调用哪一个版本。
至于编译器到底是怎么做到的,涉及虚函数表和虚表指针,放到多态下篇再讲。
3.3 什么叫虚函数重写
假设基类:
class Base
{
public:
virtual void Func(int x)
{
cout << "Base" << endl;
}
};
派生类:
class Derive : public Base
{
public:
void Func(int x)
{
cout << "Derive" << endl;
}
};
此时派生类的:
Func(int)
就重写了基类的:
Func(int)
讲义给出的核心规则是:派生类中的虚函数与基类虚函数在函数名、参数列表和返回类型规则上对应,就构成虚函数重写,也称覆盖。
3.4 派生类不写 virtual 行不行
可以。
例如:
class Base
{
public:
virtual void Func()
{
}
};
class Derive : public Base
{
public:
void Func()
{
}
};
仍然构成虚函数重写。
原因是:
基类中的虚函数被继承之后,在派生类中仍然保持虚函数属性。
讲义也专门提醒过这一点。
不过从现代 C++ 的代码风格来看,更推荐:
class Derive : public Base
{
public:
void Func() override
{
}
};
因为:
override
比再次写一个 virtual 更能明确表达:
“这个函数就是用来重写基类虚函数的。”
3.5 override 有什么作用
考虑:
class Car
{
public:
virtual void Drive()
{
}
};
你本来想在 Benz 中重写:
Drive()
结果不小心写错:
class Benz : public Car
{
public:
void Dirve()
{
cout << "Benz" << endl;
}
};
注意:
Drive
Dirve
拼写错了。
如果不加 override,你可能很长时间都发现不了。
但如果写:
void Dirve() override
{
}
编译器就会报错:
这个函数并没有重写任何基类虚函数。
讲义同样使用 Drive/Dirve 的拼写错误说明了 C++11 override 的作用。
所以推荐:
class Benz : public Car
{
public:
void Drive() override
{
cout << "Benz" << endl;
}
};
3.6 final 又是什么
override 是:
检查我有没有正确重写。
final 则是:
到我这里结束,不允许继续重写。
例如:
class Base
{
public:
virtual void Func() final
{
}
};
再写:
class Derive : public Base
{
public:
void Func()
{
}
};
编译器会报错。
因为:
Base::Func()
已经被标记:
final
virtual
↓
这是虚函数
↓
派生类重写
↓
override 检查重写是否正确
↓
final 禁止继续重写
四、重载、重写和隐藏
这三个名字非常像,也是 C++ 继承和多态中的高频考点。
最容易混淆的是:
重载 overload
重写 override
隐藏 hide
4.1 重载
重载发生在:
同一个作用域。
例如:
class A
{
public:
void Func(int x)
{
}
void Func(double x)
{
}
void Func(int x, int y)
{
}
};
三个函数都叫:
Func
但是参数列表不同。
这就是函数重载。
它属于:
编译时多态。
4.2 重写
重写发生在:
基类和派生类之间。
例如:
class Base
{
public:
virtual void Func(int x)
{
}
};
class Derive : public Base
{
public:
void Func(int x) override
{
}
};
这里:
Derive::Func
重写:
Base::Func
重写是:
运行时多态的重要基础。
4.3 隐藏
来看:
class Base
{
public:
void Func()
{
cout << "Base::Func()" << endl;
}
void Func(int x)
{
cout << "Base::Func(int)" << endl;
}
};
class Derive : public Base
{
public:
void Func(double x)
{
cout << "Derive::Func(double)" << endl;
}
};
派生类中出现了一个同名的:
Func
这会影响基类同名函数在派生类作用域中的名字查找。
如果你要显式调用基类版本,可以写:
Derive d;
d.Base::Func();
所以隐藏和重写最大的区别就是:
重写必须围绕虚函数形成覆盖关系,而隐藏主要是父子作用域中的同名函数问题。
4.4 三者快速区分
| 位置 | 同一作用域 | 基类和派生类 | 基类和派生类 |
| 函数名 | 相同 | 相同 | 相同 |
| 参数列表 | 不同 | 对应相同 | 可以不同 |
| 基类是否要求 virtual | 不涉及 | 要求 | 不要求 |
| 是否与运行时多态有关 | 否 | 是 | 通常否 |
五、虚函数重写中的特殊问题
这一部分有两个非常经典的点:
默认参数
协变
尤其默认参数那道题,非常适合考试和面试。
5.1 经典问题:为什么输出 B->1
来看讲义中的代码:
#include <iostream>
using namespace std;
class A
{
public:
virtual void Func(int val = 1)
{
cout << "A->" << val << endl;
}
virtual void Test()
{
Func();
}
};
class B : public A
{
public:
void Func(int val = 0) override
{
cout << "B->" << val << endl;
}
};
int main()
{
B* p = new B;
p->Test();
delete p;
return 0;
}
最终结果:
B->1
为什么不是:
B->0
需要把:
调用哪个函数
和:
默认参数是多少
分开看。
5.2 第一步:调用哪个函数
执行:
p->Test();
虽然 p 是:
B*
但是 B 没有重写:
Test()
因此执行:
A::Test()
函数内部:
virtual void Test()
{
Func();
}
现在调用的是虚函数:
Func()
实际对象是:
B
所以最终选择:
B::Func()
因此输出一定是:
B->
5.3 第二步:默认参数是多少
基类:
virtual void Func(int val = 1)
派生类:
void Func(int val = 0) override
问题是:
Func();
到底使用 1 还是 0?
这里要记住:
默认参数是在编译阶段根据静态环境确定的。
当前这句:
Func();
写在:
A::Test()
里面。
因此默认参数取:
A::Func
中的:
1
而函数体本身因为虚函数机制,最后调用:
B::Func
所以效果相当于:
B::Func(1);
因此最终输出:
B->1
这道题可以浓缩成一句:
虚函数的函数体动态选择,默认参数静态确定。
六、协变返回类型
正常情况下,虚函数重写要求返回值类型保持对应。
不过有一个特殊情况:
基类虚函数返回基类对象的指针或引用,而派生类重写时返回派生类对象的指针或引用。
这种情况叫:
协变。
讲义将这一部分标记为了解内容。
例如:
class A
{
};
class B : public A
{
};
然后:
class Person
{
public:
virtual A* BuyTicket()
{
cout << "Person" << endl;
return nullptr;
}
};
派生类:
class Student : public Person
{
public:
B* BuyTicket() override
{
cout << "Student" << endl;
return nullptr;
}
};
这里:
A*
和:
B*
并不相同。
但是由于:
B
继承自:
A
这种返回值形式允许构成重写。
这一部分知道概念即可,不需要投入太多精力。
七、为什么多态基类通常需要虚析构函数
这是多态里最重要的工程问题之一,也是很经典的面试题。
假设:
Base* ptr = new Derive;
最后:
delete ptr;
到底应该调用谁的析构函数?
7.1 先看一个存在风险的版本
#include <iostream>
using namespace std;
class Base
{
public:
~Base()
{
cout << "~Base()" << endl;
}
};
class Derive : public Base
{
public:
Derive()
{
_ptr = new int[100];
}
~Derive()
{
cout << "~Derive()" << endl;
delete[] _ptr;
}
private:
int* _ptr;
};
然后:
int main()
{
Base* ptr = new Derive;
delete ptr;
return 0;
}
这里最大的风险是:
ptr
的类型是:
Base*
如果不能通过多态正确进入派生类析构过程:
Derive::~Derive()
那么:
delete[] _ptr;
就无法正确执行。
于是派生类负责管理的资源就可能得不到正确释放。
A* p2 = new B; delete p2; 这个例子说明:如果基类析构函数没有设计成虚函数,派生类负责释放的资源可能无法得到正确处理。
7.2 正确做法:基类析构函数写成虚函数
class Base
{
public:
virtual ~Base()
{
cout << "~Base()" << endl;
}
};
派生类:
class Derive : public Base
{
public:
Derive()
{
_ptr = new int[100];
}
~Derive() override
{
cout << "~Derive()" << endl;
delete[] _ptr;
}
private:
int* _ptr;
};
然后:
Base* ptr = new Derive;
delete ptr;
销毁时就能够按照派生对象的实际类型正确进入析构链:
~Derive()
↓
~Base()
这样派生类自身管理的资源也可以正确释放。
7.3 为什么析构函数名字不同还能构成重写
普通虚函数重写要求函数名相同。
但是:
~Base()
和:
~Derive()
显然名字不同。
析构函数属于特殊情况,编译器会对析构函数进行特殊处理,因此当基类析构函数是虚函数时,派生类析构函数能够参与虚函数覆盖机制。
所以工程中有一个非常重要的习惯:
class Base
{
public:
virtual ~Base() = default;
};
只要这个类明确要作为多态基类使用,这种写法就很常见。
八、纯虚函数与抽象类
前面我们写:
class Animal
{
public:
virtual void Talk()
{
cout << "Animal" << endl;
}
};
但是思考一个问题:
“动物”这个概念本身到底应该怎么叫?
狗:
汪汪
猫:
喵喵
但:
Animal
本身并没有一个统一具体的叫法。
这时候就可以使用:
纯虚函数。
8.1 什么是纯虚函数
写法:
virtual void Talk() = 0;
例如:
class Animal
{
public:
virtual void Talk() = 0;
};
这里:
= 0
表示:
Talk 是纯虚函数
在虚函数后写 = 0 即成为纯虚函数,包含纯虚函数的类称为抽象类。02.多态.pdfPDF
8.2 什么是抽象类
只要类中包含纯虚函数,这个类就是:
抽象类。
例如:
class Animal
{
public:
virtual void Talk() = 0;
};
那么:
Animal animal;
不能通过编译。
也就是说:
抽象类不能直接实例化对象。
Car 也是相同情况:
class Car
{
public:
virtual void Drive() = 0;
};
因此:
Car car;
无法实例化。02.多态.pdfPDF
8.3 派生类必须重写纯虚函数
例如:
class Animal
{
public:
virtual void Talk() = 0;
};
派生类:
class Dog : public Animal
{
public:
void Talk() override
{
cout << "汪汪" << endl;
}
};
此时:
Dog dog;
可以正常实例化。
但如果:
class Cat : public Animal
{
};
没有重写:
Talk()
那么:
Cat
自身仍然是抽象类。
也不能:
Cat cat;
如果派生类没有重写继承下来的纯虚函数,那么派生类仍然是抽象类。
九、抽象类真正解决了什么问题
不要只记:
纯虚函数 = 0
抽象类不能实例化
那只是语法现象。
真正重要的是:
抽象类可以规定统一接口。
例如:
class Animal
{
public:
virtual void Talk() const = 0;
virtual ~Animal() = default;
};
这里相当于规定:
只要你想成为一种真正可以使用的 Animal,你就必须提供 Talk()。
然后:
class Dog : public Animal
{
public:
void Talk() const override
{
cout << "汪汪" << endl;
}
};
class Cat : public Animal
{
public:
void Talk() const override
{
cout << "喵喵" << endl;
}
};
统一接口:
void LetAnimalTalk(const Animal& animal)
{
animal.Talk();
}
调用:
int main()
{
Dog dog;
Cat cat;
LetAnimalTalk(dog);
LetAnimalTalk(cat);
return 0;
}
结果:
汪汪
喵喵
调用方根本不需要知道:
到底是Dog
还是Cat
它只知道:
Animal
一定提供:
Talk()
这就是抽象类非常重要的意义:
只规定“必须有什么能力”,而把“具体怎么实现”交给派生类。



