0%

C++(三) Effective C++(中)

原图

设计与声明

条款19:设计class犹如设计type

C++就像在其他OOP(面向对象编程)语言一样,当你定义一个新class,也就定义了一个新type。这意味你并不只是 class 设计者,还是type设计者。重载( overloading)函数和操作符、控制内存的分配和归还、定义对象的初始化和终结全都在你手上。因此你应该带着和“语言设计者当初设计语言内置类型时”一样的谨慎来研讨 class 的设计。

条款20:宁以传递const引用替换传递值

缺省情况下C++以 by value方式传递对象至(或来自)函数。除非你另外指定,否则函数参数都是以实际实参的复件(副本)为初值,而调用端所获得的亦是函数返回值的一个复件。这些复件(副本)系由对象的capy构造函数产出,这可能使得pass-by-value成为费时的操作。

如果有什么方法可以回避所有那些构造和析构动作就太好了。有的,就是 pass by reference-to-const:这种传递方式的效率高得多:没有任何构造函数或析构函数被调用,因为没有任何新对象被创建。修订后的这个参数声明中的 const是重要的。

以 by reference方式传递参数也可以避免 slicing(对象切割)问题。当一个 derived class对象以 by value方式传递并被视为一个 base class 对象, base class 的copy构造函数会被调用,而“造成此对象的行为像个 derived class对象”的那些特化性质全被切割掉了,仅仅留下一个 base class 对象。这实在不怎么让人惊讶,因为正是 base class构造函数建立了它,但这几乎绝不会是你想要的。

请记住

  • 尽量以 pass-by-reference-to-const替换 pass-by-value。前者通常比较高效,并可避免切割问题( slicing problem)
  • 以上规则并不适用于内置类型,以及STL的迭代器和函数对象。对它们而言pass-by-value往往比较适当

条款21:必须返回对象时,别妄想返回其引用

1
2
3
4
5
6
7
const Rational& operator* (const Rational& lhs
const Rational&rhs)
{ //警告,又一堆烂代码
static Rational result; // static对象,此函数将返回其 reference
result = ... //将lhs乘以rhs,并将结果置于 result内
return result;
}

就像所有用上 static对象的设计一样,这一个也立刻造成我们对多线程安全性的疑虑。不过那还只是它显而易见的弱点。如果想看看更深层的瑕疵,考虑以下面这些完全合理的客户代码

1
2
3
4
5
6
7
8
bool operator==(const Rational& lhs     //ー个针对 Rationals
const Rational& rhs); //而写的 operator:==
Rational a, b, c, d;
if((a*b) == (c*d) {
... //当乘积相等时,做适当的相应动作
} else {
... //当乘积不等时,做适当的相应动作
}

猜想怎么着?表达式(a * b)==(c * d))总是被核算为true,不论a,b,c和d的值是什么! 一旦将代码重新写为等价的函数形式,很容易就可以了解出了什么意外:

1
if (operator==(operator*(a, b), operator*(c, d)))

注意,在 operator==被调用前,已有两个 operator* 调用式起作用,每一个都返回 reference指向operator的内部定义的 static Rational对象。因此 operator=被要求将“ operator* 内的 static Rational对象值”拿来和“ operator* 内的 static Rational对象值”比较,如果比较结果不相等,那オ奇怪呢。(译注:这里我补充说明:两次 operator*调用的确各自改变了 static Rational对象值,但由于它们返回的都是 reference,因此调用端看到的永远是 static Rational对象的“现值”。)

请记住

绝不要返回 pointer或 reference指向一个 local stack对象,或返回 reference指向个heap allocated对象,或返回 pointer或 reference指向一个 local static对象而有可能同时需要多个这样的对象。

条款22:将成员变量声明为private

对于private变量,如果你通过函数访问成员变量,日后可改以某个计算替换这个成员变量,而 class客户一点也不会知道 class的内部实现已经起了变化,完全具备封装性。

