Effective C++ 学习笔记 第六章:继承与面向对象设计

第一章见 Effective C++ 学习笔记 第一章:让自己习惯 C++
第二章见 Effective C++ 学习笔记 第二章:构造、析构、赋值运算
第三章见 Effective C++ 学习笔记 第三章:资源管理
第四章见 Effective C++ 学习笔记 第四章:设计与声明
第五章见 Effective C++ 学习笔记 第五章:实现
第六章见 Effective C++ 学习笔记 第六章:继承与面向对象设计
第七章见 Effective C++ 学习笔记 第七章:模板与泛型编程
第八章见 Effective C++ 学习笔记 第八章:定制 new 和 delete
第九章见 Effective C++ 学习笔记 第九章:杂项讨论

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

Make sure public inheritance models “is-a”.

public 继承表示 is-a 关系,也就是它始终能表示派生类是一个/是一种基类,能接受基类的方法也一定能接受派生类,基类的属性也一定应该是派生类的属性。比如学生是人,人有的东西,学生也有。但反过来不成立。
但是,有些时候这种 is-a 关系并不一定始终成立。比如企鹅是一种鸟,鸟能飞,但企鹅不能飞。
这个例子告诉我们的是,我们永远无法构建出适用于“所有软件”的完美设计,我们只需要专注于我们构建的世界是否完美即可。

仍以企鹅与鸟的例子来说,如果我们构建的软件里,不需要关系(无论现在还是将来)飞的问题,那么企鹅类完全可以 public 继承鸟类;但反之则不行。虽然你可以让企鹅类中的飞这个能力,在运行时抛出错误,但那不是优雅的解决方式,更好的办法是,设计会飞的鸟类和不会飞的鸟类,都 public 继承鸟类,而企鹅类可以 public 继承不会飞的鸟类。当然,如果你的软件系统里不在乎飞的问题,那还这么做就有点多此一举。

不要滥用 public 继承,有些时候看似是 is-a 的关系,但实际却不一定是。
正方形属于矩形的一种特例,所以正方形类 public 继承矩形类。但是,如果矩形类有个方法,实现只调整长,不调整宽而增加矩形的面积,那么这个方法就不能被正方形所接受。这种情况下,其实就不满足 is-a 关系。

除了 is-a 关系外,还有 has-a 关系和 is-implemented-in-terms-of (根据某物实现)关系。 public 继承一定描述的是 is-a 关系。

总结

  • “public 继承” 意味着 is-a。适用于 base classes 身上的每一件事情一定也适用于 derived classes 身上,因为每一个 derived class 对象也都是一个 base class 对象。

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

Avoid hiding inherited names.

我们都知道,内部作用域和外部作用域有同名的对象或函数时,内部的那个会遮掩外部的那个,使外部的那个不可见。继承中也遵循这个原理,派生类就像内部作用域,基类像外部作用域。

对于 public 继承来说,为了能满足 is-a 关系,对于基类中的所有属性和方法,我们都应该继承在派生类中。

然而,实际操作时可能我们不一定愿意那么干,除了不使用 public 继承以外,我们还可以使用 using 来将基类中的同名对象或函数再暴露在派生类中:

class Base {
public:
    void a();
    void a(int);
};
class Derived: public Base {
public:
    using Base::a;      // 通过 using 将基类中的 a(void) 和 a(int) 函数暴露在派生类中
    void a();           // 属于派生类的 a(void) 函数
};
Derived d;
int x;
d.a();                  // 调用派生类中的 a(void) 函数
d.a(x);                 // 调用基类中的 a(int) 函数
// 基类中的 a(void) 函数依旧会被覆盖

但是,这样做仍然有个问题。如果我们只想让基类中的一部分 a() 函数暴露出来,比如说基类中还有个 void a(double) 函数,按上例的做法,在派生类中也可以访问到 a(double) 函数。
一个解决办法是用转交函数:

class Base {
public:
    void a();
    void a(int);
    void a(double);
};
class Derived: public Base {
public:
    void a();
    void af(int) { Base::a(int); }   // 转交函数,编译器会通过 inline 展开内函数
};
Derived d;
int x;
double y;
d.a();              // 调用派生类的 a() 函数
d.a(x);             // 错误,基类的 a(int) 无法访问
d.af(x);            // 转交函数,调用到基类的 a(int)
d.a(y);             // 错误,无法访问其他没有转交的基类 a() 函数

