终于学习到面向对象的设计思想了吗?不容易啊!

Item32: Make sure public inheritance models “is-a”

public inheritance 的实际含义就是“is-a”。

“If you write that class D (“Derived”) publicly inherits from class B (“Base”), you are telling C++ compilers (as well as human readers of your code) that every object of type D is also an object of type B, but not vice versa.”

子类公共继承了父类,实际上就是在说,子类的某个对象,在逻辑上也是被包含在父类中的。子类是父类的特化,父类是子类的泛化。

在一切需要使用父类对象的时候,使用子类对象也一定是合法的——因为子类对象在逻辑上同时也是父类的对象。

父类有的特征(成员),子类一定有(但不能直接访问父类的 private 成员);子类有的特征(成员),父类通常没有。

不要被直觉误导,自然语言的表述是不准确的:

  • “鸟会飞”指的是“大部分鸟会飞”,而不是“所有鸟都会飞”。
  • 如果我们要写一个 Bird 类,那么我们不能将 fly() 定义为 Bird 类中的纯虚函数,因为 fly() 不是所有鸟类共有的特征。

继承其实远比我们想的复杂:有些事情,可以对基类做,但是不能对派生类做,但 public inheritance 的语义是“所有对基类能做的操作,一定能对派生类做”。这就很麻烦了。

所以,我们以前建立的语义直觉不一定是正确的,需要重新学习。

Things to Remember:

  • Public inheritance means “is-a”. Everything that applies to base classes must also apply to derived classes, because every derived class object is a base class object.

Item33: Avoid hiding inherited names

这一节我们探讨类继承中 hiding inherited names 的问题。

我们知道,C++ 中每个变量/函数有各自的作用域 (scope)。对于同名变量/函数而言,调用它时,会由内而外一步步搜索各个作用域中的该变量/函数的名字,直到找到第一个名字符合要求的变量/函数。

在类继承时,作用域的关系是:派生类的作用域被包在基类的作用域之内。所以,当我们在类中调用某个变量/函数时,同样符合上面的规则。

但有一点需要注意:基类中具有相同函数名的全部函数(包括参数列表不同的重载函数),都会被派生类中的同名函数 hiding names。

具体可以看下面的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class Base {
private:
    int x;
public:
    virtual void mf1() = 0;
    virtual void mf1(int);
    virtual void mf2();
    void mf3();
    void mf3(double);
    // ...
};

class Derived : public Base {
public:
    virtual void mf1();
    void mf3();
    void mf4();
    // ...
};

基类中所有名为 mf1 的函数,不论其有无参数,都会被派生类中的同名函数“隐藏”。这是我们在 public 继承中不希望看到的,因为在前一节中我们提到,public 表示“is-a”的语义,即派生类在逻辑上应当就是基类,派生类需要有基类的所有基础功能。

那么怎么办呢?使用 using 关键字,让基类中被隐藏的成员函数在派生类中也可见:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class Base {
private:
    int x;
public:
    virtual void mf1() = 0;
    virtual void mf1(int);
    virtual void mf2();
    void mf3();
    void mf3(double);
    // ...
};

class Derived : public Base {
public:
	using Base::mf1;
	using Base::mf3;
    virtual void mf1();
    void mf3();
    void mf4();
    // ...
};

但有时候,我们不想用 public 继承(也就是不需要“is-a”语义的时候),也不想让派生类拥有基类所有的函数实现,只是希望可以调用部分函数。这时候,可以直接定义一个 inline forwarding function,直接调用基类的对应函数:

1
2
3
4
5
6
7
8
class Derived : private Base {
public:
    virtual void mf1()          // 这个函数是 public 的
    { 
        Base::mf1();            // 调用基类的(private)版本
    }
    // 注意:没有提供 mf1(int) 的转发
};

注:如果 Base::mf1() 被声明为 virtual void mf1() = 0;,那么必须在某处提供 Base::mf1() 的定义(例如 void Base::mf1() {}),否则链接时会报“未定义引用”。

Things to Remember:

  • Names in derived classes hide names in base classes. Under public inheritance, this is never desirable.
  • To make hidden names visible again, employ using declarations or forwarding functions.

Item34: Differentiate between inheritance of interface and inheritance of implementation

这一节教我们,需要根据继承的目的,来选择是否将一个类成员函数声明为纯虚函数、一般的虚函数,还是非虚函数。

继承通常有三种不同的需要:

  1. 只继承接口
  2. 同时继承接口和实现,但需要派生类 override。
  3. 同时继承接口和实现,不允许派生类 override。

这三种继承,各自需要不同的函数类型来实现。简单来说:

  1. 只继承接口,需要使用纯虚函数。
  2. 同时继承接口和实现且需要 override,需要使用非纯虚的虚函数。
  3. 同时继承接口和实现且不允许 override,需要使用非虚函数。

