08 Discovering Inheritance Techniques

C++ 支持类的继承,用于表达两个类型之间 is-a 的关系。术语可能很多,比如可以说派生自或继承自某个类,被继承的类可以称为父类、基类,继承的类可以称为子类、派生类。基本语法如下

class Base;
class Derived : public Base
派生类可以访问基类中 public protect 的成员变量和成员函数,因此基类中如果想做封装,依旧要保持所有的数据都是 private 的,实现 protected 的函数让派生类访问。

如果不想一个类被继承,那么在类名后面加上 final 即可。

派生类可以继承基类函数,可以添加新的函数,还可以覆盖(override)基类,这就是虚函数。

基类的函数声明添加 virtual 关键字,派生类相同签名的函数覆盖基类函数。推荐派生类添加 override 关键字,可以避免一些错误,比如基类重构修改了函数名或者修改了参数列表,那么派生类是新增函数而不是覆盖基类函数,如果派生类有 override 关键字,编译器会报错。

从使用者看来,一个基类的指针或者引用,根据指针、引用实际指向的对象,调用真实对应的虚函数。如果直接使用对象,没有这种效果。

有虚函数的类包含一个 vtable 虚函数表(virtual table),里面有指向实际函数的指针。当使用对象的指针或者引用时,通过虚函数表运行时找到对应的函数,这称为动态绑定(dynamic binding)。与此相对应的是静态绑定(static binding),对于非虚函数,编译期就硬编码到了对象上。

虚函数比非虚函数的运行时开销少多一丢丢,基本上可以忽略不计,除非短时间内调用了数百万次。包含虚函数的类也会大一丢丢,除非有数百万个对象,否则也可以忽略不计。

析构函数基本上都需要加 virtual,除非是 final 类。原因在于如果不是虚函数,释放一个指向派生类的基类指针,无法调用到派生类的虚函数,如果派生类有自己持有的资源,那么会泄露。派生类的析构函数添加 override 有助于解决这个问题。除非是 final 类,给析构函数加上 virtual,如果不需要额外做什么事情,=default 即可。为了解决这个问题,相当于显式声明了析构函数,那么捎带手需要声明其他四个成员函数,=default 就好。

final 可以作用于整个类,还可以作用于单个的虚函数,效果就是它的派生类无法再 override 了。

在继承链上,构造对象的顺序是

  1. 调用基类构造函数
  2. 按照非 static 成员变量声明的顺序依次初始化
  3. 执行构造函数体内的代码

因此,如果需要显式调用基类构造函数,使用构造函数的参数而不是成员变量,因为此时后者还没有初始化。

析构对象的顺序与上述恰好相反

  1. 执行析构函数体内的代码
  2. 按照非 static 成员变量声明的逆序依次析构
  3. 调用基类析构函数

基类构造函数中调用虚函数使用的自身的实现,派生类构造函数调用是派生类的实现,也就是说,这些都是静态绑定的,编译期就确定了。析构也是类似的。根据上述构造、析构的顺序,基类构造时派生类还没有初始化,调用派生类的实现可能会出问题。

不推荐在构造的时候调用虚函数,如果真的有这个需求,可以添加两个虚函数 initialize()shutdown(),使用者在构造完对象和使用完之后手动调用这两个函数。

在派生类中常常有调用基类函数的时候,比如 override 基类虚函数,很多时候是先执行基类的操作,然后附加一些派生类自己的操作,此时使用 BaseClass::FunctionName() 即可。

用派生类初始化一个基类对象,会出现切片问题,有一些信息丢掉了。如果类型是引用或者指针,就没有这个问题,这称为向上转型(upcasting)。与之相对的还有向下转型(downcasting),将一个基类指针转成一个派生类的指针。如果出现了向下转型,意味着设计可能不合理,应该改善设计尽可能避免这种情况。如果不得不做向下转型,使用 dynamic_cast,它会使用 vtable 里面的内置信息判断是否可以向下转型,如果不行,如果目标类型是指针,返回 nullptr,如果目标类型是引用,抛出 std::bad_cast 异常,这总比得到乱七八糟不合理的数据强多了。

继承加上虚函数的作用之一是重用代码,更重要的是运行时多态的能力。

至少包含一个纯虚函数(pure virtual)的类称为抽象类(abstract class),编译器保证无法实例化抽象类,语法是 virtual T FunctionName() = 0。如果继承自抽象类的派生类仍旧没有实现所有的纯虚函数,那么派生类依旧是抽象类。

技术上讲,可以给纯虚函数一个实现,这个实现需要在类的定义的外部,这个函数也可以被调用。但是即便给了一个实现,这个函数依旧是纯虚函数,类还是抽象类,派生类如果需要被实例化,依旧需要实现这个函数。

class Base
{
public:
    virtual void DoSomething() = 0; // Pure virtual member function.
};

// An out-of-class implementation of a pure virtual member function.
void
Base::DoSomething()
{
    std::println("Base::DoSomething()");
}

C++ 允许多继承,即继承多个类。比如

class Baz
    : public Foo
    , public Bar
{ /* Etc. */
};
Baz 拥有 Foo Barpublic 成员函数和成员变量。Baz 可以访问 Foo Barprotect 成员函数和成员变量。Baz 可以向上转型成 Foo 或者 BarBaz 需要调用 Foo Bar 的构造函数,按照继承的顺序构造,反之析构的时候按照反向顺序调用父类析构函数。

