Skip to content

2.5 虚函数与运行时多态 ​

到目前为止,我们定义的类都可以独立使用。本节才开始认识经典面向对象中的另一组概念:继承和多态。

我们会从语法和一个具体问题开始。等看清它究竟解决了什么,再讨论什么时候不应该使用它。

从两种通知方式开始 ​

假设程序可以通过邮件或短信发送通知:

cpp
#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,但它们是两个毫无关系的类型。如果要把「发送通知」写成一个函数,参数应该是什么类型?

cpp
void alert(const EmailNotifier &notifier) {
    notifier.send("Server is down");
}

这个函数只能接受邮件通知器。可以再为 SmsNotifier 写一个重载,但每增加一种通知方式就要复制一次逻辑。

cpp
void alert(const EmailNotifier &notifier) {
    notifier.send("Server is down");
}
void alert(const SmsNotifier &notifier) {
    notifier.send("Server is down");
}

这很不好。我们想要表达的是:对 alert 来说,这些对象都是「一种通知器」,都能执行「发送消息」这项操作。

继承建立类型关系 ​

可以先定义一个更一般的 Notifier:

cpp
class Notifier {
  public:
    void send(std::string_view message) const {
        std::cout << "Notification: " << message << '\n';
    }
};

class EmailNotifier : public Notifier {};

Notifier 称为基类(base class),EmailNotifier 称为派生类(derived class)。public Notifier 表示公有继承,表达「EmailNotifier 是一种 Notifier」。

派生类会包含一个基类部分,因此它可以使用基类的公有成员:

cpp
EmailNotifier email;
email.send("Hello"); // 调用 Notifier::send

更重要的是,需要 Notifier 引用的地方可以接受 EmailNotifier 对象:

cpp
void alert(const Notifier &notifier) { notifier.send("Server is down"); }

EmailNotifier email;
alert(email);

这里没有复制对象。notifier 只是以基类的视角借用 email。

只有继承还不够 ​

现在让邮件通知器实现自己的 send:

cpp
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:

cpp
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 要求编译器检查它确实在重写某个虚函数。

cpp
EmailNotifier email;
alert(email); // Email: Server is down

alert 编译时只知道自己拿到了 Notifier,程序运行到 notifier.send(...) 时才根据实际对象选择 EmailNotifier::send。这就是运行时多态。

IMPORTANT

只有通过基类的引用或指针调用虚函数时,才会发生这种动态选择。

override 编译期检查 ​

派生类函数的参数、const 等必须与基类匹配。例如遗漏 const:

cpp
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 即可。可以把它写成纯虚函数:

cpp
class Notifier {
  public:
    virtual ~Notifier() = default;
    virtual void send(std::string_view message) const = 0;
};

= 0 是 C++ 表示纯虚函数的特殊语法。

我们稍后介绍这里的 virtual ~Notifier() = default;。

包含纯虚函数的类是抽象类,不能直接创建它的对象:

cpp
Notifier notifier;
// cannot declare variable 'notifier' to be of abstract type 'Notifier'

它的作用是规定共同接口。派生类如果想成为可创建对象的具体类,就必须实现这些纯虚函数。如果派生类没有完整实现所有纯虚函数,那这个派生类也会成为抽象类。

cpp
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 接口使用它。

cpp
#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」,具体是哪一种可以在运行时决定。

cpp
const auto notifier = make_notifier("sms");
if (notifier) {
    notifier->send("Server is down"); // SMS: Server is down
}

这里涉及到了我们之前介绍的几个点:

  • unique_ptr 表达对动态对象的独占所有权;
  • 基类指针可以指向派生对象;
  • 通过基类指针调用虚函数时,程序选择实际类型的实现。

除了使用指针,在确保生命周期安全的情况下,也可以使用引用:

cpp
void alert(Notifier &notifier) { notifier.send("Server is down"); }

EmailNotifier email;
alert(email); // Email: Server is down

虚析构函数 ​

上面的指针声明为 unique_ptr<Notifier>,但它可能实际拥有 EmailNotifier。指针销毁对象时,必须先析构完整的派生类对象,再析构它的基类部分。如果通过没有虚析构函数的基类指针删除派生类对象,会产生未定义行为,可能导致子类中动态分配的内存资源无法正确释放,出现内存泄漏或者程序崩溃。

因此,准备被这样多态使用的基类需要虚析构函数:

cpp
class Notifier {
  public:
    virtual ~Notifier() = default;
    virtual void send(std::string_view message) const = 0;
};

= default 要求编译器生成普通的析构实现;virtual 保证通过基类指针销毁对象时,完整的派生对象会被正确析构。否则,这种销毁会导致未定义行为。

虚析构函数是为「通过基类指针销毁派生对象」服务的。不作为多态基类使用的类通常不需要。

对象切片会丢掉派生部分 ​

为什么前面始终使用基类引用或指针,而不直接按值传递?考虑下面的代码:

cpp
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 说得通。但考虑另一个场景:成绩报告需要使用一个格式化器,「报告是一种格式化器」就说不通。这种需求应当使用组合:

cpp
#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
  • 通过基类指针销毁派生对象时,基类需要虚析构函数
  • 多态对象通常通过引用或指针使用,按值复制可能产生对象切片
  • 组合表达「有一个」,继承表达「是一种」