Effective C++

来源:互联网 发布:算法设计与分析好难啊 编辑:程序博客网 时间:2024/05/06 10:53

1、习惯C++

条款01 视C++为一个语言联邦

通俗来说,C++(至少C++ 03)是由4个模块组成,他们是

l C,即面向过程部分,包括区块、语句、预处理器、内置数据类型、数组、指针等

l Object-Oriented C++,包括类、封装、继承、多态、虚函数(动态绑定)等

l Template C++,它带来了新的编程方式,即模板元编程

l STL,是一个Template程序库,包含容器、迭代器、算法、仿函数、配置器和适配器

对于不同的模块,高效编程守则是不一样的,所以不要一成不变。

条款02 尽量以const、enum、inline替换#define

这个条款换句话说,就是“宁愿使用编译器,也不要使用预处理器”,因为或许 #define不被视为语言本身的一部分。

例如:#define PI 3.14

在预处理阶段,所有的PI都被替换成3.14,所以到了编译器执行的阶段,记号“PI”根本就不会被看见,所以如果有关于 PI 的错误信息,编译器也只会提示 3.14 ,如果时间长了或者头文件非本人所写,很可能对这个信息不明所以。

解决方法:const double PI = 3.14;

除了避免出现不明所以的错误提示信息,这样还可以产生较小量的代码,因为预处理盲目地将宏名称替换可能导致目标码出现多份 3.14,改用常量则只会在常量存储区保存一份。

当以const替换 #define 时,有两种情况值得说说。

1) 定义常量指针

常量定义式一般放在头文件中(以便被不同的源码含入),所以指向常量的指针一般也不需要指向别物,所以指针也要加上const修饰,使其成为常指针,如下:

const(表示被指物是常量)char * const(表示指针是常量) name = “Linary”;

2) class专属常量

为了将常量的作用域(scope)限制在class内,其必须成为class的成员变量,而希望该类的所有对象持有的该常量只有一份(也可以是多份相同的常量,但既然相同,何不共用呢),所以还要加上static修饰,形式如下:

class GamePlayer {

private:

static const int numTurns = 5; // 常量声明式

int scores[numTurns]; // 使用该常量

};

上面的是声明式而不是定义式(为什么,怎么区分声明和定义?),所以必须要提供一份定义式。

这里要补充一点:如果class内的static常量为整型类型,即ints、chars、bools和enums,只要不去取它们的地址(对于一般的编译器而言),可以声明并使用它们而无须提供定义式。

如果要定义,形式如下:

const int GamePlayer::numTurns; // 定义式,不可再赋初值(声明时已赋值),若声// 明时未赋值当然需要赋初值。

回到标题提到的#define,#define并不重视作用域,一旦宏被定义,就在其后的编译程中有效(除非被#undef)。所以#define是没有任何封装性的。

某些旧式编译器可能不支持在声明时获得初值,in-class初值设定也只针对整数常量而言,则需要将初值放在定义式,形式如下:

class CostEstimate {

private:

static const double FudgeFactor;

};

const double CostEstimate::FudgeFactor = 1.35; // 位于实现文件内

一般情况下都是这样做的,但是在满足以下条件的情况下:

l 编译器不允许“static 整数型class常量”完成“in-class初值设定”;

l class在编译期间就需要一个常量值,比如前面的GamePlayer::scores的数组声明式(初值的设定必须在数组声明之前);

static常量就没办法做到了,可以改用“enum hack”补偿做法。其理论基础是:一个属于枚举类型的数值可以当ints使用。

于是GamePlayer可定义如下:

class GamePlayer {

private:

enum {numTurns = 5}; // 令numTurns成为5的一个记号名称

int scores[numTurns]; // 现在就OK了

};

对于enum hack,有以下几点要注意:

1) enum hack的行为某方面比较像#define而不像const,而这可能正是我们想要的

例如:取const的地址合法,但取enum的地址就不合法,而取一个#define的地址也不合法。如果不想让别人获得一个pointer或references指向某个整数常量,可以用enum实现。

此外,不够优秀的编译器可能会为整数型const对象设定另外的存储空间,但对于enum和#define绝不会如此。

2) enum hack是模板元编程的基础技术

此处待补充,跳过去看条款48

视角拉回到#define

有时候我们用#define定义一个宏函数,该“函数”经常要使用,且短小精悍,比如:

// 取a、b的较大值调用函数f

#define CALL_WITH_MAX(a, b) f((a) > (b) ? (a) : (b))

这样子可以避免函数调用的额外开销,但是如果在不同的宏实参时,可能会逻辑出错。

其实用一个template inline函数即可避免逻辑错误

template <typename T>

inline void callWithMax(const T & a, const T & b) {

f(a > b ? a : b);

}

总结:

n 对于单纯常量,最好以const对象或enums替换#define。

n 对于宏函数,最好以template + inline函数替换#define。

条款03 尽可能使用const

只要某个变量的值应该保持不变,就应该“说出来”,因为这样可以获得编译器的帮助,保证它确实不会被改变。

const修饰指针时放置位置变化多端,但其实就两句话:

l 出现在星号左边,表示被指物是常量;

l 出现在星号右边,表示指针时常量;

l 出现在星号两边,被指物和指针都是常量。

const和类型、static、global等的位置前后不影响数据类型,看个人习惯。

这里要说明一下迭代器在const的语法表现上与指针的差异:

l 如果希望迭代器不能指向别物,类似于(T * const),形式如下:

const std::vector<int>::iterator iter;

l 如果希望迭代器所指物不能改变,类似于(const T *),应该用const_iterator,形式如下:

std::vector<int>::const_iterator iter;

const在函数声明时最显威力,可以和函数返回值、各参数、函数自身(成员函数)产生关联。

1) const返回值

令函数返回一个常量值,往往可以降低因客户错误而造成的意外,而又不放弃安全性和高效性。如果一个函数的返回值不是const型,客户在使用这个函数时就可能因为单纯地打字错误而造成难以察觉的逻辑错误,比如:if (a * b = c),本来是想做一个比较操作,但是却做成了赋值操作。

2) const成员函数

将const实施于成员函数的目的,是为了让成员函数可作用于const对象上。这样有两个好处:

l 使得class的函数接口比较容易被理解,直到哪个函数可以改动对象内容而哪个不行,是很有助于客户端的使用的。

l 使得操作const对象成为可能,注意,不是const成员变量。

注意:两个成员函数如果只是常量性不同,可以被重载,比如 [] 操作符,见下例:

class TextBlock {

public:

const char & operator[](std::size_t position) const { // 针对const对象

return text[position];

}

char & operator[](std::size_t position) { // 针对non-const对象

return text[position];

}

};

TextBlock的operator[]s可以这么使用:

TextBlock tb(“Hello”);

std::cout << tb[0] << std::endl; // 调用non-const operator[]

tb[0] = ‘x’; // 正确

// 补充:如果函数返回值类型是个内置类型(value 而非 reference),那么改动函数// 值是不合法的;而且就算合法,改动的也只是返回对象的一个副本。

TextBlock ctb(“Hello”);

std::cout << ctb[0] << std::endl; // 调用const operator[]

ctb[0] = ‘x’; // 错误!——const char &不能修改

现在来谈一个略显高深的问题:成员函数是const意味着什么?有两种流行概念:

1) bitwise constness

成员函数不更改对象的任何成员变量,也就是说不更改对象内的任何一个bit,这也是C++对常量性(constness)的定义,因此const成员函数不能更改对象内的任何non-static成员变量(也就是说还是可以改static成员变量的)。这样只需要检查函数内的赋值语句即可。

但是编译器却不能完全按照要求检查代码,如果对象内汗一个指针,const成员并不直接更改指针,但返回值为(非const)指针或指针所指对象的引用,则可能出现代码通过编译,但其实不符合bitwise的情况,示例如下:

class CTextBlock {

public:

char & operator[](std::size_t position) const { // bitwise const声明,但其

return pText[position]; // 实并不适当

}

private:

char * pText;

};

如果客户端这么调用:

const CtextBlock cctb(“Hello”);

char * pc = &cctb[0]; // 调用[]取得一个指针指向cctb的数据

*pc = ‘J’; // cctb现在有了”Jello”这样的内容

这里虽然不是直接通过const成员函数改变的对象成员的值,但毕竟是经由它实现的更改,所以还是违反了bitconstness的意愿。

2) logical constness

const成员函数可以更改对象内的某些bits,但只有在客户端检测不出来(可通过某些手段让客户端检测不出来)的情况下如此。

如果直接在const成员函数里给成员变量赋值肯定会被编译器检测出来,可以给成员变量加上mutable修饰,即可以通过检测。

在const和non-const成员函数中避免重复代码

const和non-const成员函数实现的功能通常一样,所以可以想办法重用。有三种方法:

l 写一个公共函数供两者调用

l non-const调用const版

const char & operator[](std::size_t position) const { // 一如既往

}

char char & operator[](std::size_t position) {

return const_cast<char &>(static_cast<const TextBlock &>(*this)[position]);

}

这里先把当前对象强制转换为const型,调用const版的[],再去除返回值的const性。

l const调用non-const版

