effective c++读书总结(1)
条款1 c++是个语言联邦
c++程序设计语言从根本上讲分为四部分,其语言特性以及使用细节也可从四个部分出发:
1. c++兼容于c
2. c++是一种面向对象的程序设计语言
3. c++引入模板编程
4. c++中有std库
四个方面使得c++的准则尤其多而且如果不深入理解的话就很难使用。
文中有一例子值得注意,int,int*等内置类型通常情况下值传递快于引用传递(C语言),而对于类对象来讲通常情况下引用传递快于值传递(面向对象程序设计),在模板编程下同样如此,因为模板编程下通常无法确定参数的数据类型,所以推荐使用引用参数传递,但在std库中引入的迭代器等等由于与指针实现类似因此一般情况下更倾向于值传递。
条款2 尽量使用编译器而放弃预处理器
原因很简单:预处理器没有经过编译处理,因此出现问题不会在编译阶段被提示,很容易导致错误。典型的尽量用const,enum,inline代替#define,在使用#define宏时无非是需要定义常量表达式或者是函数表达式,常量表达式用const代替,函数表达式用inline关键词修饰函数代替。
const定义变量来代替#define宏,使得被定义的量为编译器所见,这样编译器就可以根据编译结果提示相关的错误信息或者警告信息,为了更好的表现出与宏相似的特性,我们通常需要enum,这是因为对于#define宏出来的量不可被取地址也不会占用内存空间,而const变量可以被取地址也占用相应的内存空间,enum定义的枚举类型与#define有相似的特性,因此用枚举类型代替#define是恰当的。
除此笔者认为c++11引入的exprconst关键词更好的代替了这两者,因为被exprconst修饰的变量在编译期就被计算出来,而const修饰的变量需要在运行时期才被定义。inline修饰的函数同样在编译期就进行展开,与#define所展现出来的宏展开具有相似之处,只不过#define是在预处理器阶段被展开,而inline在编译期被展开,且inline的代码更容易分析,众所周知#define所定义的函数是十分容易出错的。
条款3 尽量使用const
使用const修饰所有不变的量,让编译期更多的帮助你分析代码的正确性。整体上const既能修饰函数也能修饰变量,当修饰函数时一般为类的成员函数,修饰变量该变量可以是任何变量,const的核心效果是使得函数或一变量变为固定的,当修饰成员函数时他保证了该成员函数不会改变任何类的成员变量(除了如下情况)
上述类中,返回char&编译器不会报错,但这样是危险的,因为这样会导致调用[]操作符返回值之后改变内部成员变量pText,如下:
除此之外该条款还提出了const和非const版本在同一类中属于函数重载的范畴,但由于const和非const版本的成员函数所进行的操作有很多相似之处因此我们可以用非const版本调用const版本的成员函数,但必须进行两次显示的转换如下:
故而使两个成员函数维护成本降低且降低代码的冗余程度。
条款4 在调用对象之前确保对象已被初始化
这点必要性不必多说,由于c++中的多性质融合(见条款1)因此有些对象不会被附一初值(C中的表现),而有些对象会被赋初值(非C表现,例如vector等),为了统一性,通常将所有对象都统一赋初值,其中可在构造函数的初始化表统一赋初值,注意对于类对象的成员构造顺序,首先是调用父类的构造函数,然后是成员变量按照声明的顺序被构造,一定注意是按照声明的顺序被构造,无论初始化表中对象是何顺序皆需要用该顺序被依次构造。
另外需要注意的是对于全局变量,namespace作用域内的变量,class内、file内的static变量来讲,当这些变量位于不同文件时其初始化顺序是不确定的(注意是不同文件,同一文件顺序应该确定???这点有待商榷),因此他们的初始化注意不应该相互被调用,因为你并不清楚此时被调用的对象是否被初始化!
改进的方法是定义函数返回static的局部对象(函数内的static对象),这样就保证了被调用的对象一定已经被初始化,但也引入了多线程下的static的线程安全问题,这点的解决方案可参考设计模式中的单例模式处理方式,当然最直接的方法是在主线程未开辟其他线程之前统一作一批初始化。
条款5 c++自动生成的默认函数
一般情况下,如果你不定义类的构造函数,c++编译器会为你生成构造函数
如果你不定义类的拷贝构造函数,c++编译器会为你定义拷贝构造函数(拷贝为浅拷贝)
如果你不定义类的赋值运算符,c++编译器会为你定义赋值运算符(拷贝为浅拷贝)
除了:
1. 当程序中并不需要构造函数,拷贝构造函数,赋值运算符时,即响应了“如无必要,勿增实体”。
2. 当类中成员变量存在引用时(引用对象只能在初始化时指定,不可更改)、成员变量的拷贝构造函数(或赋值运算符)被删除时、存在const成员变量时等一切不可复制的成员变量时,c++无能为力(不会自动生成默认的拷贝构造函数或赋值运算符)。
3. 父类无法生成可自动生成的函数(包括构造函数,拷贝构造函数,赋值运算符),例如父类中将构造函数、拷贝构造函数或者赋值运算符声明为私有或者delete等。
条款6 删除确定不需要的函数
默认生成的函数在某些情况下会存在问题,比如该类不需要存在拷贝问题(例如一些功能类),遇到这些类时我们应该尽可能的删除那些默认生成函数(默认构造函数,默认拷贝构造函数,默认赋值操作符)以避免不必要的麻烦或者减少出错率,让编译器进一步帮助我们分析代码。c++11中典型的用法是****=delete,在c++11以前有两种解决方法:
1. 将拷贝构造函数或赋值运算符定义为私有且不予实现,这样会使得在链接器阶段出现错误从而终止程序
2.为了能够提早(在编译时期就发现)发现该问题,可以将拷贝构造函数或者复制操作符定义为私有的,当定义为私有时该类以外的其他地方是无法访问的,但对于友元类可以访问,因此仍然存在问题,因此更好的解决方案是定义公共父类,将父类中的拷贝构造函数或者赋值运算符定义为私有,让其它类继承他,这就使得友元类仍然无法拷贝他,如下:
当然在c++11中delete关键词解决了该问题。
条款7 给带多态性质的基类定义虚析构函数
为具有多态性质的基类定义虚析构函数,不存在多态性质的类不需要定义虚基类函数(大多数情况下不需要)
原因在于在类的多态中,出现如下情况:
如上述代码可知,当调用函数getTimeKeeper()后,基类的指针绑定到了不同的派生类中(这也引发了c++中的动态多态),当delete该对象指针时,会调用基类对象的析构函数,但此时为基类的指针,因此如果不将析构函数定义为虚析构函数,那么此时仅仅会调用基类的析构函数,对于派生类的析构函数不会被调用,因此派生类中所生成的内存不会被释放,这就引起了内存管理的问题(构造时构造派生类,析构时仅仅析构基类)。而如果定义为虚析构函数,那么会首先调用派生类的析构函数,然后调用基类的析构函数,完成内存释放。
其实上述情况发生的根本在于虚函数表的使用,当基类析构函数定义为虚函数,那么他的派生类在构造时会将基类中虚函数表中析构函数的指针指向派生类的析构函数,因此在释放时自然会调用派生类的虚函数。但同样引发了问题,虚函数表也需要存储而且需要占用一定的内存空间,因此我们说当类不具备多态特性时我们不要刻意的将析构函数定义为虚函数,以避免造成不必要的内存占用。
还有一点关于虚析构函数的问题,当析构函数定义为纯虚函数时,该类也就被称为虚基类了,此时析构函数仍应该被定义,没错纯虚函数可以被定义,而且此刻必须被定义!因此他即将被调用!当派生类被析构时必然会调用到该基类的析构函数,如果不被定义那么必然会导致问题。因此纯虚析构函数可以被定义且必须被定义!
条款8 不要让异常逃离析构函数
首先应明确在c++中当两个异常同时产生时(一个异常产生后没有被处理第二个发生),会引发std::terminate使得程序终止或者未定义的行为,因此必须确保在异常生成后异常被处理,而当析构函数中出现异常时,两种解决方法,第一种是在析构函数中判断是否存在异常,第二种方式是在析构函数调用后,在函数外判断是否出现异常。这两种方式第二种方式会出现问题:当将类的对象存储在vector容器中时,如果该vector被销毁时会依次调用该vector中所存储的对象的析构函数,如果析构函数中存在异常且该异常不被处理,那么在析构所有vector中对象之前该异常一直不被处理,那么如果vector中存在100个对象,那么必须保证99个对象是无异常的,如果出现一个以上的对象析构函数出现异常那么程序必然崩溃,而且这种概率将极高,所以必须确保析构函数是无异常的或者必须保证异常的析构函数内部被及时处理!
条款9 避免在构造函数和析构函数中调用虚函数
(1)避免在构造函数中调用虚函数原因在于,在基类中定义了虚函数,在基类中重载了该虚函数,当进行动态联编时,我们将基类的指针绑定到派生类的对象上,此时由于派生类对象被构造时首先调用基类的构造函数再调用派生类的构造函数,因此当调用基类的构造函数时,虚函数表中所指向的虚函数指针仍然指向基类的虚函数而非派生类函数,因此就使得多态性失效,更严重的当基类中的虚函数为纯虚函数时(函数没有被实现)必然会导致错误,因此避免在构造函数中调用虚函数。如下:
上述代码当定义Transaction* tra = new SellTransaction();后,会终止程序,因为你调用了纯虚函数virtual void logTransaction() const = 0;函数。
(2)在析构函数中也不应该调用虚函数,这是由于:在派生类对象被析构时首先调用的是派生类的析构函数,派生类的析构函数通常会将派生类中用到的内存释放掉,然后去调用基类的析构函数,当基类的析构函数调用虚函数时,虚函数指针由于多态性指向的是派生类的函数,而派生类的函数可能会访问到早已被派生类析构函数释放掉的内存空间,这时就会产生未定义行为,进而造成不确定的结果。因此不应在析构函数中调用虚函数!
总的来说析构函数和构造函数仅应该调用只与本类相关的函数、成员,不应牵扯继承。
条款10 在赋值运算符被定义时返回值为*this的左值引用
这点不是确定的,之所以这么做有两点原因:
1. 为了完成连等的运算
2. std库中所有赋值运算符皆这么书写
笔者:不然呢?返回何值?返回右值引用吗显然不行,因为右值引用不能作为左值进行运算,比如
int a=b=c=1
因为该式的运算是右结合的,因此可以被看作a=(b=(c=1)),如果=运算符返回右值,那么c=1就不被允许,所以右值被否定
如果返回值不返回引用,那么就多了一次拷贝构造或复制运算符,因此必然是*this的左值引用
条款11 赋值运算符处理自我复制
赋值运算符顾名思义就是进行对象之间的复制操作,一般的赋值方法:
上述赋值运算符的实现方式中会出现问题,首先是当赋值运算符的两个操作对象是同一个时,这种方式便不可使用,原因在于delete了该对象又在new Bitmap时调用该对象。一般的解决思路是,在进入赋值运算符函数之后首先判断是否为同一对象:
这样解决了上述问题,但仍然存在另一问题:当delete pb之后,我们调用pb=new Bitmap(*rhs.pb)时出现异常,导致pb赋值失败,这时就会出现pb的未定义行为(此时pb已经被delete),也就是pb此时不知道指向何处。为了解决该问题:
该方法通过首先定义Bitmap指针指向原先的内存,然后new Bitmap,然后将new出来的内存空间赋值给pb,这一切都成功之后再delete原内存空间,如果new内存空间出现异常,那么pb指针仍然指向元内存空间,不会发生未定义行为。注意此实现方法中没有对两被赋值对象相同时特殊对待,这是因为该方法天然可以解决这类问题。当然如果要考虑效率的极限问题,也可以加入“证同测试”。该方式一种代替实现方法如下:
注意两点:
(1)参数rhs是值传递方式
(2)使用swap(rhs)交换指针,swap函数是无异常函数,后面条款25将详细介绍swap的实现方式以及为何其为无异常函数(不允许出现异常)。
条款12 赋值操作符和拷贝构造函数中勿忘记复制所有成员变量
容易忘记的成员包括:
(1)指针的深拷贝和浅拷贝问题
(2)作为子类需要考虑父类成员复制的问题
(3)尤其注意在开发过程中添加新的类成员变量之后所有的拷贝构造函数以及复制运算符需要重新审查。