2.5 虚函数与运行时多态
到目前为止,我们定义的类都可以独立使用。本节才开始认识经典面向对象中的另一组概念:继承和多态。
我们会从语法和一个具体问题开始。等看清它究竟解决了什么,再讨论什么时候不应该使用它。
从两种通知方式开始
假设程序可以通过邮件或短信发送通知:
#include <iostream>
#include <string_view>
class EmailNotifier {
public:
void send(std::string_view message) const {
std::cout << "Email: " << message << '\n';
}
};
class SmsNotifier {
public:
void send(std::string_view message) const {
std::cout << "SMS: " << message << '\n';
}
};两个类都有 send,但它们是两个毫无关系的类型。如果要把「发送通知」写成一个函数,参数应该是什么类型?
void alert(const EmailNotifier ¬ifier) {
notifier.send("Server is down");
}这个函数只能接受邮件通知器。可以再为 SmsNotifier 写一个重载,但每增加一种通知方式就要复制一次逻辑。
void alert(const EmailNotifier ¬ifier) {
notifier.send("Server is down");
}
void alert(const SmsNotifier ¬ifier) {
notifier.send("Server is down");
}这很不好。我们想要表达的是:对 alert 来说,这些对象都是「一种通知器
继承建立类型关系
可以先定义一个更一般的 Notifier:
class Notifier {
public:
void send(std::string_view message) const {
std::cout << "Notification: " << message << '\n';
}
};
class EmailNotifier : public Notifier {};Notifier 称为基类(base classEmailNotifier 称为派生类(derived classpublic Notifier 表示公有继承,表达「EmailNotifier 是一种 Notifier
派生类会包含一个基类部分,因此它可以使用基类的公有成员:
EmailNotifier email;
email.send("Hello"); // 调用 Notifier::send更重要的是,需要 Notifier 引用的地方可以接受 EmailNotifier 对象:
void alert(const Notifier ¬ifier) { notifier.send("Server is down"); }
EmailNotifier email;
alert(email);这里没有复制对象。notifier 只是以基类的视角借用 email。
只有继承还不够
现在让邮件通知器实现自己的 send:
class EmailNotifier : public Notifier {
public:
void send(std::string_view message) const {
std::cout << "Email: " << message << '\n';
}
};
EmailNotifier email;
email.send("Hello"); // Email: Hello
alert(email); // Notification: Server is down结果可能出乎意料。直接用 email 调用时,编译器看到它的类型是 EmailNotifier,所以选择 EmailNotifier::send。在 alert 内,参数的类型却是 const Notifier&,所以选择了 Notifier::send。
也就是说这里的类型绑定是静态的。我们希望在共享 Notifier 这个兼容类型的基础上,在运行时不同的类对象传入之后,应该调用自己的 send 方法。同一行为在不同对象上可以表现出多种不同形式或结果,这称为多态(polymorphism
虚函数让运行时选择实现
把基类中的函数声明为 virtual:
class Notifier {
public:
virtual void send(std::string_view message) const {
std::cout << "Notification: " << message << '\n';
}
};
class EmailNotifier : public Notifier {
public:
void send(std::string_view message) const override {
std::cout << "Email: " << message << '\n';
}
};基类函数前的 virtual 表示:通过基类引用或指针调用这个函数时,要根据对象的实际类型选择实现。派生类函数后的 override 要求编译器检查它确实在重写某个虚函数。
EmailNotifier email;
alert(email); // Email: Server is downalert 编译时只知道自己拿到了 Notifier,程序运行到 notifier.send(...) 时才根据实际对象选择 EmailNotifier::send。这就是运行时多态。
IMPORTANT
只有通过基类的引用或指针调用虚函数时,才会发生这种动态选择。
override 编译期检查
派生类函数的参数、const 等必须与基类匹配。例如遗漏 const:
class SmsNotifier : public Notifier {
public:
void send(std::string_view message) override {
std::cout << "SMS: " << message << '\n';
}
};
// 'void SmsNotifier::send(std::string_view)' marked 'override', but does
// not override如果没有 override,这可能只会意外定义出另一个函数。写上 override 后,编译器会指出它没有成功重写基类函数。因此,重写虚函数时应当始终写上 override。
抽象类只规定接口
「普通通知」并没有明确的发送方式。Notifier 不应该提供默认实现,只要求每种具体通知器实现 send 即可。可以把它写成纯虚函数:
class Notifier {
public:
virtual ~Notifier() = default;
virtual void send(std::string_view message) const = 0;
};= 0 是 C++ 表示纯虚函数的特殊语法。
我们稍后介绍这里的
virtual ~Notifier() = default;。
包含纯虚函数的类是抽象类,不能直接创建它的对象:
Notifier notifier;
// cannot declare variable 'notifier' to be of abstract type 'Notifier'它的作用是规定共同接口。派生类如果想成为可创建对象的具体类,就必须实现这些纯虚函数。如果派生类没有完整实现所有纯虚函数,那这个派生类也会成为抽象类。
class EmailNotifier final : public Notifier {
public:
void send(std::string_view message) const override {
std::cout << "Email: " << message << '\n';
}
};
class SmsNotifier final : public Notifier {
public:
void send(std::string_view message) const override {
std::cout << "SMS: " << message << '\n';
}
};final 表示不允许再从这个类派生新类。这个标记并不是必须的,只是表达「这是最终设计」的意图。
运行时选择具体类型
如果用户可以在运行时选择通知方式,程序要先创建对应的具体对象,然后通过统一的 Notifier 接口使用它。
#include <memory>
#include <string>
std::unique_ptr<Notifier> make_notifier(std::string_view kind) {
if (kind == "email") {
return std::make_unique<EmailNotifier>();
}
if (kind == "sms") {
return std::make_unique<SmsNotifier>();
}
return nullptr;
}std::unique_ptr<Notifier> 表示这个指针独占拥有「某种 Notifier
const auto notifier = make_notifier("sms");
if (notifier) {
notifier->send("Server is down"); // SMS: Server is down
}这里涉及到了我们之前介绍的几个点:
unique_ptr表达对动态对象的独占所有权;- 基类指针可以指向派生对象;
- 通过基类指针调用虚函数时,程序选择实际类型的实现。
除了使用指针,在确保生命周期安全的情况下,也可以使用引用:
void alert(Notifier ¬ifier) { notifier.send("Server is down"); }
EmailNotifier email;
alert(email); // Email: Server is down虚析构函数
上面的指针声明为 unique_ptr<Notifier>,但它可能实际拥有 EmailNotifier。指针销毁对象时,必须先析构完整的派生类对象,再析构它的基类部分。如果通过没有虚析构函数的基类指针删除派生类对象,会产生未定义行为,可能导致子类中动态分配的内存资源无法正确释放,出现内存泄漏或者程序崩溃。
因此,准备被这样多态使用的基类需要虚析构函数:
class Notifier {
public:
virtual ~Notifier() = default;
virtual void send(std::string_view message) const = 0;
};= default 要求编译器生成普通的析构实现;virtual 保证通过基类指针销毁对象时,完整的派生对象会被正确析构。否则,这种销毁会导致未定义行为。
虚析构函数是为「通过基类指针销毁派生对象」服务的。不作为多态基类使用的类通常不需要。
对象切片会丢掉派生部分
为什么前面始终使用基类引用或指针,而不直接按值传递?考虑下面的代码:
void alert(Notifier notifier);
// cannot declare parameter 'notifier' to be of abstract type 'Notifier'
EmailNotifier email;
alert(email);
// cannot construct an object of abstract type 'Notifier'但现在 Notifier 是抽象类,根本不能按值创建,这段代码无法编译。即使基类不是抽象类,按值复制时也只会复制其中的基类部分,派生类增加的状态会被丢掉。这称为对象切片(object slicing
因此,运行时多态对象通常:
- 使用
Base&或const Base&借用; - 使用
Base*表达可空借用; - 使用
std::unique_ptr<Base>表达所有权; - 在容器中保存
std::unique_ptr<Base>,而不是把派生对象塞进std::vector<Base>。
继承与组合
看懂继承的机制不等于应该到处使用继承。公有继承做出了一个很强的承诺:派生类是基类的一种,任何需要基类的地方都应当能合理使用派生类。
EmailNotifier 作为一种 Notifier 说得通。但考虑另一个场景:成绩报告需要使用一个格式化器
#include <string>
#include <string_view>
#include <utility>
class Formatter {
public:
std::string format(std::string_view text) const;
};
class Report {
public:
explicit Report(Formatter formatter)
: formatter_{std::move(formatter)} {}
private:
Formatter formatter_;
};Report 拥有一个 Formatter,而非是一种 Formatter。
组合还可以通过重用现成类型实现功能,却不会建立不真实的类型关系。在考虑继承与组合关系时,要先区分「有一个」与「是一种
不同问题有不同的抽象方式
运行时多态适合这样的问题:
- 具体类型需要在运行时选择;
- 不同类型要通过一组稳定的共同操作使用;
- 以后可能添加新的具体类型,而调用者不应随之修改。
它不是唯一选择。我们将在下一节介绍 std::variant、模板与泛型编程。它们与虚函数的功能具有一定相似性,在不同的场景下,虚函数不一定是最优解。
小结
- 公有继承表达「派生类是基类的一种」
- 虚函数使基类引用或指针能在运行时选择具体实现
- 纯虚函数只规定操作,使类成为不能直接实例化的抽象类
- 重写虚函数时应当写
override - 通过基类指针销毁派生对象时,基类需要虚析构函数
- 多态对象通常通过引用或指针使用,按值复制可能产生对象切片
- 组合表达「有一个
继承表达「是一种」」 ,