补充一点:非虚函数实际上代表着基类的不变约束 (invariant over specialization),即它对应的行为在所有的派生类中应该保持一致,不应该被改变。

需要注意的是,有时候我们希望基类提供一个默认实现,同时派生类能选择是否继承这个默认实现。这时候,有两种做法:

  1. 使用纯虚函数,同时在基类在 protected 中提供一个默认实现的函数,让需要继承默认实现的派生类直接调用这个函数,而需要 override 的派生类则自己实现。
  2. 使用纯虚函数,同时给纯虚函数提供一个默认实现(是的,我们知道纯虚函数可以有默认实现,只是调用时需要加上基类的前缀),让需要继承默认实现的派生类直接调用纯虚函数的默认实现,而需要 override 的函数则自己实现。

这样,使用了纯虚函数,就可以避免“在未确认的情况下,自动继承默认实现”的事情出现。

注意,不论是将所有函数都声明为非虚函数,还是将所有函数都声明为虚函数,都是不太好的做法。我们需要根据实际情况,自己做出最合适的决策。

Things to Remember:

  • Inheritance of interface is different from inheritance of implementation. Under public inheritance, derived classes always inherit base class interfaces.
  • Pure virtual functions specify inheritance of interface only.
  • Simple (impure) virtual functions specify inheritance of interface plus inheritance of a default implementation.
  • Non-virtual functions specify inheritance of interface plus inheritance of a mandatory implementaion.

Item35: Consider alternatives to virtual functions

当你设计一个类层次结构,需要让不同的派生类提供各自的行为实现时,最容易想到的方案就是把函数声明为 virtual。作者用游戏角色类的设计来展开讨论:不同类型的角色可能有不同的血量计算方式,把 healthValue 设计成 virtual function 似乎顺理成章:

1
2
3
4
class GameCharacter {
public:
    virtual int healthValue() const;   // 派生类可重定义
};

但正因为这个方案太 obvious,我们往往不会停下来想想有没有其他可能性。这个 item 的目的就是帮你跳出这个思维惯性。作者介绍了四种替代方案。

NVI (Non-Virtual Interface) Idiom

有一种思路认为,virtual functions 几乎总是应该声明为 private。具体做法是:把 healthValue 保留为 public non-virtual 成员函数,由它去调用一个 private virtual 函数(比如叫 doHealthValue),让后者完成实际工作:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class GameCharacter {
public:
    int healthValue() const           // 派生类不重定义此函数
    {
        // ... “before” stuff ...
        int retVal = doHealthValue();
        // ... “after” stuff ...
        return retVal;
    }

private:
    virtual int doHealthValue() const  // 派生类可重定义此函数
    {
        // 默认的血量计算算法
    }
};

这就是 NVI idiom,它是 Template Method 设计模式的一种具体体现。我把那个 public non-virtual 函数(healthValue)称为 virtual function 的 wrapper。

这么做最大的好处是:wrapper 可以在调用前后插入固定逻辑。”before” 部分可以做加锁、写日志、检查类不变量和函数前置条件之类的事情;”after” 部分可以做解锁、检查后置条件、重新验证类不变量之类的事情。如果让 client 直接调用 virtual function,这些操作根本无法可靠地实施。

有一个常见的疑惑:NVI idiom 要求派生类去重定义一个 private virtual 函数——派生类自己都调用不了它!但这并不是设计上的矛盾。重定义一个 virtual function,指定的是”怎么做”;调用一个 virtual function,指定的是”什么时候做”。这两个关注点是独立的。基类保留对执行时机的控制权,派生类只负责提供具体的实现。

不过 NVI 不强制要求 virtual function 一定是 private。如果派生类的实现需要调用基类的对应版本,那 virtual function 应该声明为 protected。有些场景下(比如多态基类的析构函数),virtual function 甚至必须是 public,这时 NVI 就不适用了。

Strategy via Function Pointers

NVI idiom 本质上还是在用 virtual function。一个更彻底的思路是:把血量计算完全移出角色类的继承体系。具体做法是让每个角色的构造函数接收一个函数指针,指向实际的健康值计算函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class GameCharacter;  // forward declaration

int defaultHealthCalc(const GameCharacter& gc);

class GameCharacter {
public:
    using HealthCalcFunc = int (*)(const GameCharacter&);

    explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc)
        : healthFunc(hcf)
    {}

    int healthValue() const
    { return healthFunc(*this); }

private:
    HealthCalcFunc healthFunc;
};

这是 Strategy 设计模式的一个简单应用。对比 virtual function 方案,它的灵活性体现在几个方面。

同一角色类型的不同实例可以使用不同的计算函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class EvilBadGuy : public GameCharacter {
public:
    explicit EvilBadGuy(HealthCalcFunc hcf = defaultHealthCalc)
        : GameCharacter(hcf)
    { ... }
};

int loseHealthQuickly(const GameCharacter&);
int loseHealthSlowly(const GameCharacter&);

