导读
C++中memory leak泄露的不止是内存,还有资源。如:构造函数中分配的文件描述符、信号量、窗口句柄、数据库锁等。
1 仔细区别pointers和references
引用必须总是代表某个对象,因此必须要有初值。指针则没有这个限制(空指针)。
指针可以被重新赋值从而指向别的对象。而引用总是指向最初的那个对象。
实现某些操作符时需要使用引用,比如operator[],其返回对象要求能够被赋值。若返回指针,则必须使用间址运算符,不直观。
使用指针时必须测试它是否为null。
2 最好使用C++转型操作符
Effecitve C++ 27条描述过。
C式转型几乎允许不加区分地进行任何类型之间的转换。并且由于使用常见的小括号,在代码中难以辨认出来。
新式转型虽然又臭又长,但是容易被解析,且编译器能过诊断转型错误。
static_cast和C式转型差不多。其他cast均缩窄了转型范围.
- const_cast只用来改变常量性。
- dynamic_cast只用来安全向下转型。
- reinterpret_cast常用来做任意函数指针之间或和整数的转换。不具移植性,应该尽量避免。
3 绝对不要以多态方式处理数组
array[i]实际上为(array+i)的简写。array+i在运算时转换为array+isizeof(object)。因此偏移量取决于数组中的对象大小。
如果向接受父类对象数组的函数传入子类对象数组,编译器依然会按照父类对象的大小来在计算偏移量,而该偏移量往往小于子类对象的大小,造成不可预期的结果。
同理,通过父类指针删除子类对象数组也会导致不可预期的结果。
4 非必要不提供default constructor
默认构造函数是无需提供变量就能调用的构造函数,在以下场合有调用需求:
- 产生数组
- 模版容器类实例化
- 继承虚基类的子类初始化
添加无意义的default constructor(提供了无意义的默认值)会影响效率,因为成员函数必须测试字段是否被初始化。
5 对定制的“类型转换函数”保持警觉
单自变量(能够以一个变量调用)的构造函数和隐式转换操作符允许编译器进行隐式转换,可能会导致非预期的调用。
为了避免这种情况,不要定义隐式转换操作符,且把构造函数声明为explicit。
string没有定义从string
到char *
的隐式转换函数,而是提供了显式的 c_str 来执行转换,由此可见隐式转换函数并非想象中的那么好。
6 区别increment/decrement操作符的前置(prefix)和后置(postfix)形式
前置运算符:先改变后取出,返回引用
后置运算符:先取出再改变,返回const对象
为了解决语法问题,前置和后置式只能以参数来区分,后置式拥有一个不会用到的参数,因此在调用时会有构造和析构临时对象的额外开销。
7 千万不要重载 && ,|| 和 , 操作符
&&
和 ||
拥有“短路”逻辑,它们从左到右进行表达式评估,一旦能够确定表达式的真假,评估工作就会结束。比如A && B
,一旦A为false,则表达式的结果已经确定为false,所以B不会被执行。
,
会从左到右对表达式进行评估,最后返回右侧表达式的值。
我们无法完全模拟 &&
,||
和 ,
操作符的行为,因为对于函数调用,我们无法保障左侧表达式一定比右侧表达式先评估,所以最好不要去重载它们。
8 了解各种不同意义的new和delete
-
operator new:为对象分配内存,能够被重载,但规定第一个参数必须为size_t,用于指定分配的内存大小。声明如下:
void * operator new(size_t size);
调用:
void * rawMemory = operator new(size(string));
-
new operator:C++自建的操作符,总是为对象分配内存(调用operator new)然后调用其构造函数。该操作符无法被重载。如:
string *ps = new string("test");
虽然我们可以自己手动分配内存,却无法手动调用构造函数(但编译器可以),所以要想新建堆对象,总是要使用new operator。
-
placement new:特殊的operator new,能够在分配好的内存上构造对象。调用如下:
Widget * constructWidgetInBuffer(void *buffer, int widgetSize) { return new (buffer) Widget(widgetSize); }
不能使用delete释放,应该直接调用对象的析构函数,然后在必要时释放那块提供给placement new的内存。
-
operator new[]:为数组分配内存。对于数组,new operator会先调用它,然后对数组中的每个对象调用构造函数。
9 利用destructors避免泄漏资源
引入异常的原因是因为异常无法被忽略(以往的手段,如状态变量和错误返回码,都可以被忽略)
为了避免抛出异常导致堆对象没有被释放,使用智能指针指向堆对象。
更具体来缩,我们需要把资源存放在对象内,这样资源就会随着对象的析构函数而被释放。
10 在constructors内阻止资源泄漏
若在构造函数中抛出异常,由于构造不完全,无法通过调用析构函数来释放,因为析构函数不知道哪些成员被构建完,哪些没有构建完。
所以应该在构造函数中try...catch异常,然后一旦失败立刻释放资源。为了捕捉初始化列表中的异常,应该把对象初始化操作放入某些私有成员函数内。
更好的方法是改用智能指针。
11 禁止异常流出destructors之外
析构函数会在以下情况下被调用:
- 离开了作用域
- 被明确删除(delete)
- 被异常处理机制(栈展开)销毁
若析构函数抛出异常并传出析构函数之外,而该析构函数又是由于异常而调用的,程序会被terminate函数结束。
除此之外,析构函数抛出异常会导致析构不完全。
为了避免这种情况,应该使用try...catch,并且在catch中什么都不做,避免在catch中抛出新异常。
12 了解“抛出一个exception“与”传递一个参数“或”调用一个虚函数“之间的差异
差异:
- 调用函数后控制权最终会回到调用端,而抛出异常后控制权不会。
- 一个对象被作为异常抛出时,无论是by value还是by reference传递,总会发生复制,否则该对象可能会因为离开作用域而被析构。
- 由于2,所以抛出异常速度慢(发生复制),且无法修改抛出对象(只能修改其副本),因此声明为const的意义不大。
- 抛出对象根据静态类型调用复制构造函数,失去的多态性
- 函数传参过程可以发生各种隐式转换(如int -> double,derived -> base等等)后进行调用,而抛出异常只能进行继承体系内的转换(如out_of_range -> logic_error -> exception)和有型指针到无型指针的转换(如
double *
->void *
)。 - 虚函数调用总是进行最佳匹配,而异常捕捉总是进行最先匹配(按catch子句的出现顺序进行尝试)。
由于异常的最先匹配,应该将捕捉子类异常的catch子句放到捕捉父类异常子句前。
13 以by reference方式捕捉exceptions
抛出指针(catch-by-pointer)可以避免对象复制,但如果对象因为离开作用域而被析构,则指针指向不存在的对象。如果该指针指向一个堆对象,则不能确定是否应该释放该对象。
抛出值(catch-by-value)存在两次复制以及切割问题(子类对象被切割成基类对象,从而调用基类的函数,丢失了虚特性)。
抛出引用(catch-by-reference)没有以上的问题,因此最佳。
14 明智运用exception specifications
exception specifications可以规范exception的运用。
一旦违反exception specification,程序会被终止,导致资源泄漏。
没有任何方法知道一个template类型参数可能抛出什么异常,因此不要为template提供任何exception specification。
如果被调用的函数无exception specifications,那么调用函数也不要设置exception specifications(回调函数要特别注意)。
将非预期的异常都以UnexpectedException取代之,并通过set_unexpected取代默认的unexpected函数。这样程序就不会被非预期的异常所终止。
15 了解异常处理(exception handling)的成本
处理异常会使代码膨胀、程序变大、执行变慢。成本来自于:异常处理机制(无论有无使用都要付出,除非禁用该机制)和try语句块。
16 谨记80-20法则
一个程序80%的资源用于20%的代码身上。
如何找出这20%的瓶颈?可以借助程序分析器,使用有代表性、典型的(representative)数据去测试,观察性能指标。
17 考虑使用lazy evaluation(缓式评估)
从效率来看,最好的运算是从未被执行的运算。通过缓式评估,能够将运算延缓到必须用到运算结果的前一刻:如果运算结果不被需要,运算就一直不执行。
应用:
-
引用计数
通过引用计数,使得多个变量共享一个对象,避免产生副本的开销。
-
区分读和写
直到需要对对象作出修改(写)时,才产生对象的副本。
-
缓式取出
在产生一个大对象时,只产生该对象的外壳,不读取(从网络、磁盘)任何数据。当对象内某个字段被需要时才读取。最简单的方法就是产生指针,在需要时指向读取的数据。
-
表达式缓评估
将计算量大的表达式运算(如矩阵运算)记录成一个操作(两个指针指向操作数和一个枚举值表示操作即可),等到真正需要结果时才进行计算。若只需要计算其中的一部分结果,则只计算这部分。若结果最终没有用到,则避免了计算。
如果计算必要,缓式评估不仅不会节省任何东西,还会导致额外的开销。
18 分期摊还预期的计算成本
极速评估(over-eager evaluation):超前进度地做要求以外的工作。这样一旦需要时能立刻返回结果。典型应用:
- caching。将查询的结果缓存起来,今后首先在缓存中查找,找不到再进行常规查找,然后将结果加入缓存中
- 为了避免频繁的系统调用,为动态数组预先分配较大的空间
如果计算会常常被执行,极速评估可以降低每次计算的成本。是典型的空间换时间。
访问局部性(locality of reference):如果某处的数据被需要,通常其临近的数据也会被需要。
19 了解临时对象的来源
临时对象一般在隐式类型转换(传参时)或函数返回对象时(return时)出现。
临时对象可能很耗成本,应该尽可能消除它们。
为了避免临时对象被修改从而影响到原对象,不允许non-const reference产生临时对象。
20 协助完成“返回值优化(RVO)”
如果函数一定要以值的方式返回对象,我们绝对无法消除之。
返回值优化:在返回时才构造返回对象,编译器能够直接将对象构造于调用者变量的存储内。如:
return Rational(lhs.numerator() * rhs.numerator, lhs.denominator() * rhs.denominator());
当然也可不必如此,考虑 NRVO 。
为了消除函数调用的开销,还可以声明为inline。
21 利用重载技术(overload)避免隐式类型转换(implicit type conversions)
可以通过函数重载来避免隐式转换,因为一旦参数完全匹配,就不会在传参前进行隐式了。
重载操作符必须获得至少一个用户定制类型的变量。
22 考虑以操作符复合形式(op=)取代其独身形式(op)
考察:
x = x + y;
x += y;
在设计类时,必须两者都提供,独身形式可以通过调用复合形式来实现。
独身版本必须返回一个新对象,因此带来额外的构造和析构成本,效率不如复合形式。应该尽量使用复合形式。
23 考虑使用其他程序库
stdio一般比iostream快。
如果有不同的程序库提供相同的功能,应该根据程序的特点来选择效率更高的库。
24 了解virtual functions、multiple inheritance、virtual base classes、runtime type identification的成本
编译器一般通过virtual table pointer(vptr)和virtual table(vtbl)来实现虚函数调用。
vtbl一般被存放在类中第一个非inline且非纯虚的虚函数目标文件中(一般为析构函数),如果没有,则会为每一个使用了vtbl的目标文件都产生一个副本。每一个声明或继承虚函数的类都有一个自己的vtbl。vtbl由指针组成,指针指向各个虚函数的实现(会覆盖父类同名函数指针)。如果子类没有重新定义继承而来的虚函数,则会指向父类的实现。
凡是拥有虚函数的类,其对象都有一个隐藏的成员——vptr。vptr指向该对象对应的vtbl。
因为虚特性意味着在运行期才决定哪个函数被调用,而inline意味着在编译器就确定该函数,所以虚函数不能为inline。
因此虚函数的成本:类的数据量增加、对象变大、不能inline
多继承和虚基类的成本:类的数据量增加(类需要保存vtbl)、对象变大(对象会含多个vptr和指向虚基类的指针)
runtime type identification(RTTI)成本:类的数据量增加(类需要保存type_info)
25 将constructor和non-member functions虚化
以下函数并非真正意义上的构造函数:
-
virtual constructor:能够根据获得的输入产生不同类型的对象,常用于从硬盘或网络读取对象信息。
-
virtual copy constructor:返回一个指向调用者的新副本的指针,通常直接调用类相应的复制构造函数。常命名为clone。当子类重定义父类的虚函数时,如果原返回的是父类的指针,那么子类可以返回子类的指针。引用同理。
-
virtual non-member function:
为了让operator<<能够有虚特性,可以定义virtual ostream & print(ostream &s),然后重载全局的operator<<:
inline ostream & operator<<(ostream &s, const X& c) { return c.print(s); }
26 限制某个class所能产生的对象数量
0:把构造函数和拷贝构造函数都声明为private。
1(单例):
方法一:把构造函数和拷贝构造函数都声明为private,然后:用friend函数封装一个static对象,并返回该对象的引用,最好都放入namespace内。或者直接使用static函数。
后者无论是否用到,static对象都会被构造,并且初始化的时机不确定。而前者只会在函数被调用时才构造static对象,更符合C++设计思想。
方法二:使用static来记录类当前实例化的对象个数。超过个数(1)时抛出异常。
然而这样有个致命的问题:当该类被继承或组合后,新类的实例化数也会被记到该static值上。为了避免这种情况,可以定义构造函数为private(带有私有构造、析构函数的类无法被继承也无法被内嵌)。
n:可采用1的方法二。
也可以实现一个计数模版类Counted,然后那些需要限制对象数量的类私有继承之,如 class Printer: private Counted<Printer>
。
27 要求(或禁止)对象产生于Heap之中
要求对象产生于堆中:私有化(private)析构函数,使得无法创建栈对象(因为无法隐式调用析构函数),只能通过new创建,然后析构时显式调用destory()。
为了继承,析构函数可以改为protected。为了组合,始终使用该类的指针而不是对象。
但这样并不能保证子类的对象是否产生于堆中,更准确的说是子类对象的父类部分。
判断对象是否处于堆内:
- 无法通过设立一个类成员变量(static)标识,然后在构造函数中检验来判断。因为一次性建立多个对象时,总是先分配完内存再多次调用构造函数。
- 无法通过新建一个栈对象,然后比较它和对象的地址值来判断。因为并不是所以系统的栈地址都高于堆地址,并且低于栈地址的不止有堆地址,还有静态成员地址。
- 定义一个抽象mixin类,通过list来记录由new返回的指针。然后子类通过调用基类函数来判断是否是堆对象。
禁止对象产生于堆中:通过将operator new和delete私有化,只能解决自身,不能解决子类和被包含的情况。于是又回到判断对象是否处于堆内的问题。
28 smart pointers(智能指针)
智能指针提供以下功能:
- 控制指针产生和销毁时执行的动作(如析构所指的资源对象)
- 控制复制和赋值时发生的动作(如是否允许,深复制还是浅复制)
- 控制间址访问(dereference)时发生的动作(如延时获取)
为了避免莫名其妙的类型转换,不要提供将smart pointer转换为dumb pointer的类型转换函数。
为了将子类的智能指针转换为父类的智能指针,应在智能指针中定义类型转换函数:
template<class newType>
operator SmartPtr<newType>()
{
return SmartPtr<newType>(pointee);
}
T* pointee;
发生转换时newType被实例化为父类类型。
为了实现指向const对象的智能指针,应该定义两个智能指针类,然后用非const的去继承const的。为了避免包含两个指针,可以使用Union。
29 Reference counting(引用计数)
允许多个等值对象共享同一实值,避免储存多余的副本造成资源浪费,常用于垃圾回收机制。
适用于:
- 相对多数的对象共享相对少量的实值。
- 对象实值的产生或销毁的成本很高,或占用很多内存。
实现:
定义一个struct,存放对象的数据以及引用数,并放在类的私有字段。类同一个私有指针指向该struct的对象。
类在构造函数中初始化该struct;在拷贝构造函数中单纯复制指针和增加引用数;在析构函数中单纯减少引用数,如果引用数变为0,则析构struct对象。
在拷贝赋值运算符函数中先减少左操作数的引用,再增加右操作数的引用,并将左操作数的指针赋值为右操作数的指针。
写时复制(copy on write):和其他变量共享一份实值,直到必须要对实值进行修改时才复制。
对于string的operator [],由于不知道被用于读还是写,因此需要进行复制。先减少引用数,然后指针指向新创建的struct副本,最后返回相应位置的引用。
但是以下情况会很麻烦:保存了s1[]返回对象的指针或引用,之后对象被拷贝(s2 = s1),则利用先前保存对象的修改会影响到s2。
一种解决方法是为struct加上sharable flag,flag在[]操作时设为false,在构造时检查flag,然后复制对象。
可以通过继承引用计数基类来实现引用计数功能:继承RCObject基类,然后该类用智能指针RCPtr管理对象指针。
30 Proxy classes(替身类,代理类)
用来代理其他对象的对象称为代理,而它的类被称为代理类。
应用:
-
区分左右值
对于string的operator [],利用代理类我们能够区分对象是读还是写。
首先在operator []时返回一个代理对象(CharProxy)。
因为CharProxy操作了String类的私有成员,因此需要声明为String的友元。
CharProxy保存了原string和operator []的参数(通过构造函数初始化)。拷贝赋值运算符函数(返回左值)在被调用时先复制String然后再赋值,但要考虑参数为CharProxy和char的情况;其余函数(返回右值)用于保持和char一致的行为:char类型转换函数返回相应位置的字符,operator []返回proxy对象。
为了避免CharProxy和char不一致,还需重载取址运算符。
-
实现动态多维数组
C++中,静态数组的大小必须在编译期已知。动态数组不能多维。
可以自己定义二维数组类,然后重载operator []。为了处理连续两个[]的情况(如array[1][2]),让二维数组的operator []返回一维数组的代理对象。
-
压抑隐式转换
为了避免单自变量(能够以一个变量调用)的构造函数造成隐式转换,将该变量定义为嵌套在类内的自定义类型。这样要隐式调用构造函数就因为必须进行两次隐式转换而无法发生。
Proxy类的缺点:
- 对于模版版本,为了避免不一致,还要重载++、--、+=、-=、
*=
等等等等运算符。 - 必须对代理类的所有函数进行重载
- 无法传递代理对象给接受non-const引用对象类型参数的函数
- 无法作为原来就需要隐式转换到的函数参数(编译器无法进行连续隐式转换)
- 带来额外的构造和析构成本
31 让函数根据一个以上的对象类型来决定如何虚化
C++只能根据一个对象的动态类型来确定调用的虚函数,无法根据多个来调用。
解决方案:
-
虚函数+RTTI(运行时类型识别)
根据一个对象的动态类型来确定调用的虚函数,然后在该虚函数通过RTTI来确定另一个对象的类型。
缺点:破坏了封装性,因为每一个子类都必须知道其他的兄弟类。一旦定义新的兄弟类,需要改动所有兄弟类的代码。
-
只使用虚函数
父类重载虚函数的所有(参数类型)版本,并在子类中实现。并通过反向调用来两次利用虚特性:
void A::call(Object & obj) { obj.call(*this); }
缺点:还是破坏了封装性。
-
自行仿真虚函数表格(Virtual Function Tables)
利用map实现虚函数表(vtbl),表中存储相应函数的指针。map维护类名(typeid().name())到函数指针的映射。
定义一个私有的static函数初始化map,然后用智能指针管理。
不能采用函数重载,而是应该定义名称不同的函数,因为这些函数的参数类型必须都为基类引用类型(否则无法放到map中,reinterpret_cast不靠谱)。
缺点:要修改兄弟类的定义,还是破坏了封装性。此外,map无法作用于继承子类的类型对象,尽管它们也是子类(is-a)对象,但是名称上不匹配。
-
使用非类成员函数
将函数表格定义到类外的一个匿名namespace中。map需要维护三元信息:(classA_name, classB_name, fun_ptr),先用pair将两个类名称捆起来作为key。
优点:逻辑上更合理,并非是A作用于B,也不是B作用于A,而是A和B相互作用的过程,不应该调用某某类的某某函数来解决,而应该用中立的(非类成员函数)函数来解决。
缺点:无法作用于继承子类的类型对象。
32 在未来时态下发展程序
采用“未来式”思维:对变化有良好的适应能力。在设计和实现时应注意帮助他人理解、修改、强化你的程序。
考虑:
- 以C++本身语法(而非注释和说明文件)来表现规范:如果不符合,直接编译不通过。
- 如果设计上合适(满足抽象性),声明函数为虚函数。
- 为每一个类处理拷贝构造函数和拷贝赋值运算符函数的动作。如果不使用,声明为私有,避免默认版本被调用。
- 努力让类的操作符函数拥有自然的语法和直观的语义(参考内建类型和STL的设计)。
- 使类有预防、侦测,甚至更正的能力(不信任原则)。
- 努力写出可移植代码。
- 提高封装程度:尽量让实现成为private,尽量采用匿名namespace或文件内的static对象和函数,尽量避免设计出虚基类,尽量避免RTTI和+一堆if...else的用法。
- 除非有不良的巨大后果,尽量使代码一般化(泛化)
33 将非尾端类(non-leaf classes)设计为抽象类(abstract classes)
子类对象会误调用成基类版本的函数(如#3
中的数组问题)
方法1: 定义基类版本函数为private
问题:造成子类对于函数无法调用基类版本(定义为protected可以解决)。基类对象无法调用。
方法2:定义为虚函数
问题:带来语义上的问题:对于赋值运算符函数,会带来允许异型赋值的问题
方法3:用抽象基类取代具体的基类。
面向对象设计的目标是辨识出一些有用的抽象类,并强迫它们成为抽象类。当原有具体类被当作基类使用时,加入一个新的抽象类。
只有你有能力设计某个抽象类,使得未来的类可以直接继承它(而无需改变它)时,才能从抽象类中获得好处。如果不能确定,就应该定义为具体类,直到以后能够进行抽象时才补上抽象类。
34 如何在同一个程序中结合C++和C
Name Mangling(名称重整)
在C++中,由于函数重载,编译器需要为每一个函数编出独一无二的名称。如果需要混编,应该告诉编译器不要重整C函数的名称(不然连接器会找不到):
extern "C"
{
void C_function();
}
Statics的初始化
在main()里的内容调用之前,需要初始化static成员。在main()里的内容调用之后,需要析构static成员。
一般来说,编译器会将static的构造和析构函数塞到main()的第一行和最后一行。所以尽量使用C++的main()函数,而不是C的。
动态内存分配
C++:用new分配,用delete释放
C:用malloc分配,用free释放
千万不要混用。
数据结构的兼容性
内建类型和struct兼容。但如果struct加上虚函数就无法兼容了。
35 让自己习惯于标准C++语言
语言特性:RTTI、namespace、mutable、explicit
模版弹性:允许非类型变量
异常处理:exception specifications、unexpected
新式转型:各种cast
IO:iostream
STL:容器、算法、迭代器
摘自: https://www.binss.me/blog/my-excerpt-of-more-effective-c++/?c=515#your_comment