Effective C++读书笔记~6 继承与面向对象设计

条款32:确定你的public继承塑模出is-a关系

Inheritance and Object-Oriented Design

public继承奉行的规则

如果class D以public形式继承自class B,便是告诉编译器:每个类型为D的对象同时是一个类型为B的对象,反之不成立。需要用到D对象的时候,B对象无法效劳;反之,可以效劳。

  • 继承设计1:普通的2 class继承体系
    举例,切(penguin)是一种鸟,鸟可以飞,都是事实。如果这样描述这层关系:
class Bird {
public:
      virtual void fly(); // 鸟可以飞
};
class Penguin: public Bird { // 企鹅是一种鸟
    ... // 问题: 企鹅不会飞
};
  • 继承设计2:分类class继承体系
    存在这样一个问题:企鹅不会飞。也是说,我们上面的模塑是有问题的。如果考虑有数种鸟不会飞,可以这样重建模塑关系:
class Bird {
public:
    ... // 没有声明fly函数
};
class FlyingBird: public Bird { // 会飞的鸟
    virtual void fly(); // 会飞的鸟 可以飞
};
class Penguin: public Bird { // 企鹅是一种鸟
    ... // 没有声明fly函数
};

这样的设计更能反映真实的意思。但也存在一个问题,那就是我们的软件系统,可能完全不在乎是否会飞行,也就不需要区分会飞的鸟和不和飞的鸟。这种情况下,原来的2个class的继承体系,会更合适。
这2种情形说明一个问题:没有一个适用于所有软件的完美设计。所谓最佳设计,取决于客户想做什么,系统希望做什么事,包括现在与未来。

这样的设计更能反映真实的意思。但也存在一个问题,那就是我们的软件系统,可能完全不在乎是否会飞行,也就不需要区分会飞的鸟和不和飞的鸟。这种情况下,原来的2个class的继承体系,会更合适。
这2种情形说明一个问题:没有一个适用于所有软件的完美设计。所谓最佳设计,取决于客户想做什么,系统希望做什么事,包括现在与未来。

如何避免继承违反规则的错误?

企鹅不会飞,如何避免在继承体系下,违反会飞规则的这个错误?
两种方式:

  1. 重新定义fly函数,令它产生一个运行期错误。背后的思想是:如果让企鹅尝试飞行,这是一种错误。
void error(const string& msg); // 定义于别处

class Bird {
public:
      virtual void fly(); // 鸟可以飞
};
class Penguin: public Bird { // 企鹅是一种鸟
public:
      virtual void fly() { error("Attempt to make a penguin fly!"); } // 如果尝试让企鹅飞, 就运行期报错
};

缺点:能通过编译,只有运行期才能检测到“企鹅会飞”这条错误。

  1. 不为Penguin定义fly函数,也不为Bird定义fly函数。
class Bird {
    ... // 没有声明fly函数
};
class Penguin: public Bird { // 企鹅是一种鸟
       ... // 没有声明fly函数
};

方式2能在编译器就发现错误,解决了方式1只能在运行期报错的问题。

小结

1)“public继承”意味着is-a。适用于base classes身上的每件事情一定也适用于derived classes身上,因为每个derived class对象也都是一个base class对象。
2)如果关注派生类与基类的差别,可以根据差别多分派生类,各派生类拥有自己独特能力;如果不关注,可以从基类移除。
3)将错误提前到编译期发现,尽量不留到运行期。

[======]

条款33:避免遮掩继承而来的名称

Avoid hiding inherited names.

继承关系中,名称查找顺序

继承关系中,同名成员函数和成员变量存在同名遮掩的问题。编译器碰到成员变量和成员函数名称时,查找顺序是:function作用域(local 作用域) => Derived作用域 => Base作用域 => Namespace作用域 => Global作用域。

基类的同名函数遮掩范围

当派生类成员函数名称覆盖了基类同名函数时,基类所有同名函数都会被遮掩,包括类型完全相同的覆盖函数,以及同名重载函数。实际上,我们通常并不想遮掩同名的重载函数。