EvilBadGuy ebg1(loseHealthQuickly);  // 同类型,不同行为
EvilBadGuy ebg2(loseHealthSlowly);

你甚至可以在运行时替换计算函数——只要给 GameCharacter 加一个 setHealthCalculator 成员函数就行。

代价是计算函数不再是一个成员函数,因此无法访问角色的非公开成员。如果精确计算血量需要用到 private 信息,你就得权衡了:要么削弱封装性(把计算函数设为 friend,或者暴露公开的 accessor),要么换一种方案。

Strategy via std::function

函数指针的做法虽然灵活,但限制也比较多:计算函数必须是一个普通函数(不能是 function object 或 member function),返回类型也必须是 int。用 std::function 替换函数指针,可以把这些限制全部解除:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class GameCharacter {
public:
    using HealthCalcFunc = std::function<int(const GameCharacter&)>;

    explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc)
        : healthFunc(hcf)
    {}

    int healthValue() const
    { return healthFunc(*this); }

private:
    HealthCalcFunc healthFunc;
};

std::function<int(const GameCharacter&)> 是一种”目标签名”——它描述的是”接受一个 const GameCharacter&,返回一个 int”的可调用实体。只要签名兼容(参数类型可以隐式转换,返回类型可以隐式转换成 int),任何 callable entity 都可以往里塞:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
short calcHealth(const GameCharacter&);            // 普通函数,返回 short

struct HealthCalculator {                           // function object
    int operator()(const GameCharacter&) const { ... }
};

class GameLevel {
public:
    float health(const GameCharacter&) const;       // member function
};

EvilBadGuy ebg1(calcHealth);
EyeCandyCharacter ecc1(HealthCalculator());

GameLevel currentLevel;
EvilBadGuy ebg2(std::bind(&GameLevel::health, currentLevel, std::placeholders::_1));

最后一个例子值得展开说一下。GameLevel::health 表面上只接受一个 GameCharacter 参数,但作为 member function,它实际上有一个隐式的 this 参数,所以总共有两个参数。std::bindcurrentLevel 绑定为每次调用时的 GameLevel 对象,从而生成一个只接受 GameCharacter 的可调用实体。这种灵活性是原始函数指针完全做不到的。

原书使用的是 std::tr1::functionstd::tr1::bind,这是 C++03 时代 TR1 库中的名字。C++11 把它们并入了标准库,去掉了 tr1:: 前缀,所以现在直接写 std::functionstd::bind 即可。在现代 C++ 中,更推荐用 lambda 表达式替代 std::bind,代码会更简洁直观。

Classic Strategy Pattern

如果你更喜欢传统的面向对象风格,可以把血量计算逻辑单独抽取成一个独立的类层次结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class HealthCalcFunc {
public:
    virtual int calc(const GameCharacter& gc) const
    { ... }
};
HealthCalcFunc defaultHealthCalc;

class GameCharacter {
public:
    explicit GameCharacter(HealthCalcFunc* phcf = &defaultHealthCalc)
        : pHealthCalc(phcf)
    {}

    int healthValue() const
    { return pHealthCalc->calc(*this); }

private:
    HealthCalcFunc* pHealthCalc;
};

对应的类图是:GameCharacter 是角色层次结构的根(EvilBadGuyEyeCandyCharacter 等派生自它);HealthCalcFunc 是计算算法层次结构的根(SlowHealthLoserFastHealthLoser 等派生自它);每个 GameCharacter 对象持有一个 HealthCalcFunc 指针。

熟悉 Strategy 设计模式的开发者一眼就能认出这种写法。它的一个额外好处是容易扩展:当你需要调整某个计算算法时,只需在 HealthCalcFunc 的继承体系中新增一个派生类即可。

Things to Remember

  • Virtual function 的替代方案包括 NVI idiom 以及多种形式的 Strategy 设计模式。NVI idiom 本身就是 Template Method 模式的一种体现。
  • 把功能从成员函数移到类外部,意味着失去了对该类非公开成员的访问权。这可能会迫使你削弱封装性。
  • std::function 对象可以扮演广义函数指针的角色,能持有任何与目标签名兼容的可调用实体。

Item 36: Never Redefine an Inherited Non-Virtual Function

问题的提出

假设 D 以 public 继承自 BB 有一个 non-virtual 函数 mf。给定一个 D 类型的对象 x,以下两种调用方式:

1
2
3
D x;
B *pB = &x;  pB->mf();
D *pD = &x;  pD->mf();

直觉上,它们应该表现一致,因为调用的是同一个函数、同一个对象。但如果 D 重新定义了 mf,且 mf 是 non-virtual,结果就会出人意料:

1
2
3
4
5
6
7
class D : public B {
public:
    void mf();  // hides B::mf
};

pB->mf();  // calls B::mf
pD->mf();  // calls D::mf

静态绑定与动态绑定

这种两面行为的根源在于绑定方式的不同。