假设我们有一个 public成员变量,而我们最终取消了它,多少代码可能会被破坏呢?唔,所有使用它的客户码都会被破坏,而那是一个不可知的大量,因此 public成员变量完全没有封装性。

假设我们有一个 protected成员变量,而我们最终取消了它,有多少代码被破坏?唔,所有使用它的 derived classes都会被破坏,那往往也是个不可知的大量。因此, protected成员变量完全没有封装性。

条款23:宁以non-member、non-friend替换 member 函数

让我们从封装开始讨论。如果某些东西被封装,它就不再可见。愈多东西被封装,愈少人可以看到它,我们就有愈大的弹性去变化它。因此,愈多东西被封装,我们改变那些东西的能力也就愈大。

现在考虑对象内的数据。愈少代码可以看到数据,愈多的数据可被封装,而我们也就愈能自由地改变对象数据,愈多函数可访问它,数据的封装性就愈低。

条款22曾说过,成员变量应该是 private,能够访问 private成员变量的函数只有 class 的member函数加上 friend函数而已。如果要你在一个 member函数和一个non-member,non-friend函数之间做抉择,而且两者提供相同机能,那么,导致较大封装性的是non-member non-friend函数,因为它并不增加“能够访问class内之 private,成分”的函数数量。

将所有便利函数放在多个头文件内但隶属同一个命名空间,意味客户可以轻松扩展这一组便利函数。他们需要做的就是添加更多non-member non-friend函数到此命名空间内。

条款24:若所有参数皆需类型转换,请为此采用non-member函数

假设你这样开始你的Rational class:

1
2
3
4
5
6
7
8
9
10
class Rational
{
public:
Rational(int numerator =0, //构造函数刻意不为 explicit
int denominator=1);//允许 int-to-Rational 隐式转换。
int numerator() const; //分子( numerator)和分母( denominator)
int denominator() const; //的访问函数( accessors)
private:
...
}

你想支持算术运算诸如加法、乘法等等,但你不确定是否该由 member函数、non-member函数,或可能的话由non-member friend函数来实现它们,先研究一下将operator*写成 Rational成员函数的写法:

1
2
3
4
5
class Rational
{
public:
const Rational operator*(const Rational& lhs) const;
}

这个设计使你能够将两个有理数以最轻松自在的方式相乘:

1
2
3
4
Rational oneEighth(1, 8);
Rational oneHalf(1, 2);
Rational result= oneHalf * oneEighth; //很好
result = result * oneEighth; //很好

但你还不满足。你希望支持混合式运算,也就是拿 Rationals和……嗯例如int相乘。毕竟很少有什么东西会比两个数值相乘更自然的了一即使是两个不同类型的数值。然而当你尝试混合式算术,你发现只有一半行得通:

1
2
result = oneHalf * 2;   //很好
result =2 * oneHalf //错误!

这不是好兆头。乘法应该满足交换律,不是吗?当你以对应的函数形式重写上述两个式子,问题所在便一目了然了:

1
2
result= oneHalf.operator*(2);   //很好
result =2.operator*(oneHalf); //错误!

是的, oneHalf是一个内涵 operator* 函数的 class 的对象,所以编译器调用该函数。然而整数2并没有相应的 class,也就没有 operator* 成员函数。

结论是,只有当参数被列于参数列( parameter list)内,这个参数才是隐式类型转换的合格参与者。这就是为什么上述第一次调用可通过编译,第二次调用则否,因为第一次调用伴随一个放在参数列内的参数,第二次调用则否。 然而你一定也会想要支持混合式算术运算。可行之道终于拨云见日:让 operator成为一个non-member函数,俾允许编译器在每一个实参身上执行隐式类型转换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Rational
{
... //不包括 operator
}
const Rational operator*(const Rational& lhs, //现在成了一个
const Rational& rhs); //non- member函数
{
return Rational (lhs.numerator() * rhs.numerator(),
lhs.denominator() * rhs.denominator());
}