// 重载函数被覆盖示例
class Base {
private:
    int x;
public:
    virtual void mf1() = 0; // 派生类mf1同名 同类型函数, 会被覆盖
    virtual void mf1(int);   // 基类mf1重载函数, 会被遮掩
    virtual void mf2();
    void mf3(); // mf3同名 同类型函数
    void mf3(double); // mf1重载函数, 会被遮掩
    ...
};
class Derived: public Base {
public:
    virtual void mf1(); // 派生类同名函数被覆盖
    void mf3(); // 重载函数被遮掩
    void mf4();
    ...
};

// 客户使用
Derived d;
int x;
...
d.mf1();      // OK, 调用Derived::mf1
d.mf1(x);    // 错误, 因为Derived::mf1覆盖了Base::mf1
d.mf2();  // OK, 调用Base::mf2
d.mf3();  // OK, 调用Derived::mf3
d.mf3(x); // 错误, 因为Derived::mf3覆盖了Base::mf3

如何使用被覆盖的基类函数?

1)使用using声明,让class Base内(已被覆盖的)函数mf1,mf3在Derived作用域内可见(且为public)。如果当前class作用域内没有匹配的函数, 就会去在当前类中可见的基类成员函数中找。

// 派生类中使用基类被覆盖的重载函数示例

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; // 基类mf1在Derived中可见, 不再被遮掩
    using Base::mf3; // 基类mf3在Derived中可见, 不再被遮掩
    virtual void mf1();
    void mf3();
    void mf4();
    ...
};

// 客户使用
Derived d;
int x;
...
d.mf1();  // OK, 调用Derived::mf1
d.mf1(x); // 现在OK, 调用Base::mf1
d.mf2();  // OK, 调用Base::mf2
d.mf3();  // OK, 调用Derived::mf3
d.mf3(x); // 现在OK, 调用Base::mf3

2)有时并不想可见base class的所有被覆盖函数,而是少数几个函数版本。而方式1)使用using会让被覆盖的所有同名函数,都对派生类可见。此时,可以用"Base::mf1()"方式,调用之。

class Derived: public Base {
public:
    virtual void mf1() // 暗自成为inline转交函数(forwarding function)
    { Base::mf1(); } // 调用Base class的mf1函数, 即使基类mf1已经被覆盖 而且派生类没有使用using让其对派生类可见
    void mf3();
    void mf4();
    ...
};

当继承结合template, 面对 继承名被覆盖的问题,见条款43.

小结

1)derived class内的名称会覆盖base class内的名称。在public继承下从来没有人希望如此。
2)为了让被遮掩的名称再见天日,可以使用using声明式,或者转交函数(forwarding function)。

[======]

条款34:区分接口继承和实现继承

Differentiate between inheritance interface and inheritance of implementation.

public继承

public继承概念由2部分组成:函数接口(function interface)继承和函数实现(function implement)继承。2种继承的差异类似于函数声明与定义之间的差异。

有时,会希望derived class只继承成员函数的接口(即声明);有时又希望derived class同时继承函数的接口和实现,但又希望能override(覆写)所继承的实现;有时又希望derived class同时继承接口和实现,而且不允许override任何实现。

例,

class Shape {
public:
    virtual void draw() const = 0; // pure virtual 函数
    virtual void error(const string& msg); // impure virtual 函数
    int objectID() const; // non-virtual函数, 返回当前对象的一个独一无二的整数识别码
    // ...
};
class Rectangle: public Shape { ... };
class Ellipse: public Share { ... };
  • 成员函数的接口总是会被继承

Shape的3个成员函数都会被派生类继承。
pure virtual 函数最2个突出特性:
1)必须被任何派生类重新声明;
2)在抽象类(Shape)中通常没有定义;(但C++并不阻止这样做, 也就是有这样的灵活性)

  • 声明一个pure virtual函数的目的是为了让derived class只继承函数接口,自行实现