总结

  • 派生类内的名字会遮掩基类内的名字,在 public 继承下从来没人愿意如此。
  • 为了让被遮掩的名字再见天日,可使用 using 声明式或转交函数(forwarding functions)。

条款 34:区分接口继承和实现继承 (重要)

Differentiate between inheritance of interface and inheritance of implementation.

public 继承分为接口继承和实现继承,接口继承就是只针对那些接口性质的函数做继承(纯虚函数和虚函数),实现继承是针对那些定义了默认操作或不变性操作的函数做继承(虚函数和普通成员函数)。

第一种:纯虚函数。

这种函数一般情况下在基类中不给出定义,它的作用是提供一种接口,要求继承该基类的派生类必须实现该接口。这种叫做接口继承。

第二种:虚函数。

这种函数会在基类中提供一种定义,但它的作用是针对类层次中的不同类提供可能不同的实现,继承该基类的派生类可以选择针对自己的需求实现该虚函数,也可以选择使用基类中的实现。所以这种情况就叫做同时继承接口和实现。

书中提到,有些时候,我们会担心一个基类中的虚函数,在长期的维护中,比如一个新的派生类,程序员会忘记对该虚函数实现一份属于新派生类的定义,同时该派生类又不能直接引用基类的该虚函数实现,从而造成隐患。
一种做法是用纯虚函数替代虚函数,再在基类中实现一个默认的函数(普通成员函数),再在派生类的不同纯虚函数的实现中应用这个普通默认函数。

class Base {
public:
    virtual void F() = 0;    // 纯虚函数
protected:
    void defaultF() { ... }; // 普通函数,不应该被派生类覆盖,后边会讲到
};
class Derived1: public Base {
public:
    virtual void F() {
        defaultF();
    }
};
class Derived2: public Base {
public:
    virtual void F() {
        D2F();        // 假设这是属于 Derived2 的专有实现,用来完成 F 的功能
    }
private:
    void D2F() { ... };
};

有人会认为这样的设计,F 和 defaultF 两个函数,一个作用,不好维护。
另一种做法是,使用纯虚函数的默认版本,是的,纯虚函数也可以在基类中提供实现,但必须指定类名来引用:

class Base {
public:
    virtual void F() = 0;     // 纯虚函数
};
void Base::F() { ... }
class Derived1: public Base {
public:
    virtual void F() {
        Base::F();            // 调用 Base 中纯虚函数 F 的默认实现
    }
};

第三种:普通函数

普通函数在继承体系的类结构中,应该是不能被覆盖的。它所表示的是一种不变性(invariant)的属性。
在这种属性下,派生类使用普通函数,便是继承实现。

总结

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

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

Consider alternatives to virtual functions.

注意:这一条款略有生涩,涉及到设计模式的知识。

使用 virtual 函数来设计一些具有默认实现的接口设计,是一种常规的面向对象设计思路,但我们应该同样去寻求一些能替代 virtual 函数实现的方案。
这一部分作者没有说清为什么要替换掉 virtual 函数的实现。

话题 1:Template Method 模式

这里的 Template 和 C++ 的模板没有关系,它是一种设计模式。
将 virtual 函数放到基类的 private 中,然后设计一个普通成员函数放到 public 中,并调用 virtual 函数:

class G {
public:
    int h() const {
        doH();
    }
private:
    virtual void doH() const { ... }
};

继承 G 类的其他派生类,可以选择重新实现 doH() 函数,但他们都统一使用 h() 函数来实现曾经 virtual 函数的功能。
这种方案被称为 Non-Virtual Interface,简称 NVI。

话题 2:由函数指针实现的 Strategy 模式

将曾经要做的 virtual 函数功能省略,转为通过传入一个函数指针,将一个类外的设计传入类内。

class G;
int doH(const G&);    // 需要传入的函数实现,这里是一种默认实现
class G {
public:
    typedef int (*H)(const G&);    // 函数指针类型声明
    explicit G(H d = doH) : hF(d) {};    // 构造函数传入函数指针,并赋值给内部资源
    int h() const {
        hF(*this);    // 引用函数指针完成功能
    }
private:
    H hF;
};
// 以下为使用方法:
int doH1(const G&);    // 省略定义
int doH2(const G&);
G g1(doH1);
G g2(doH2);