如果派生类 D 继承了 B1 B2 两个类,两个基类有相同签名的函数 Func(),那么就会有名字歧义。解决方式是指定希望调用的函数 d.B1::Func(),另一种方式是 D 自己 override Func(),或者直接 using 其中一个基类的。

多继承会导致另一个问题是菱形继承。此时根部基类是纯虚函数即可,这也是推荐做法。

override 的时候可以修改函数的返回类型。基类函数的返回类型是某个类的指针或者是引用,那么派生类 override 时返回类型可以是基类返回的类型的派生类。这满足里氏替换原则(Liskov substitution principle)。不过需要注意,如果返回类型是智能指针,那么不能这么用,因为智能指针是模板类,两个有继承关系的类实例化的两个版本类之间没有继承关系。

派生类可以重载基类的虚函数,这本质上是添加了一个新的函数,只是函数名恰好一样罢了。

在派生类中,可以使用 using Base::Base; 来继承基类的构造函数。继承的构造函数访问权限和基类一致,与 using 写在哪类访问修饰符下面无关。如果基类有 =delete 的构造函数,那么派生类的构造函数也是删除的。继承就会继承全部的构造函数,无法继承构造函数的子集。如果派生类定义有相同参数列表的构造函数,会隐藏继承过来的构造函数。如果从两个不同基类继承了相同参数列表的构造函数,由于歧义,派生类没有这种参数列表的构造函数了,解决办法就是再定义一个,然后调用基类的构造函数。由于继承构造函数可能会导致某些构造路径上没能初始化一些成员变量,因此一个好的习惯是类内初始化。

基类一个函数是 static 的,那么不能是 virtual 的,因此派生类“覆盖”这个函数,本质上是两个完全无关的函数,都属于各自的类,无多态。

如果基类的虚函数有重载,而派生类只 override 了其中一个,那么其他重载会被隐藏,即使用派生类对象无法调用到。这让我们思考这样一个问题:为什么需要 override 其中一个而无需 override 其他的重载?可以使用 using Base::Overload 来解决这个问题。但是这样可能会引入新问题,比如基类添加了一个新的重载虚函数,派生类默认就有了,但是可能不符合派生类的预期而导致 bug。因此,一个好的做法是如何 override 基类有重载的函数,那么就 override 所有的重载。

C++ 支持 override private virtual 函数。一般用法是基类有一个 public 函数,非 virtual,可以看做是一个模版,其中调用 private virtual 函数完成工作。派生类 override private virtual 函数,就改变了 public 函数的行为。

如果基类 virtual 函数有默认参数,派生类 override 的时候会改变默认参数,不过会有一个略微诡异的行为。当使用多态的时候,持有基类的指针或者引用,会调用派生类的实现,但是默认参数是基类指定的,因为默认参数是在编译期绑定。因此,推荐做法是基类创建一个常量值,基类和派生类统一使用相同的常量值作为默认参数。

派生类 override 时可以修改访问修饰符,可以放宽也可以收紧。不过使用多态时,是否可以访问以对象当前声明的类型为准。比如基类 virtualprotected,派生类是 public,持有指向派生类对象的基类指针无法访问这个函数。反之,访问级别收紧,那么从 public 变成 protected,持有指向派生类对象的基类指针就能执行 protected 实现。

如果派生类没有引入动态分配的资源,无需写拷贝构造函数和赋值运算符,那么编译器会生成一个,生成的会调用基类的对应函数。如果在派生类中声明了自己的拷贝构造函数和赋值运算符,那么就需要调用基类的拷贝构造函数或赋值运算符。

typeid 返回 std::type_info 的引用,一般而言,只有调试和打日志的时候需要使用,其他时候如果使用这个运算符,需要重新考虑设计。

继承的时候,可以使用非 public 继承,比如 class Derived : protected Base。对于 class 默认继承级别是 private,对于 struct 默认级别是 public。如果是 protected 继承,那么基类的 public 函数和成员变量会变成 protected,如果是 private 继承,那么基类的 public protected 函数和成员变量都会变成 private

如果是菱形继承,根类会出现两次,有歧义,一个解决方案是纯虚函数,另一个方案是虚继承:class Derived : public virtual Base。但是这种方案也有问题,因为最底层的类会调用两个基类的构造函数,这两个函数都会调用根类,会构造两个根类对象,编译器会禁止这种事情,最后结果就是根类的构造函数没有被调用,没能正确初始化一些参数。解决方案是两个基类各自实现一个 protected 的构造函数,仅仅初始化自己的字段而不调用根类构造函数,最底层的类调用根类和两个基类的构造函数。

C++ 提供几种 Cast,分别有不同的作用。const_cast 用于移出 const 修饰。static_cast 用于内置类型或者用户自定义构造函数的类型转换。bit_cast 是逐字节拷贝,相当于安全的 std::memcpy,要求两个无关的类长度一样,且都是 POD 类型。dynamic_cast 用于有继承关系的类的指针或引用相互转换。reinterpret_cast 用于不相关的类的指针或引用的转换,还可以用于函数指针的转换。