pure virtual函数是一个不错的选择。但也存在问题:无法提供缺省实现,因为pure virtual通常没有定义。
不过,如果抽象类如果定义了pure virtual函数draw,子类只能通过调用Shape::draw来实现缺省实现。

  • 声明简朴的impure virtual函数的目的,是让derived class继承该函数的接口和缺省实现

以error为例,派生类继承了Shape的error,如果没有实现error时将Shape的作为缺省实现。

class Shape {
public:
    virtual void error(const string& msg); // impure virtual 函数
    // ...
};

允许impure virtual函数同时指定函数声明和缺省行为,同样存在问题。如果一个派生类忘记实现接口,而已经存在的缺省行为却不适用新增派生类,就会造成灾难。

  • 如何解决impure virtual函数同时指定函数声明和缺省行为,造成的问题?
    将基类impure virtual函数修改为pure virtual函数,同时增添protected访问权限的默认实现。这样,可以强制派生类实现接口。
// 良好设计: impure virtual函数(接口) + default实现 分开, 子类隐式调用基类缺省实现
class Airplane {
public:
       virtual void fly(const Airport& destination) = 0; // pure virtual函数
protected: // 实现如何飞的细节, 客户不关心如何飞
       void defaultFly(const Airport& destination); // fly缺省实现
};

class ModelA : public Airplane {
public:
       void fly(const Airport& destination) override
       {
              defaultFly(destination);
       }
};
class ModelB : public Airplane {
public:
       void fly(const Airport& destination) override
       {
              defaultFly(destination);
       }
};
class ModelC: public Airplane {
public:
       void fly(const Airport& destination) override; // ModelC必须实现pure virtual函数fly
};

// ModelC的fly方法实现
void ModelC::fly(const Airport& destination)
{
    将C型飞机飞至指定目的地
}

另一种设计方案:为pure virtual添加定义作为缺省行为,如果需要缺省行为,则在派生类中显式调用。

// 良好设计: impure virtual函数(接口) + 子类通过基类显式调用default实现
class Airplane {
public:
       virtual void fly(const Airport& destination) = 0; // pure virtual函数
};

/* 通常不定义pure virtual函数, 但这里作为缺省设计. 编译器并不阻止这样做 */
void Airplane::fly(const Airport& destination)
{

}

class ModelA : public Airplane {
public:
       void fly(const Airport& destination) override
       {
             Airplane::fly(destination);
       }
};
class ModelB : public Airplane {
public:
       void fly(const Airport& destination) override
       {
             Airplane::fly(destination);
       }
};
class ModelC: public Airplane {
public:
       void fly(const Airport& destination) override; // ModelC必须实现pure virtual函数fly
};

// ModelC的fly方法实现
void ModelC::fly(const Airport& destination)
{
    将C型飞机飞至指定目的地
}
  • 声明non-virtual函数的目的,是为了令derived class继承函数的接口及一份强制性实现

经验不足class设计者 2个常犯错误:
1)将所有成员函数声明为non-virtual。non-virtual析构函数会带来问题(条款7)。
2)将所有成员函数声明为virtual函数。除了Interface class(条款31),其他情况说明设计者缺乏坚定立场,某些函数就是不应该在derived class中被重新定义。

小结

1)接口继承和实现继承不同。在public继承之下,derived class总是继承base class的接口。
2)pure virtual函数只具体指定接口继承。
3)简朴的impure virtual函数具体指定接口继承及缺省实现继承。
4)non-virtual函数具体指定接口继承以及强制性实现继承。

[======]

条款35:考虑virtual函数以为的其他选择

Consider alternatives to virtual functions.

例如,假如在写一个游戏软件,你打算为游戏内的人物设计一个继承体系。游戏中人物因被伤害,或因其他因素而降低健康状态。因为不同人物可能以不同的计算方式计算人物的健康指数,将healthValue声明为virtual似乎是很合理做法:

class GameCharacter {
public:
virtual int healthValue() const; // 返回人物健康指数. derived class可重新定义, 或者不定义使用base class的缺省实现
...
};
healthValue未被声明为pure virtual,暗示着将会有一个计算人物健康指数的缺省算法。