non-virtual 函数是 statically boundpB 的类型是 B*,所以通过 pB 调用的 non-virtual 函数永远是 B 中定义的版本,即使 pB 实际指向一个 D 对象。Virtual 函数则不同,它是 dynamically bound,调用哪个版本取决于指针实际指向的对象类型,而非指针的声明类型。

用表格对比:

特性non-virtualvirtual
绑定时机编译期运行期
依据指针/引用的声明类型对象的实际类型
pB->mf() vs pD->mf()可能调用不同函数一定调用同一函数

实用层面的问题

从工程角度看,一个 D 对象在 mf 被调用时,有时表现得像 B,有时像 D,决定因素居然是指针的声明类型,而非对象本身。这种不一致极易导致难以追踪的 bug。References 和 pointers 在此表现完全相同,因此问题不会因为使用引用而消失。

理论层面的论证

Item 32 指出 public inheritance 意味着 is-a,即凡适用于 B 对象的也适用于 D 对象。Item 34 则说明,在类中声明 non-virtual 函数,意味着为该类的所有特化版本建立了一个 invariant——这个函数的行为在继承体系中应当保持不变。

将这两点应用于 B::mf

  • 每个 D 都是 B,所以 D 对象在 mf 上的行为应与 B 对象一致。
  • mf 是 non-virtual,所以 B 已经规定了 mf 的实现方式,派生类必须同时继承接口和实现。

D 重新定义 mf,设计就出现矛盾。假设 D 确实需要不同的实现,那么只有两种可能:

  1. D 不是真正的 B ——此时不应使用 public inheritance。
  2. mf 并非 invariant——此时 mf 应当是 virtual。

无论哪种情况,重新定义 inherited non-virtual 函数都是错误的

与 Item 7 的关系

Item 7 要求多态基类的析构函数必须是 virtual。这其实是 Item 36 的特例:若基类析构函数为 non-virtual,派生类不可避免地会重新定义它(即使不显式声明,编译器也会自动生成)。因此,Item 7 本质上是从 Item 36 推导出的必然结论,只是因其重要性而单独成条。

Things to Remember

Never redefine an inherited non-virtual function.

这条规则没有例外。如果派生类需要不同的行为,要么将基类函数改为 virtual,要么重新审视继承关系本身是否合理。

Item37: Never redefine a function’s inherited default parameter value

讨论范围的限定

Item 36 已经论证过,重新定义 inherited non-virtual 函数永远是错误的。因此 Item 37 的讨论范围可以安全地限定在一种情形:继承一个带有默认参数值的 virtual 函数,并在派生类中修改这个默认值。

核心矛盾

Virtual 函数是 dynamically bound 的,但 default parameter values 是 statically bound 的。这个不对称是问题的根源。

考虑如下类层次:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class Shape {
public:
    enum ShapeColor { Red, Green, Blue };
    virtual void draw(ShapeColor color = Red) const = 0;
};

class Rectangle : public Shape {
public:
    virtual void draw(ShapeColor color = Green) const;
};

class Circle : public Shape {
public:
    virtual void draw(ShapeColor color) const;
};

定义三个指针:

1
2
3
Shape *ps;
Shape *pc = new Circle;
Shape *pr = new Rectangle;

pspcpr 的 static type 都是 Shape*,这与它们实际指向的对象无关。pc 的 dynamic type 是 Circle*pr 的 dynamic type 是 Rectangle*。virtual 函数的调用由 dynamic type 决定:

1
2
pc->draw(Shape::Red);  // calls Circle::draw(Shape::Red)
pr->draw(Shape::Red);  // calls Rectangle::draw(Shape::Red)

但当调用使用默认参数时,情况变得反常:

1
pr->draw();  // calls Rectangle::draw(Shape::Red)

pr 的 dynamic type 是 Rectangle*,因此调用的是 Rectangle::draw。但 pr 的 static type 是 Shape*,而 default parameter values 由 static type 决定,所以实际传入的参数是 Shape::Red 而非 Rectangle::Green。最终得到一个由两个类的声明拼凑而成的调用,这几乎一定不是程序员的本意。

这个行为与指针还是引用无关,关键在于 draw 是 virtual 函数,且其默认参数值在派生类中被重新定义了。

为什么 C++ 这样设计

如果 default parameter values 也采用 dynamic binding,编译器必须在运行时确定 virtual 函数的适当默认值,这比编译期确定更慢、更复杂。C++ 选择在效率和实现简洁性上妥协,代价就是程序员若忽视本条规则,会得到令人困惑的行为。

重复默认参数的陷阱

遵守规则的一个直接做法是在基类和派生类中写相同的默认值:

1
2
3
4
5
6
7
class Shape {
    virtual void draw(ShapeColor color = Red) const = 0;
};

class Rectangle : public Shape {
    virtual void draw(ShapeColor color = Red) const;
};