Rational onefourth(1, 4);
Rational result;
result = onefourth * 2; //没问题
result =2 * onefourth; //万岁,通过编译了!

实现

条款26:尽可能延后变量定义式的出现时间

尽可能延后变量定义式的出现,既包括延后构造它,保证只有真正使用才构造;也包括只有到赋值时才构造它,避免默认构造函数无谓调用。 对于循环操作,在循环前还是中进行构造,取决于赋值操作与构造+析构操作的成本对比。

  • 循环前:1个构造函数+1个析构函数+n个赋值操作
  • 循环后:n个构造函数+n个析构函数

条款30:透彻了解inline的里里外外

在一台内存有限的机器上,过度热衷 inline会造成程序体积太大。即使拥有虚拟内存, inline造成的代码膨胀亦会导致额外的换页行为( paging),降低指令高速缓存装置的击中率( instruction cache hit rate),以及伴随这些而来的效率损失。

换个角度说,如果 inline函数的本体很小,编译器针对“函数本体”所产出的码可能比针对“函数调用”所产出的码更小。果真如此,将函数 inline确实可能导致较小的目标码( object code)和较高的指令高速缓存装置击中率!

程序库设计者必须评估“将函数声明为 inline”的冲击: inline函数无法随着程序库的升级而升级。换句话说如果f是程序库内的一个 inline函数,客户将“f函数本体”编进其程序中,一旦程序库设计者决定改变f,所有用到f的客户端程序都必须重新编译。这往往是大家不愿意见到的。

记住, inline只是对编译器的一个申请,不是强制命令。这项申请可以隐喻提出,也可以明确提出。隐喻方式是将函数定义于 class定义式内。

条款31:将文件间的编译依存关系降至最低

针对 Person我们可以这样做:把 Person分割为两个classes,一个只提供接口,另一个负责实现该接口。如果负责实现的那个所谓implementation class 取名为PersonImpl, Person将定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <string>   //标准程序库组件不该被前置声明。
#include <memory> //此乃为了tr1: shared ptr而含入;详后

class PersonImpl; // Person实现类的前置声明。
class Date; // Person接口用到的 classes 的前置声明。
class Address;

class Person {
public:
Person(const std: string& name, const Date& birthday,
const Address& addr);
std::string name()const;
std::string birthdate() const;
std::string address() const;
private:
std::tr1::shared_ptr<PersonImpl> pimpl;//指针,指向实现物
//std::tr1::shared_ptr见条款13.
}

这样就不必include Date、Address等头文件而只是声明该class(定义式改声明式)。

pimp方式

在这里, class Person只内含一个指针成员,指向其实现类( PersonImpl)。这般设计常被称为 pimpl idiom(pimp是" pointer to implementation"的缩写)。

这样的设计之下, Person的客户就完全与 Dates, Addresses以及 Persons的实现细目分离了。那些 classes的任何实现修改都不需要 Person客户端重新编译。此外由于客户无法看到 Person的实现细目,也就不可能写出什么“取决于那些细目”的代码。这真正是“接口与实现分离。

这个分离的关键在于以“声明的依存性”替换“定义的依存性”,那正是编译依存性最小化的本质:现实中让头文件尽可能自我满足,万一做不到,则让它与其他文件内的声明式(而非定义式)相依。其他每一件事都源自于这个简单的设计策略。

  • 如果使用 object references或 object pointers可以完成任务,就不要使用objects。你可以只靠一个类型声明式就定义出指向该类型的 references和pointers;但如果定义某类型的 objects,就需要用到该类型的定义式
  • 如果能够,尽量以class声明式替换 class定义式

abstract baseclass方式

另一个制作 Handle class的办法是,令 Person成为一种特殊的 abstract baseclass(抽象基类),称为 Interface class。这种 class 的目的是详细一描述 derived classes的接口,因此它通常不带成员变量,也没有构造函数,只有个 virtual析构函数以及一组 pure virtual 函数,用来叙述整个接口。

