一 基础议题
1 仔细区分pointer和reference
指针和引用的区别:
- 引用是一个标记符别名,而指针是一个变量;
- 引用必须赋予初值且不能修改,指针可以存在空指针可修改;
- 引用没有地址,指针在内存中存在。
指针和引用除了上面的区别外,基本上的行为是一致的,因此在具体使用场景上该使用哪一个主要是看是否会修改指针或者引用指向或者表示的对象,如果会修改则使用指针,不会修改则使用引用。
2 最好使用C++类型转换
C中的隐式类型转换或者显式类型转换都应该被弃用,使用用途更加明确的C++类型转换:
- static_cast:和显示类型转换类似,能够完成简单的类型转换;
- const_cast:去除const类型的const属性;
- dynamic_cast:继承体系中向下安全地类型转换;
- reinterpret_cast:重新解释给定的内存区域,不具备可移植性。
3 不要以多态的方式处理数组
不要以多态的方式处理数组,这里有点迷惑。按照书上给的例子,本身对象数组就不具备多态的属性,如果是存储的指针另说。我认为提出这点的理由可能是一般习惯性的将子类的指针赋予基类,而当参数是基类的数组时,其行为和指针无疑,可能传递子类的数组不会报错,但是实际上是参数传递错误造成的。
当把子类的数组传递给基类数组时除了第一个对象会发生对象切片外,其他的对象都无法有效解析,从而出现运行错误。
4 非必要不提供默认构造函数
缺乏默认构造函数可能造成一些不便:
- 生成对象的数组不便,因为new []只能使用默认构造函数,可以通过自定义operator new或者placement new改进;
- 很难适应模板容器类,因为模板容器类可能或许要默认构造函数构建对象;
- 虚基类如果没有默认构造函数,则子类必须显式初始化
对于需不需要为类提供默认的构造函数,这个取决于所使用的环境,如果一味地不添加或者使用默认参数来代替都不是一种好的习惯;反之,全部添加默认构造函数又会增加系统的开销,也不算明智之举。
添不添加默认构造函数尽量根据当前使用的环境进行考虑,不要一味地认定某一种方法。
二 操作符
5 对自定义类型转换保持警惕
C++的强大之一是它不同于C语言严格的数据类型检查,但是有些情况下C++编译器为了方便用户操作给用户带来的一些麻烦也不容忽视。
C++中自定义类型发生隐式类型转换一般有两种场景:
- 自定义内置类型的转换operator导致的隐式转换,一般不建议自定义直接使用相关函数比如toin,todouble更有效、安全;
- 构造函数触发的隐式类型转换,这种情况需要根据具体的场景分析,如果不需要则添加explicit关键字杜绝。
6 ++和–操作符前置和后置的区别
class fun
{
int _num;
public:
fun(int num) :_num(num){}
fun& operator++() //前置
{
_num++;
return *this;
}
const fun operator++(int) //后置
{
fun fun_rst(_num);
_num++;
return fun_rst;
}
fun& operator--() //前置
{
_num--;
return *this;
}
const fun operator--(int) //后置
{
fun fun_rst(_num);
_num--;
return fun_rst;
}
//友元函数--类似
friend fun& operator++(fun f);
friend const fun operator++(fun f,int);
void run()
{
std::cout << _num << std::endl;
}
};
//这里重定义有语法错误,只是演示一下
fun& operator++(fun f)
{
f._num++;
return f;
}
const fun operator++(fun f, int)
{
fun tmp(f._num);
f._num++;
return tmp;
}
前置式返回一个const是为了防止出现f++++的情况,这种情况会产生误解。然而,应用时尽量使用前置++而不是用后置++就可以避免类似困扰,或者将后置++的实现依赖于前置++就不会导致两者行为不一致的情况发生。
7 千万不要重载&&``````||
和,
操作符
C/C++逻辑判断采用“骤死式”判断,即逻辑语句中的短路。一旦语句的结果可以得到确定,就不会在执行语句之后的表达式,如num_rst || num_snd,如果num_rst是一个不为0的数则判断结束,编译器不会判断num_snd所代表的表达式是否有语法错误。
在使用操作符重载时有一个问题是,操作符重载的调用类似于调用一个函数operator&&(),这和语言中所拥有的操作符行为方式不同;语言中的操作符的解析方向是从左向右解析,而且拥有“骤死式”的特点,而在使用重载操作符的时候,你无法实现像编译器一样的解析行为,这就可能导致在阅读代码时产生的误解。所以非必要不要使用这些操作符重载,实在必须使用一定要小心。
重载逗号同理,无构建和编译器解析方式相同的函数。这会带来麻烦。
8 了解不同意义的new和delete
new或者delete有几种,分别为new operator,operator new和placement new,三者的区别,delete同理:
- new不能被重载,行为总是一致,先分配内存再调用构造函数返回指针;
- operator new作为一个operator可以被重载,如果类没有重载operator new则调用的是全局的new;
- placement new只是operator new重载的一个版本,它并不分配内存,它允许用户将对象创建在指定的内存上,可以使堆也可以是栈。
三 异常
9 利用析构函数避免内存泄露
对象在堆上分配的内存需要声明相关的释放资源的函数,该函数的实现可以在析构函数中,也可以在其他函数中显式调用。也可以使用shared_ptr管理堆内存,但是需要注意的是shared_ptr的循环引用的问题。
10 在构造函数中阻止资源泄露
当创建一个类,这个类中包含多个类;假如在这个类进行初始化的时候构造函数抛出一个异常,此时按理说在处理的时候编译器调用析构函数来释放掉分配好的内存,无论是栈上的还是堆上的。但是事实是编译器不会这么做,因为要对一个构造失败的类进行对象的销毁要知道这个类的初始化进行到什么程度才能准确的释放内存,这就需要一个标识,但是加入标识就对于每一个类的创建都会增加开销;而且C++是基于对象的,这无疑是为编译器带来了很大的麻烦。另外,智能指针无法处理未完成的对象当然可以在上述代码中添加try…catch…异常处理。所以当在构造函数中初始化失败时编译器并不调用任何析构函数。
解决在对象构造的时候抛出异常的方法:
- 使用异常处理机制捕获异常(普通指针类型);
- 将成员的初始化封装,但对于管理类不利(const 指针和普通指针);
- 将成员封装于一个类中,有该类来代理成员的管理(const 指针和普通指针)。
11 禁止异常流出析构函数之外
了解析构函数中的异常之前先了解何种情况下析构函数会被调用:
- 对象被正常销毁时;
- 当对象被异常处理机制进行栈展开进行销毁时。
当析构函数被调用可能是因为一个异常的产生,如果此时析构函数再抛出一个异常,编译器会调用terminate来终止程序即便对象销毁失败。为了避免析构函数中再次抛出异常,可以添加异常处理。
12 了解“抛出一个异常”与“传递一个参数”之间的差异
函数调用参数传递分为三种形式:传值,传引用,传地址,除了传值会发生对象或者数据的拷贝其他两种都不会触发拷贝动作;而异常处理不同,无论是通过传值还是传引用(传地址不允许,会造成类型不吻合)都会发生对象或者数据的拷贝动作,因为抛出异常意味着离开调用函数返回调用端,这样局部变量会被销毁,如果允许传引用,那么catch将收到的是被销毁的对象。(即使对象不会被销毁,拷贝动作也会发生)通过值传递时会复制两次。
在抛出异常时和调用函数有很大的不同是,调用函数如果函数参数类型不匹配,编译器会尝试进行隐式类型转换来匹配参数,而异常类型匹配时不会调用任何隐式类型转换的功能。
继承类异常的捕获意味着父类异常可以兼容子类异常。
13 以by reference方式捕获异常
异常处理时该如何选择使用传值,传址,传引用。
传址不会发生对象的复制,这相对于传值来说性能方面要优越不少,但是传址又会衍生很多问题,比如传址不能传递一个局部的指针,这样离开作用域对象被销毁指针会失效,当然,可以使用传递全局变量或者静态变量来消除这些问题,但是全局变量和静态变量的销毁也是一个问题。可能你想使用throw new execption;来解决问题但是这样抛出的指针你什么时候销毁,该不该销毁又是一个很头疼的问题。另外,语言中有一些惯例,比如:bad_alloc,bad_cast,bad_typeid,bad_exception诠释对象,不能用传址来捕获。
传值不存在传址中存在的诸多问题,不用考虑何时进行对象的销毁,但是传值有两个很严重的缺点:
- 传值会在发生异常时进行自动的拷贝对象,造成较高的资源耗费;
- 传值在面对继承类问题时比较尴尬,会发生对象的切片,子类对象会丢失对应的子类数据和成员函数,多态机制也会失效。
当使用引用时以上问题都不存在,而且对象只会被复制一次,相对于使用传址的安全性和传值造成的对象切片相对来说要好得多。
14 明智使用execption specifications
使用异常规范能够使得程序更加清晰刻度,但是如果程序不按照规定抛出未规定的异常编译器也只是给出警告而已。而对于未捕获的一场程序的基本处理逻辑是terminate。为了避免该情况发生可以:
- 不要将规范和template混合使用,因为模板你不知道可能传入什么参数;
- 当A函数调用B函数时,若B函数未使用异常规范A函数就不要使用异常规范。另外,回调函数是一个很难发现的点;
- 处理系统中可能抛出的异常。将非预期的异常转换为一个类型(继承)是一个比较不错的做法;另外,如果非预期的异常的复本被抛出,该异常会被标准异常bad_exception代替。
15 了解异常处理的成本
异常处理在每个块语句都要进行大量的统计工作,要对try语句的进入点和离开点进行记号,以及对应处理的exception类型这都会带来一定的开销,而且即便你的程序并未抛出异常,而只是提供了异常的支持这也会带来一定的开销,特别是你本打算不使用异常而你调用的库函数中使用了异常时,就意味着你支持了异常,这可能很头疼,但没办法,另外,当异常抛出时程序所遭受的成本是很大的,因此在不要使用异常的时候尽量不要使用异常,这会使你的代码更加高效。
四 效率
16 谨记80-20法则
80-20法则:一个程序的80%的资源用在20%的代码上,比如80%的内存,时间,磁盘访问,CPU使用等都用在20%的代码上。但是80-20法则并不拘泥于这一数字,可以理解为大部分系统资源花费在少量的代码上。但是对于程序中如何适当的把握这个80-20法则,大部分的选择是“猜”,但是“猜”这一做法并不可取。当项目的架构过于庞大时仅靠猜的话是无法确定程序中的低效率块的。
在程序分析时尽量使用适当的代码分析工具对你的代码进行分析来帮助你找到程序中比较耗费资源的代码段,因为依靠具体的数据比靠直觉更加可靠。
所以进行程序的优化时尽量不要靠你的直觉或者某些没有具体依据的方法进行估计,应该使用多种代码分析工具得到大量的关于你的代码的数据来分析代码。
17 考虑使用lazy evaluation(缓式评估)
考虑到程序的效率问题,如果让程序员在程序完成时尽可能的进行代码的优化,这对于程序员来说并不是一件简单的事情。所以我们可以采用一种我们小时候经常干的一种方案:在父母要求我们整理房间时,我们一般都不喜欢立马去按照父母的要求进行整理,而是尽可能在父母在进行检查时才会尽快完成父母下达的命令,并且如果父母不进行检查我们可能就不进行相应的整理。这种思想可以应用在代码的编写中,可以使一个代码只在被需要时被创建,在不需要的时候就不创建。
引用计数
一般会构造对应的拷贝构造函数来防止浅拷贝的发生完成深拷贝,防止两个对象拥有同一片内存,而缓式评估就是要利用浅拷贝在这种特性,浅拷是为了防止对两个中的一个数据进行修改的时候而导致另一个数据也别修改的现象发生,但是如果只是发生数据的拷贝和读取不存在数据的修改等操作,深拷贝就是在浪费系统资源。只需在需要对数据进行修改时发生深拷贝这就对系统资源进行了最大程度的节约。
当我们需要对数据进行修改时就需要考虑“收拾房间”了不能载拖延了。这就造成一个问题,不同的函数在使用该类成员时,都可能对数据进行修改所以这就需要程序员来判断如何组织程序中拖延的代码,当然也可以使用标志位来判断深拷贝是否进行。
区分读写
对于类的成员数据的读写问题,读取数据的工作很是廉价,但是写数据的同时我们可能会对数据进程副本操作,从而效率方面可能不太好。当我们想要通过区分数据的读和写来在对应的代码中做正确的事情但是我们无能为力判断代码的读写操作,但是如果使用代理(proxy)类或许是一个不错的选择。
Lazy Fetching
对于较大的数据,很多时候我们可能仅仅需要其中的一部分,而不是全部,因此在对数据构建数据结构时可以针对具体的数据进行拆分,只有整整需要读取之时在featch特定字段的数据。
Lazy Expression Evaluation(表达式缓评估)
思路和之前的类似,评估代码中需要进行大量数据计算的部分,只有在真正需要数据的地方再计算出具体的值。
上述的只是一种思想可以运用到很多的代码设计中而且上述思想使用应该仔细考虑你的类的工作环境不要一味的使用缓式评估。
18 分期摊还预算的计算成本
函数实现总共有三种方式:
- 实时评估(eager evaluation),说干就干;
- 缓式评估(Lazy evaluation),尽可能推迟;
- 急式评估(over-eager evaluation),尽可能早的执行。
急式评估相比于缓式评估会提前做一些事情,比如:
Caching
当需要经常读取某些数据,很少修改或者每次修改量很小时,对该数据进行缓存能够大幅提升执行的速率。
Prefetching
提前取出可能相关的数据,基于程序的局部性原理,当你需要a地址的数据时,你大概率会需要a附近的数据,提前设置一个窗口将a周围的数据调入内存能够增加缓存的命中率。
急式评估是用空间换时间,具体工程中使用哪种方式要看具体的场景进行判断。
19 了解临时对象的来源
C++中真正的临时对象是不可见的也不会出现在你的代码中。一个非堆对象(non-heap object)并且没有为它命名便是临时对象。临时对象的两种情况:
- 隐式类型转换被强行使用来试函数调用成功,比如根据参数利用对象的构造函数构建出临时对象传递给函数;
- 函数返回对象时,函数会将返回值拷贝生成一个临时对象赋值给函数外接收返回值的对象。
有些时候对象的构造或者销毁可能伴随着巨大的开销,多余的对象的构造可能影响程序的性能,因此了解在哪里有临时对象是对程序可能的额外开销的充分认识。
20 协助完成“返回值优化”
C++本身有返回值优化技术,利用下面的方式直接上仍然会产生临时对象,但是实际上编译器会优化相关代码,没有额外的临时对象产生。
const Rational operator*(const Rational& _rst,const Rational _snd)
{
return Rational(_rst.mer() * _snd.mer(), _rst.deno()._snd.deno());
}
21 利用重载技术避免隐式类型转换
重载可以避免可能的临时对象的生成减小不必要的性能消耗。
22 考虑操作符复合形式(op=)取代其独身形式(op)
一般建议用对象的复合操作符(如:operator+=)来实现对象的独身操作符(如:operator+),这样在进行代码的维护时我们只需对复合操作符进行维护就可以了。
class number
{
public:
number& operator+=(const number& _rst);
number& operator-=(const number& _rst);
};
const number operator+(const number& _rst,const number& _snd)
{
return number(_rst) += _snd;
}
const number operator-(const number& _rst,const number& _snd)
{
return number(_rst) -= _snd;
}
- 一般来说调用复合操作符的效率相对于独身版本来说效率要高。因为独身版本要返回一个新对象要承担对象的构造和析构的代价,而复合版本不用考虑这些;
- 同时提供独身版本和复合版本为用户提供多种选择。
23 使用其他程序库
在对程序进行优化时,要根据你的程序的权重来选择不同的程序库和优化方案来达到你的目标。
24 了解virtual functions、multiple inheritance、virtual base class、runtime type identification的成本
虚函数
在C++中因为虚函数的存在使得多态机制可以让用户类的设计和使用时有更大的灵活性,而虚函数如何管理和调用对于程序的效率来说很是重要。
类中的虚函数的实现原理是函数指针也就是虚函数指针(virtual table pointres简写为vtbl),而这些虚函数指针存储在一个虚函数表(virtual tables简写为vtbls)中。vtbl通常是一个有函数指针组成的数组,但是有些编译器也会选择使用链表来管理虚函数,策略基本相同,就是使用线性表来管理函数指针。程序中的每一个class一旦声明或者继承了虚函数,那么这个类一定有一个vtbl,而虚函数表中保存的就是这个对象的个中虚函数的指针。
类的一般管理一个指向虚函数表的指针,该指针一般存储在对象的第一个机器字节长的内存中,虚函数表中的虚函数的排列顺序是和代码的顺序相关的。
多重继承
在C++中多继承往往将问题复杂化,在多重继承中类中一般有多个虚函数表(每个基类对应一个虚函数表),而且定位相关的虚函数表也比较复杂。成本方面多重继承相对于单继承的成本增加和继承的类相关,内存空间的压力增加了一些,运行期的调用成本也有一定的增加。
多重继承也会增加另一个麻烦,菱形继承中孙子中就会存在两个祖先的成员数据,这可以通过虚继承和虚基类来解决。但是会在类中增加隐藏的指针,这些指针用来防止基类对象的复制。
RTTI(runtime type identification)
在运行时类的信息会被存储在type_info对象中,可以使用type_id来获取类的相关信息。拥有虚函数的类为了管理这个type_info对象可能将他保存在虚函数表中,因此每个类都要为type_info的对象负责。但这并不不意味着程序会因为RTTI机制而付出更多的效率代价。RTTI让用户可以在程序中实时的获取类的类型等方面的信息,这自己管理类来说可能更加有效,因为无法保证自己的管理能够让类在任何地方都被识别。因此需要了解它的成本而不是摒弃。
一般RTTI信息存储在虚函数表的-1位置。
五 技术
25 将constructer和nonmember function虚化
虚化构造函数
虚化non-member function
这里说的是将构造函数虚化,不是构建虚构造函数。
- 第一种场景就是根据输入的信息构造具体的对象。
- 第二种是虚构造函数就是虚拷贝构造函数,虚拷贝构造函数会返回一个自身的指针,指向自身的一个副本,比如clone的实现。
26 限制某个class所能产生的对象的数量
允许对象0个或者1个
允许零个或者一个对象
每当产生一个对象最基本的步骤就是调用构造函数,因此阻止对象产生最简单的办法是将对象的构造函数声明为私有的,这时只有拥有特权的类或者函数才能创建这个对象。比如单例的实现。
多个对象
可以抽象一个管理对象数量的基类,所有的需要数量限制的类继承自该类即可。
27 要求或者禁止对象产生于heap中
只能构建于heap的对象:将析构函数私有化,然后声明显式销毁的成员函数即可。
只能构建于stack的对象:将operator new声明为private即可。
28 智能指针
学会使用智能指针。
29 引用计数
引用计数的使用有两个目的:第一个是简化内存管理,当然这并不简单,特别是类似于智能指针中指针的对象传递问题;第二个是节省内存,加快程序运行。
引用计数的基本实现就是利用一个计数器计数当前共享同一份内存的数量,当改数量减少至0时则销毁内存。并且引用计数应该遵守写时复制,这样能够最大程度减少性能损耗。
30 Proxy classes
代理模式。
31 让函数根据一个以上的对象类型来决定如何虚化
然函数决定根据具体的对象应该如何进行操作,可以有三种实现:
- 用虚函数和RTTI,在派生类的重载虚函数中使用if-else对传进的不同类型参数执行不同的操作,这样做几乎放弃了封装,每增加一个新的类 型时,必须更新每一个基于RTTI的if-else链以处理这个新的类型,因此程序本质上是没有可维护性的;
- 只使用虚函数,通过几次单独的虚函数调用,第 一次决定第一个对象的动态类型,第二次决定第二个对象动态类型,如此这般然而,这种方法的缺陷仍然是:每个类必须知道它的所有同胞类,增加新类时,所有 代码必须更新;
- 模拟虚函数表,在类外建立一张模拟虚函数表,该表是类型和函数指针的映射,加入新类型是不须改动其它类代码,只需在类外增加一个处理函数即可。
六 杂项
32 在未来时态下发展的程序
一些在写程序中应坚持的原则:
- 准备好未知的变化;
- 避免将规范规定化;
- 为类的赋值和拷贝函数做好准备;
- 尽量将你的代码局部化;
- 将代码泛化;
- 将设计需求放在首位。
33 将非尾端类(non-leaf classes)设计为抽象类(abstract classes)
只要不是最根本的实体类(不需要进一步被继承的类),都设计成抽象类。
34 如何在同一个程序中结合C++和C
C++和C可能涉及的兼容问题有:
- Name Mangling,编译时编译器会对函数变量等名称进行调整,C和C++的规则不同,C++中可以使用extern C按照C的规则进行命名调整;
- 动态内存分配,c时malloc和free;
- 数据结构的兼容,C++中的POD数据基本兼容C,非POD一般为包含虚函数、虚继承之类的对象。
35 习惯C++
熟悉C++的特性,比如模板、STL、lambda等。