但这引入了 code duplication,更糟的是带有依赖关系的重复:一旦 Shape 中的默认值改变,所有重复该值的派生类也必须同步修改,否则就再次违反了本条规则。

NVI Idiom 作为解决方案

Item 35 中讨论的 non-virtual interface idiom 可以解决这个问题。让 public non-virtual 函数负责指定默认参数,同时调用一个 private virtual 函数完成实际工作:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class Shape {
public:
    enum ShapeColor { Red, Green, Blue };
    void draw(ShapeColor color = Red) const {
        doDraw(color);
    }
private:
    virtual void doDraw(ShapeColor color) const = 0;
};

class Rectangle : public Shape {
private:
    virtual void doDraw(ShapeColor color) const;
};

由于 non-virtual 函数不应被派生类重新定义(Item 36),这个设计明确保证了 draw 的默认参数值永远是 Red。派生类只负责实现 doDraw,无需关心默认参数。

静态绑定与动态绑定的对比

特性函数调用默认参数值
绑定机制dynamic bindingstatic binding
决定依据对象的 dynamic type指针/引用的 static type
修改风险多态行为符合预期参数来源与函数来源不一致

需要记住的

Never redefine an inherited default parameter value. Default parameter values are statically bound, while virtual functions are dynamically bound.

Item 38: Model “has-a” or “is-implemented-in-terms-of” through Composition

什么是 Composition

Composition 是指一个类型的对象包含另一个类型的对象。例如:

1
2
3
4
5
6
7
8
9
class Person {
public:
    ...
private:
    std::string name;
    Address address;
    PhoneNumber voiceNumber;
    PhoneNumber faxNumber;
};

Person 对象由 stringAddressPhoneNumber 对象组合而成。在程序员圈子里,composition 还有很多别名:layering、containment、aggregation、embedding,它们指的都是同一个基本机制。

Composition 的两种含义

Composition 实际上有两层含义,取决于你所处的领域。

在 application domain 中,composition 表达的是 has-a 关系。Person has a name,Person has an address。你不会说一个人是一个名字,或者一个人是一个地址。这种区分大多数人都能直观理解,所以 is-a 和 has-a 的混淆相对少见。

在 implementation domain 中,composition 表达的是 is-implemented-in-terms-of 关系。这时的对象不是现实世界的映射,而是软件实现的产物,比如缓冲区、互斥锁、搜索树等。

一个典型的错误

假设你需要一个表示小集合的模板,集合中不能有重复元素。你首先想到复用标准库的 set,但 set 通常用平衡搜索树实现,每个元素有三个指针的开销。你的应用更看重空间而非时间,所以标准库的 set 不适合你。

你决定自己实现,而底层用链表。标准库正好有 list 模板,于是你写了:

1
2
3
4
template<typename T>
class Set : public std::list<T> {
    ...
};

这看起来合理,因为一个 Set 对象确实底层是一个 list 对象。但这里有问题。list 允许重复,插入两次 3051,list<int> 里就有两个 3051。但 Set 不允许重复,插入两次 3051,集合里只能有一个。

所以 Set is not a list。有些对 list 成立的事情,对 Set 不成立。Public inheritance 用错了。

正确的做法

关系不是 is-a,而是 is-implemented-in-terms-of。应该用 composition:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
template<class T>
class Set {
public:
    bool member(const T& item) const;
    void insert(const T& item);
    void remove(const T& item);
    std::size_t size() const;
private:
    std::list<T> rep;
};

实现可以充分利用 list 和标准库已有的功能:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
template<typename T>
bool Set<T>::member(const T& item) const {
    return std::find(rep.begin(), rep.end(), item) != rep.end();
}

template<typename T>
void Set<T>::insert(const T& item) {
    if (!member(item)) rep.push_back(item);
}

template<typename T>
void Set<T>::remove(const T& item) {
    typename std::list<T>::iterator it =
        std::find(rep.begin(), rep.end(), item);
    if (it != rep.end()) rep.erase(it);
}

template<typename T>
std::size_t Set<T>::size() const {
    return rep.size();
}

这些函数足够简单,可以考虑 inline,但 Item 30 的讨论应该在做决定之前回顾。

关于接口设计的说明

有人可能认为 Set 的接口应该遵循 STL 容器的约定,这样更符合 Item 18 的原则。但遵循那些约定需要添加大量东西,会掩盖 Setlist 之间的关系。既然这个关系正是本条要说明的重点,这里选择教学清晰性而非 STL 兼容性。而且,Set 的接口细节不应掩盖一个无可争议的事实:Setlist 的关系不是 is-a,而是 is-implemented-in-terms-of。

需要记住的

Composition 的含义与 public inheritance 完全不同。在 application domain 中,composition 意味着 has-a。在 implementation domain 中,它意味着 is-implemented-in-terms-of。

Item 39: Use Private Inheritance Judiciously

语义与行为