继承与面向对象设计

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

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

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

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

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

1
2
3
4
5
6
7
8
9
class Shape
{
public
virtual void draw() const = 0;
virtual void error(const std: string& msg);
int objectid( ) const;
};
class Rectangle: public Shape {...};
class Ellipse: public Shape {...};

shape class声明了三个函数。draw是个 pure virtual函数; error是个简朴的(非纯) impure virtual函数; objectid是个non-virtual 函数。这些不同的声明带来什么样的暗示呢?

  • 声明一个 pure virtual 函数的目的是为了让 derived classes只继承函数接口
  • 声明简朴的(非纯) impure virtual 函数的目的,是让 derived classes继承该函数的接口和缺省实现
  • 声明non-virtual函数的目的是为了令 derived classes继承函数的接口及一份强制性实现

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

假设你正在写一个视频游戏软件,你打算为游戏内的人物设计一个继承体系。你的游戏属于暴力砍杀类型,剧中人物被伤害或因其他因素而降低健康状态的情况并不罕见。你因此决定提供一个成员函数 healthvalue,它会返回一个整数,表示人物的健康程度。由于不同的人物可能以不同的方式计算他们的健康指数,将healthvalue声明为 virtual似乎是再明白不过的做法:

1
2
3
4
5
6
class GameCharacter
{
public:
virtual int heal_thvalue() const//返回人物的健康指数
... // derived classes可重新定义它。
}

healthvalue并未被声明为 pure virtual,这暗示我们将会有个计算健康指数的缺省算法。这的确是再明白不过的设计,但是从某个角度说却反而成了它的弱点。由于这个设计如此明显,你可能因此没有认真考虑其他替代方案。为了帮助你跳脱面向对象设计路上的常轨,让我们考虑其他一些解法。

藉由Non-Virtual Interface手法实现 Template Method模式

我们将从一个有趣的思想流派开始,这个流派主张 virtual函数应该几乎总是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; // derived classes不重新定义它,
{ //见条款36
... //做一些事前工作,详下
int retval=dohealthvalue(); //做真正的工作。
... //做一些事后工作,详下。
return retval
}
...
private:
virtual int dohealthvalue() const; // derived classes可重新定义它。
{
... //缺省算法,计算健康指数。
}

令客户通过 public non-virtual成员函数间接调用 private virtual 函数,称为mom-virtual interface(NVI)手法。它是所谓 Template Method设计模式(与C++ templates并无关联)的一个独特表现形式。

NVI手法的一个优点隐身在上述代码注释“做一些事前工作”和“做一些事后工作”之中。那些注释用来告诉你当时的代码保证在“ virtual 函数进行真正工作之前和之后”被调用。

藉由 Function Pointers实现 Strategy模式

另一个更戏剧性的设计主张“人物健康指数的计算与人物类型无关”,这样的计算完全不需要“人物”这个成分。例如我们可能会要求每个人物的构造函数接受一个指针,指向一个健康计算函数,而我们可以调用该函数进行实际计算:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Gamecharacter;         //前置声明( forward declaration)
//以下函数是计算健康指数的缺省算法。
int defaulthealthcalc(const Gamecharacter& gc);

class Gamecharacter
{
public:
typedef int (*Healthcalcfunc)(const Gamecharacter&);
explicit GameCharacter(Healthcalcfunc hcf= defaulthealthcalc) :healtheunc(hcf)
{}
int healthvalue() const { return healthfunc (*this); };
private:
Healthcalcfunc healtheunc;
};

这个做法是常见的 Strategy设计模式的简单应用。拿它和“植基于GameCharacter继承体系内之 virtual函数”的做法比较,它提供了某些有趣弹性:

  • 同一人物类型之不同实体可以有不同的健康计算函数
  • 某已知人物之健康指数计算函数可在运行期变更