另一些方法:
1. 借由Non-virtual Interface方法实现Template Method模式
保留healthValue为public函数,成为non-virtual,并且调用一个private virtual函数。核心思想:将virtual函数设为private。

class GameCharacter {
public:
    int healthValue() const // 返回人物健康指数. derived class可重新定义, 或者不定义使用base class的缺省实现
    {
        ...                                  // 做一些事前工作
        int retValue = doHealthValue();
        ...                                  // 做一些事后工作
        return retValue;
    }
    ...
private: // 如果要让derived class可见, 使用protected更合适
    virtual int doHealthValue() const // derived class可重新定义, 计算健康指数的缺省算法
    {
        ...
    }
};

NVI:令客户通过public non-virtual成员函数间接调用private virtual函数,称为non-virtual interface(NVI)手法。
NVI是Template Method设计模式的一个独特表现形式。healthValue可称为virtual函数的包装器(wrapper)。
这种方法的优点:可以在上述代码注释处“做一些事前工作”和“做一些事后工作”,能保证在virtual函数的前后。适合用于特定场景,比如事前做准备工作,事后做清理工作。

NVI方法下,没必要让virtual函数一定是private的,为了让derived class也调用virtual函数doHealthValue,protected更合适。

2. 借由Function Pointers实现Strategy模式

如果健康指数的计算与人物无关,需要由客户(调用者)提供,那么可以要求每个任务构造函数接受一个指针,指向一个健康计算函数。

class GameCharacter;
// 利用函数指针实现Strategy模式, 计算健康指数的算法由用户提供
int defaultHealthCalc(const GameCharacter& gc);
class GameCharacter {
public:
    typedef int (*HealthCaclFunc)(const GameCharacter&);
    // 利用构造函数传入计算健康指数算法的函数指针, 设置缺省算法
    explicit GameCharacter(HealthCaclFunc hcf = defaultHealthCalc) : healthFunc(hcf) 
    {}
    int healthValue() const
    { return healthFunc(*this); }
        ...
private:
        HealthCaclFunc healthFunc;
};

函数指针做法的优点:

  • 同一个人物类型,可以实现不同的健康指数计算方法,不过需要用户提供。
  • 运行期可改变健康计算指数方法。

缺点:

  • 传入的函数不是类成员函数,无法访问non-public函数/变量。不过,可以通过降低类封装性,将传入的函数声明为friend来解决这个问题。

3. 借由tr1::function实现Strategy模式
函数指针是C编程的用法,C++中可用用function模板来替代。function代表了一切可调用单元,包括:函数、函数指针、lambda、 bind创建的对象、函数对象类。
除了将函数指针替换为function,其他几乎什么都不用做。

#include <functional> // 添加头文件

class GameCharacter;
// 利用函数指针实现Strategy模式, 计算健康指数的算法由用户提供
int defaultHealthCalc(const GameCharacter& gc);
class GameCharacter {
public:
    typedef std::tr1::function<int (const GameCharacter&> HealthCaclFunc; // 只修改了这一行, 将原来表示函数指针的类型, 修改成表示function<>类型
    // 利用构造函数传入计算健康指数算法的函数指针, 设置缺省算法
    explicit GameCharacter(HealthCaclFunc hcf = defaultHealthCalc) : healthFunc(hcf)
    {}
    int healthValue() const
    { return healthFunc(*this); }
        ...
private:
        HealthCaclFunc healthFunc;
};

4. 古典Strategy模式 -- 用类绑定策略函数
有点类似于函数对象类,不过函数对象类是重载operator(),而用类绑定策略函数可以自定义函数名。
不同的case,用户传入不同的对象即可。

class  GameCharacter;
// 策略base class, 每个类代表一个策略
class HealthCalcFunc {
public:
    ...
    virtual int calc(const GameCharacter& gc) const // 策略函数, virtual代表希望子类自己实现
    { ... }
    ...
};

HealthCalcFunc defaultHealthCalc;
// 使用策略的类, 用一个对象指针保存传入的策略对象
class GameCharacter {
public:
    explicit GameCharacter(HealthCalcFunc* phcf = &defaultHealthCalc) : pHealthCalc(phcf)
    {}
    int healthValue() const
    { return pHealthCalc->calc(*this); }
    ...
private:
    HealthCalcFunc* pHealthCalc; // 保存类对象指针
};

优点:容易辨识为标准Strategy模式。通过继承体系实现策略,要添加一个不同的策略,只需要派生出一个不同的derived class。

小结

本条款的意义是,当你为解决问题而寻找某个设计时,不妨考虑virtual函数替代方案。下面是几个替代方案要点:
1)使用non-virtual interface(NVI)方法,是Template Method设计模式的一种特殊形式。以public non-virtual成员函数包裹private或protected的virtual函数。
2)将virtual函数替换为“函数指针成员”,这是Strategy设计模式的一种表现形式。
3)方式2)的替换,用function替换 函数指针。
4)将virtual函数替换为另外一个继承体系的virtual函数,这是经典strategy模式的实现方法。