C++ 把 public inheritance 视为 is-a 关系。Item 32 展示了编译器会隐式将 Student 转换为 Person 以让函数调用成功。如果换成 private inheritance,这个转换就不会发生:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class Person { ... };
class Student : private Person { ... };

void eat(const Person& p);
void study(const Student& s);

Person p;
Student s;
eat(p);  // fine
eat(s);  // error! a Student isn't a Person

Private inheritance 有两条基本规则。第一,编译器不会将派生类对象转换为基类对象。第二,从 private base 继承来的成员在派生类中变成 private,无论它们在基类中是 protected 还是 public。

含义

private inheritance 意味着 is-implemented-in-terms-of。选择让 D privately inherit from B,是因为想利用 B 的某些特性,而非因为 BD 的对象之间存在概念上的关系。它是一种纯粹的实现技术,继承来的所有内容都变成 private,因为这些都只是实现细节。用 Item 34 的术语来说,private inheritance 只应继承实现,接口应当被忽略。

与 Composition 的对比

Item 38 指出 composition 也可以表达 is-implemented-in-terms-of。那么该如何选择?答案很简单:能用 composition 时就用 composition,必须用 private inheritance 时才用。

什么时候必须?主要是当 protected 成员和 virtual 函数进入视野时。还有一个边缘情况涉及空间优化,但那是边缘情况,可以稍后考虑。

典型用例:重写 Virtual 函数

假设我们在开发一个涉及 Widget 的应用,需要追踪 Widget 成员函数的调用频率,以及这些比例随时间的变化。为此需要设置一个定时器来收集统计信息。

工具箱里正好有这样一个类:

1
2
3
4
5
6
class Timer {
public:
    explicit Timer(int tickFrequency);
    virtual void onTick() const;
    ...
};

Widget 需要重新定义 Timer 的 virtual 函数 onTick,但 Widget is not a Timer。让客户端能在 Widget 上调用 onTick 会误导他们,违反 Item 18 的原则。因此选择 private inheritance:

1
2
3
4
5
class Widget : private Timer {
private:
    virtual void onTick() const;
    ...
};

Timer 的 public onTickWidget 中变成 private,重新声明时保持 private。如果把它放到 public 接口,客户端会误以为可以调用。

替代方案:Nested Class + Composition

private inheritance 并非唯一选择。可以在 Widget 内部声明一个 private nested class,让它 public 继承 Timer,在那里重写 onTick,然后在 Widget 中放一个该类型的对象:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class Widget {
private:
    class WidgetTimer : public Timer {
    public:
        virtual void onTick() const;
        ...
    };
    WidgetTimer timer;
    ...
};

这个设计更复杂,涉及 public inheritance、composition 和新类 WidgetTimer。展示它主要是为了提醒:解决问题通常有多种途径,值得训练自己考虑多种方案。

尽管如此,有两个理由可能让你倾向于这种 public inheritance + composition 的设计。

第一,你可能希望允许 Widget 有派生类,但阻止它们重写 onTick。如果 Widget 继承自 Timer,即使继承是 private,派生类仍然可以重写 virtual 函数。但 WidgetTimerWidget 的 private 成员,Widget 的派生类无法访问它,自然无法继承或重写。如果你在 Java 或 C# 中用过 finalsealed,这就是在 C++ 中近似实现相同行为的方法。

第二,你可能希望最小化 Widget 的编译依赖。如果 Widget 继承自 Timer,编译 Widget 时必须能看到 Timer 的定义,Widget 的头文件很可能需要 #include Timer.h。但如果 WidgetTimer 被移出 WidgetWidget 只持有一个指向 WidgetTimer 的指针,那么 Widget 只需要 WidgetTimer 的前向声明,无需包含任何与 Timer 相关的内容。对大型系统而言,这种解耦可能很重要。

边缘情况:Empty Base Optimization

Private inheritance 的另一个用途是空间优化,但这只适用于没有数据的类。这样的类没有 non-static 数据成员,没有 virtual 函数,也没有 virtual base classes。从概念上说,这种空类的对象不应占用空间,但 C++ 出于技术原因要求独立对象必须有非零大小。

1
2
3
4
5
6
7
class Empty {};

class HoldsAnInt {
private:
    int x;
    Empty e;
};

sizeof(HoldsAnInt) > sizeof(int)。大多数编译器中 sizeof(Empty) 是 1,因为编译器会向空对象中静默插入一个 char。而对齐要求可能导致 HoldsAnInt 的对象大小增加到一个完整的 int,实测结果通常如此。

但基类部分不是独立对象,不受此约束。如果用继承代替包含:

1
2
3
4
class HoldsAnInt : private Empty {
private:
    int x;
};

sizeof(HoldsAnInt) == sizeof(int)。这就是 empty base optimization(EBO),所有测试过的编译器都实现了它。EBO 通常只在单继承下可行,C++ 的对象布局规则一般不允许将其应用于多继承的派生类。