藉由tr1: function完成 Strategy模式

一旦习惯了 templates以及它们对隐式接口的使用,基于函数指针的做法看起来便过分苛刻而死板了。为什么要求“健康指数之计算”必须是个函数,而不能是某种“像函数的东西”(例如函数对象)呢?如果一定得是函数,为什么不能够是个成员函数?为什么一定得返回int而不是任何可被转换为int的类型呢?

如果我们不再使用函数指针(如前例的 healtheunc),而是改用一个类型为tr1::function的对象,这些约束就全都挥发不见了。就像条款54所说,这样的对象可持有(保存)任何可调用物( callable entity,也就是函数指针、函数对象、或成员函数指针),只要其签名式兼容于需求端。以下将刚才的设计改为使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
tr1::function
class GameCharacter //如前
int defaulthealthcalc(const Gamecharacter& gc); //如前
class Gamecharacter
{
public:
//Hea1 thcalcfunc可以是任何“可调用物”( callable entity),可被调用并接受
//任何兼容于 Gamecharacter之物,返回任何兼容于int的东西。详下。
typedef std::tr1::function<int(const Gamecharacter&)> Healthcalcfunc;
explicit Gamecharacter(Healthcalcfunc hcf = defaulthealthcalc)
:healtheunc(hcf)
int healthvalue() const
{return healthfunc (*this);}
..
private:
HealthCalcfunc healtheunc;
};

现在我们靠近一点瞧瞧HealthCalceunc是个什么样的 typedef std::tr1::function<int (const Gamecharacter&)> 这里我把tr1::function具现体( instantiation)的目标签名式( target signature),那个签名代表的函数是“接受一个 reference指向 const Gamecharacter,并返回int”。

和前一个设计(其 GameCharacter持有的是函数指针)比较,这个设计几乎相同。唯一不同的是如今 Gamecharacter持有一个tr1::function对象,相当于个指向函数的泛化指针。这个改变如此细小,我总说它没有什么外显影响,除非客户在“指定健康计算函数”这件事上需要更惊人的弹性:

本条款的根本忠告是,当你为解决问题而寻找某个设计方法时,不妨考虑 virtual函数的替代方案:

  • 使用non-virtual interface(NVI)手法,那是 Template Method设计模式的一种特殊形式。它以 public non-virtual成员函数包裹较低访问性( private或 protected)的 virtual函数
  • 将 virtual函数替换为“函数指针成员变量”,这是 Strategy设计模式的一种分解表现形式
  • 以tr1::function成员变量替换 virtual函数,因而允许使用任何可调用物( callable entity)搭配一个兼容于需求的签名式。这也是 Strategy设计模式的某种形式
  • 将继承体系内的 virtual函数替换为另一个继承体系内的 virtual函数。这是Strategy设计模式的传统实现手法

条款36:绝不重新定义继承而来的非虚函数

non-virtual函数是静态绑定。这意思是,由于pb被声明为一个 pointer-to-B,通过pb调用的non-virtual函数永远是B所定义的版本,即使pb指向一个类型为“B派生之class”的对象。

但另一方面, virtual函数却是动态绑定。如果mf是个 virtual函数,不论是通过pB(指向D)或pD(指向D)调用mf,都会导致调用D::mf,因为pB和pD真正指的都是一个类型为D的对象。

条款37:绝不重新定义继承而来的缺省参数值(缺省参数值都是静态绑定而)