不该这么做,因为调用const版的一定是一个const对象,我们应该保证它不会被改变,但non-const版的[]可能不能提供这种保证,所以消除这个想法。

总结:

n 将某些东西声明为const可帮助编译器侦测出错误用法。const可被施加于任何作用域内的对象、函数参数、函数返回类型、成员函数本体。

n 编译器强制实施bitwise constness,但你编写程序时应该使用“概念上的常量性”。

n 但cons和non-const成员函数有着实质等价的实现时,令non-const版本调用const版本可避免代码重复。

条款04 确定对象使用前已经初始化

关于“对象的初始化动作何时一定发生,何时不一定发生”,是有一定的规则的,但是规则非常复杂,没必要去记忆它。最佳的处理方法就是:永远在使用对象之前将他初始化。

对于无成员的内置类型,手工赋值,形式如下:

int x = 0;

const char * text = “A C-Style string”; // const对象 + const指针

double d;

std::cin>>d; // 以输入流的方式初始化

对于有成员的类类型,初始化择优构造函数负责,规则:确保每一个构造函数都将对象的每一个成员初始化,关键在于,别混淆了赋值和初始化。

在构造函数的函数体部分,即 {} 中的部分,都是赋值操作,而不是初始化。C++规定,对象的成员变量的初始化动作发生在进入构造函数的本体之前,可以认为发生在函数参数列表的后面,{ 的前面。所以初始化要用成员初值列替换赋值操作(这样可以避免初始化操作浪费)。对于一下几点要注意:

l 内置类型对象,初始化和赋值成本相同,但为了一致性最好也通过成员初值列;

l 有些成员变量,可能不知道该初始化为什么值,可以使用期默认构造函数(不传参);

l 总是在成员初值列中写出所有的成员变量,以免还得记得哪些变量还未赋值;

l 对于const和reference型的内置类型,必须得用初值列初始化;

l 当调用一个base class或member class的constructor,而它拥有一组参数时;

l 如果成员变量比较多,构造函数也比较多,反复写初值列表可能有大量代码重复,我们可以把其中的内置类型成员变量的初始化(赋值)写到一个公共函数(private),然后在各个构造函数的本体中写上该初始化函数;

l class的成员变量总是以其声明次序被初始化;

还有一个大问题:C++对“不同编译单元内定义的non-local static对象”的初始化次序并无明确定义。这里可能需要去了解 implicit template instantiations 。

但是C++保证:函数内的local static 对象会在该函数被调用期间首次遇上该对象之定义式时被初始化。所以如果以函数调用(返回一个reference指向local static对象)替换直接访问non-local static对象,就能就获得了保证。而且,如果从未调用该函数,就不会引发构造和析构成本。形式通常如下:

class FileSysytem { … };

FileSystem & tfs() { // 该函数适合作为inline函数

static FileSystem fs; // static对象的声明周期与程序一样

Return fs;

}

注意:在多线程系统中带有不确定性,任何一种non-const static对象,不论是local还是non-local,在多线程环境下“等待某事发生”都会有麻烦。解决方法是:在单线程启动阶段手工调用所有reference-returning函数,可消除与初始化有关的“竞速形式”。(这里没看懂,后期补上)。

总结:

n 为内置类型进行手工初始化,因为C++不保证初始化它们。

n 构造函数最好使用成员初值列,而不要在构造函数本体内使用赋值操作。初值列列出的成员变量,其排列次序应该和它们在class中的声明次序相同。

n 为避免“跨编译单元之初始化次序”的问题,请以local static对象 + reference-return函数替换non-local static对象。

2、构造、析构、赋值运算

条款05 了解C++默默编写并调用哪些函数

一个无任何成员变量、成员函数的空类所占内存为1个字节。当C++编译器处理过它们之后,会被声明出一个default构造函数、copy构造函数、copy assignment操作符和一个析构函数,且都是public + inline的。

比如:

class Empty {};

就好像是写下了这样的代码:

class Empty {

public:

Empty() { … }

Empty(const Empty & rhs) { … }

~Empty() { … }

Empty & operator = (const Empty & rhs) { … }

当这些函数被调用的时候,它们才会被编译器创建出来。注意:编译器产生的析构函数是non-virtual的,除非器base-class带有virtual析构函数。

以下内容来源于《深度探索C++对象模型》

对于class X,如果没有任何用户声明的构造函数,那么会有一个default构造函数被隐式声明出来,而很多时候这样的构造函数是trival(没什么用)的,可以认为没有被合成出来,下面的几种情况产生的default构造函数是non-trival(有用)的。

1) 带有default constructor的成员类对象

如果一个class没有任何constructor,但它含有一个member object,而后者有default constructor,那么这个class的implicit default constructor就是non-trival的,因为它会调用该成员的default constructor,不过这个合成操作只有在constructor被调用的时候才会发生。

此处page 41中关于inline default constructor的论述没看懂!

看以下的例子:

class Foo { public: Foo(); … }; // 拥有default constructor

class Bar { public: Foo foo; char * str; }; // 是聚合,不是继承

被合成的Bar的default constructor只满足编译器的需要,而不是程序的需要,并不会产生任何代码来初始化str,这是程序员的责任,该default constructor看起来像这样:

inline Bar::Bar() {

// 伪码

foo.Foo::Foo(); // 仅仅构造了foo,并没有初始化str

}

假设程序员提供了str的初始化操作:

Bar::Bar() { str = 0; }

由于default constructor已经被显式定义出来,编译器便不会再不会再自己产生新的了,但现在的default constructor中却又少了对foo的构造,怎么办?没关系,编译器现在会扩张已存在的constructor,在其中安插一些代码,使得user code被执行之前,先调用必要的default constructor。扩张后的constructor看起来像这样:

Bar::Bar() {

foo.Foo::Foo(); // 由编译器安插的代码

str = 0;

}

如果有多个class member objects都要求constructor初始化操作,编译器会按声明顺序来调用各个constructor;

2) 带有default constructor的Base Class

如果一个没有任何constructors的class派生自一个带有default constructor的base-class,那么这个derived class的default constructor的会被合成出来,并将调用上一层base class的default constructor(根据它们的声明顺序)。

如果设计者提供了多个constructors,但其中都没有default constructor(初始化所有成员变量),那么编译器会扩张每一个现有的constructors。如果存在着带有default constructor的member class objects,那些default constructor也会被调用——在所有base class constructor都被调用之后。

3) 带有virtual function的class

对于一个含有虚函数的class,下面两个扩张行动会在编译期发生:

l 一个virtual function table(vtbl)会被产生出来,内放class的virtual functions地址;

l 在每一个class object中,一个额外的pointer member(vptr)会被编译器合成出来,内含相关的class vtbl的地址。

为了让这个机制运作,编译器必须为每一个Widget(或其派生类)object的vptr设定初值,放置适当的virtual table地址,所以对于class的每一个constructor,编译器会安插一些代码来做这些事情。

4) 带有virtual base class的class

与3)类似,这里要在derived class object的每一个virtual base class中安插一个指针,比如命名为:__vbcx,为了初始化__vbcx,在constructor中安插一些代码。

有两个常见的误解:

l 任何class如果没有定义default constructor,就会被合成一个出来。

l 编译器合成出来的default constructor会显式设定class内每一个data member的默认值,也就是调用它们的default constructor。

我的点评:虽然《深度探索C++对象模型》好像很有论调的样子,但是错的。

与default constructor类似的还有copy constructor,有三种情况,会以一个object的内容作为另一class object的初值,这三种情况需要有 copy constructor。

l 最明显的当然是对一个object做明确的初始化操作。

l 当object被当做参数交给某个函数

l 当函数返回一个class object。

Default Memberwise Initialization

如果class 没有提供一个 explicit copy constructor时,当class object以“相同的另一个object”作为初值时,其内部是以所谓的default memberwise initialization方式完成的。也就是把每一个内建的或派生的 data member(例如一个数组或指针)的值,从某个object拷贝一份到另一个object上,但不拷贝其具体内容。例如只拷贝指针地址,不拷贝一份新的指针指向的对象,这也就是浅拷贝,不过它并不会拷贝其中member class object,而是以递归的方式实行memberwise initialization。

这种memberwise initialization是如何实现的呢?答案就是Bitwise Copy Semantics和default copy constructor。如果class展现了Bitwise Copy Semantics,则使用bitwise copy(bitwise copy semantics编译器生成的伪代码是memcpy函数),否则编译器会生成default copy constructor。

那什么情况下class不展现Bitwise Copy Semantics呢?有四种情况:

1) 当class内含有一个member class object,而这个member class 内有一个默认的copy 构造函数(不论是class设计者明确声明,或者被编译器合成);

2) 当class 继承自 一个base class,而base class 有copy构造函数(不论是class设计者明确声明,或者被编译器合成);

3) 当一个类声明了一个或多个virtual 函数;

4) 当class派生自一个继承串链,其中一个或者多个virtual base class。

下面我们来理解这四种情况为什么不能使用bitwise copy,以及编译器生成的copy constructor都干了些什么。

在前2种情况下,编译器必须将member或者base class的“copy constructor的调用操作”安插到被合成的copy constructor中。

第3种情况下,因为class 包含virtual function,编译时需要做扩张操作:

l 增加virtual function table,内含有一个有作用的virtual function的地址;

l 创建一个指向virtual function table的指针,安插在class object内。

所以,编译器对于每一个新产生的class object的vptr都必须被正确地赋值,否则将跑去执行其他对象的function了,其后果是很严重的。因此,编译器导入一个vptr到class之中时,该class 就不再展现bitwise semantics,必须合成copy constructor并将vptr适当地初始化。

第4种情况,virtual base class的存在需要特别处理。一个class object 如果以另一个 virtual base class subobject那么也会使“bitwise copy semantics”失效。每一个编译器对于虚拟继承的支持承诺,都是表示必须让“derived class object中的virtual base class subobject 位置”在执行期就准备妥当,维护“位置的完整性”是编译器的责任。Bitwise copy semantics可能会破坏这个位置,所以编译器必须自己合成出copy constructor。

这也就是说,拷贝构造函数和默认构造器一样,需要的时候会进行构建,而并非程序员不写编译器就帮着构建。

至于copy构造函数、copy assignment操作符,编译器只是简单地将来源对象的每一个non-static成员拷贝到目标对象,如果成员是内置对象(int、double),会执行拷贝操作,否则会调用该成员对象的copy构造函数(如果有的话)。但是:对于copy assignment操作符,只有当生出的代码合法且有适当机会证明它有意义时,才会被创建出来。

如果成员变量的类型是reference或者const,就不满足上述条件,因为:1)C++并不允许“让reference改指向不同对象”;2)const成员是不能更改的,至少一般情况下不允许。还有如果base-class的copy-assignment操作符声明为private,编译器也不会为其derived-class生成,因为编译器生成的copy-assignment操作符是想调用base-class的copy-assignment的。

总结:

n 编译器可以暗自为class创建default构造函数、copy构造函数、copy assignment操作符和一个析构函数。

条款06 若不想使用编译器自动生成的函数,就该明确拒绝

如果一个类不允许被拷贝,我们可以这样设计:

class Uncopyable { // 相当于一个接口

protected:

Uncopyable() {}

~ Uncopyable() {}

private:

Uncopyable(const Uncopyable &);

Uncopyable & operator=(const Uncopyable &);

};

上面的设计有个关键点:

l copying函数声明为private,可以防止客户端调用copying操作以及其derived class生成copy构造函数和copy assignment操作符;

l 声明但不实现可以防止member和friend函数调用它。

想要自己的类拥有防止拷贝的能力,只需要这样:

class Widget : private Uncopyable {

};

Uncopyable的应用颇为微妙,不一定得以public继承它,以及它的析构函数不一定得是virtual。更多细节参见条款32、39、40

总结:

n 为驳回编译器自动(暗自)提供的机能,可将相应的成员函数声明为private并且不予实现。使用像Uncopyable这样的base class也是一种做法。

条款07 为多态基类声明virtual析构函数

当基类的指针指向派生类的对象的时候,当我们使用完,对其调用delete的时候,其结果将是未有定义——基类成分通常会被销毁,而派生类的成分可能还留在堆里。这可是形成资源泄漏、败坏之数据结构。

消除以上问题的做法很简单:给基类一个virtual析构函数。

任何类只要带有virtual函数都几乎确定应该也有一个virtual析构函数。 如果一个类不含virtual函数,通常表示它并不意图被用做一个基类,当类不企图被当做基类的时候,令其析构函数为virtual往往是个馊主意。因为实现virtual函数,需要额外的开销( 指向虚函数表的指针vptr)。

STL容器都不带virtual析构函数,所以最好别派生它们。

最好为pure virtual类的pure virtual析构函数提供一份定义,注意:即使是纯虚函数也是可以有定义的。析构函数的运作方式是:最深层派生的析构函数先被调用,然后是每一个base class,在derived class的析构函数中有一个调用base class析构函数的操作,如果在base class中找不到定义,如果不这样做,连接器会报错。

对于上面这段话,我的理解是:虚析构函数都应该有一份定义,无论是不是pure virtual的,不知道作者为什么要强调pure virtual函数。

注意:析构函数可以是内联函数。不能作为内联函数的有普通函数、构造函数、静态函数、友元函数。

总结:

n 带有多态性质的基类应该声明一个virtual析构函数。

n 一个类的设计目的不是作为基类使用,或不是为了具备多态性,就不该声明virtual析构函数。

条款08 别让异常逃离析构函数

C++并不禁止析构函数吐出异常,但它不鼓励你这样做。C++不喜欢析构函数吐出异常。 比如下面的代码:

void doSomething() {

std::vector<Widget> v;

} // v在这里被自动销毁

如果v在析构第一个元素期间,有个异常被抛出(交给外层代码处理),剩余的元素仍然要继续析构,如果第二个又发生异常,那这样就同时存在着两个异常了,如此类推,可能会有更多的异常,在C++看来,这太危险了,程序不是终止(较好的结果)就是不明确的行为。

对析构函数内的某些可能抛出异常的行为,如关闭文件,关闭数据库连接之类,可以将他们用try catch包围,如果抛出异常,可以这样做:

l 结束程序(强迫结束程序是个合理选项,毕竟它可以阻止异常从析构函数传播出去);

l 捕获异常,但什么也不做。

还有一种设计方案是:把这种关闭操作留一个接口给客户端调用,析构函数加一个条件判断也调用,如此做一个双保险操作(其实我感觉这样很不人性化)。

如果某个操作可能在失败时抛出异常,而又存在某种需要必须处理该异常,那么这个异常只允许来自析构函数以外的某个函数。

总结:

n 析构函数绝对不要吐出异常。如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕捉任何异常,然后吞下它们(不传播)或结束程序。

n 如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么类应该提供一个普通函数(而非在析构函数中)执行该操作。

条款09 绝不在构造和析构函数中调用virtual函数

Derived Class的初始化顺序是这样的:

l 初始化Base Class部分;

l 初始化成员变量(初值列表);

l 执行构造函数体部分

所以:基类的构造函数的执行要早于派生类的构造函数,当基类的构造函数执行时,派生类的成员变量尚未初始化。派生类的成员变量没初始化,即为指向虚函数表的指针vptr没被初始化又怎么去调用派生类的virtual函数呢?析构函数也相同,派生类先于基类被析构,又如何去找派生类相应的虚函数? 一句话概括:基类好时派生类还没好,基类还在时派生类已经不在了。

唯一好的做法是:确定构造函数和析构函数都没有调用虚函数,而它们调用的所有函数也不应该调用虚函数。

解决的方法可能是:既然无法使用虚函数从基类向下调用,那么我们可以使派生类将必要的构造信息向上传递至基类构造函数。即在派生类的构造函数的成员初始化列表中显示调用相应基类构造函数,并传入所需传递信息。

总结:

n 在构造和析构函数期间不要调用虚函数,因为这类调用从不下降至派生类。

条款10 令operator = 返回一个reference to *this

对于赋值操作符,我们常常要达到这种类似效果,即连续赋值:

int x, y, z;

x = y = z = 15;

为了实现”连锁赋值”,赋值操作符必须返回一个“引用”指向操作符的左侧实参,即:Widget & operator = ( const Widget &rhs) {

...

return  *this;

}

如果返回值类型是value,改动的将是对象的副本而不是副本本身。

其他赋值相关运算符:+=、-= 等也适用。

所有内置类型和标准程序库提供的类型,如string,vector,complex或即将提供的类型都共同遵守。

总结:

n 令赋值操作符返回一个reference to *this。

条款11 在operator = 中处理自我赋值

一般而言,如果某段代码操作指针或者引用而它们被用来指向多个相同类型(包含派生类型)的对象,就需要考虑他们所指对象赋值时是不是同一个。最普遍的一个异常安全的operator = 可能像这样:

Widget& Widget::operator=(const Widget& rhs) {

          Bitmap *pOrig = pb; // 记住原先的pb

         pb = new Bitmap(*rhs.pb); // 令pb指向*pb的一个副本

         delete pOrig; // 删除原先的pb

         return *this;

关键点就是:在new成功之前别着急delete,要创建一个备份。

还可以使用所谓的copy and swap技术,后续有讲,一般形式如下:

Class Widget {

void swap(Widget & rhs);

};

Widget & Widget::operator=(const Widget & rhs) {

Widget temp(rhs);

swap(temp);

retrun *this;

}

这里有一个利用by value 执行copying动作产生更高效代码的技法(略)。

总结:

n 确保当对象自我赋值时operator =有良好行为。其中技术包括比较“来源对象”和“目标对象”的地址、精心周到的语句顺序、以及copy-and-swap。

n 确定任何函数如果操作一个以上的对象,而其中多个对象是同一个对象时,其行为仍然正确。

条款12 复制对象时勿忘其每一个成分

首先要知道:编译器生成的copying函数的行为:将被拷贝对象的所有成员变量都做一份拷贝。我们自己编写的copying函数也应该有这样的行为,所以要确保:

l 复制所有的local成员变量;

l 调用所有base class内的适当copying函数,否则子类的base class部分会调用默认构造函数,那通常是trival的。

总结:

n copying函数应该确保复制“对象内的所有成员变量”及“所有基类成员”;

n 不要尝试以某个copying函数实现另一个copying函数。应该将共同机能放进第三个函数中,并由两个copying函数共同调用。

3、资源管理

条款13 以对象管理资源

资源是,一旦用了它,将来要还给系统。一般包括:文件描述符、互斥锁、图形界面的字型和画刷、数据库连接以及网络sockets。

把资源放进对象内,我们便可依赖C++的“析构函数自动调用机制”确保资源被释放。许多资源被动态分配于堆内而后被用于单一区块或函数内,它们应该在控制流离开那个区块或函数时被释放。标准程序库的auto_ptr和tr1::shared_ptr就是这样的能管理资源的对象,注意:它们的析构函数内执行delete而不是delete [],所以别把对象数组放进去。使用形式如下:

void f() {

std::auto_ptr<Investment> pInv1(createInvestment());

std::tr1::shared_ptr<Investment> pInv2(createInvestment());

}            // 函数退出,auto_ptr调用析构函数自动调用delete,删除pInv1;

“以对象管理资源”的 两个关键点:

l 获得资源后立刻放进管理对象内(如auto_ptr)。每一笔资源都在获得的同时立刻被放进管理对象中。“资源取得时机便是初始化时机”(Resource Acquisition Is Initialization;RAII)。

l 管理对象运用析构函数确保资源被释放。即一旦对象被销毁,其析构函数被自动调用来释放资源。

注意:auto_ptr是独占式指针,copying会交移拥有权;而tr1::shared_ptr是共享式引用计数型智能指针,但也无法避免引用环的问题。

总结:

n 为防止资源泄漏,请使用RAII对象,它们在构造函数中获得资源并在析构函数中释放资源。

n 两个常被使用的RAII类分别是auto_ptr和tr1::shared_ptr。后者通常是较佳选择,因为其拷贝行为比较直观。若选择auto_ptr,复制动作会使他(被复制物)指NULL。

条款14 在资源管理类中小心copying行为

有些资源不适合直接交给智能指针管理,我们需要写自己的资源管理类。

比如对互斥器资源,我们想用一个自定义的Lock类管理它,在构造时锁定互斥器,在析构时解锁互斥器。实现并不难,但有一点需要我们考虑:当一个RAII对象被复制时,应该发生什么事,这应该是由我们决定的。可能包含以下几种可能:

l 禁止复制。将copying操作声明为private即可;

l 对底层资源使用”引用计数法”,使我们的资源管理类持有一个tr1::shared_ptr即可实现,还可以指定所谓的”删除器”,当引用次数为0时便被调用。上述的Lock可能像这样:

class Lock {

public:

explicit Lock(Mutex * pm) : mutexPtr(pm, unlock) {} // 将unlock指定为删除器

… // 不需要声明析构函数

private:

std::tr1::share_ptr<Mutex> mutexPtr;

};

l 复制底部资源,深度拷贝;

l 转移底部资源,auto_ptr就是这种机制。

copying函有可能被编译器自动创建出来,因此除非编译器所生成版本做了你想要做的事,否则你得自己编写它们。

总结:

n 复制RAII对象必须一并复制它所管理的资源,所以资源的copying行为决定RAII对象的copying行为。

n 普遍而常见的RAII类拷贝行为是:抑制拷贝,施行引用计数法。不过其它行为也可能被实现。

条款15 在资源管理类中提供对原始资源的访问

class Font  {

public:

FontHandle get() const  {        // FontHandle是资源;显式API

return f;

}

operator FontHandle() const {         // 隐式转换,可能引起“非故意之类型转换”

return f;

}

};

是否该提供一个显示转换函数(例如get成员函数)将RAII类转换为其底部资源,或是应该提供隐式转换,答案主要取决于RAII类被设计执行的特定工作,以及它被使用的情况。

显示转换可能是比较受欢迎的路子,但是需要不停的get;而隐式转换又可能引起“非故意之类型转换”。

总结:

l APIs往往要求访问原始资源,所以每一个RAII类应该提供一个“取得其所管理之资源”的方法。

l 对原始资源的访问可能经由显示转换或隐式转换。一般而言显示转换比较安全,但隐式转换对客户比较方便。

条款16 成对使用new和delete时要采用相同的形式

当我们使用new,有两件事情发生:1)内存被分配出来;2)针对此内存会有一个(或更多)构造函数被调用。当使用delete,也有两件事发生:1)针对此内存会有一个(或多个)析构函数被调用;2)然后内存才被释放。delete的最大问题在于:即将被删除的内存之内究竟有多少对象?这个问题的答案决定了有多少个析构函数必须被调用起来。

解决以上问题事实上很简单:我们显式告诉(加不加”[ ]”)编译器就好了。

尽量不要对数组作typedefs动作,这样容易引起delete操作的“疑惑”(要不要[ ]呢?)。

总结:

n 如果你在new表达式中使用[],必须在相应的delete表达式中也使用[];如果你在new表达式中不使用[],一定不要在相应的delete表达式中使用[]。

条款17 以独立语句将newd对象置入智能指针

为了避免资源泄漏的危险,最好在单独语句内以智能指针存储newed所得对象。

int priority();

void processWidget(std::tr1::shared_ptr<Widget> pw, int priority);

如果直接写成这样:

processWidget(std::tr1::shared_ptr<Widget> (new Widget), priority());

可能导致内存泄漏,因为可能编译器先执行”new Widget”,再调用priority(),再调用智能指针的构造函数,万一在调用priority()时抛出异常了,就内存泄漏了。

为了避免此问题,应该在传入函数之前对智能指针初始化:

std::tr1::shared_ptr<Widget> pw(new Widget);   

processWidget(pw, priority()); // 现在绝对不会泄漏

总结:

n 以独立语句将newed对象存储于(置入)智能指针内。如果不这样做,一旦异常抛出,有可能导致难以察觉的资源泄漏。

4、设计与声明

条款18 让接口易被正确使用

总结:

n 好的接口很容易被正确使用,不容易被误用。你应该在你的所有接口中努力达成这些性质,站在客户的角度考虑各种可能性。

n “促进正确使用”的办法包括接口的一致性,以及与内置类型的行为兼容。

n “阻止误用”的办法包括建立新类型、限制类型上的操作,束缚对象值,以及消除客户的资源管理责任。

n tr1::shared_ptr支持定制删除器。这可防范DLL问题,可被用来自动解除互斥量等。

条款19 设计class犹如设计type

C++就像在其它面向对象编程语言一样,当你定义一个新class,也就定义了一个新type。这意味着你并不只是类的设计者,更是类型的设计者。重载函数和操作符、控制内存的分配和归还、定义对象的初始化和终结......全部在你手上。

设计一个良好的类,或者称作类型,考虑以下设计规范:

l 新类型的对象应该如何被创建和销毁?

l 对象的初始化和对象的赋值该有什么样的差别?

l 新类型的对象如果被passed by value(值传递),意味着什么?

l 什么是新类型的“合法值”?

l 你的新类型需要配合某个继承图系吗?

l 你的新类型需要什么样的转换?

l 什么样的操作符和函数对此新类型而言是合理的?

l 什么样的标准函数应该驳回?

l 谁该取用新类型的成员?

l 什么是新类型的“未声明接口”?

l 你的新类型有多少一般化?

l 你真的需要一个新类型吗?

总结:

n class的设计就是type的设计。在定义一个新的type之前,请确定你已经考虑过本条款覆盖的所有讨论主题。

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

如果以传值的方式传递对象至函数,会招致”一次copy构造函数,一次析构函数”的成本,而这个copy构造函数中,可能包含:1)所有base class部分的copy构造;2)所有local变量的copy。这其实开销是很大的。

如果以const引用的形式传入,既可以避免这些开销,又同样保证原对象不会改变。传值的方式还有一个危害:禁用了多态性,因为派生类对象在传给基类形参时被切割了,vptr被丢弃了。

但对于内置类型和STL的迭代器和函数对象,往往是传值的。

总结:

n 尽量以pass-by-reference-to-const替代pass-by-value。前者通常比较高效,并可避免切割问题。

n 以上规则并不适用于内置类型,以及STL的迭代器和函数对象。对它们而言,pass-by-value往往比较适当。

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

看了上一条,很容易让人无时不刻都想着传引用,返回引用。此时,很可能导致一个问题:开始传递一些引用指向并不存在了的对象。

函数创建新对象的途径有二:在栈空间和堆空间

栈上:即在函数内的局部变量。局部变量在函数返回后就没有存在的意义,若还对它”念念不忘”,将带来灾难性后果。所以传引用在栈上不可行。

堆上:在堆上构造一个对象,并返回。看似可行,也埋下了资源泄漏的危险。谁该对这对象实施delete呢?别把这种对资源的管理寄托完全寄托于用户。所以传引用在堆上不可行。

可能还有一种想法:让返回的引用指向一个被定义于函数内部的静态对象。出于我们对多线程安全性的疑虑,以及当线程中两个函数对单份对象的处理也可能带来不可测行为。所以静态对象也是不可行的。

一个必须返回新对象的函数的正确写法是:就让那个函数返回一个新对象。可以使用返回值优化省掉一些开销。

总结:

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

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

