1.3. 多态与虚函数

之前讨论公有继承时,提到派生类对象可以在需要基类对象的地方使用。尽管有此要求,但通常了解对象的实际类型仍然很有用 —— 即对象创建时的类型是什么:

Derived d;
Base& b = d;
...
b.some_method(); // b 实际上是一个 Derived 对象

some_method() 是基类公共接口的一部分,对于派生类也必须是有效的。但在基类接口约定所允许的灵活性范围内,可以做一些不同的事情。举个例子,之前已经使用过鸟类型层次结构来表示不同的鸟类,特别是能够飞行的鸟类。可以假设 FlyingBird 类具有一个 fly() 方法,每个从它派生的具体鸟类类都必须支持飞行。但鹰的飞行方式与秃鹫不同,在这两个派生类 Eagle 和 Vulture 中,fly() 方法的实现可以不同。操作于 FlyingBird 对象的代码都可以调用 fly() 方法,但结果会根据对象的实际类型而有所不同。

这种功能在 C++ 中通过虚函数来实现。虚的公共函数必须在基类中声明:

class FlyingBird : public Bird {
public:
  virtual void fly(double speed, double direction) {
    ... 此处实现让鸟以指定速度在给定方向上移动的逻辑 ...
  }
  ...
};

派生类继承了该函数的声明和实现,必须遵守该声明及其提供的约定。如果基类的实现满足派生类的需求,则无需进行其他操作。但如果派生类需要更改实现,可以重写基类的实现:

class Vulture : public FlyingBird {
public:
  virtual void fly(double speed, double direction) {
    ... 实现飞行逻辑,但增加速度过快时积累疲劳的机制 ...
  }
};

当在派生类中使用关键字 virtual 来重写基类的虚函数时,该关键字完全可选,并且没有实际作用。

当调用虚函数时,C++ 运行时系统必须确定对象的实际类型。通常,这个信息在编译时未知,必须在运行时确定:

void hunt(FlyingBird& b) {
  b.fly(...); // 可能是 Vulture 或 Eagle
  ...
};

Eagle e;
hunt(e); // 现在 hunt() 中的 b 是 Eagle
         // 调用 Eagle::fly()

Vulture v;
hunt(v); // 现在 hunt() 中的 b 是 Vulture
         // 调用 Vulture::fly()

代码操作任意数量的基类对象并调用相同的方法,但结果取决于这些对象的实际类型,称为运行时多态,支持这种技术的对象称为多态对象。在 C++ 中,多态对象必须至少有一个虚函数,并且只有那些使用虚函数实现部分或全部功能的接口部分才算是多态。

从这一解释中可以看出,虚函数的声明及其重写版本应该完全一致 —— 开发者在基类对象上调用函数,但实际执行的是派生类中实现的版本。这种情况,只有在两个函数具有完全相同的参数和返回类型时才能发生。例外是,基类中的虚函数返回某个类型的对象指针或引用,重写函数可以返回该类型派生类对象的指针或引用(这称为协变返回类型)。

多态层次结构中一种非常常见的特殊情况是,基类对虚函数没有合适的默认实现。例如,所有会飞的鸟类都会飞,但飞行的速度各不相同,因此没有理由选择某一个速度作为默认值。在 C++ 中,可以拒绝为基类中的虚函数提供实现。

这样的函数称为纯虚函数,而包含纯虚函数的基类称为抽象类:

class FlyingBird {
public:
  virtual void fly(...) = 0; // 纯虚函数
};

抽象类仅定义接口,由具体的派生类来实现该接口是派生类的责任。如果基类包含纯虚函数,那么程序中每一个实例化的派生类都必须提供相应的实现,不能直接创建抽象基类的对象(派生类本身也可能是一个抽象类,但此时它也不能被直接实例化,必须从它再派生出新的类)。然而,可以拥有指向基类对象的指针或引用 —— 实际上指向的是派生类对象,但可以通过基类接口进行操作。

关于 C++ 语法的几点说明 —— 在重写虚函数时,不需要重复 virtual 关键字。只要基类中声明了具有相同名称和参数的虚函数,派生类中的同名函数就自动成为虚函数,并会重写基类中的那个函数。如果参数不同,派生类中的函数就不会重写,而是隐藏了基类函数的名称。这可能导致一些错误:开发者本意是重写基类函数,但因没有正确复制声明而导致意外的函数隐藏:

class Eagle : public FlyingBird {
public:
  void fly(int speed, double direction);
};

参数的类型略有不同。Eagle::fly() 函数虽然是虚函数,但并不会重写 FlyingBird::fly()。如果后者是纯虚函数,这个错误会被发现,每个纯虚函数都必须在派生类中实现。但如果 FlyingBird::fly() 有默认实现,那么编译器将无法检测到这个错误。C++11 提供了一个非常有用的功能,可以大大简化此类错误的排查 —— 需要重写基类虚函数的函数都可以使用 override 关键字进行声明:

class Eagle : public FlyingBird {
public:
  void fly(int speed, double direction) override;
};

virtual 关键字可选,但如果 FlyingBird 类中没有与此声明相匹配的虚函数可供重写,这段代码将无法通过编译。

还可以将虚函数声明为 final 来阻止派生类对其进行重写:

class Eagle : public FlyingBird {
public:
  // 所有鹰的飞行方式都相同,派生类 BaldEagle(白头鹰)
  // 和 GoldenEagle(金鹰)都不能改变此方式。
  void fly(int speed, double direction) final;
};

使用 final 关键字的情况很少见:通常很少会有设计要求在继承层次结构中的某一点之后,完全禁止进一步的定制。final 关键字也可以应用于整个类:不能再从此类派生新的类。