[======]

条款36:绝不重新定义继承而来的non-virtual函数

Never redefine an inherited non-virtual function.

假设class D是public 继承class B。class B定义有一个public成员函数mf()。

class B {
public:
    void mf(); // non-virtual 函数
    ...
};

class D: public B { ... };

// 客户通过class B、class D类型指针,调用mf()
D x; // 构建D对象x

B *pB = &x;
pB->mf();  // 调用B::mf()

D *pD = &x;
pD->mf(); // 调用D::mf()

同一个对象,2种类型的指针调用mf,却出现2种不同的结果。这是因为non-virtual函数如B::mf和D::mf是静态绑定的(staticlly bound,条款37)。
而当把mf修改为virtual函数后,两处调用mf() 都是调用同一个D::mf,因为virtual函数的动态绑定的(dynamically bound),指针绑定的实际对象是D类x。

这就是说,当你在编写class D并重新定义继承自class B的non-virtual函数mf时,D对象可能展现出精神分裂的不一致行径:当mf被调用时,D对象可能表现出B或D的行为,决定因素不在于自身,而取决于指向该对象的指针类型。reference也会展现出一样难以理解的行径。

条款32说过,所谓public继承意味着is-a(是一种)的关系。条款34则描述为什么在class内声明一个non-virtual函数会为该class建立起一个不变性(invariant),凌驾其特异性(specialization)。

因此,对于这种情况,函数不应该声明为virtual。

小结

1)适用于B对象的每件事,也适用于D对象,因为每个D对象都是B对象;
2)B的derived class一定会继承mf的接口和实现,因为mf是B的一个non-virtual函数;
3)绝不要重新定义继承而来的non-virtual函数,类同条款7(base class内的析构函数应该是virtual);

[======]

条款37:绝不重新定义继承而来的缺省参数值

Never redefine a function's inherited default parameter value.

什么是继承而来的缺省参数值?

类只能继承2种函数:virtual函数,non-virtual函数。而重新定义一个继承而来的non-virtual函数是错误的(条款36),因此,本条款讨论局限于“继承一个带有缺省参数值的virtual函数”。

下面看一个包含带缺省参数值的virtual函数的例子:

class Shape {
public:
       enum ShapeColor { Red, Green, Blue};
       // 所有形状都必须提供一个函数,用来绘制出自己
       virtual void draw(ShapeColor color = Red) const = 0; // 带缺省参数的virtual函数
};
class Rectangle : public Shape {
public:
       // 糟糕的设计(但非语法错误),赋予不同的缺省参数值
       virtual void draw(ShapeColor color = Green) const
       {
              cout << "Rectangle::draw = " << color << endl;
       }
};
class Circle : public Shape {
public:
       // OK.
       // 当客户以对象调用此函数时, 一定要指定参数值. 因为静态绑定下, 该函数并不从其base继承缺省参数值;
       // 当客户以指针或reference调用此函数, 无需指定参数值. 因为动态绑定下, virtual函数会从base继承缺省参数值;
       virtual void draw(ShapeColor color) const
       {
              cout << "Circle::draw = " << color << endl;
       }
};