实践中,空类并不真正为空。它们通常包含 typedef、enum、static 数据成员或非 virtual 函数。STL 中有许多技术上为空但包含有用成员的类,比如 unary_functionbinary_function,用户定义的函数对象通常继承自它们。由于 EBO 的广泛实现,这种继承很少增加继承类的大小。

回到基本原则

大多数类不是空的,所以 EBO 很少能成为使用 private inheritance 的正当理由。大多数继承对应的是 is-a,那是 public inheritance 的职责。Composition 和 private inheritance 都表示 is-implemented-in-terms-of,但 composition 更容易理解,应优先使用。

Private inheritance 最可能是合理设计策略的场景,是两个非 is-a 关系的类之间,一个需要访问另一个的 protected 成员,或需要重写其 virtual 函数。即使在这种情况下,public inheritance 加 composition 的组合往往也能达到目的,只是设计更复杂。审慎地使用 private inheritance,意味着在考虑了所有替代方案之后,它仍是表达两个类之间关系的最佳方式。

需要记住的

Private inheritance 意味着 is-implemented-in-terms-of。它通常不如 composition,但在派生类需要访问基类的 protected 成员或需要重写继承的 virtual 函数时,它是合理的选择。与 composition 不同,private inheritance 可以实现 empty base optimization,这对致力于最小化对象大小的库开发者可能很重要。

Item 40: Use Multiple Inheritance Judiciously

两种立场

关于 multiple inheritance(MI),C++ 社区大体分为两派。一派认为如果 single inheritance(SI)是好的,MI 必然更好。另一派认为 SI 是好的,但 MI 不值得麻烦。本条的目标是理解这两种视角。

名字歧义

MI 引入了一个 SI 没有的问题:可能从多个基类继承同名成员。这会产生新的歧义机会:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class BorrowableItem {
public:
    void checkOut();
    ...
};

class ElectronicGadget {
private:
    bool checkOut() const;
    ...
};

class MP3Player : public BorrowableItem, public ElectronicGadget {
    ...
};

MP3Player mp;
mp.checkOut();  // ambiguous!

注意,即使只有一个函数是可访问的(BorrowableItem::checkOut 是 public,而 ElectronicGadget::checkOut 是 private),调用仍然是 ambiguous 的。这是因为 C++ 解析重载函数调用的规则:编译器先找出最佳匹配的函数,然后才检查可访问性。在这个例子中,checkOut 这个名字在名字查找阶段就是 ambiguous 的,所以根本不会进入函数重载解析或最佳匹配判定,ElectronicGadget::checkOut 的可访问性因此从未被检查。

解决歧义必须显式指定:

1
mp.BorrowableItem::checkOut();

显式调用 ElectronicGadget::checkOut 也可以,但歧义错误会被替换为"试图调用 private 成员函数"的错误。

菱形继承与 Virtual Inheritance

MI 常常出现在带有更高层基类的层次结构中,这可能导致所谓的"致命菱形":

1
2
3
4
class File { ... };
class InputFile : public File { ... };
class OutputFile : public File { ... };
class IOFile : public InputFile, public OutputFile { ... };

只要继承层次中存在多条路径连接同一个基类和派生类(如 FileIOFile 通过 InputFileOutputFile 两条路径),就必须面对一个问题:基类的数据成员是否应该在每条路径上复制一份。假设 File 有数据成员 fileNameIOFile 应该有两个 fileName 还是只有一个?

C++ 不站队,两种方案都支持,默认行为是复制。如果不想要复制,必须让有数据的类成为 virtual base class。所有直接继承它的类都要使用 virtual inheritance:

1
2
3
4
class File { ... };
class InputFile : virtual public File { ... };
class OutputFile : virtual public File { ... };
class IOFile : public InputFile, public OutputFile { ... };

标准库中有一个类似的 MI 层次,只不过类是类模板,名字分别是 basic_iosbasic_istreambasic_ostreambasic_iostream

从正确行为的角度看,public inheritance 应该总是 virtual 的。如果这是唯一的视角,规则就很简单:任何时候用 public inheritance,都用 virtual public inheritance。但正确性不是唯一的视角。避免继承字段的复制需要编译器在幕后做一些技巧,结果是使用 virtual inheritance 的类创建的对象通常比不使用时要大。访问 virtual base class 中的数据成员也比访问非 virtual base class 中的要慢。编译器之间细节不同,但基本趋势是明确的:virtual inheritance 有代价。

代价还体现在其他方面。Virtual base class 的初始化规则比非 virtual base 的更复杂、更不符合直觉。初始化 virtual base 的责任由层次结构中最派生的类承担。这条规则的含义包括:从 virtual base 派生出来的类必须意识到它们的 virtual base,无论距离多远;当层次结构中加入新的派生类时,它必须承担其 virtual base(包括直接和间接的)的初始化责任。