将成员变量隐藏在函数接口的背后,至少有三个好处

l 提供统一的接口,语法一致性,不用记得是否要加小括号;

l 可以实现出”只读”,”只写,”可读可写”的访问控制,利用set/get;

l 可以为日后”所有可能的实现”提供弹性。例如,这可使得成员变量被读或写时轻松通知其它对象、可以验证class的约束条件以及函数的前提和事后状态、可以在多线程环境中执行同步控制......

不封装意味不可改变!成员变量的封装性与”成员变量内容改变时所坏量的代码数量”成反比。protected成员改变时,所有派生类的代码都需要改变,其实也是没有封装的。

总结:

n 切记将成员变量声明为private。这可赋予客户访问数据的一致性、可细微划分访问控制、允许约束条件获得保护,并提供class作者以充分的实现弹性。

n protected并不比public更具封装性。

条款23 宁以非成员、非友元替换成员函数

一般我们相当然以为类中的成员函数更具封装性,而实际上并不是那么一回事,因为成员函数不仅可以访问private成员变量,也可以取用private函数、enums、typedefs等等。而非成员非友元函数能实现更大的封装性,因为它只能访问public函数。

将所有工具函数(比如调用一系列class的public成员函数,模板方法)放在多个头文件内但隶属同一个命名空间,意味客户可以轻松扩展这一组便利函数。需要做的就是添加更多non-member non-friend函数到此命名空间内。

总结:

l 宁可拿non-member non-friend函数替代member函数。这样做可以增加封装性、包裹弹性和机能扩充性。

条款24 若所有参数皆需类型转换,用非成员函数

通常,令类支持隐式类型转换通常是个糟糕的主意。当然这条规则有其例外,最常见的例外是在建立数值类型时。如果定义一个有理数类,并实现*操作符为成员函数:

const Rational operator*(const Rational& rhs) const;

那么考虑一下调用:

Rational oneHalf(1, 2);

result = oneHalf * 2; // 正确,2被隐式转换为Rational(2,1)

编译器眼中应该是这样:

const Rational temp(2);

result = oneHalf * temp;

而交换顺序调用:

result = 2 * oneHalf; // 错误,2不被认为是Rational对象,无法调用operator*

可见,这样并不准确,因为乘法(*)应该满足交换律,不是吗?

所以,支持混合式算术运算的可行之道应该是:让operator*成为一个non-member函数,允许编译器在每一个实参上执行隐式类型转换:

const Rational operator*(const Rational& lhs,  Rational& rhs) {

return Rational(lhs.numerator() * rhs.numerator(),

lhs.denominator() * rhs.denominator());

}

成员函数的反面是非成员函数,而不是友元函数。

可以用类中的public接口实现的函数,最好就用非成员函数,而不是采用友元函数。

总结:

n 如果你需要为某个函数的所有参数(包括被this指针所指的那个隐喻参数)进行类型转换,那么这个函数必须是个non-member。

条款25 写一个不抛异常的swap函数

对于内含pimpl(一个指针,指向大型对象)的类,其copying如果是pimpl的对象也要拷贝,开销将会很大,所以再讨论本条款前应尝试改写类的copying行为。

如果缺省版本的swap对你的class或者class template提供可以接受的效率(没有出现大对象的copying),什么都不用做;否则,试着做以下事情:

l 为该类提供一个public swap成员函数,使它高效地置换对象值(往往通过交换指针本身来实现),且该函数绝对不能抛出异常;

l 特化std::swap,并令它调用我们写的swap成员函数;

l 如果是class template,我们不可以对std::swap函数进行偏特化(C++只允许偏特化class template),也不能在重载function template(C++不允许添加新的templates到std里),我们只能新建一个命名空间,在该命名空间内写我们的class template,然后写上一个非成员的swap函数,并令它调用class的swap成员函数。

l 调用swap时确保包含一个using声明式,以便让std::swap在函数内曝光可见,然后不加任何namespace修饰符,赤裸裸地调用swap。

5、实现

大多数情况下,适当提出你的类定义以及函数声明,是花费最多心力的两件事。尽管如此,还是有很多东西需要小心:太快定义变量可能造成效率上的拖延;过度使用转型(casts)可能导致代码变慢又难维护,又招来微妙难解的错误;返回对象“内部数据之号码牌(handls)”可能会破坏封装并留给客户虚吊号码牌;为考虑异常带来的冲击则可能导致资源泄漏和数据败坏;过度热心地inlining可能引起代码膨胀;过度耦合则可能导致让人不满意的冗长建置时间。

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

”尽可能延后”的真正意义应该是:不只应该延后变量的定义,直到非得使用该变量的前一刻为止(避免无谓的构造和析构),甚至应该尝试延后这份定义直到能够给它初值实参为止(避免无谓的default ctor)。

如果是循环语句,把构造放在循环外的成本是:1个构造函数 + 1个析构函数 + n个赋值操作;把构造放在循环内的成本:n个构造函数 + n个析构函数。

除非:1、你知道赋值成本比“构造+析构”成本低;2、你正在处理代码中效率高度敏感的部分,否则应该把构造放在循环里面。

总结:

n 尽可能延后变量定义式的出现,这样做可增加程序的清晰度并改善程序效率。

条款27 尽量少做转型动作

C++提供四种新式转型:

l const_cast:通常被用来将对象的常量性转除,即去掉const。

l dynamic_cast:将一个基类对象指针(或引用)cast到继承类指针,dynamic_cast会根据基类指针是否真正指向继承类指针来做相应处理,即会作一定的判断。

对指针进行dynamic_cast,失败返回null,成功返回正常cast后的对象指针;

对引用进行dynamic_cast,失败抛出一个异常,成功返回正常cast后的对象引用。

注意:dynamic_cast在将父类cast到子类时,父类必须要有虚函数。

l reinterpret_cast:意图执行低级转型,实际动作可能取决于编译器,这也就表示它不可移植。可以转换任意一个32bit整数,包括所有的指针和整数。可以把任何整数转成指针,也可以把任何指针转成整数,以及把指针转化为任意类型的指针,威力最为强大!但不能将非32bit的实例转成指针。总之,只要是32bit的东西,怎么转都行。

l static_cast:用来强迫隐式转换,例如将non-const转型为const,int转型为double等。

l static_cast和dynamic_cast可以执行指针到指针的转换,或实例本身到实例本身的转换,但不能在实例和指针之间转换。static_cast只能提供编译时的类型安全,而dynamic_cast可以提供运行时类型安全。

不要以为转型操作没有成本,至少令编译器编译出运行期间执行码是肯定有的。单一对象可能有一个以上的地址:例如以Base *指向它和以Derived *指向它的地址就相差一个偏移量(详见C++对象模型)

总结:

n 如果可以,尽量避免转型,特别是在注重效率的代码中避免dynamic_casts。如果有个设计需要转型动作,试着发展无需转型的替代设计。

n 如果转型是必要的,试着将它隐藏于某个函数背后。客户随后可以调用该函数,而不需将转型放进他们自己的代码内。

n 宁可使用C++-style(新式)转型,不要使用旧式转型。前者很容易辨识出来,而且也比较有着分门别类的执掌。

条款28 避免返回handles指向对象内部成分

成员变量一般是private的,然后会有一个它的get方法,如果get方法返回的是该成员变量的引用,那么其实相当于没有封装,其访问级别仍然是public,不过有时候确实得返回这个数据,为了防止被修改,可以给返回值加上const;

如果get方法返回的是值类型,外部函数中调用该get方法得到一个拷贝,如果对该该拷贝取地址,很容易发生该拷贝对象已析构,指针指向一个死亡的对象的情况。

返回一个handle代表对象内部成分总是危险的,不论handle是指针、迭代器或引用,也不论是否为const,关键点在于:一旦有个handle被传出去了,就暴露在”handle比其所指对象更长寿”的风险下。

总结:

l 避免返回handles(包括reference、指针、迭代器)指向对象内部。遵守这个条款可增加封装性,帮助const成员函数的行为像个const,并将发生“虚吊号码牌”(dangling handles)的可能性降至最低。

条款29 为异常安全努力是值得的

当异常被抛出时,带有异常安全性的函数会:

l 不泄露任何资源,比如:互斥锁,数据库连接等;

l 不允许数据败坏,不能产生脏数据。

异常安全函数提供以下三个保证之一:

l 基本保证:如果异常被抛出,程序内的任何事务是合法值,即程序不会崩溃;

l 强烈爆震:如果异常被抛出,程序状态不改变,类似于数据库的事务机制;

l 不抛掷保证:决不抛出异常,总能安全完成任务。

总结:

n 异常安全函数(Exception-safe functions)即使发生异常也不会泄漏资源或允许任何数据结构败坏。这样的函数区分为三种可能的保证:基本型、强烈型、不抛异常型。

n ”强烈保证”往往能够以copy-and-swap实现出来,但”强烈保证”并非对所有函数都可实现或具备现实意义,因为开销很大。

n 函数提供的“异常安全保证”通常最高只等于其所调用之各个函数的“异常安全保证”中的最弱者。

条款30 透彻了解inlining的里里外外

对于inline函数,需要知道以下几点:

l inline函数可以免去函数调用的开销,对每一个函数调用都用函数本体替换,所以会增大目标码大小;

