读过这本书,就获得了迅速提升自己C++功力的契机,地位就是这么高。
Effec C++
改善程序与设计的55个具体做法
[美] Scott Meyers 著
译序
C++是一个难学易用的语言
C++语法广播,语法及语义背后又有深层思维,深层思维背后又有对象模型。
C++还有四种不同而又相辅相成的编程泛型:基于过程,基于对象,面向对象,泛型编程。
然而:一旦学成,妙用无穷。
让自己习惯C++
驾驭C++之前要先习惯C++的办事方式。
条款1:视C++为一个语言联邦
最开始的C++,只是在C语言的基础上加了一些对象特性,他被称为C with Classes。但今天的C++已经发展成一种多重范型编程语言。他同时支持过程形式,面向对象形式,函数形式,泛型形式,元编程形式。这些弹性和能力使C++成为了一个无可匹敌的工具,但也造成了所有的“适当用法”似乎都有例外。我们应该如何理解这个语言?
最简单的方法就是讲C++是为一个由相关语言组成的联邦而非单一语言。我们叫他的相关语言为次语言。他只有四个次语言,而且每个次语言中守则和通例都倾向简单,直观易懂。然而当你从一个次语言移往另一个次语言,守则可能改变。
次语言包括
C: 区块,语句,预处理器,内置数据类型,数组,指针等统统来自于C
Object-Oriented C++:class,封装,继承,多态,动态绑定
Template C++:泛型编程部分,他十分强大,并带来一种新的编程范型,TMP——模板元编程。TMP的相关规则很少与C++主流编程互相影响。
STL:STL是templete程序库,它有自己特殊的办事方式,和它一起,必须遵守他的规约
记住这四种次语言,当你从某个次语言切换到另一个,导致高效编程守则要求你改变策略时,不要感动惊讶。
值传递(pass-by-value)与址传递(pass-by-reference)对于次语言C来讲,值传递比较高效。但对于Object-Oriented C++来讲,由于用户自定义的构造函数和析构函数的存在,址传递往往更好。Template C++更是如此。然而一旦跨入STL你就会了解,迭代器和函数对象都是C指针上塑造出来的,所以对STL的迭代器和函数对象而言,值传递再次适用了。
请记住:C++高效编程守则视情况而定,取决于你使用C++的哪一部分。
条款2:尽量以const,enum,inline替换#define
这句话理解为“宁可使用编译器,也不使用预处理器”,因为#define不被视为语言的一部分。
当我们#define ASPECT_RATIO 1.653
记号ASPECT_RATIO 也许从未被编译器看见,也许在编译器开始处理源码之前它就被预处理器移走了。由于记号ASPECT_RATIO 有可能没有进入记号表。于是你运用磁场量但获得一个编译错误信息时,可能会带来困惑。因为这个错误信息也许会提到1.653,而不是ASPECT_RATIO 。如果ASPECT_RATIO 被定义在非你所写的头文件内,会更加使人毫无概念,这个问题可能出现在记号式调试器中,原因相同:你所使用的名称可能并未进入记号表。
使用:const double AspectRatio = 1.653来代替。
作为一个语言常量,AspectRatio肯定会被编译器看到,当然就会进入记号表,此外,使用常量可能比使用#define导致较小量的代码,因为预处理器“盲目地将宏名称ASPECT_RATIO替换为1.653”可能导致目标码出现多份1.653。
当我们使用常量替换#define,有两种情况值得说说。
第一是定义常量指针。由于常量定义式通常被放在头文件中,以便被不同的源码含入,因此有必要将指针声明为const,例如若要在头文件内定一个常量的char * based字符串,你必须写const两次。
const char * const authorName = “Scott Meyers”
string对象更为合适:const std::string authorName (“Scott Meyers”);
第二个值得注意的就是class专属常量。为了将常量的作用域限制在class内,你必须让他成为class的一个成员,而为了确保此常量至多只有一份实体,你必须让它成为一个static成员。
1 | class GamePlayer{ |
我们看到了NumTurns的声明式而非定义式,通常C++要求你对你所使用的任何东西提供一个定义式,但如果他是个class专属常量又是static且为整数类型(int ,char,bool),则需要特殊处理。只要不取他们的地址,你可以声明并使用它们,而无需提供定义式。但如果你取某个class专属常亮的地址,或纵使你不取地址而你的编译器却坚持要看到一个定义式,你就必须另外提供定义式如下:
1 | const int GamePlayer::NumTurns;//NumTurns的定义。 |
请把这段代码放到一个实现文件而非头文件,由于class常量已经在声明时获得初值,因此在定义的时候不可以再设置初值。
我们无法利用#define创建class专属常量,因为它并不重视作用域。一旦被定义,在使用#undef之前,都有效,这意味着它也不能提供任何封装性。
如果你的编译器比较旧,你可能需要把初值放在定义式中,不放在声明式中
1 | class GamePlayer{ |
这时候,我们会有些疑虑,scores的数组声明式中,要求知道大小,这时候万一编译器不允许static整数型class常量完成int class的初值设定,可改用所谓的”the enum hack”补偿做法,其理论基础是:“一个枚举类型的数字可权充int类型被使用”
于是我们可以:
1 | class GamePlayer{ |
enum hack非常值得我们认识,理由如下:
第一:enum hack的某些方面比较像#define而不像const,有时候这正是你想要的,例如取一个const的地址是合法的,但取一个enum的地址是不合法的。如果你不想别人的指针指向你的整数常量,enum可以帮助你实现这个约束。有些优秀的编译器不会为“const int”对象设定另外的存储空间,除非你创建一个指针或者引用指向该对象,不够优秀的编译器做不到这一点。enum和#define一样,绝不会导致非必要的内存分配。
第二:enum hack非常实用。事实上,他是TMP的基础技术。
我们接着说预编译器,另一个常见的#define误用情况是以他实现宏,宏看起来像个函数,但不会招致函数调用带来的额外开销,下面这个宏夹带着宏实参,调用函数f:
1 | //以a和b的较大值调用f |
我们一定要记住,为宏中所有实参加上小括号,否则会有麻烦。但纵使加上小括号,也可能会有麻烦:
1 | int a = 5,b = 0; |
在这里,调用f之前,a的递增次数惊叹取决于“它被拿开和谁比较”。
有一种方式,我们既可以免去这些麻烦,还能获得宏带来的效率以及一般函数的所有预料行为和类型安全性。这种方法就是“template inline”函数。
1 | template<typename T> |
这个template产出一整群函数,每个函数都接收两个同型对象,并以其中较大者调用f,这里不需要在函数本体中为参数加上括号,也不需要操心参数被核算等等,此外,由于callWithMax是个真正的函数,它遵循作用域和访问规则,例如你绝对可以写出一个“class内的private inline函数”。一般而言宏无法完成此事。
有了const,enum,和inline,我们对预处理器的需求降低了,但并没有完全消除,#include仍然是必需品,而#ifdef/#ifndef也继续扮演控制编译的重要角色,目前还不到预处理器全面隐退的时候,但你应该明确地给予他更长更频繁的假期。
请记住:
对于单纯常量,最好以const对象或者enum替换#define
对于形似函数的宏,最好改用inline函数替换#define
条款3:尽可能使用const
const的一件奇妙事情是,它允许你指定一个语义约束,也就是指定一个“不该被改动”的对象,而编译器会强制实施这项约束,他允许你告诉编译器和其他程序员某值应该保持不变,只要这个值保持不变是事实,你就该说出来,因为说出来可以获得编译器的帮助。确保这条约束不被违反。
关键字const多才多艺,你可以是用它在classes外部修饰global或namespace作用域中的常量,也就是我们在条款2中提到的。他可以修饰文件,函数,或者区块作用域中被声明为static的对象。你也可以用它修饰classes内部的static和non-static成员变量。面对指针,你也可以指出指针自身,指针所指物,或二者都(或都不)是const。
const语法虽然变化多端,但并不高深莫测。如果const出现在*左边,表示被指的事物是常量,出现在右边,表示指针自身是常量。
1 |
|
STL迭代器系是以指针为根据塑模出来的,所以迭代器的作用就像个T指针。声明迭代器为const就是声明指针为const一样。即:T const pw,表示这个迭代器不能指向不同的东西,但他所指的东西的值是可以改动的。如果你希望迭代器所指向的东西不可被改动,你需要的是const_iterator;
1 | std::vector<int> vec; |
const最具为例的用法是面对函数声明时的应用,在函数声明式内,const可以和函数返回值,各参数,函数自身产生关联。
令函数返回一个常量值,往往可以降低因客户错误而造成的意外,而又不至于放弃安全性和高效性。举个例子:考虑有理数的operator * 声明式:
1 | class Rational{}; |
如果这样,客户就不能出现下面这种情况:
1 | Rational a,b,c; |
const成员函数
将const实施于成员函数的目的是为了确认该成员函数可用作于const对象身上。这一类成员函数之所以重要,基于两个理由:第一,他们使class接口比较容易被理解,因为得知哪儿个函数可以改动对象内容,而哪儿个函数不行,这个是很重要的。第二,他们使“操作const对象”成为可能。这对编写高效代码是个关键。
有一个重要的C++特性,如果两个成员函数只是常量性不同,是可以重载的。
1 | class Textblock |
只要重载operator[]并对不同的版本给予不同的返回类型,就可以令const,和non-const TextBlocks获得不同的处理:
1 | std::cout<<tb[0]; |
这个错误只因为operator[]的返回类型以致,企图对一个const char & 施行复制动作。
如果返回的是char,而不是char &,那么不能通过编译。因为如果函数的返回类型是一个内置类型,那么改动函数返回至从来就是不合法的。
综上:成员函数只有在不更改对象的任何成员变量(static除外)时才可以说是const,也就是说它不更改对象内的任何一个bit。编译器只需要寻找成员变量的赋值动作即可。const成员函数不能更改对象内任何non-static的成员变量。
一个更改了指针所指物的成员函数虽然不能算是const,但如果只有指针隶属于对象,编译时不会报错的:
1 | class CTextblock |
这个类,不适当地将operator[]声明为const。
1 |
|
使用mutable修饰后,这些成员变量总是会被更改,即使是在const成员函数内。
请记住
将某些东西声明为const可帮助编译器侦测出错误用法,const可被施加于任何作用域内的对象,函数参数,函数返回类型,成员函数本体。
编译器强制实习bitwise constness,但你编写程序时应该使用“conceptual constness”。
当const和non-const成员有着实质等价的实现时,令non-const版本调用const版本可避免代码重复。
条款4:确定对象被使用前已先被初始化。
在某些平台上,仅仅是读取未初始化的值,就可能让你的程序终止运行。更可能的情况是读入一些半随机的bits,污染了正在进行读取峰做的那个对象,最终导致不可预知的结果。
通常如果你使用C part of C++,而且初始化可能招致运行期成本,那么就不保证发生初始化。一旦进入non-C parts of C++,规则有些变化。这就很好的解释了为什么array不保证其内容被初始化,而vector却有保证。
表面上这似乎是一个无法决定的状态,而最佳的处理办法就是,永远在适用对象之前先将他初始化,对于无任何成员的内置类型,你必须手工完成此事。例如:
1 | int x = 0; |
对于内置类型以外的任何其他东西,初始化的责任落在构造函数的身上。规则很简单,确保每一个构造函数都将对象的每一个成员初始化。
不要混淆赋值和初始化:
1 | class PhoneNumber{...}; |
上面的构造函数中对变量的处理都属于赋值而不是初始化。这些能够导致ABEntry对象带有你期望的值,但不是最佳的做法。C++规定,对象的成员变量的初始化发生在进入构造函数本体之内。但是numTimesConsulted可能不是这样的,因为它属于内置类型,内省类型不保证在你所看到的的那个复制动作的时间点之前获得初值。
更好的协写法是使用成员初始化列表:
1 | ABEntry(const std::string& name,const std::string& address, |
这个构造函数和上一个的最终结果是相同的,但通常效率更高。
作为内置类型对象的numTimesConsulted,其初始化和赋值的成本相同,但为了一致性,最好也通过成员初始化列表来初始化。
甚至,当你想要默认构造一个成员变量,你都可以使用成员初始化列表。
1 |
|
有些情况下,即使面对的成员变量属于内置类型,也一定要使用初始化列表。那就是当成员变量是const和引用的时候,他们是一定需要初值,而不能被赋值的。
许多classess拥有多个构造函数,每个构造函数有自己的初始化列表,如果成员变量很多,我们需要在每个构造函数中都写会有些麻烦。这种情况下可以合理地在初始化列表中去掉一些成员变量,去掉那些“赋值表现向初始化一样好”的成员变量。使用赋值的方式,并将赋值用函数包装,供所有构造函数使用。这种方法在“成员变量的初值是由文件或者数据库读入”时特别有用。然而比起这种赋值操作,真正的初始化通常更加可取。
C++有着十分固定的“成员初始化次序”。class的成员变量总是以其声明次序被初始化。回头看看ABCentry,theName成员永远最先被初始化,即使他在初始化列表中位置不一样。
如果你已经将内置类型成员变量明确的加以初始化。而且也确保你的构造函数运行成员初始化列表来初始化了其他成员变量,那么只剩唯一一个需要考虑的问题:不同编译单元内定义的non-local static 对象的初始化次序。
所谓 static对象,他的寿命从被构造出来知道程序结束为止。因此,stack和heap-based对象都被排除。这种对象包括global对象,定义与namespace作用域内的对象,在classes内,在函数内,以及在file作用域内被声明为static对象。函数内的static对象称为local static对象。其他的称为non-local static 对象。程序结束时对象会被自动销毁,也就是他们的析构函数会在main()结束的会后自动调用。
所谓编译单元是指产出单一目标文件的那些源码。基本上它是单一源码文件加上其所含入的头文件。
现在,我们关心的问题涉及至少两个源码文件,每个内含至少一个non-local static对象,也就是说该对象是global活位于namespace作用域,抑或在class内活file作用域内被声明为static。如果某编译单元内的某non-local static对象的初始化动作,使用了另一个编译单元内的某个non-local static对象,它所用到的这个对象可能尚未被初始化。
比如:
1 | class FileSystem{ |
现在初始化次序的重要性显现出来了。除非tfs在tempDir之前先被初始化,否则tempDir的构造函数会用到尚未初始化的tfs,但tfs和tempDir是不同的人,在不同的时间与不同的源码文件建立起来的,他们是定义不同编译单元的non-local static对象。能如何确定tfs会在tempDir之前先被初始化。
一个小小的设计就可以完全消除这个问题:将每个non-local static对象搬到自己的专属函数内。这些函数返回一个引用,然后用户调用这些函数,而不直接指涉这些对象。换句话说,non-local static对象被local static对象替换了。了解设计模式的人应该认出了,这是单例模式的一个常见实现手法。
C++保证:函数内的local static对象会在“该函数被调用期间”首次遇上该对象的定义式时被初始化。所以如果你以函数调用替换直接访问non-local static对象,就获得了保证。
1 | class FileSystem{...}; |
为了避免在对象初始化之前过早的使用它们,你需要做三件事。
第一,手工初始化内置型non-number对象。
第二,使用成员初值列对付对象的所有成分。
最后,在“初始化次序不确定”氛围下加强你的设计。
请记住
为内置对象进行手工初始化,因为C++不保证初始化它们。
构造函数最好使用成员初始化列表,而不要再构造函数本体内使用赋值操作。顺序与在class中声明的次序相同
为免除“跨编译单元之初始化次序”问题,请以local static对象替换non-local static对象。