为了实现和第一种一样的功能,我们实际上是需要传入一个第一种中的默认参数的,也就是一个 G 对象(this 指针)。
这样做,可以将不同的实现,比如 int doH1(const G&);int doH2(const G&); 分别赋给不同的 G 对象,从而实现不同的功能。

话题 3:由 tr1::function 实现的 Strategy 模式

大概意思是,第二种中我们只能传入函数指针,仍然不够自由,如何能传入泛化的函数指针,比如第二种中,我们还想传入 float doH3(const G&); 这种函数指针。使用 tr1::function 能够实现。

class G;
int doH(const G&);    // 仍然要提供一种默认实现
class G {
public:
    typedef std::tr1::function<int (const G&)> H;    // 这是 tr1::function 的类型声明
    explicit G(H d = doH) : hF(d) {};    // 这次我们传入的是 tr1::function 的结构
    int h() const {
        hf(*this);
    }
private:
    H hF;
};
// 以下为使用方法:
short doH1(cosnt G&);    // 第一种使用
struct doH2 {    // 第二种使用
    int operator() (const G&) const { ... }
};      
G g1(doH1);    // 类型不同的函数也可传入
G g2(doH2());  // 传入的是函数对象

// 还有第三种更复杂点的使用
class M {
public:
    float doH3(const G&) const;
};
M m;
G g3(std::tr1::bind(&M::doH3, m, _1));    // 传入一个成员函数,将其与一个对象(m)绑定

这种实现某种程度上就是函数指针更自由化的结果。

话题 4:古典的 Strategy 模式

这种比较简单,传入的是一个对象:

class G;
class H {
public:
    virtual int doH(cosnt G&) const { ... }
};
H h;
class G {
public:
    explicit G(H* p = &h) : pH(p) {}    // 将外部的对象传入函数内部
    int h() const {
        pH->doH(*this);
    }
private:
    H* pH;
};

总结

本条款的忠告是,当在解决一个问题时,不妨考虑一下 virtual 函数的替代方案。它们各有优缺点,要针对实际软件需求来选择。

  • virtual 函数的替代方案包括 NVI 手法及 Strategy 设计模式的多种实现。 NVI 手法自身是一种特殊形式的 Template Method 设计模式。
  • 将功能从成员函数移动到函数外部,带来的缺点是,无法访问到类内的非公有资源。
  • tr1::function 对象的行为就像一般的函数指针,这种对象可以接受与给定的类型兼容的一系列类似对象。

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

Never redefine an inherited non-virtual function.

派生类中重新定义普通函数,可能会导致不容易发现的错误。

class B {
public:
    void mf();
};
class D : public B {
public:
    void mf();
};
// 以下使用
D x;         // 一个 D 的对象 x
B* pB = &x;  // 一个 B 的指针指向 x
pB->mf();    // 实际调用了 B::mf()
D* pD = &x;  // 一个 D 的指针指向 x
pD->mf();    // 实际调用了 D::mf()

这会让错误很困惑,因为同样的对象 x,同样的函数 mf(),却会表现出不同的结果。引用也会导致这种问题。这让 x 对象变得精神分裂。

另一个不这么做的原因是,public 继承是 is-a 关系(条款 32),既然 D public 继承 B,那就表示 D 是一个 B,而普通成员函数要表现的是一种不变性(条款 34),D 修改了这种不变性,那就不应该 public 继承;换句话说,如果必须要 public 继承,那就不要修改这种不变性。

总结

  • 绝对不要重新定义继承而来的 non-virtual 函数。

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

Never redefine a function’s inherited default parameter value.

本条款讨论的问题是,若基类中的虚函数中有默认参数值,而派生类中继承的该虚函数版本指定了另一个默认参数值,可能会导致意想不到的 bug。看个例子:

class B {
public:
    enum SC { R, G };
    virtual void draw(SC c = R) const = 0;    // 纯虚函数,虚函数也同理,这里指定了默认参数 R
};
class D : public B {
public:
    virtual void draw(SC c = G) const;        // 派生类中修改了默认参数
};
// 以下为使用
B* pd = new D();    // 一个静态类型是 B,动态类型是 D 的对象
pd->draw(R);        // 正确,传入参数覆盖了默认参数 G
pd->draw();         // 出错,我们本意是想调用派生类中的带默认参数 G 的 draw 版本,
                    // 但却调用了基类中的带默认参数 R 的 draw 版本