l inline只是对编译器的一个申请,不是强制命令;头文件内定义的函数会隐喻提出,包括定义于class内的friend函数;

l inline和template具现化一般都完成于编译期内,但不要以为function template出现在头文件就将它声明为inline;

l 编译器往往拒绝让含有循环或递归的函数inlining,虚函数也是不能的;以函数指针调用inline函数也不会被inlined,因为函数展开后就没有所谓的函数地址了;

l 构造函数和析构往往看起来没有内容,但编译器会在其中填充很多东西,所以它们一般也不会被inlined;

l inline函数没办法调试。

在我们写程序的时候,一开始先不要将任何函数声明为inline,写完之后,再来寻找系统中可以有效增进程序整体效率的20%代码,然后竭尽所能将其瘦身并inline。

总结:

n 将大多数inlining限制在小型、被频繁调用的函数身上。这可使日后的调试过程和二进制升级更容易,也可使潜在的代码膨胀问题最小化,是程序的速度提升机会最大化。

n 不要只因为function templates出现在头文件,就将它们声明为inline。

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

编译器必须在编译期就知道对象的大小,所以必须看到对象的定义式;如果用指针代替对象,编译器则不需要知道对象的大小,Java就是这样的。

减少依赖关系的方式是在头文件中尽量使用前置声明,然后使用指针(只要在头文件中尽量的减少include别的头文件,就可以做到尽可能的减少依赖关系)。

Handle class是一个内含implemention pointer的类。

Interface class是一个只包含pure virtual的类(C++中可以包含成员变量),还可以包含一个静态factory函数。

上述两种实现方式都会导致运行期速度降低,但很多时候是值得的。

总结:

n 支持“编译依存性最小化”的一般构想是:相依于声明式,不要相依于定义式。基于此构想的两个手段是Handle classed和Interface classes。

n 程序库头文件应该以“完全且仅有声明式”(full and declaration-only forms)的形式存在。这种做法不论是否涉及templates都适用。

6、继承与面向对象设计

public继承意味着is-a,virtual函数意味”接口必须被继承”,non-virtual函数意味”接口和实现都必须被继承”。

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

这一条就是在阐述”里氏代换原则”,下面是几条经验:

l 世界上并不存在一种”适用于所有软件的”完美设计;

l 好的接口可以防止无效代码通过编译;

l 有些按直觉理解的is-a在C++继承中会有一些缺陷;

l clases之间的关系还有has-a,is-implemented-in-terms-of(根据某物实现出)。

总结:

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

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

C++的名称遮掩规则所做的唯一一件事情就是:遮掩名称。至于名称是否是相同类型,并不重要。名称遮掩规则:1)内层作用域的名称遮掩外层作用域的名称;2)派生类作用域被嵌套在基类作用域类,其实也就是派生类遮掩基类;

派生类的一个函数中出现了一个”未见过”的方法,其查找顺序是:

l 函数体本身的作用域;

l 派生类作用域

l 基类作用域

l 全局作用域

如果派生类遮掩了基类的方法,可以再public区段中声明基类的方法,那样在派生类中那些方法又是可见的了,如:using Base::mf1();

如果派生类指向继承基类中某个无参数版本的函数,可以先用同名函数遮掩,再在同名函数内部调用基类的方法。这就是所谓的inline转交函数。

补充关于重载、覆盖、隐藏的说明:

重载:只有在同一类定义中的同名成员函数才存在重载关系 ,主要特点是 函数的参数类型和数目有所不同 ,但不能函数参数的个数和类型均相同 ,仅仅依靠返回值类型不同来区分的函数,这和普通函数的重载是完全一致的。另外,重载和成员函数是否是虚函数无关。

覆盖:在派生类中覆盖基类中的同名函数,要求两个函数的参数个数、参数类型、返回类型都相同,且基类函数必须是虚函数。

隐藏:派生类中的函数屏蔽了基类中的同名函数:

2个函数参数相同,但基类函数不是虚函数(和覆盖的区别在于基类函数是否是虚函数)。

2个函数参数不同,无论基类函数是否是虚函数,基类函数都会被屏蔽(和重载的区别在于两个函数不在同一类中)。

总结:

n derived calsses内的名称会遮掩base classes内的名称。在public继承下从来没有人希望如此。

n 为了让被遮掩的名称再见天日,可使用using声明式或转交函数(forwarding function)。

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

表面上直截了当的public继承概念,经过更严密的检查之后,发现它由两部分组成:函数接口继承和函数实现继承。

l 成员函数的接口总是会被继承;

l 声明一个纯虚函数的目的是为了让派生类只继承函数接口,但请注意:纯虚函数也是可以有一份自己的默认实现的,但即使是有了默认实现,子类还是得写一份实现(可以利用这个性质替换public pure function + protetced non-virtual function实现接口和缺省实现的分离);

l 声明一个虚函数的目的是让派生类继承该函数的接口和缺省实现,必须得有默认实现,即使是空函数体;

l 声明一个非虚函数的目的是为了令派生类继承函数的接口及一份强制性实现;

总结:

n 接口继承和实现继承不同。在public继承之下,derived classes总是继承base class的接口。

n pure virtual函数只具体制定接口继承。

n 简朴的(非纯)impure virtual函数具体制定接口继承及缺省实现继承。

n non-virtual函数具体制定接口继承以及强制性实现继承。

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

1、借由non-virtual interface(NVI)手法实现Template Method模式

在基类定义一个public的non-virtual函数,该函数中调用private(也可以是public或protected的)的virtual函数,可以在调用之前做一些预处理,之后做后处理工作。由于virtual是private的,每个派生类必须得自己再定义一个virtual函数。这里base claas控制”某些事”何时完成,派生类负责如何实施。

其实这种方案本质上还是依赖于virtual函数。

2、借由Function Pointer实现Strategy模式

我们可以定义一些non-member函数,然后让class的构造函数接受函数指针,或者让函数指针设值注入,就能实现:1)同一类的不同对象可以拥有不同的处理事情的方式(函数);2)一个对象的处理方式可以在运行期改变。

这样提高了灵活性,但降低了封装性,因为处理事情的方式是隶属于该类的,现在却放到了类外。

3、借由tr1::function实现Strategy模式

这一条是对上一条的扩充,不一定得接受函数指针,凡是”可调用物”,如:函数对象、成员函数指针,只要起签名式兼容于需求段,都可以传进去。兼容的意思是:参数或返回值可以隐式转换为需求段的类型。

看这个例子:

class GameLevel {

public:

float health(const GameCharacter &) const; // 成员函数

};

GameLevel currentLevel;

// 暂不理会这里的语法,可以看出构造函数是接受了一个成员函数指针的

EvilBadGuy ebg(std::tr1::bind(&GameLevel::health, current, _1));

4、古典的Strategy模式

Context类持有一个Strategy指针,可以在构造函数中初始化,也可以在set方法中赋值,具体的策略继承自抽象Strategy类。

总结:

n virtual函数的替代方案包括NVI手法及Strategy设计模式的多种形式。NVI手法自身是一个特殊形式的Template Method设计模式。

n 将机能从成员函数移到class外部函数,带来的一个缺点是,非成员函数无法访问class的non-public成员。

n tr1::function对象的行为就像一般函数指针。这样的对象可接纳“与给定之目标签名式(target signature)兼容”的所有可调用物(callable entities)。

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

我们观察这样一个语句:p->mf();

如果mf是non-virtual函数,那么由于其静态绑定性质,会调用p的声明类的mf;如果是virtual函数,由于其动态绑定性质,会调用其真实所指对象的mf。引用也是一样的性质。

适用于基类的每一件事,一定也适用于派生类,而派生类会继承基类中non-virtual函数的接口和实现,如果有重新定义该non-virtual函数,就不满足第一条规则了。

总结:

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

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

先简化本条款讨论的范围,Derived class只能继承virtual和non-virtual函数,然而重新定义一个non-virtual函数永远是错误的,所以这里讨论是virtual函数的缺省参数问题。

如果有个Base class的一个virtual函数draw的默认参数是color = Red,而Derived class中将缺省参数赋值为color = Green,那么在这样调用时:

pD->draw(); // pD是指向Derived class的基类指针

由于pD的动态类型是Derived class,将调用Derived中的draw函数;但是由于pD的静态类型是Base class,将使用Base class中draw的缺省参数color = Red,这样就基类派生类各出一半力。

可以用NVI手法避免此问题:

Class Shape {

public:

enum ShapeColor { Red, Green, Blue };

void draw(ShapeColor color = Red) const { // non-virtual,指定缺省参数

doDraw(color);

}

Private:

virtual void doDraw(ShapeColor color) const = 0;

};

class Rectangle {

public:

private:

virtual void doDraw(ShapeColor color) const; // 不需指定缺省参数

};

总结:

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

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

如果我们想用std::list来实现一个Set,一开始可能会这样写:

template <typename T>

class Set : public std::list<T> { … };

但这样很明显是错误的,因为list允许有相同元素,而Set则不允许,而public继承表示is-a的关系;然后我们想到这样做:

template <typename T>

class Set {

private: // 如果试图加入相同元素可以做一些处理

std::list<T> rep;

};