这种情况下,本条款成立的理由就非常直接而明确了: virtual函数系动态绑定( dynamically bound),而缺省参数值却是静态绑定( statically bound)。静态绑定又叫前期绑定,earbinding:动态绑定又名后期绑定, late binding。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//ー个用以描述几何形状的cass
class Shape
{
public:
enum Shapecolor { Red, Green, Blue };//所有形状都必须提供一个函数,用来绘出自己
virtual void draw(Shapecolor color=Red)const =0;
...
};
class Rectangle: public Shape
{
public: //注意,赋予不同的缺省参数值,这真糟糕,可能是Red
virtual void draw( Shapecolor color=Green ) const;
...
};
class Circle: public Shape
{
public:
virtual void draw( Shapecolor color) const; //译注:请注意,以上这么写则当客户以对象调用此函数,一定要指定参数值。
//因为静态绑定下这个函数并不从其base继承缺省参数值。
//但若以指针(或 reference)调用此函数,可以不指定参数值
//因为动态绑定下这个函数会从其base继承缺省参数值。
...
};

这个继承体系图示如下

现在考虑这些指针:

1
2
Shape* pr =new Rectangle;   //静态类型为 Shape*
prー>draw(); //调用 Rectangle:draw( Shape:Red)!

此例之中,pr的动态类型是 Rectangle,所以调用的是 Rectangle的 virtual函数,一如你所预期。 Rectangle::draw函数的缺省参数值应该是GREN,但由于pr的静态类型是 Shape,所以此一调用的缺省参数值来自 shape class 而非Rectangle class!结局是这个函数调用有着奇怪并且几乎绝对没人预料得到的组合,由 Shape class 和 Rectangle class 的draw声明式各出一半力。

请记住

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

条款38:通过复合表示 has-a 或者“根据某物实现出”的关系

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

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

其中 class Student以 public形式继承 class Person,于是编译器在必要时刻(为了让函数调用成功)将 Students暗自转换为 Persons。现在我以 private继承替换 public继承:

1
2
3
4
5
6
7
8
class Person {...};
class Student: private Person; //这次改用 pnvate继承
void eat(const Person& p); //任何人都会吃
void study(const Student& s); //只有学生才在校学习
Person p; //p是人
Student s; //s是学生
eat (p); //没问题,p是人,会吃。
eat(s); //错误!吓,难道学生不是人?!

如果classes之间的继承关系是 private,编译器不会自动将一个 derived class对象(例如Student)转换为一个 base class对象(例如 Person)。这和 public继承的情况不同。这也就是为什么通过s调用eat会失败的原因。第二条规则是,由 private base class 继承而来的所有成员,在 derived class 中都会变成 private属性,纵使它们在base class 中原本是 protected或 public属性。

Private继承意味 implemented-in-terms-of(根据某物实现出), private继承意味只有实现部分被继承,接口部分应略去。

请记住

  • Private继承意味 is-implemented-in-terms of(根据某物实现出)。它通常比复合( composition)的级别低。但是当 derived class 需要访问 protected base class 的成员,或需要重新定义继承而来的 virtual函数时,这么设计是合理的
  • 和复合( composition)不同, private继承可以造成 empty base最优化。这对致力于“对象尺寸最小化”的程序库开发者而言,可能很重要

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

使用 virtual继承的那些 classes所产生的对象往往比使用non-virtual继承的兄弟们体积大,访问 virtual base classes的成员变量时,也比访问non-virtual base classes 的成员变量速度慢。

virtual继承的成本还包括其他方面。支配“ virtual base classes初始化”的规则比起non-virtual bases的情况远为复杂且不直观。 virtual base的初始化责任是由继承体系中的最低层( most derived) class负责,这暗示 (1)classes若派生自 virtual bases而需要初始化,必须认知其 virtual bases一不论那些 bases距离多远, (2)当一个新的 derived class 加入继承体系中,它必须承担其 virtual bases(不论直接或间接)的初始化责任。

我对 virtual base classes(亦相当于对 virtual继承)的忠告很简单。第一,非必要不使用ⅵrtual bases。平常请使用non-virtual继承。第二,如果你必须使用 virtual base classes,尽可能避免在其中放置数据。这么一来你就不需担心这些 classes身上的初始化(和赋值)所带来的诡异事情了。

参考文献

《Effective C++》