是否应该在重写函数时使用 virtual 关键字呢?这属于编码风格问题,但该风格会影响代码的可读性和可维护性。以下是推荐的做法:

这种做法有两个优点。第一个是清晰性和可读性:如果看到 virtual,说明这是一个不重写函数的虚函数;如果看到 override,说明这必定是一个重写(否则代码无法编译);如果看到 final,这也是一种重写(同样,否则代码无法编译),并且它是该继承链中的最后一次重写。第二个优点体现在代码维护过程中。维护继承层次结构时最常见的问题之一是基类的脆弱性:编写了一组基类和派生类,后来有人在基类函数中添加了一个参数,突然之间你所有的派生类函数都不再重写基类函数,导致这些函数从未调用。如果一致地使用 override 关键字,这种情况就不会发生。

迄今为止,虚函数最常见的用途是在使用公有继承的继承层次结构中 —— 由于每个派生对象也都是一个基类对象(即“is-a”关系),程序通常可以将一组派生对象当作同一种类型来操作,而通过虚函数的重写机制,可以确保每个对象都得到正确的处理:

void MakeLoudBoom(std::vector<FlyingBird*> birds) {
  for (auto bird : birds) {
    bird->fly(...); // 执行相同的操作,但产生不同的结果
  }
}

但虚函数也可以用于私有继承。这种用法不那么直接(也少见得多) —— 通过基类指针无法访问私有派生的对象(私有基类称为不可访问基类,尝试将派生类指针转换为基类指针会失败)。然而,有一种情况下可以进行这种转换,就是在派生类的成员函数内部。因此,以下是从私有继承的基类调用派生类虚函数的方法:

class Base {
public:
  virtual void f() {
    std::cout << "Base::f()" << std::endl;
  }
  void g() { f(); }
};

class Derived : private Base {
public:
  virtual void f() {
    std::cout << "Derived::f()" << std::endl;
  }
  void h() { g(); }
};

Derived d;
d.h(); // 输出 "Derived::f()"

基类的公有方法在派生类中变为私有,不能直接调用,但可以从派生类的另一个方法(例如公有方法 h())中调用它们。然后可以从 h() 直接调用 f(),但这并不能证明什么 —— 如果 Derived::h() 调用了 Derived::f(),这并不令人意外。

相反,调用从基类继承的 Base::g() 函数。这个函数内部处于基类的上下文中 —— 这个函数的主体可能在派生类实现之前很久就已经编写和编译好了。然而,在此上下文中,虚函数的重写仍然正确工作,调用了 Derived::f(),就像继承是公有的一样。

上一节中建议,除非有特殊理由,否则应优先使用组合而不是私有继承。使用组合无法很好地实现类似的功能;如果需要虚函数的行为,私有继承是唯一的选择。

一个带有虚方法的类必须将其类型信息编码到每个对象中 —— 这是在运行时唯一能知道对象构造时类型的方法,尤其是在将指针转换为基类指针,并丢失了关于原始类型的其他信息之后。这种类型信息并非免费,需要占用空间 —— 多态对象比具有相同数据成员,没有虚方法的对象更大(通常多出一个指针的大小)。

额外的空间大小并不取决于类有多少个虚函数 —— 只要有一个虚函数,就必须在对象中编码类型信息。现在,指向基类的指针可以转换为指向派生类的指针,但前提是必须知道派生类的正确类型。使用 static_cast 时,无法验证判断是否正确。对于非多态类(即没有虚函数的类),没有更好的办法;当原始类型丢失,就无法再恢复。但对于多态对象,类型信息已编码在对象中,因此必须有一种方法可以利用该信息来检查当前对象实际所属的派生类的假设是否正确。确实存在这样的方法,这就是 dynamic_cast 提供的功能:

class Base { ... };

class Derived : public Base { ... };

Base* b1 = new Derived; // 实际上是 Derived
Base* b2 = new Base; // 不是 Derived

Derived* d1 = dynamic_cast<Derived*>(b1); // 转换成功
Derived* d2 = dynamic_cast<Derived*>(b2); // d2 == nullptr

dynamic_cast 并不会表明对象的实际类型是什么;相反,它允许提出一个问题 —— 实际类型是 Derived 吗?如果类型猜测正确,转换就会成功,并返回指向派生对象的指针。如果实际类型是其他类型,转换就会失败,并返回一个空指针。dynamic_cast 也可以用于引用,效果类似,但有一点不同 —— 不存在空引用。返回引用的函数必须始终返回对某个有效对象的引用。由于当请求的类型与实际类型不匹配时,dynamic_cast 无法返回对有效对象的引用,唯一的方法就是抛出异常。

对于注重性能的代码,了解 dynamic_cast 的运行时开销非常重要。天真地认为虚函数调用和 dynamic_cast 耗时相近是错误的:两者看似都归结为同一个问题 —— 这个指向 Base 的指针实际上是指向 Derived 吗?但一个简单的基准测试表明,事实并非如此:

// Example 02_dynamic_cast.C
class Base {
protected:
  int i = 0;
public:
  virtual ~Base() {}
  virtual int f() { return ++i; }
};

class Derived : public Base {
  int f() override { return --i; }
};

Derived* p = new Derived;
// 测量 p->f() 的运行时间;
// 测量 dynamic_cast<Derived*>(p) 的运行时间;

基准测试的结果大致如下(具体数值取决于硬件):虚函数调用耗时约1纳秒,而dynamic_cast耗时5到10纳秒。为什么dynamic_cast如此耗时?在回答这个问题之前,需要更深入地了解类型层次结构。

到目前为止,只讨论了单一基类的情况。虽然将类型层次结构想象成一棵树(基类作为根节点,多个派生类作为分支)会更容易理解,但C++并不限制这种结构。接下来,将介绍如何同时从多个基类进行继承。