我们知道,虚函数的调用是在运行期动态绑定的,所以上例中 pd 本应该调用到 D 中的 draw,但 C++ 中,虚函数的默认参数却是静态绑定的,而 pd 的静态类型是 B,所以它会找到 B 中的 draw。

之所以这么设计,是 C++ 为了权衡性能的一个妥协,实现默认参数的动态绑定会有比较严重的性能损失。
所以,最好的办法就是,不要在派生类中派生虚函数时,重新指定默认参数值

虽然在派生类中指定和基类中一样的默认参数值,不会有啥问题,但却不易于维护,编写了重复代码,若基类的默认参数需要修改时,派生类也需要同步做修改。

另一种替代办法是使用条款 35 中的 NVI 方式,在基类中设置一个调用私有虚函数的普通成员函数,在派生类中继续继承私有的虚函数,而不覆盖普通成员函数,这样避免了在虚函数里直接指定默认值(在普通成员函数中指定)。

class B {
public:
    enum SC { R, G };
    void draw(SC c = R) const {    // 一个普通函数
        doDraw(c);
    }
private:
    virtual void doDraw(SC c) const = 0;    // 注意没有默认值了
};
class D : public B {
private:
    virtual void doDraw(SC c) const;    // 完全避免了重复指定默认值的操作
};

总结

  • 绝对不要重新定义一个继承而来的缺省参数值,因为缺省参数值都是静态绑定,而 virtual 函数——你唯一要覆写的东西——却是动态绑定的。

条款 38:通过复合塑造出 has-a 或 “根据某物实现出” (重要)

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

复合是一种类型间关系,当某种类型中包含另一种类型,他们就是复合关系。复合包括 has-a 关系和 “根据某物实现出” 关系。
需要说明,复合这种关系虽然听着挺陌生,但它却存在于代码中的各个角落,所以这一条条款也很重要。

has-a 的关系很好理解,比如:一个 Address 类,一个 PhoneNumber 类,还有一个 Person 类,Person 类内会包含一些 Address 和 PhoneNumber 的成员,这种关系就是 has-a 关系,也就是一个 Person 有一个 Address 和一个 PhoneNumber。

根据某物实现出,是指一个对象,是依赖于另一个对象实现出来的,但是他们却不能采用 public 继承的结构。书中的例子是 set 数据结构和 list 数据结构。
我们要手动实现一个 set 模板类,并且想依赖于已有的 list 标准模板类来完成大部分工作,因为 public 继承必须满足 is-a 关系,而 set 并不是一个 list,因为 list 允许重复元素,但 set 不允许。
但我们可以通过 “根据某物实现出” 的关系来完成,也就是根据 list 来实现出一个 set。
大概代码是:

template<class T>
class Set {    // 为了和标准模板库的 set 区分,要大写 S
public:
    // some function
private:
    std::list<T> rep;    // 依赖于 list 的结构来管理 Set 的内容
}

总结

  • 复合(composition)的意义和 public 继承完全不同。
  • 在应用域中,复合意味着 has-a。在实现域中,复合意味着 “根据某物实现出”。

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

Use private inheritance judiciously.

有些时候,派生类和基类之间不满足 is-a 关系,使用 public 继承会有问题。
private 继承意味着 implemented-in-terms-of 关系,它表示派生类需要基于基类中的内容来完成实现,所以需要继承基类。private 继承只在软件实现中有意义,在软件设计上没有意义。

话题 1:使用复合关系替代 private 继承

上一条中,我们提到,复合结构也可以描述 implemented-in-terms-of 关系,那么,什么时候用 private 呢?作者的意见是,不到不得已,能使用复合就不要用 private,而 private 的必要性包括:

  • 派生类要使用基类中的 protected 成员;
  • 派生类需要重写基类中的 virtual 成员函数;
  • 还有一种非常小众的用法,利用 private 实现 EBO(empty base optimization);

使用复合替换 private 继承的一个例子如下:

// private 继承的实现
class W : private T {
private:
    virtual void fun() const; // fun 是 T 中的虚函数,这里在 W 中重写
};
// 使用复合关系替代
class W {
private:
    class WT : public T {
    public:
        virtual void fun() const;
    };
};