考虑客户通过指针或对象调用draw:

Shape* ps;                    // 静态类型为Shape*
Shape* pc = new Circle;       // 静态类型为Shape*
Shape* pr = new Rectangle;    // 静态类型为Shape*

ps = pc;                      // ps动态类型是Circle*
ps->draw(); // 打印0 (Red)

ps = pr;                      // ps动态类型是Rectangle*
ps->draw(); // 打印0 (Red)

// derived中继承的virtual函数没有指定默认参数时, 如果通过指针访问, 无需指定参数值
pc->draw(); // 打印0 (Red)

pr->draw(); // 打印0 (Red)

// derived中继承的virtual函数没有指定默认参数时, 如果通过对象访问, 必须指定参数值
Circle c;
c.draw(Shape::Blue); // 打印2 (Blue)

Rectangle r;
r.draw();  // 打印1 (Green)

不难看出,
1)只要是通过指针访问的virtual函数,都继承了base class的virtual函数缺省参数。比如,通过指向Rectangle对象的pr指针调用virtual函数draw,会自动继承base class(Shape)的virtual函数的缺省参数。
2)而通过对象调用virtual函数,则不会继承base class的virtual函数的缺省参数。

Shape* pr = new Rectangle;
pr->draw();  // 调用Rectangle::draw(Shape::Red);

聪明的做法是替代设计。参考条款35,使用NVI(non-virtual interface)方法:令base class内一个public non-virtual函数调用private/protected virtual函数,后者可被derived class重新定义。

class Shape {
public:
    enum ShapeColor {Red, Green, Blue };
    void draw(ShapeColor color = Red) const // 现在是non-virtual, 根据条款36, 绝不重新定义non-virtual函数
    {
        doDraw(color); // doDraw 是virtual函数, 不同类对象运行时执行不同的doDraw函数
    }
    ...
private:
        virtual void doDraw(Shape color) const = 0; // 真正完成绘制工作
};

class Rectangle: public Shape {
public:
    ...
private:
    virtual void doDraw(ShapeColor color) const; // base class中的draw调用. 无需指定参数
    ...
};

小结

1)绝对不要重新定义一个继承而来的缺省参数值,因为缺省参数值是静态绑定的,而virtual函数是动态绑定的;

[======]

条款38:通过复合塑模出has-a或“根据某物实现出”

Model "has-a" or "is-implemented-in-terms-of" through composition.

复合(composition)是类型之间的一种关系,当某种类型的对象内含其他种类型的对象,便是这种关系。
例如,

class Address { ... };
class PhoneNumber { ... };
class Person {
public:
    ...
private:
    std::string name;   // composed object
    Address address;    // composed object
    PhoneNumber voiceNumber;  // composed object
    PhoneNumber faxNumber;    // composed object
};

复合与public继承

例子中,Person对象由string,Address,PhoneNumber构成,后面3类称为comosed object(合成成分物)。
复合(composition)还有很多同义词,包括layering(分层),containment(内含),aggregation(聚合),embedding(内嵌)。

public继承是带有is-a(是一种)的意思,复合是has-a(有一个)或is-implemented-in-terms-of(根据某物实现出)的意思。
当复合发生于应用域内的对象之间,表现出has-a的关系;当它发生于实现域内,则表现is-implemented-in-terms-of的关系。

如何区分is-a与is-implemented-in-terms-of?

我们以用STL list实现自定义set template的例子来说明。

如条款32,如果D是一种B(is-a关系),对B为真的每件事对D应该也为真。
如果list跟set是is-a关系,那么set应该继承自list。也就是说,list对象适用的,set对象也适用,因为set对象也是list对象(反过来不成立)。
然而,list可以包含重复元素,而set不允许。因此,list跟set并不是is-a的关系。

这样的话,我们可以让set拥有一个list(has-a的关系),利用list特性实现set功能。

// 错误做法:set并不是list (is-a)
template<typename T>
class Set: public std::list<T> {...};

// OK:set拥有一个list (has-a)
template<typename 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; //用来表述Set的数据
};

