Effective C++的50条建议
2020年11月16日16:11:06
- 尽量用const和inline而不用#define
尽量用编译器而不用预处理。
- 尽量用<iostream>而不用<stdio.h>
scanf和printf很有用,但不是类型安全的,而且没有扩展性。
on the other hand,①有些iostream的操作实现起来要比相应的C stream效率要低,但不是对所有的iostream而言,而是一些特殊的实现;②在标准化的过程中,iostream库在底层做了很多修改,所以对那些要求最大可移植性的应用程序来说,会发现不同的厂商遵循标准的程度也不同;③iostream库的类有构造函数而<stdio.h>里的函数没有,在某些涉及到静态对象初始化顺序的时候,如果可以确认不会带来隐患,用标准C库更加简单实用。
- 尽量用new和delete而不用malloc和free
malloc和free(及其变体)产生问题的原因是因为它们太简单:它们不知道构造函数和析构函数。
- 尽量使用C++风格的注释
- 对应的new和delete要采用相同的形式
用new的时候会发生两件事:首先,内存会被分配,然后,为分配的内存调用一个或多个构造函数;用delete的时候,也有两件事发生,首先,为将被释放的内存调用一个或多个析构函数,然后释放内存。对于delete来说有这样一个重要的问题:内存中有多少个对象要被删除?答案决定了有多少个析构函数将被调用。
这个问题简单说来就是,要被删除的指针指向的是单个对象呢,还是对象数组?这只有你来告诉delete。如果你在delete时没有用括号,delete就会认为指向的是单个对象;否则,它就会认为指向的是一个数组:
string *stringPtr1 = new string;
string *stringPtr2 = new string100;
……
delete stringPtr1;
delete[] stringPtr2;
如果在stringPtr1前加了[]和stringPtr2前没有加[],结果都是不可预测的。解决这类问题的规则:如果你调用new时用了[],那么调用delete时也要用[],如果调用new时没有用[],那么调用delete时也不要用[].
- 析构函数里对指针成员调用delete
如果在析构函数里没有删除指针,它不会表现出很明显的外部症状。相反,它可能只是表现为一点微小的内存泄露,并且不断增长,最后吞噬了你的内存空间,导致程序夭折。
删除空指针是安全的(因为它什么也没做)。
当然对本条款的使用也不要绝对,比如,你不会用delete去删除一个没有用new来初始化的指针,而且,就像用智能指针对象时不用劳你来删除一样,你也永远不会去删除一个传递给你的指针。换句话说,除非类成员最初用了new,否则是不用在析构函数里用delete的。
- 预先准备好内存不够的情况
- 写operator new和operator delete时要遵循常规
要有正确的返回值;可用内存不够时要调用出错处理函数;处理好0字节内存请求的情况;还要避免不小心隐藏了标准形式的new。
处理零字节请求的技巧在于把它作为请求一个字节来处理。
- 避免隐藏标准形式的new
在类里定义了一个称为“operator new”的函数后,会不经意地阻止了对标准new 的访问。条款50 解释了为什么会这样,这里我们更关心的是如何想个办法避免这个问题。一个办法是在类里写一个支持标准 new 调用方式的operator new,它和标准new 做同样的事。这可以用一个高效的内联函数来封装实现。另一种方法是为每一个增加到 operator new 的参数提供缺省值(见条款24)。
- 如果写了operator new就要同时写operator delete
为什么有必要写自己的operator new和operator delete?为了效率。缺省的operator new和operator delete具有非常好的通用性,它的这种灵活性也使得在某种特定的场合下,可以进一步改善它的性能。尤其在那些需要动态的分配大量的但很小的对象的应用程序里,情况更是如此。
如果写了一个自己的内存分配程序,就要同时写一个释放程序。
- 为需要动态分配内存的类声明一个拷贝构造函数和一个赋值操作符
用delete去删除一个已经被删除的指针,其结果是不可预测的。
只要类里有指针时,就要写自己版本的拷贝构造函数和赋值操作符函数。在这些函数里,你可以拷贝那些被指向的数据结构,从而使每个对象都有自己的拷贝;或者你可以采用某种引用计数机制(见条款 M29)去跟踪当前有多少个对象指向某个数据结构。
对于有些类,当实现拷贝构造函数和赋值操作符非常麻烦的时候,特别是可以确信程序中不会做拷贝和赋值操作的时候,去实现它们就会相对来说有点得不偿失。照本条款的建议去做:可以只声明这些函数(声明为private 成员)而不去定义(实现)它们。这就防止了会有人去调用它们,也防止了编译器去生成它们。关于这个俏皮的小技巧的细节,参见条款27。
- 尽量使用初始化而不要在构造函数里赋值
从纯实际应用的角度来看,有些情况下必须用初始化。特别是 const 和引用数据成员只能用初始化,不能被赋值。
对有基类的对象来说,基类的成员初始化和构造函数体的执行发生在派生类的成员初始化和构造函数体的执行之前。
通过成员初始化列表来进行初始化总是合法的,效率也绝不低于在构造函数体里赋值,它只会更高效。另外,它简化了对类的维护(见条款M32),因为如果一个数据成员以后被修改成了必须使用成员初始化列表的某种数据类型,那么,什么都不用改变。
- 初始化列表中成员列出的顺序和它们在类中声明的顺序相同
类成员是按照它们在类里被声明的顺序进行初始化的,和它们在成员初始化列表中列出的顺序没一点关系。
- 确定基类有虚析构函数
当通过基类的指针去删除派生类的对象,而基类又没有虚析构函数时,结果将是不可确定的。
虚函数的目的是让派生类去定制自己的行为(见条款36),所以几乎所有的基类都包含虚函数。如果某个类不包含虚函数,那一般是表示它将不作为一个基类来使用。当一个类不准备作为基类使用时,使析构函数为虚一般是个 坏主意。
实现虚函数需要对象附带一些额外信息,以使对象在运行时可以确定该调用哪个虚函数。对大多数编译器来说,这个额外信息的具体形式是一个称为vptr(虚函数表指针)的指针。vptr 指向的是一个称为vtbl(虚函数表)的函数指针数组。每个有虚函数的类都附带有一个vtbl。当对一个对象的某个虚函数进行请求调用时,实际被调用的函数是根据指向vtbl 的vptr 在vtbl 里找到相应的函数指针来确定的。
如果声明虚析构函数为inline,将会避免调用它们时产生的开销,但编译器还是必然会在什么地方产生一个此函数的拷贝。
- 让operator=返回*this的引用
C++程序员经常犯的一个错误是让operator=返回void,这好象没什么不合理的,但它妨碍了连续(链式)赋值操作,所以不要这样做。
另一个常犯的错误是让 operator=返回一个const 对象的引用。
当定义自己的赋值运算符时,必须返回赋值运算符左边参数的引用,*this。如果不这样做,就会导致不能连续赋值,或导致调用时的隐式类型转换不能进行,或两种情况同时发生。
- 在operator=中对所有数据成员赋值
只要想对赋值过程的某一个部分进行控制,就必须负责做赋值过程中所有的事。
派生类的赋值运算符也必须处理它的基类成员的赋值。
- 在operator=中检查给自己赋值的情况
在赋值运算符中要特别注意可能出现别名的情况,其理由基于两点。其中之一是效率。如果可以在赋值运算符函数体的首部检测到是给自己赋值,就可以立即返回,从而可以节省大量的工作,否则必须去实现整个赋值操作。另一个更重要的原因是保证正确性。一个赋值运算符必须首先释放掉一个对象的资源(去掉旧值),然后根据新值分配新的资源。在自己给自己赋值的情况下,释放旧的资源将是灾难性的,因为在分配新的资源时会需要旧的资源。
- 争取使类的接口完整并且最小
概括起来就是说,无端地在接口里增加函数不是没有代价的,所以在增加一个新函数时要仔细考虑:它所带来的方便性(只有在接口完整的前提下才应该考虑增加一个新函数以提供方便性)是否超过它所带来的额外代价,如复杂性,可读性,可维护性和编译时间等。
- 分清成员函数、非成员函数和友元函数
成员函数和非成员函数最大的区别在于成员函数可以是虚拟的而非成员函数不行。所以,如果有个函数必须进行动态绑定(见条款38),就要采用虚拟函数,而虚拟函数必定是某个类的成员函数。
explicit 构造函数不能用于隐式转换。
假设 f 是想正确声明的函数,C 是和它相关的类:虚函数必须是成员函数。如果 f 必须是虚函数,就让它成为C 的成员函数。operator>>和operator<<绝不能是成员函数。如果f 是operator>>或operator<<,让f 成为非成员函数。如果f 还需要访问C 的非公有成员,让f 成为C 的友元函数。只有非成员函数对最左边的参数进行类型转换。如果 f 需要对最左边的参数进行类型转换,让f 成为非成员函数。如果f 还需要访问C 的非公有成员,让f 成为C 的友元函数。其它情况下都声明为成员函数。如果以上情况都不是,让 f 成为C 的成员函数。
- 避免public接口出现数据成员
在public 接口里放上数据成员无异于自找麻烦,所以要把数据成员安全地隐藏在与功能分离的高墙后。
- 尽可能使用const
char *p = "Hello"; // 非const 指针,非const 数据
const char *p = "Hello"; // 非const 指针, const 数据
char * const p = "Hello"; // const 指针,非const 数据
const char * const p = "Hello"; // const 指针,const 数据
语法并非看起来那么变化多端。一般来说,你可以在头脑里画一条垂直线穿过指针声明中的星号(*)位置,如果const 出现在线的左边,指针指向的数据为常量;如果const 出现在线的右边,指针本身为常量;如果const 在线的两边都出现,二者都是常量。
一个好的用户自定义类型的特征是,它会避免那种没道理的与固定类型不兼容的行为。
- 尽量用传引用而不用传值
C 语言中,什么都是通过传值来实现的,C++继承了这一传统并将它作为默认方式。除非明确指定,函数的形参总是通过“实参的拷贝”来初始化的,函数的调用者得到的也是函数返回值的拷贝。
传递引用是个很好的做法,但它会导致自身的复杂性,最大的一个问题就是别名问题,这在条款17 进行了讨论。另外,更重要的是,有时不能用引用来传递对象,参见条款23。最后要说的是,引用几乎都是通过指针来实现的,所以通过引用传递对象实际上是传递指针。因此,如果是一个很小的对象——例如int— — 传值实际上会比传引用更高效。
23. 必须返回一个对象时不要试图返回一个引用
引用只是一个名字,一个其它某个已经存在的对象的名字。无论何时看到一个引用的声明,就要立即问自己:它的另一个名字是什么呢?因为它必然还有另外一个什么名字(见条款M1)。
24. 在函数重载和设定参数缺省值间慎重选择
答案取决于另外两个问题。第一,确实有那么一个值可以作为缺省吗?第二,要用到多少种算法?一般来说,如果可以选择一个合适的缺省值并且只是用到一种算法,就使用缺省参数(参见条款38)。否则,就使用函数重载。
- 避免对指针和数字类型重载
- 当心潜在的二义性
C++认为潜在的二义性不是一种错误。多继承(见条款43)充满了潜在二义性的可能。最常发生的一种情况是当一个派生类从多个基类继承了相同的成员名时。
- 如果不想使用隐式生成的函数就要显式的禁止它
方法是声明这个函数(operator=),并使之为private。显式地声明一个成员函数,就防止了编译器去自动生成它的版本;使函数为private,就防止了别人去调用它。
但是,这个方法还不是很安全,成员函数和友元函数还是可以调用私有函数,除非——如果你够聪明的话——不去定义(实现)这个函数。这样,当无意间调用了这个函数时,程序在链接时就会报错。
这适用于条款45 所介绍的每一个编译器自动生成的函数。实际应用中,你会发现赋值和拷贝构造函数具有行为上的相似性(见条款11 和16),这意味着几乎任何时候当你想禁止它们其中的一个时,就也要禁止另外一个。
- 划分全局名字空间
名字空间带来的最大的好处之一在于:潜在的二义不会造成错误(参见条款26)。所以,从多个不同的名字空间引入同一个符号名不会造成冲突(假如确实真的从不使用这个符号的话)。
- 避免返回内部数据的句柄
对于 const 成员函数来说,返回句柄是不明智的,因为它会破坏数据抽象。对于非const 成员函数来说,返回句柄会带来麻烦,特别是涉及到临时对象时。句柄就象指针一样,可以是悬浮(dangle)的。所以一定要象避免悬浮的指针那样,尽量避免悬浮的句柄。
30. 避免这样的成员函数:其返回值是指向成员的非const 指针或引用,但成员的访问级比这个函数要低
31. 千万不要返回局部对象的引用,也不要返回函数内部用new 初始化的指针的引用
先看第一种情况:返回一个局部对象的引用。它的问题在于,局部对象 ----- 顾名思义 ---- 仅仅是局部的。也就是说,局部对象是在被定义时创建,在离开生命空间时被销毁的。所谓生命空间,是指它们所在的函数体。当函数返回时,程序的控制离开了这个空间,所以函数内部所有的局部对象被自动销毁。因此,如果返回局部对象的引用,那个局部对象其实已经在函数调用者使用它之前被销毁了。当想提高程序的效率而使函数的结果通过引用而不是值返回时,这个问题就会出现。
写一个返回废弃指针的函数无异于坐等内存泄漏的来临。
- 尽可能的推迟变量的定义
推迟变量定义可以提高程序的效率,增强程序的条理性,还可以减少对变量含义的注释。
- 明智的使用内联
在一台内存有限的计算机里,过分地使用内联所产生的程序会因为有太大的体积而导致可用空间不够。即使可以使用虚拟内存,内联造成的代码膨胀也可能会导致不合理的页面调度行为(系统颠簸),这将使你的程序运行慢得象在爬。过多的内联还会降低指令高速缓存的命中率,从而使取指令的速度降低,因为从主存取指令当然比从缓存要慢。
另一方面,如果内联函数体非常短,编译器为这个函数体生成的代码就会真的比为函数调用生成的代码要小许多。如果是这种情况,内联这个函数将会确实带来更小的目标代码和更高的缓存命中率!
一个给定的内联函数是否真的被内联取决于所用的编译器的具体实现。幸运的是,大多数编译器都可以设置诊断级,当声明为内联的函数实际上没有被内联时,编译器就会为你发出警告信息。
- 将文件间的编译依赖性降到最低
C++的类定义中不仅包含接口规范,还有不少实现细节。
如果可以使用对象的引用和指针,就要避免使用对象本身。定义某个类型的引用和指针只会涉及到这个类型的声明。定义此类型的对象则需要类型定义的参与。
尽可能使用类的声明,而不使用类的定义。因为在声明一个函数时,如果用到某个类,是绝对不需要这个类的定义的,即使函数是通过传值来传递和返回这个类。
不要在头文件中再(通过#include 指令)包含其它头文件,除非缺少了它们就不能编译。相反,要一个一个地声明所需要的类,让使用这个头文件的用户自己(通过#include 指令)去包含其它的头文件,以使用户代码最终得以通过编译。
-
使公有继承体现“是一个”的含义
C++面向对象编程中一条重要的规则是:公有继承意味着 "是一个" 。
- 区分接口继承和实现继承
纯虚函数最显著的特征是:它们必须在继承了它们的任何具体类中重新声明,而且它们在抽象类中往往没有定义。
定义纯虚函数的目的在于,使派生类仅仅只是继承函数的接口。声明简单虚函数的目的在于,使派生类继承函数的接口和缺省实现。声明非虚函数的目的在于,使派生类继承函数的接口和强制性实现。
- 绝不要重新定义继承而来的非虚函数
如果写类 D 时重新定义了从类B 继承而来的非虚函数mf,D 的对象就可能表现出精神分裂症般的异常行为。也就是说,D 的对象在mf 被调用时,行为有可能象B,也有可能象D,决定因素和对象本身没有一点关系,而是取决于指向它的指针所声明的类型。引用也会和指针一样表现出这样的异常行为。
任何条件下都要禁止重新定义继承而来的非虚函数。
38.绝不要重新定义继承而来的缺省参数值
虚函数是动态绑定而缺省参数值是静态绑定的。这意味着你最终可能调用的是一个定义在派生类,但使用了基类中的缺省参数值的虚函数。
如果缺省参数值被动态绑定,编译器就必须想办法为虚函数在运行时确定合适的缺省值,这将比现在采用的在编译阶段确定缺省值的机制更慢更复杂。做出这种选择是想求得速度上的提高和实现上的简便,所以大家现在才能感受得到程序运行的高效;当然,如果忽视了本条款的建议,就会带来混乱。
39.避免“向下转换”继承层次
这种类型的转换 ---- 从一个基类指针到一个派生类指针 ---- 被称为 "向下转换",因为它向下转换了继承的层次结构。在刚看到的例子中,向下转换碰巧可以工作;但正如下面即将看到的,它将给今后的维护人员带来恶梦。
"向下转换" 可以通过几种方法来消除。最好的方法是将这种转换用虚函数调用来代替,同时,它可能对有些类不适用,所以要使这些类的每个虚函数成为一个空操作。第二个方法是加强类型约束,使得指针的声明类型和你所知道的真的指针类型之间没有出入。为了消除向下转换,无论费多大工夫都是值得的,因为向下转换难看、容易导致错误,而且使得代码难于理解、升级和维护(参见条款M32)。
40.通过分层来体现“有一个”或“用···来实现”
使某个类的对象成为另一个类的数据成员,从而实现将一个类构筑在另一个类之上,这一过程称为 "分层"(Layering)。"分层" 这一术语有很多同义词,它也常被称为:构成(composition),包含(containment)或嵌入(embedding)。条款35 解释了公有继承的含义是 "是一个"。对应地,分层的含义是 "有一个" 或 "用...来实现"。
当通过分层使两个类产生联系时,实际上在两个类之间建立了编译时的依赖关系。
41.区分继承和模板
模板类的特点:行为不依赖于类型。
当对象的类型不影响类中函数的行为时,就要使用模板来生成这样一组类。当对象的类型影响类中函数的行为时,就要使用继承来得到这样一组类。
42.明智的使用私有继承
这为我们引出了私有继承的含义:私有继承意味着 "用...来实现"。如果使类D 私有继承于类B,这样做是因为你想利用类B 中已经存在的某些代码,而不是因为类型B 的对象和类型D 的对象之间有什么概念上的关系。因而,私有继承纯粹是一种实现技术。
私有继承意味着 "用...来实现" 这一事实会给程序员带来一点混淆,因为条款40 指出,"分层" 也具有相同的含义。怎么在二者之间进行选择呢?答案很简单:尽可能地使用分层,必须时才使用私有继承。
43.明智的使用多继承
44.说你想说的;理解你所说的
45.弄清C++在幕后为你所写、所调用的函数
至于拷贝构造函数和赋值运算符,官方的规则是:缺省拷贝构造函数(赋值运算符)对类的非静态数据成员进行 "以成员为单位的" 逐一拷贝构造(赋值)。即,如果m 是类C 中类型为T 的非静态数据成员,并且C 没有声明拷贝构造函数(赋值运算符),m 将会通过类型T 的拷贝构造函数(赋值运算符)被拷贝构造(赋值)---- 如果T 有拷贝构造函数(赋值运算符)的话。如果没有,规则递归应用到m 的数据成员,直至找到一个拷贝构造函数(赋值运算符)或固定类型(例如,int,double,指针,等)为止。默认情况下,固定类型的对象拷贝构造(赋值)时是从源对象到目标对象的 "逐位" 拷贝。对于从别的类继承而来的类来说,这条规则适用于继承层次结构中的每一层,所以,用户自定义的构造函数和赋值运算符无论在哪一层被声明,都会被调用。
46.宁可编译和链接时出错,也不要运行时出错
将检查从运行时转移到编译或链接时一直是值得努力的目标,只要实际可行,就要追求这一目标。这样做的奖赏是,程序会更小,更快,更可靠。
47.确保非局部静态对象在使用前被初始化
非局部静态对象指的是这样的对象:
定义在全局或名字空间范围内(例如:theFileSystem 和tempDir),
在一个类中被声明为static,或,
在一个文件范围被定义为static。
对于不同被编译单元中的非局部静态对象,你一定不希望自己的程序行为依赖于它们的初始化顺序,因为你无法控制这种顺序。让我再重复一遍:你绝对无法控制不同被编译单元中非局部静态对象的初始化顺序。
很自然地想知道,为什么无法控制?
这是因为,确定非局部静态对象初始化的 " 正确" 顺序很困难,非常困难,极其困难。即使在它最普通的形式下 ---- 多个被编译单元,多个通过隐式模板实例化所生成的非局部静态对象(隐式模板实例化时,它们本身可能都会产生这样的问题) ---- 不仅不可能确定正确的初始化顺序,往往连找一个可以确定正确顺序的特殊情况都不值得。
48.重视编译器警告
49.熟悉标准库
标准库提供了下列高效的实现:vector(就象动态可扩充的数组),list(双链表),queue, stack,deque,map,set和bitset。唉,竟然没有hash table(虽然很多制造商作为扩充提供),但多少可以作为补偿的一点是, string 是容器。这很重要,因为它意味着对容器所做的任何操作(见下文)对string 也适用。
50.提高对C++的认识
QQ二群 166427999
博客文件如果不能下载请进群下载
如果公司项目有技术瓶颈问题,请联系↓↓
如果需要定制系统开发服务,请联系↓↓
技术服务QQ: 903464207