这样做的好处:

  1. 可以避免继承 W 的后续派生类重写 fun() 虚函数。
  2. 可以进一步将 WT 的实现拿到其他文件中,而在 W 内只留一个指向 WT 对象类型的指针,这样可以将 T 的关系从 W 所在的定义文件中剥离出来。详见条款 31。

话题 2:private 继承触发的 Empty Base Optimization 应用

必要性的前两种情况不需要特别阐述。第三种情况说明如下。

C++ 中,对于没有内置非静态数据成员,也没有虚函数(会引入虚函数表)的类,按道理是不应当占用内存空间的。但是 C++ 规范规定,对于这种空类,必须留一个 char,如果将这种空类使用复合关系放到其他类里边,还可能会因为对齐要求,占用超过一个 char 的内存。比如:

class Empty { };    // 这是一个没有数据和虚函数的类,应当不占用任何空间
class H {
private:
    int x;
    Empty e;
};
// 实际上
sizeof(Empty);    // 结果是 1
sizeof(H);        // 可能的结果是 8,因为对齐,实际占用内存是 int(4) + Empty(1) + alignment(3)

虽然我们不会去写那些空类,但我们很可能会写一些内部只包含 typedef、enum、static 成员数据的类,这些类就可以看做上例中的 Empty。

C++ 提供一个叫 empty base optimization 的操作,就是 EBO,即如果一个类继承自空类,这个空类就不占用内存空间:

class Empty { };
class H : private Empty {
private:
    int x;
};
// 实际上
sizeof(H);        // 结果是 4

在这种极端情况下,private 继承发挥了复合关系所无法实现的目的,节省内存,虽然只是几个字节,但在一些库的设计和一些嵌入式设备上,有时很关键,事实上,STL 库中就有很多这种应用。

总结

  • private 继承意味着 is-implemented-in-terms-of。它通常比复合关系的级别低,但当派生类需要访问基类的 protected 成员,或需要重新定义继承的 virtual 函数时,使用 private 继承。
  • private 继承会触发 empty base optimization。这对于要求极致内存占用的应用场合,可能很重要。

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

Use multiple inheritance judiciously.
多重继承比较复杂,有些人认为多重继承很重要,而另一些人认为它很多余。

多重继承的概念就是一个派生类同时继承多个基类。这样本身也没问题,问题会在于,如果多个基类又继承了同一个基类,整体形成一个闭环结构,同时最高层的基类里边有数据成员,那么,按照 C++ 的默认设计,在最底层的派生类中,就会有多份最高层基类的数据成员的拷贝(有几份取决于有几个继承路径)。而有些时候,我们的设计并不希望这样。

既如此,C++ 提供了一种解决方案,叫 virtual 继承,通过这种语法,实现底层派生类,只会有一份公共高层基类的数据:

class IO { ... }; // 其中可能包含数据成员
class IStreamer : virtual public IO { ... };
class OStreamer : virtual public IO { ... };
class IOStreamer : public IStreamer, public OStreamer { ... }; 
// IOStreamer 只会有一份 IO 中的数据继承下来

我们可能发现,似乎应当总使用 virtual 继承,但事实不是如此。第一,virtual 继承是有代价的,引用这种继承结构更费时间和空间;第二,所有基类(包括非底层的那些类)的初始化的责任总要落入最底层派生类中,这增加了底层派生类的负担。

作者的建议是,不得已不要使用 virtual 继承,或者,如果非要使用,尽量不要在基类中放置数据。
另外,如果单一继承能满足设计要求,尽量不要使用复杂的多重继承。

但有些时候,多重继承也有它无法被取代的作用,一个例子如书中所示,不再摘抄。即一个派生类同时继承一个需要重写接口的虚拟类(public 继承,is-a 关系),同时还要继承一个能辅助设计的类(private 继承,is-implemented-in-terms-of 关系)。

要注意区分虚拟继承(virtual inheritance)、虚基类(virtual base class),抽象类(abstract class),纯虚函数(pure virtual function)、虚函数(virtual function)这几个概念。

总结

  • 多重继承比单一继承复杂,它可能导致新的歧义,以及还可能需要 virtual 继承的支持;
  • virtual 继承会增加空间大小、速度、初始化需求等成本。如果 virtual base classes 不带有任何数据,这样设计是最有实用价值的;
  • 多重继承也有其用途;
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值