关于 virtual base class 的建议很简单。第一,除非需要,否则不要用。默认使用非 virtual inheritance。第二,如果必须用 virtual base class,尽量不要在里面放数据。这样就不必担心这类类的初始化(以及赋值)规则中的怪异之处。值得注意的是,Java 和 .NET 中的 Interface 在很多方面与 C++ 的 virtual base class 类似,它们不允许包含任何数据。

MI 的正当用法:接口与实现的分离

考虑下面这个建模 Person 的 Interface class(见 Item 31):

1
2
3
4
5
6
class IPerson {
public:
    virtual ~IPerson();
    virtual std::string name() const = 0;
    virtual std::string birthDate() const = 0;
};

IPerson 的客户端必须按 IPerson 指针和引用来编程,因为抽象类不能实例化。为了创建可操作的对象,客户端使用工厂函数来实例化 IPerson 的派生类:

1
std::tr1::shared_ptr<IPerson> makePerson(DatabaseID personIdentifier);

makePerson 如何创建返回指针指向的对象?显然必须有一个 IPerson 的 concrete 类可供实例化。假设这个类叫 CPerson。作为 concrete 类,CPerson 必须提供 IPerson 纯虚函数的实现。它可以从头写,但更好的是利用已有组件。

假设一个旧的数据库相关类 PersonInfo 提供了 CPerson 需要的大部分功能:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class PersonInfo {
public:
    explicit PersonInfo(DatabaseID pid);
    virtual ~PersonInfo();
    virtual const char * theName() const;
    virtual const char * theBirthDate() const;
    ...
private:
    virtual const char * valueDelimOpen() const;
    virtual const char * valueDelimClose() const;
    ...
};

PersonInfo 的设计目的是以各种格式打印数据库字段,每个字段值的开始和结束有特殊字符串作为分隔符。默认情况下,分隔符是方括号,所以字段值 “Ring-tailed Lemur” 会格式化为 [Ring-tailed Lemur]PersonInfo 的虚函数 valueDelimOpenvalueDelimClose 允许派生类指定自己的分隔符。PersonInfo::theName 的实现大致如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const char * PersonInfo::valueDelimOpen() const {
    return "[";
}

const char * PersonInfo::valueDelimClose() const {
    return "]";
}

const char * PersonInfo::theName() const {
    static char value[Max_Formatted_Field_Value_Length];
    std::strcpy(value, valueDelimOpen());
    // append name field
    std::strcat(value, valueDelimClose());
    return value;
}

theName 调用 valueDelimOpen 生成开头分隔符,然后生成名字值本身,再调用 valueDelimClose。因为这两个函数是 virtual 的,theName 返回的结果不仅取决于 PersonInfo,还取决于 PersonInfo 的派生类。

作为 CPerson 的实现者,你发现 IPerson 的文档要求 namebirthDate 返回未修饰的值,不允许有分隔符。也就是说,如果某人叫 Homer,name 应该返回 "Homer" 而不是 "[Homer]"

CPersonPersonInfo 的关系是 PersonInfo 碰巧有一些函数能让 CPerson 更容易实现。仅此而已。它们的关系是 is-implemented-in-terms-of,可以用 composition(Item 38)或 private inheritance(Item 39)表示。Item 39 指出 composition 通常是首选,但如果要重新定义 virtual 函数,就必须用 inheritance。这里 CPerson 需要重新定义 valueDelimOpenvalueDelimClose,所以简单的 composition 不行。最直接的方案是让 CPerson private 继承 PersonInfo。同时 CPerson 必须实现 IPerson 接口,这需要 public inheritance。于是得到一个 MI 的合理应用:public 继承 Interface,private 继承 Implementation:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class CPerson : public IPerson, private PersonInfo {
public:
    explicit CPerson(DatabaseID pid) : PersonInfo(pid) {}

    virtual std::string name() const {
        return PersonInfo::theName();
    }

    virtual std::string birthDate() const {
        return PersonInfo::theBirthDate();
    }

private:
    const char * valueDelimOpen() const { return ""; }
    const char * valueDelimClose() const { return ""; }
};

这个例子展示了 MI 可以既有用又可理解。

总结

Multiple inheritance 只是面向对象工具箱中的另一个工具。与 single inheritance 相比,它通常更复杂、更难理解。如果有一个与 MI 设计大致等价的 SI 设计,SI 设计几乎肯定更可取。如果唯一想出的设计涉及 MI,应该再想想,几乎肯定有办法让 SI 工作。同时,MI 有时是最清晰、最可维护、最合理的方式。当那种情况出现时,不要害怕使用它,只是要确保审慎地使用。

需要记住的

Multiple inheritance 比 single inheritance 更复杂。它可能导致新的歧义问题和 virtual inheritance 的需求。Virtual inheritance 在大小、速度以及初始化和赋值的复杂性方面都有代价。当 virtual base class 没有数据时,它最实用。Multiple inheritance 有正当的用途,一种场景是结合 public 继承 Interface class 和 private 继承一个帮助实现的类。