template<typename T>
bool Set<T>::member(const T& item) const // 查找item是否存在
{
    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();
}

小结

1)复合的意义和public继承完全不同;
2)在应用域(application domain),复合意味着has-a(有一个);在实现域(implement domain),复合意味着is-implemented-in-terms-of(根据某物实现);

[======]

条款39:明智而审慎地使用private继承

Use private inheritance judiciously.

什么是private继承?

下面例子中,我们称类Student private继承类Person。

class Person {...};
class Student : private Person {...}; // private继承

根据条款32,public继承意味着is-a关系,那么private继承意味着什么?
两点:
1)编译器不会自动将一个derived class对象转换为base class对象;
2)由private base class继承而来的所有成员,在derived class中都会变成private属性,即使它们在base class中原本是protected或public属性;

对于2)不过多解释,对于1),我们看下面的例子:

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

Person p;  // p是人
Student s; // s是学生

eat(p); // OK:p是人,会吃
eat(s);     // 编译器报错:因为Student私有继承自Person,编译器不会将Student对象自动转换为Person对象

我们会看到,编译器并不会将一个private继承关系中的derived 对象自动转换为base对象。

private继承与implemented-in-terms-of(根据某物实现)

如果让class D以private继承class B,用意是为了采用class B内已经准备妥当的某些特性,不是因为B对象和D对象在存在任何观念上的关系。private继承纯粹是一种实现技术,根据条款34,private继承意味着只有实现部分被继承,接口部分应略去。
如果D private继承B,i.e. D对象根据B对象实现而得,没有其他意涵。private继承在软件“设计”层面没有意义,只存在软件实现层面。

既然private意味着implemented-in-terms-of(根据某物实现),条款38复合(composition)也是如此,那么如何选择?
答:尽可能使用复合,必要时才使用private继承。什么时候才是必要?主要是当protected成员或virtual函数牵扯进来的时候,因为通过复合只能访问public成员函数和变量,除非降低类封装性(使用友元或者添加public接口)。下文提到的极端情况,也适用private继承。

如何使用private继承?

假设我们要修改Widget class,让它记录每个成员函数被调用次数。运行期间,将周期性审查这些信息(被调用次数、运行时间等)。为了完成这项工作,需要设定某种定时器,使我们知道收集统计数据的时候是否到了。

我们发现有个Timer class,可以复用既有代码

class Timer {
public:
    explicit Timer(int tickFrequency);
    virtual void onTick() const; // 定时器每滴答一次, 就调用一次该函数
    ...
};

1)使用private继承
为了让Widget重新定义Timer内的virtual函数,Widget可以继承自Timer。由于Widget并不是Timer,因此不能使用public继承,只能使用private继承。

class Widget: private Timer {
private:
virtual void onTick() const; // 周期性执行, 查看Widget数据等
};

2)使用复合
然而该设计有一个缺陷:无法阻止derived class重新定义onTick(不论private继承,还是public继承)。改用复合+public继承的方法,在Widget内声明一个嵌套private class,后者以public形式继承Timer并重新定义onTock。

class Widget {
private:
    class WidgetTimer: public Timer { // 内嵌类public继承Timer
    public:
        virtual void onTick() const;
        ...
    };
    WidgetTimer timer;
    ...
};

这样设计有2个优点:
(1)能解决private继承无法解决的derived class重新定义onTick问题;
(2)如果想要将Widget编译依存性降至最低,可以将WidgetTimer移除Widget类外,在Widget类内只需要一个指针指向WidgetTimer即可,头文件中不再需要include Timer或者WidgetTimer,而只需要class声明。

极端情况

当一个类是空类时(没有任何non-static成员变量,virtual函数)。如果一个类含有一个Empty,可能会导致占用的内存空间变大。比如:

class Empty { }; // 空类占用1byte空间
class HoldsAnInt {
private:
    int x; // 4byte
    Empty e; // 1byte,实际可能占用4byte(对齐)
};

理论上,空类应该不占用空间,但实际上C++实现,空类占用1byte,即sizeof(Empty) = 1。这样,HoldsAnInt原本只应该占用4byte,实际上可能占用5byte或者8byte(如果有4byte对齐要求)。

