第一章见 Effective C++ 学习笔记 第一章:让自己习惯 C++
第二章见 Effective C++ 学习笔记 第二章:构造、析构、赋值运算
第三章见 Effective C++ 学习笔记 第三章:资源管理
第四章见 Effective C++ 学习笔记 第四章:设计与声明
第五章见 Effective C++ 学习笔记 第五章:实现
第六章见 Effective C++ 学习笔记 第六章:继承与面向对象设计
第七章见 Effective C++ 学习笔记 第七章:模板与泛型编程
第八章见 Effective C++ 学习笔记 第八章:定制 new 和 delete
第九章见 Effective C++ 学习笔记 第九章:杂项讨论
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 关系。
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() 函数
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)的属性。
在这种属性下,派生类使用普通函数,便是继承实现。
Consider alternatives to virtual functions.
注意:这一条款略有生涩,涉及到设计模式的知识。
使用 virtual 函数来设计一些具有默认实现的接口设计,是一种常规的面向对象设计思路,但我们应该同样去寻求一些能替代 virtual 函数实现的方案。
这一部分作者没有说清为什么要替换掉 virtual 函数的实现。
这里的 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。
将曾经要做的 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 对象,从而实现不同的功能。
大概意思是,第二种中我们只能传入函数指针,仍然不够自由,如何能传入泛化的函数指针,比如第二种中,我们还想传入 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)绑定
这种实现某种程度上就是函数指针更自由化的结果。
这种比较简单,传入的是一个对象:
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 函数的替代方案。它们各有优缺点,要针对实际软件需求来选择。
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 继承,那就不要修改这种不变性。
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; // 完全避免了重复指定默认值的操作
};
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 的内容
}
Use private inheritance judiciously.
有些时候,派生类和基类之间不满足 is-a 关系,使用 public 继承会有问题。
private 继承意味着 implemented-in-terms-of 关系,它表示派生类需要基于基类中的内容来完成实现,所以需要继承基类。private 继承只在软件实现中有意义,在软件设计上没有意义。
上一条中,我们提到,复合结构也可以描述 implemented-in-terms-of 关系,那么,什么时候用 private 呢?作者的意见是,不到不得已,能使用复合就不要用 private,而 private 的必要性包括:
使用复合替换 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;
};
};
这样做的好处:
必要性的前两种情况不需要特别阐述。第三种情况说明如下。
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 库中就有很多这种应用。
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)这几个概念。