这就是复合,在应用域(人、电话等具体事务类)称为”has-a”,在实现域(缓冲区、互斥器、查找树等底层实现)称为”is-implemented-in-terms-of”。

总结:

n 复合(composition)的意义和public继承完全不同。

n 在应用域(application domain),复合意味has-a(有一个)。在实现域(implementation domain),复合意味is-implemented-in-terms-of(根据某物实现出)。

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

如果两个类之间的继承关系是private,那么1)编译器不会自动将一个derived class对象转换成一个base class对象;2)由private base class继承而来的所有成员,在derived class 中都变成了private属性。

private继承的意义是implemented-in-terms-of(根据某物实现出),其实只是一种软件实现技术。复合也是这样的用意,一般情况下我们还是应该选择复合

假设有这样一个计时器类:

class Timer {

public:

explicit Timer(int tickFrequency);

virtual void onTick() const; // 定时器每滴答一次,该函数就被调用一次

};

为了让我们的小玩意Widget也有这样的能力,我们可以选择private继承(public继承肯定是被抛弃的,因为Widget不是Timer)

class Widget : private Timer {

private:

virtual void onTick() const; // 查看Widget的数据等

};

然而我们还可以用复合加public继承实现:

class Widget : private Timer {

private:

class WidgetTimer : public Timer {

public:

virtual void onTick() const;

};

WidgetTimer timer;

};

一般而言,第二种比第一种更好:

l 如果Widget被继承,但不想让其derived class重新定义onTick了,用第一种方案就不能实现,因为即使是base class中的private virtual函数,derived class都很有可能需要实现,详见条款35 NVI)。第二种方案就保证了derived class不能重新定义virtual函数。

l 我们想要编译依存降至最低,如果采用private继承,当Widget被编译时Timer的定义必须可见;然而如果将WidgetTimer移出类外,并令Widget持有一个WidgetTimer指针,编译时便不再需要其定义。

在以下情况下,第一种比第二种好:

l Base class是一个没有成员变量的空类,编译器会为其分配1个字节的空间,如果derived class采用private继承base class,那么这一个字节的空间便会”消失”,而采用has-a实现仍然会拥有那一个冗余的空间(对一般编译器而言)。这就是所谓的EBO(空白基类最优化,base class虽然不包含成员,但可以拥有typedefs,enums等)。

l 当两个并不存在is-a关系的class,其中一个需要访问另一个的protected成员,或需要重新定义其虚函数。

总结:

n private继承意味着根据某物实现出。它通常比复合级别低,但是当derived class需要访问另一个的protected成员,或需要重新定义其虚函数时,这个设计是合理的。

n 和复合不同,private继承可以造成EBO,这对致力于对象尺寸最小化的程序库开发者而言,可能很重要。

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

总结:

n 多重继承比单一继承更复杂,它可能导致新的歧义性,以及对virtual继承的需要。

n Virtual继承会增加大小、速度、初始化(及赋值)复杂度等成本,如果virtual base class不带有任何数据,将是最具实用价值的情况。

n 多重继承的确有正当用途,其中一个情节涉及”public继承某个Interface class”和”private 继承某个协助实现的class”的两相组合。

7、模板和泛型编程

C++ Template机制自身是一部完整的图灵机:可以计算任何可计算的值。

条款41 了解隐式接口和编译器多态

我们写下一个class template或是function template,其隐式接口就是template中的有效表达式,如:操作符、函数调用等,凡涉及模板对象的任何函数调用,都可能造成template具现化,这样的具现行为发生在编译期。

总结:

n classes和templates都支持接口和多态。

n 对classes而言接口是显式的,以函数签名为中心,多态是通过virtual函数发生于运行期。

n 对template参数而言,接口是隐式的,奠基于有效表达式。多态则是通过template具现化和函数重载解析发生于编译期。

条款42 了解typename的双重意义

嵌套从属名称:template内出现的依赖于某个template参数的名称,比如:

template<typename Iter>

void workWithIterator(Iter iter) {

typename std::iterator_traits<Iter>::value_type temp(*iter);

};

其中的value_type就是依赖于参数Iter的,他就是一个嵌套从属(类型)名称。

C++解析器在解析中遭遇一个嵌套从属名称,它默认改名称不是一个类型,除非我们告诉它(仅有一个例外),告诉它的方式就是加上”typename”。

默认不需要加typename的嵌套从属类型名称的例外是:继承时的base class列表中,以及构造函数的成员初值列,见下例:

Template<typename T>

class Derived : public Base<T>::Nested { // base class list不能加

public:

explicit Derived(int x) : Base<T>::Nested(x) { // mem init list不能加

typename Base<T>::Nested temp; // 这里必须加

}

};

总结:

n 声明template参数时,前缀class和typename可互换。

n 请使用typename修饰嵌套从属类型名称,但不能在base class list和member initial list内以它作为base class修饰符。

条款43 学习处理模板化基类的名称

如果我们有一个MsgSender类,并有若干个CompanyX类,考虑下面的代码:

Template <typename Company>

class LoggingMsgSender : public MsgSender<Company> {

public:

void sendClearMsg(const msgInfo & info) {

// 志记log

sendClear(info); // MsgSender内的函数,这里无法通过编译

// 志记log

}

};

编译器遭遇class LoggingMsgSender定义式时,会担心MsgSender存在某个特化版不包含sendClear成员函数,所以它的规则是:拒绝在templatized base class(模板化基类)内寻找继承而来的名称。

我们要做的事就是告诉编译器,基类中是有该名称的,让它放心地去找,有三种方法:

l this-> sendClear(info);,这个语法是什么含义现在还不理解。

l 在整个函数前面加上using MsgSender<Company>::sendClear;

l MsgSender<Company>:: sendClear(info);,不过这样会关闭virtual绑定行为。

总结:

n 可在derived class template内通过this->指涉base class template内的成员名称,或借由一个明白写出的base class资格修饰符完成。

条款44 将与参数无关的代码抽离templates

这一条没怎么看懂,尤其是P216~P217

总结:

n templates生成多个classes和函数,所以任何template代码都不该与某个构成膨胀的特template参数产生相依关系。

n 因非类型模板参数而造成的代码膨胀,往往可以消除,做法是以函数参数或class成员变量替换template参数。

n 因类型参数而造成的代码膨胀,往往可降低,做法是让带有完全相同的二进制表述的具现类型共享实现码。

条款45 运用成员函数模板接受所有兼容类型

同一个template中的不同具现体之间没有什么关系,比如:D派生于B,但SmartPtr<D>与SmartPtr<B>的关系并不比vector<double>和string紧密。

如果我们想实现智能指针的”派生类”向”基类”转型,可以利用member function template,就是在class template里写function template(模板参数与class template的不同),就像这样:

template <typename T>

class SmartPtr {

public:

template <typename U>

SmartPtr(const SmartPtr<U> & other) : heldPtr(other.get()) {}

private:

T * heldPtr;

};

这里只有存在U*指针转为一个T*指针时才能通过编译,这正是我们想要的。

member template并不改变语言规则,如果我们没有为class声明copy构造函数,编译器还是会为我们生成一个,所以我们对于一个class template,我们最好同时声明泛化的copy构造函数和”正常的”copy构造函数。

总结:

n 请使用member function template(成员函数模板)生成”可接受所有兼容类型”的函数。

n 如果声明了member templates用于泛化构造或泛化asignment操作,还是需要声明正常的copy构造和copy assignment操作符。

条款46 需要类型转换时请为模板定义非成员函数

条款24说到:要支持混合式算术运算,需要定义一个non-member函数,在模板这一块,简单定义这样的no-member函数行不通:

Template <typename T>

const Rational<T> operator* (const Rational<T>& lhs, const Rational<T> & rhs) {}

调用:

Rational<int> oneHalf(1, 2);

Rational<int> result = oneHalf * 2; // 无法通过编译

因为function template实参推导中并不将隐式转换纳入考虑,但是class template不依赖实参推导,编译器总能在class Rational<T>具现化时得知T。因此将函数声明为friend,但是简单地在class内声明,在类外定义还不行,不能通过连接(这里的解释没看懂)。还需要让friend函数定义也写在class内,不过可以调用non-member的辅助函数。

为了让类型转换可能发生于所有实参身上,我们需要一个non-member函数;为了令这个函数被自动具现化,我们需要将它声明在class内部;而在class内部声明non-member的唯一办法就是声明为friend。

总结:

n 当我们编写一个class template,而它所提供之”与此template相关的”函数支持”所有参数之隐式类型转换”时,请将那些函数定义为”class template”内部的friend函数。

条款47 请使用traits classes表现某些类型信息

如何设计并实现一个trait class

l 确认若干希望将来可取得的类型相关信息。例如对迭代器而言,我们希望将来取得分类。

l 为该信息选择一个名称,如:iterator_category。

l 提供一个template和一组特化版本,内含希望支持的类型相关信息。

如何使用一个traits class

l 建立一组重载函数(身份向劳工)或函数模板,彼此间的差异只在于各自的traits参数。令每个函数实现码与其接受之traits信息相应和。

l 建立一个控制函数(身份像工头)或函数模板,它调用上述那些”劳工函数”并传递traits class所提供的信息