如果是private继承,就能解决这个问题。下面例子中,sizeof(HoldsAnInt) = 4。

class HoldsAnInt: private Empty { // private继承
private:
    int x; // 4byte
};

小结

1)private继承意味着is-implemented-in-terms-of(根据某物实现出)。通常比复合的级别低。只有当derived class需要访问protected base class成员,或者需要重新定义继承而来的virtual函数时,使用private继承是合理的。
2)与复合不同,private继承可以造成empty base最优化(极端情况)。这对于“对象尺寸最小化”的程序库而言,可能很重要。

[======]

条款40: 明智而审慎的使用多重继承

Use multiple inheritance judiciously.

多重继承的问题

多重继承情形下,程序可能从一个以上的base class继承相同名称的成员函数、成员变量、typedef等,可能会导致歧义(ambiguity)。
例如,

class BorrowableItem {
public:
    void checkOut();
};

class ElectronicGadget {
private:
    void checkOut() const;
};

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

// 客户调用
MP3Player mp;
mp.checkOut(); // 调用时产生歧义:到底要调用哪个checkOut(),BorrowableItem::checkOut(), or ElectronicGadget::checkOut() ?

虽然ElectronicGadget::checkOut()是private,但C++编译器是首先找到最佳匹配,然后才检验其可取用性。例子中两个checkOut具有相同的匹配程度,没有最佳匹配,因此编译器报错。为了解决这个歧义,调用者必须明白指出调用哪一个base class内的函数:

mp.BorrowableItem::checkOut(); // 另一个ElectronicGadget::checkOut是private,无法通过对象调用

virtual继承

多重继承体系中,有种情形是继承一个以上层级的base class,而中间的base class又继承自同一base class,就会导致“砖石型多重继承”。
image

class File {...};
class InputFile: public File {...};
class OutputFile: public File {...};
class IOFile: public InputFile, public OutputFile {...};

这种继承方式下,存在一个严重问题:如果File存在名为fileName的数据成员,那么InputFile和OutputFile都会继承得到,而IOFile自然也会继承得到2个同名的fileName数据成员。这样,如果在IOFile中,访问fileName,就会产生歧义。最好的解决方式,就是采用virtual继承,让带有fileName数据的class(即File类)称为virtual base class。

class File {...};
class InputFile: virtual public File {...};  // 让拥有fileName数据的File成为virtual base class
class OutputFile: virtual public File {...}; // 让拥有fileName数据的File成为virtual base class
class IOFile: public InputFile, public OutputFile {...}; // InputFile, OutputFile不是fileName的拥有者, 无需成为virtual base class

普通继承中,base class的数据成员会直接成为derived class的一部分,也就是说,每个derived class都有一份base class的拷贝;
virtual继承中,derived class会产生一个bptr(base class table pointer,基类表指针),指向base class table(基类表),这样每个virtual base class在内存中只有一份。

什么时候使用virtual public继承?

既然virtual public继承能杜绝“钻石型多重继承”的问题,确保正确性,那么是否任何时候都用virtual public继承呢?
答案是否定的。使用virtual继承是有代价的:使用virtual继承的class产生的对象往往比使用non-virtual继承的体积大,访问virtual base class的成员变量速度也更慢。

对virtual base class的使用建议:
1)非必须不使用virtual base,平时使用non-virtual继承;
2)如果必须使用,尽可能避免在其中放置数据,类似于Java的Interface,只有接口没有数据;

小结

1)多重继承比单一继承复杂。它可能导致新的歧义,以及对virtual继承的需要;
2)virtual继承会增加大小、速度、初始化(及赋值)复杂度等成本。如果virtual base class不带任何数据,将是最具实用价值的情况;
3)多重继承的确有正当用途。其中一个情节涉及“public继承某个Interface class”和“private继承某个协助实现的class”的两项组合。

[======]

posted @ 2021-11-27 17:03  明明1109  阅读(112)  评论(0编辑  收藏  举报