总结:

n traits class使”类型相关信息”在编译器可用,他们以template和”templates特化”实现。

n 整合重载技术后,traits class有可能在编译期对类型执行if…else测试。

条款48 认识模板元编程

编译器必须确保所有的源码都有效,纵使是不会执行起来的代码!

看看”递归模板具现化”:

template <unsignd int n>

struct Factorial {

enum { value = n * Factorial<n - 1>::value };

};

template<>

struct Factorial<0> {

enum {value = 1};

};

运用TMP可以生成客户定制之设计模式。

总结:

n template metaprogamming(TMP,模板元编程)可将工作由运行期移至编译期,因而得以实现早期错误侦测和更高的执行效率。

n TMP可被用来生成”基于政策选择组合”的客户定制代码,也可以用来避免生成对某些特殊类型并不适合的代码。

8、定制new和delete

条款49 了解new-handler的行为

编写代码过程中,总是避免不了new出一些对象出来,new操作符私底下通过调用operator new来实现内存分配的。当operator new抛出异常以反映一个未获满足的内存需求之前,它会调用一个客户指定的错误处理函数,一个所谓的new-handler。而客户是通过set_new_handler将自己的new-handler传递给它的,其声明在<new>标准库函数中:

namespace std{

        typedef void (*new_handler)();

        new_handler set_new_handler( new_handler p ) throw();

}

当operator new无法满足内存申请时,它会不断调用new-handler函数,直到找到足够内存。关于反复调用代码我们在条款51中我们会讨论。一个设计良好的new-handler函数必须做以下事情:

l 让更多内存可被使用;

l 安装另一个new-handler;

l 卸除new-handler;

l 抛出bad_alloc(或派生自bad_alloc)的异常;

l 不返回。

我们来把目光转到nothrow new,nothrow版的operator new被调用,用以分配足够内存给Widget对象。如果分配失败便返回null指针,一如文档所言,如果分配成功,接下来构造函数会被调用,而在那一点上所有的筹码便被耗尽,因为Widget构造函数可以做它想做的任何事情。它有可能又new一些内存,而没人可以强迫它再次使用nothrow new。现在我们可以得出结论:使用nothrow new只能保证operaor new不抛掷异常,不保证像'new (std::nothrow) Widget'这样的表达式不导致异常,其实并没有运用nothrow new的需要。

总结:

n set_new_handler允许客户指定一个函数,在内存分配无法获得满足时调用。

n nothrow new是一个颇为局限的工具,因为它只适用于内存分配;后继的构造函数调用还是可能抛出异常。

条款50 了解new和delete的合理替换时机

怎么会有人想要替换编译器提供的operator new或operator delete呢?我们可以列出如下三个常见的理由

l 检测运用上的错误:程序员从开始编写代码到调试直至最终完成,这一过程当中肯定会犯下各种各样的内存错误,如果我们自定义operator news,我们就可以超额分配内存,用额外空间放置特定签名来检测类问题(搞的跟TCP/IP一样)。

l 为了强化效能:因此编译器所带的operator new和operator delete对每个人都是适度地好,但不对特定任何人有最佳表现。通常定制版之operator new和operator delete性能胜过缺省版本,包括速度更快,空间更小。

l 收集使用上的统计数据:自行定义的operator new和operator delete使我们得以轻松收集内存的使用信息。

l 弥补缺省分配器中的非最佳齐位:编译器自带的operator news不保证内存齐位。

总结:

n 有许多理由需要写个自定义的new和delete,包括改善效能,对heap运用错误进行调试,收集heap使用信息。

条款51 编写new和delete时需固守常规

当用户自定义operator new时,应当满足以下要求:

l operator new应当包含一个无穷循环,并在其中尝试分配内存,如果分配失败,就应当调用new-handler。

l 同时它应当有能力处理0-byte申请,一种常见做法是将它视为1-byte申请。C++规定,即使客户要求0字节,operator new也应该返回一个合法指针。

l 类专属版本的operator new应该处理“比正确大小更大的(错误)申请”。注意对基类适用的内存分配方案可能对派生类不适用,如果一个类被继承,它的派生类很可能调用基类的operator new,防止这种情况出现的方法是在该函数中增加一个判断:判断该类是否为基类(通过sizeof()取大小来比较判断),如果不是则调用全局版本。

PS我想调用全局版本可能并不是最好的方法,之所以这样做可能是在不给派生类添加专属operator new的情况下的考虑,如果不怕辛苦可以为派生类设计一套内存方案。

一个示例:

void* operator new(std::size_t size) throw(std::bad_alloc){

using namespace std;

if(size == 0){ // 如果客户申请0位内存,则自动转换为1位的内存

size = 1;

}

while(true){

… // 无穷循环,尝试分配size bytes

if(…) // 分配成功

return …; // 一个指针,指向分配得到的内存

// 分配失败,找到目前的new-handler函数

new_handler globalHandler = set_new_handler(0);

set_new_handler(globalHandler); // 这两步是为了取得new-handler函数指针

if(globalHandler)(*globalHandler)();

else throw std::bad_alloc();

}

}

如果设计operator new[],则应当分配尽量多的内存,因为需要存储一些额外信息。

如果设计operator delete,则应当保证“删除空指针永远安全”,做法是在该函数中检查指针是否为空;如果class的专属operator new将大小有误的分配行为转交给::operator new执行,那么也必须将大小有误的删除行为转交给::operator delete执行。

总结:

n operator new应该内含一个无穷循环,并在其中尝试内存分配,如果它无法满足内存需求,就该调用new_handler,它也应该有能力处理0 bytes申请。类专属版本则还应该处理“比正确大小更大的(错误)申请”。

n operator delete应该在收到null指针时不做任何事情,类专属版本则还应该处理“比正确大小更大的(错误)申请”。

条款52 写了placement new也要写placement delete

当我们使用new创建一个对象时:Widget* pw=new Widget;

有两个函数被调用,第一个函数就是operator new,用以分配内存,第二个是Widget的default构造函数。如果第一个函数调用成功,但是第二个函数调用失败,这时需要释放第一步开辟的内存,否则就造成了内存泄露。这个时候,pw尚未被赋值,客户手中的指针还没有指向开辟的内存。释放内存的任务落到了C++运行期系统身上。

运行期系统会调用operator new所对应的operator delete版本,所谓对应的版本就是指函数参数个数及类型都相同。所以规则很简单:为了让C++能找到对应的delete,我们必须得写出一个与operator new对应的delete来。

需要注意的是,因为成员函数的名称会掩盖其外围作用域中相同名称的函数,所以要小心避免class专属的new掩盖客户希望调用的new。一个简单的做法是建立一个base class,内含所有正常形式的new和delete:

class StadardNewDeleteForms{

public:

// normal

static void* operator new(std::size_t size) throw(std::bad_alloc)

{ return ::operator new(size); }

static void operator delete(void* pMemory) throw()

{ ::operator delete(pMemory); }

// placement

static void* operator new(std::size_t size, void* ptr) throw(std::bad_alloc)

{ return ::operator new(size, ptr); }

static void operator delete(void* pMemory, void* ptr) throw()

{ ::operator delete(pMemory, ptr); }

// nothrow

static void* operator new(std::size_t size, const std::nothrow_t& nt) throw(std::bad_alloc)

{ return ::operator new(size,nt); }

static void operator delete(void* pMemory,const std::nothrow_t&) throw()

{ ::operator delete(pMemory); }

};

如果想以自定义方式扩充标准形式,可以使用继承机制和using声明

class Widget: public StandardNewDeleteForms{

public:

// 让这些形式可见

using StandardNewDeleteForms::operator new;

using StandardNewDeleteForms::operator delete;

// 添加自己定义的

static void* operator new(std::size_t size, std::ostream& logStream) throw(std:;bad_alloc);

static void operator detele(std::size_t size, std::ostream& logStream) throw();

};

总结:

n 当编写一个placement operator new时,也要编写对应版本的placement operator delete。否则就可能造成隐蔽的内存泄露。

n 当声明了placement new和placement delete时,就会掩盖正常版本。

9、杂项讨论

条款53 不要忽略编译器警告

总结:

n 严肃对待编译器发出的警告信息。努力在你的编译器的最高(最严苛)警告级别下争取”无任何警告”的荣誉。

n 不要过度依赖编译器的报警能力,因为不同的编译器对待事物的态度不同。一旦移植到另一台编译器上,原本依赖的警告信息可能消失。

条款54 让自己熟悉包括TR1在内的标准程序库

总结:

n C++标准程序库的主要机能由STL、iostream、locales组成。并包含C99标准程序库。

n TR1添加了智能指针(例如tr1::shared_ptr)、一般化函数指针、hash-based容器、正则表达式以及另外10个组件的支持。

n TR1自身只是一份规范,如果需要一份实物,一个好的来源是Boost。

条款55 让自己熟悉Boost

总结:

n Boost是一个社群,也是一个网站。致力于免费、源码开放、同僚复审的C++程序库开发。Boost在C++标准化过程中扮演深具影响力的角色。

n Boost提供许多TR1组件实现品,以及其他许多程序库。

0 0
原创粉丝点击