more effictive C++

条款1 仔细区别指针和引用

引用和指针的不同点

  1. 引用在定义时必须初始化,指针没有要求
  2. 引用在初始化时引用一个实体后,就不能再引用其他实体,而指针可以在任何时候指向任何一个同类型实体
  3. 没有NULL引用,但是有NULL指针,也正因为如此,引用可能效率更高些(不需要对其有效性进行测试)
  4. sizeof的含义不同,引用结果为引用类型的大小,但指针始终是一个地址所占字节个数
  5. 引用自加即引用的实体增加1,指针自加即指针向后偏移一个类型的大小
  6. 有多级指针,但没有多级引用
  7. 访问实体的方式不同,指针需要显式解引用,引用由编译器自己处理
  8. 引用比指针使用起来相对更安全
  9. 可以有const指针,但是没有const引用

相同点

  1. 他们都需要间接引用其他对象

结论
当你知道你需要指向某个东西,而且绝不会改变指向其他东西,或者当你实现一个操作而其语法需求无法通过指针完成时(operator[])你应该选择引用,任何其他时候都应该选择指针


条款2 最好使用C++转型操作符

旧式转型的缺点

  1. 因为它几乎能够完成任何类型转换,所以不能精确的指明意图
  2. 辨识度低

新式转换的优点

  1. 严谨的意义和易辨识度
  2. 编译器可能得到诊断转型错误

新式类型转换


条款3 绝对不要以多态方法处理数组

  • C++规范允许通过基类的指针或者引用来从操作派生类对象所形成数组,但是一般不是你想要的结果,因为在指针算术运算过程中是依据该指针的静态类型来计算大小的,而一般而言派生是比基类要大的,因此如果通过指针运算将会产生未定义行为。得到结论多态和指针算术不能混用,数组对象几乎总是会涉及指针的算术运算,所以数组和多态不要混用
  • C++规范中说,通过基类指针删除一个由派生类组成的数组其结果是未定义的。因为在我们delete[]的时候同样是一个数组的遍历和析构函数的调用

条款4 非必要不提供默认构造函数

如果一个类没有默认构造,那么使用上有哪些限制?
  1. 创建数组的时候,一般没有办法可以为数组中的对象的成员变量初始化。解决方法:1. 不在堆上创建数组(缺点不能在堆上创建)。2. 创建指针数组而不是对象数组(缺点是容易资源泄露以及空间浪费(该问题可以通过placement new解决)),释放空间的时候,我们需要使用operator delete[]释放,而不可以使用delete[]释放,因为删除一个不以new operator获得的指针,其结果是未定义的
  2. 在模板类的使用过程中会遇到问题,因为模板类被实例化的时候要求目标类型必须有一个默认构造,因为在那些模板内几乎总是会产生一个模板类型的数组(该问题可以通过技术的提高来消除,但是现实中的很多模板编写者不具备这样的能力)
  3. 虚基类如果缺少了默认构造,那么与这种类合作十分心累,因为虚基类构造函数的参数必须由产生该对象的派生层次最深的类提供。

结论
       如果类的构造函数可以保证对象所有字段都被正确初始化,那么检查字段是否正确的时间和空间成本便都可以免除,如果默认构造函数无法提供这种保证,那么最好避免让默认构造函数出现,虽然这可能对类的使用方式带来某种限制,但是同时带来了一种保证,即当你真的使用了这样的类,你可以预期他们所产生的对象会被完全地初始化,实现上亦富有效率。


条款5 对定制的类型转换函数保持警觉

  • C++继承了C语言的伟大传统,允许编译器在不同类型之间进行隐式转换。对于内置类型的转换我们无能为力,因为他们是语言提供的,但是我们对于自己定义的类型就可以选择是否提供某些函数供编译器拿来做隐式类型转换之用。两种函数允许编译器执行类型转换:单参数的构造函数和隐式类型转换操作符单参数的构造函数是指能够以单一自变量调用成功的构造函数,如此的构造函数可能拥有单一参数,也可能拥有多个参数(其余参数具有默认值)。隐式类型转换操作符是一个成员函数,函数名为operator 类型名,你不能为此函数指定返回值类型,因为其返回值类型已经在函数名上反映出来了

  • 为什么最好不要提供任何类型转换函数?因为这样会导致在非预期情况下,此类函数可能会被调用, 而结果可能是不正确、不直观的程序行为,很难调试。一般来说越有经验的C++程序员越可能避免使用类型转换操作符。

  • 通过单参数构造函数完成的隐式转换较难消除,此外这些函数造成的问题在许多方面比隐式类型转换操作符的情况更不好对付。只要不声明隐式类型转换操作符,便可以将它所带来的害处避免,但是单参数构造函数却不那么容易去除,因为有时候你可能真的需要一个单参数的构造函数提供给客户,与此同时,你可能希望阻止编译器部分青红皂白的去调用该构造函数,解决方案如下:

  1. 在函数声明为explicit,编译器便不能因隐式类型转换而调用他们,不过显示类型转换是允许的
  2. 因为没有任何一个转换程序可以内含一个以上的用户定制转换行为
  • 允许编译器执行隐式类型转换,害处多过好处,所以不要提供转换函数,除非你确定需要他们,对于单参数构造函数我们先给它加上explicit,如果后面我们确定需要使用其隐式类型转换特性,再去掉该关键字

条款6 区别递增递减操作符的前置和后置形式

  • 后置式有一个int自变量,并且在它被调用的时候,编译器默默地为该int指定一个0值
  • 操作符的前置和后置式返回不同的类型,前置式返回一个引用,后置式返回一个const对象
  • 累加操作符的前置式意义是increase and fetch,而后置式的意义是fetch and increase
  • 令函数返回一个const对象是否合理?后置式的递增和递减操作符就是一个例子,否则obj++++就是合法的,首先其与内置类型不一致,其次并不能达到加两次的效果(违反直觉,引起混淆),所以返回const对象
  • 设计类的一个法则是,一旦有疑虑,尝试看int的行为,并按此行为去设计我们的类
  • 对于我们自定义类型,我们应该优先使用前置的递增和递减操作符,因为相较于后置的递增和递减操作符而言,他们有更高的效率
  • 后置式的递增或者递减操作符的实现应该以其前置式兄弟为基础,如此一来只需要维护前置式版本,这样后置式版本会自动调整为一致的行为

条款7 千万不要重载&&,||和,操作符

  • 当我们重载前两个的时候,函数调用语义会替代掉所谓的骤死式语义,这两种语义是有区别的。第一,当函数调用动作被执行,所有参数值都必须评估完成,所以当我们调用operator&&或operator||时,两个参数都已评估完成,换句话说这样就不存在骤死式语义,第二,C++规范中并未明确定义函数调用动作中各参数的评估顺序,而骤死式语义中总是由左向右评估其自变量。
  • 如果重载&&和||,就没有办法提供程序员预期的某种行为模式,所以不要重载&&和||,同理对于逗号表达式来说,无法保证左侧表达式一定比右侧表达式更早被评估,因为两个表达式都被当做函数调用时的参数传递给该函数 ,而你无法控制一个函数的自变量的评估顺序。
  • 对于逗号操作符来说,表达式的最终结果就是右侧的值。

无法被重载的操作符
. .* :: ?: new delete sizeof typeid static_cast dynamic_cast const_cast reinterpret_cast

条款8 了解各种不同意义的new和delete

  • new operator这个操作符是语言内建的,就像sizeof一样,不能改变其意义。它总是先分配足够的内存,用来放置某类型的对象,然后调用一个构造函数,为刚才分配的内存中的那个对象设定初值
  • 你可以改变的是用来容纳对象的那块内存的分配行为,new operator调用某个函数,执行必要的内存分配动作,你可以重写或重载那个函数,改变其行为。这个函数叫做operator new
  • operator new函数的返回值是一个指针,指向一块原始的、未设定初值的内存,函数中的size_t的参数表示需要分配多少内存,你可以将operator new函数重载,加上额外参数,但是第一个参数的类型必须是size_t
  • 如果你希望将对象产生于堆上,请使用new operator,因为它不但分配内存而且为该对象调用一个构造函数,如果你只打算分配内存,请调用operator new,这样的话就没有任何构造函数被调用,如果你打算在堆对象产生时自己决定内存分配方式,那么就自己写一个operator new,并使用new operator,它将会自动调用你所写的operator new。如果打算在已分配的内存中构造对象,请使用placement new。
  • 如果你打算处理原始的、未设置初值的内存,应该完全回避new operator和delete operator,改调用operator new取得内存并以operator delete归还内存。
  • 如果你使用placement new,在某块内存中产生对象,你应该避免对那块内存使用delete operator,因为delete operator会调用operator delete来释放内存,但是该内存内含的对象最初并非是由operator new分配得来的。毕竟placement new只是返回它所接收的指针而已,所以为了抵消该对象构造函数的影响,你应该先调用该对象的析构函数。但是最终你应该将空间释放掉,以免资源泄露
  • 对于数组而言,内存的分配是调用operator new[]函数来分配,该函数同样可以重载(这使得你取得了内存分配权)。operator new[]会为数组中每个对象调用构造函数。当delete operator被用于数组的时候,他会针对数组中的每个元素调用其destructor,然后再调用operator delete[]释放内存
  • 定位new表达式用于在已分配的原始内存空间中调用构造函数初始化一个对象
new和delete的实现原理

        申请和释放单个元素的空间,使用new和delete操作符,申请和释放连续的空间,使用new[]和delete[]

        new和delete是用户进行动态内存申请和释放的操作符,operator new和operator delete是系统提供的全局函数,new在底层调用operator new全局函数来申请空间,operator new 实际也是通过malloc来申请空间,如果malloc申请成功就直接返回,否则执行用户提供的空间不足应对措施,如果用户提供了该措施就继续申请,否则抛出异常,delete在底层通过operator delete全局函数来释放空间,而operator delete底层也是通过free实现的。

        内置类型:如果申请的是内置类型的空间,new和malloc,delete和free基本类似,不同的地方是:new/delete申请和释放的是单个元素的空间,new[]和delete[]申请的连续的空间,而且new在申请空间失败时会抛出异常,malloc会返回NULL。
        自定义类型
                new的原理:1)调用operator new函数申请空间 2)在申请的空间上执行构造函数,完成对象的构造
                delete的原理:1)在空间上执行析构函数,完成对象中资源的清理工作。2)调用operator delete函数释放对象的空间。
                new[]的原理:1)调用operator new[]函数,在该函数中实际调用的是operator new函数完成N个对象空间的申请。2)在申请的空间上执行N次构造函数
                delete[]的原理:1)在释放的对象空间上执行N次析构函数,完成N个对象中资源的清理。2)调用operator delete[]释放空间,该函数实际上调用operator delete来释放空间
        


C++异常

        C++异常从根本上改变了很多事情,例如,原始指针的使用成为一种高风险行为,资源泄露的机会大增,写出符合期望的构造函数和析构函数的难度大增,程序员必须特别小心,防止程序执行时突然中止;可执行文件和程序库变得很大、速度更慢

        异常是一种处理错误的方式 ,当一个函数发现自己无法处理的错误时就可以抛出异常,让函数直接或间接的调用者处理这个错误

为什么需要异常

异常无法被忽略,如果一个函数利用设定状态变量的方式或者是利用返回错误码的方式发出一个异常信号,无法保证次函数的调用者会检查那个变量或者检查那个错误码,于是程序的执行可能会一直继续下去,远离错误发生地点,但是如果函数以抛出异常的方式发出异常信号,而该异常没有被捕捉,程序的执行便会立刻中止

异常的抛出和匹配原则

  • 异常是通过抛出对象而引发的,该对象的类型决定了应该激活哪个catch的处理代码
  • 被选中的处理代码是调用链中与该对象匹配且离抛出异常位置最近的那一个
  • 抛出异常对象后,会生成一个异常对象的拷贝,因为抛出的异常对象可能是一个临时对象,所以会生成一个拷贝对象,这个拷贝的临时对象会在被catch以后销毁
  • catch(…)可以捕获任意类型的异常,问题是不知道异常错误是什么
  • 实际中抛出和捕获的匹配有个例外,并不都是类型完全匹配的,可以抛出派生类对象,使用基类捕获

在函数调用链中异常栈展开匹配原则

  • 首先检查throw本身是否在try块内部,如果是再查找匹配的catch语句,如果有匹配的,则调用catch的地方进行处理
  • 没有匹配的catch则退出当前函数栈,继续在调用函数的栈中进行查找匹配的catch
  • 如果到达main函数的栈,依旧没有匹配的catch,则终止程序,上述这个沿着调用链查找匹配的catch子句的过程称为栈展开。所以实际中我们最后都要加一个catch(…)捕获任意类型的异常,否则当有异常每捕获,程序就会直接终止
  • 找到匹配的catch子句并处理以后,会继续沿着catch子句后面继续处理

异常安全

  • 构造函数完成对象的构造和初始化,最好不要在构造函数中抛出异常,否则可能导致对象不完整或者没有完全初始化
  • 析构函数主要完成资源的清理,最好不要在析构函数内抛出异常,否则可能导致资源泄漏(内存泄漏、句柄未关闭)
  • C++中异常经常会导致资源泄漏的问题,比如在new和delete中抛出了异常,导致内存泄漏,在lock和unlock之间抛出异常导致死锁

条款9 利用析构函数避免资源泄漏

C语言传统的处理错误的方式

        1) 终止程序,如assert,缺陷:用户难以接受,如发生内存错误,除0错误会终止进程
        2) 返回错误码,缺陷:需要程序员自己去查找对应的错误
        3) C标准库中的setjmp和longjmp组合

异常
  • 局部对象总是会在函数结束时被析构,不论函数如何结束(唯一的例外是你调用longjmp而结束。也正是因为longjmp存在这个问题,我们才使用异常的)
  • auto_ptr析构函数采用单一对象形式的delete,所以auto_ptr不适合取代数组对象的指针。如果希望有一个类似auto_ptr的模板可以用在数组身上,你得自己动手写一个。不过如果真的是这样,使用vector替代数组或许是更好的选择
  • 将资源封装在对象中,通常可以在异常出现时避免资源泄漏
异常的优点
  • 异常对象定义好了,相对于错误码的方式可以清晰的展示出错误的各种信息,甚至可以包含堆栈调用的信息,这样可以帮助更好的定位程序的bug
  • 返回错误码的传统方式有个很大的问题,在函数调用链中,深层的函数返回了错误,那么我们得层层返回错误,最外层才能拿到错误,而抛出异常时会直接跳到catch捕获的地方。
  • 很多的第三方库都会包含异常,那么我们使用它们也需要使用异常
  • 很多测试框架都是用异常,这样更好的使用单元测试等白盒的测试
  • 部分函数使用异常更好处理,比如构造函数或者operator[]函数,只能通过抛出异常或者终止程序来表示错误
异常的缺点
  • 异常会导致程序的执行流乱跳,并且非常的混乱,并且是运行时出错抛出异常就会乱跳,这会导致我们跟踪调试时以及分析程序时,比较困难
  • 异常会有一些性能的开销,但是如今应减速度很快了,可以忽略不计
  • C++没有垃圾回收机制,资源需要自己管理,有了异常后非常容易导致内存泄漏、死锁等异常安全问题,这个需要使用RAII来处理资源的管理问题
  • C++标准库的异常体系定义的不好,导致大家各自定义各自的异常体系,非常混乱
  • 异常尽量规范使用,否则后果不堪设想

条款10 在构造函数内阻止资源泄漏

  • C++保证删除空指针是安全的,所以在析构函数中针对指针成员进行delete是完全没有问题的
  • 如果在构造函数中产生异常而导致的资源泄漏,析构函数是不会管的,但是对于对象成员是可以释放的。C++只会析构已经构造完成的对象。对象只有在其构造函数执行完毕才算是完全构造妥当
  • C++不会自动清理那些“构造期间抛出异常”的对象,所以你必须设计你的构造函数,使他们在那种情况下也能自我清理。通常这需要将所有可能的异常捕捉起来,执行某种清理工作,然后重新抛出异常,使它继续传播出去即可。
  • 对于类尚未完全构造完成的类对象而言,他们的非指针成员将会被自动销毁,无需程序员插手。
  • 如果你以auto_ptr对象来取代类中原始指针成员,你便对你的构造函数做了强化工事,免除了异常出现时发生资源泄漏的危机,不需要在析构函数内手动释放资源,并允许常量指针成员得以和非常量指针成员有一样的方法处理
  • 处理构造过程中可能发生的异常,相当棘手。但是auto_ptr可以消除大部分劳逸,使用他们不仅可以让代码更容易被理解,也使得程序在面对异常时更健壮。
面对尚未完全构造好的对象,为什么C++拒绝调用其析构函数呢?

如果这样做的话,许多时候是没有意义的,因为析构函数不知道构造函数进行到哪一步才产生的异常,为了解决上述问题必须要加入繁重的簿记工作会降低构造函数的速度,并且让对象变得更加庞大,C++避免这样的开销,但你必须付出仅部分构造完成的对象不会被自动销毁的代价


条款11 禁止异常流出析构函数之外

  • 两种情况下析构函数将会被调用。第一种情况是当对象在正常状态下被销毁,也就是当执行流离开了它的作用域或者是被明确删除;第二种情况是当对象被异常处理机制(异常传播过程中的栈展开机制)销毁
  • 当析构函数被调用时,可能有一个异常正在作用之中,可惜的是我们无法在析构函数中区分这些状态(现在我们有了办法,标准中加入了一个新函数uncaught_exception,如果存在一个正在作用而尚未步骤的异常的话,他会返回true)。于是当编写析构函数的代码时应当假设有个异常正在作用。如果控制权基于异常的因素离开了析构函数,而此时正有另一个异常处于作用状态,C++会调用terminate函数。此函数会将程序结束掉(它会立即动手,甚至不等局部对象被销毁)
  • 我们应当全力阻止异常传出析构函数之外。第一,他可以避免terminate函数在异常传播过程的栈展开机制中被调用;第二,他可以确保析构函数完成其应该完成的所有事情

条款12 了解抛出一个异常与传递一个参数或调用一个虚函数之间的区别

  • 当对象以异常的形式抛出的时候,复制行为是由拷贝构造执行的。这个拷贝构造相应于该对象的静态类型而非动态类型(复制动作永远是以对象的静态类型为本)

  • 一个被抛出的对象可以简单的用引用传递的方式捕捉,不需要以常量引用方式捕捉,函数调用过程中将一个临时对象传递给一个非常量引用参数是不允许的,但对异常则属于合法。

抛出一个异常与传递一个函数参数的区别

函数参数和异常的传递方式有3种,值传递、地址传递和引用传递,然而视你所传递的是参数还是异常,发生的事情可能完全不同(原因是当你调用一个函数,控制权最终会回到调用端(除非函数失败以至于无法返回),但是当你抛出一个异常,控制权不会再回到抛出端)

  1. 对于参数传递而言,值传递制造一个副本,引用传递复制两个副本。对于异常而言不论是值传递还是引用传递都会发生复制行为,而交到catch子句手上的正是那个副本。即使被抛出的对象没有瓦解的危险,复制行为还是要发生。这也说明了一个事实,抛异常通常要比参数传递慢
  2. “调用者或抛出者”和“被调用者或捕捉者”之间所存在的类型吻合规则,异常的抛出要比参数传递所能接受的转型动作要少,金支持两种转换:1.继承架构中的类转换,即针对基类编写的catch子句,可以处理类型为派生类的异常。2. 从一个有型指针到一个无型指针。对于一个const void *指针而设计的catch子句,可以捕捉任何指针类型的异常
  3. catch子句总是按照出现的顺序进行匹配尝试,如果接受派生类引用的catch子句位于接受基类引用的catch子句之后,那么往往会导致警告。当你调用一个虚函数,被调用的函数是调用者的动态类型中的函数。可以说虚函数采用的是最佳吻合匹配策略,而异常处理机制遵循所谓的最先匹配策略。
throw; 和throw w;的区别
  1. 前者重新抛出当前的exception,后者抛出的是当你牵exception的副本
  2. 前者重新抛出当前的exception,不论其类型是基类还是派生类,都以原类型抛出,这是因为当次异常重新抛出的时候,并没有发生复制行为。后者抛出一个新的exception对象,对象的累型取决于w的静态类型,过程中发生了复制行为
  3. 一般而言,我们应该使用前者才能重新抛出当前的异常,其间没有机会让你改变被传播异常的类型。此外他也比较有效率,因为不需要产生新的异常对象
异常传递过程中以by value、by reference以及by pointer的代价
  • 当我们以值传递的方式传递函数参数,便是对被传递的对象做一个副本,此副本存储于对应的函数参数中。如果以值传递的方式传递异常预期得付出被抛出物的两个副本的构造代价,其中一个构造动作用于“任何异常都会产生的临时对象”身上,另一个构造动作用于“将临时对象复制到catch子句的参数中”
  • 当我们以引用传递的方式传递函数参数并不会发生复制行为。如果以引用传递的方式传递异常预期得付出被抛出物的一个副本的构造代价,这里的副本便是指临时对象。
  • 以指针方式抛出异常实际上相当于以指针方式传递参数,两者都传递指针副本。千万不要抛出一个指向局部对象的指针,因为该局部对象会在异常离开其作用域的时候被销毁,因此catch子句会获得一个指向“已被销毁的对象”的指针。

条款13 以引用传递方式捕捉异常

  • 以指针方式传递存在的问题:1.必须保证在异常对象在控制权离开抛出指针的那个函数之后仍然存在。(这个时候我们可能就会说,到底是实现一个全局对象(或者静态对象),还是实现一个堆对象呢?引出下一个问题)2.我们是否应该通过该指针删除,如果该对象分配在堆上,那么我们就必须删除,否则将会资源泄漏。如果异常对象不是分配在堆上,他们就不必删除,否则将会遭受未定义的程序行为。3.指针传递与语言本身建立起来的惯例有冲突(他们不是通过by pointer的方式接收的)

  • 以值传递的方式的优缺点:可以消除上述指针传递方式中存在的问题,但是当异常对象被抛出的时候,就得复制两次;将会引起切割问题,从而当调用虚函数的时候将会调用基类的虚函数

  • 引用传递的优点:1.没有对象删除问题。2.与标准的异常一致,保留了捕捉标准异常的能力。3.没有切割问题。4.异常对象只被复制一次


条款14 明智运用异常声明

异常规范
  • 异常规格说明的目的是为了让函数使用者知道该函数可能抛出的异常有哪些,可以在函数的后面接throw(类型),列出这个函数可能抛出的所有的异常类型
  • 函数后面接throw(),表示函数不抛出异常
  • 若无异常接口声明,则此函数可以抛出任何类型的异常
  • 抛出异常类型都继承自一个基类
避免unexpected的经验
  • 如果没有写异常声明,那么就表示可能抛出任何类型的异常
  • 如果A函数内调用了B函数,而B函数无异常声明,那么A函数本身也不要设定异常声明(包括回调的形式)
  • 处理“系统”可能抛出的异常,常见的有bad_alloc
如何避免冲突?降低风险(预防unexpected函数调用)
  • C++允许你以不同类型的异常来取代非预期的异常(通过set_unexpected实现)
  • 如果非预期函数的替代者重新抛出当前异常,该异常将会被bad_exception代替
使用异常声明的好处
  1. 让代码更容易被理解,它明确指出一个函数可以抛出什么样的异常,而不是一个漂亮的注释
  2. 编译器能够在编译期间侦测到与异常声明不一致的行为。如果函数抛出了一个未列于其异常声明的异常,这个错误会在运行期被检验出来,于是特殊函数unexpected会被自动调用。可以说异常声明不但是一种文档式的辅助,也是一种实践式的机制,用来规范异常的使用
使用异常声明的坏处
  1. unexpected的默认行为是调用terminate函数,而terminate函数的默认行为是调用abort,所以程序如果违反异常声明,默认的结果就是程序被终止。局部变量连释放的机会都没有,因为abort会使程序终止,没有机会执行此类清理工作
  2. 编译器只会异常声明进行局部性检测,而调用某个可能违反调用本函数异常声明的情况却无法被检测出来
  3. 在模板中使用会有问题(避免将异常声明放在需要类型自变量的模板上)
  4. 很容易被不经意的违反;违反的时候程序会草草终止
  5. 当一个较高层次的调用者已经准备好要处理发生的异常时,unexpected却被调用

条款15 了解异常处理的成本

  • 为了能够在运行时期处理异常,程序必须做大量的薄记工作。(如果某点发生异常,哪些对象需要被析构)
  • 即使从未使用任何异常处理机制,也必须付出一定的时间和空间成本(比较适量)
  • exception是C++的一部分,编译器必须支持他们,即使你从未使用异常处理机制,你也不能期望编译器厂商消除这些成本
  • 如果你决定不适用异常并且让编译器知道,编译器可以完成某种程度上的性能优化。这对于不使用异常的程序库来说是个诱人的机会,前提是必须保证客户端抛出的异常绝不会传入到程序库,这是一个困难的保证,因为它会对“客户端重新定义程序库内的虚函数”带来障碍,也会对“客户端定义回调函数”带来排挤影响
  • 异常的一个成本来自于try块的使用,只要你用上一个,即只要你决定捕捉异常,那么代码膨胀大约5%-10%,执行速度下降这个数(没有异常抛出的情况下)。为了将成本最小化,应避免非必要的try块
  • 异常声明同样会导致编译器产生的代码承受类似于try语句块的成本
  • 恰当的做法是,了解这些成本,但是不要太关注这些数字,不论异常需要多少成本,你都不应该付出比你该付出的部分更多。为了让异常对的相关成本最小化:1.只要能不支持异常,编译器便不支持;2.请将try语句块和异常声明的使用限制到非用不可的地步;3.在真正异常的情况下才抛出异常。如果通过性能分析器,发现异常依旧是性能瓶颈,那么可以考虑换一个编译器了。

条款16 谨记20-80法则

  • 软件的整体性能几乎总是由其构成要素的一小部分决定。
  • 程序的性能特质倾向高度的非直觉性
  • 我们应该通过观察或者实验来找到影响性能的20%的代码,而辨识之道就是借助某个程序分析器

条款17 考虑使用缓式评估

原理

缓式评估就是以某种方式编写你的类,使他们延缓计算,直到那些运算结果刻不容缓地被迫切需要为止。如果其运算结果一直不被需要,运算也就一直不执行,然而如果你的计算是必要的,缓式评估并不会为你的程序节省任何工作或者任何时间,事实上如果你的计算是必要的,缓式评估可能会使程序变得更慢,并增加内存使用量,因为程序出了必须做你原本希望避免的所有工作外,还必须处理那些为了缓式评估而设计的数据结构。只有当你的软件被要求执行某些计算,而那些计算其实可以避免的情况下,缓式评估才有用处

用途
  • 可以避免非必要对象的赋值
  • 可区分operator[]的读取和写动作
  • 可避免非必要的数据库读取动作
  • 可避免非必要的数值计算动作

条款18 分期摊还预期的计算成本

  • 超急评估背后的观念是,如果你预期程序常常会用到某个计算,你可以降低每次计算的平均成本,办法就是设计一份数据结构以便能够极有效率地处理需求
  • 较好的速度往往导致较大的内存成本。随时记录目前的最大值、最小值和平均值总是需要额外的空间,但可以节省时间,缓存会消耗较多内存,但可以降低那些已被缓存的结果的重新生成时间。prefetching需要一些空间来放置预先取出的东西,但可以降低访问他们所需的时间。
  • 当你必须支持某些计算而其结果并不总是需要的时候,缓式评估可以改善程序的效率。当你必须支持某些运算,而其结果几乎总是被需要,或其结果常常被多次需要的时候,超急前评估可以改善程序效率

条款19 了解临时对象的来源

什么是临时对象?

C++中真正的临时对象是不可见的,不会在你的源代码中出现。只要你产生一个栈对象并且没有为它命名,便产生了一个临时对象。

临时对象的来源
  1. 当隐式类型转换发生以求函数调用成功(只有当对象以by value的方式传递,或者当对象被传递给一个常量引用参数时,这些转换才会发生,如果对象被传递给一个非常量引用参数,并不会发生此类转换)
  2. 当函数返回对象的时候(对于大多数的返回值为对象的函数,我们没有办法消除临时对象(operator+可以借助operator+=),对于此类函数我们可以借助返回值优化来消除临时对象)

任何时候只要你看到一个常量引用参数,就极有可能产生一个临时对象并绑定到该参数上。任何时候只要你看到函数返回一个对象,就会产生临时对象


条款20 协助完成返回值优化

  • 当我们考虑返回值的类型的时候,首先应该考虑是否能够返回一个引用,如果为了行为正确而不得不返回一个对象的时候,函数就要返回一个对象。
  • 如果函数一定得以by-value方式返回对象,你绝对无法消除它。从效率的角度看,你不应该在乎函数返回了一个对象,你应该在乎的是哪个对象的成本如何,你需要做的是努力找出某种方法以降低被返回对象的成本,而不是想尽办法消除对象本身,如果这样的对象不需要什么成本,谁在乎产生多少个对象呢?
  • 我们可以用某种特殊写法来撰写函数,使它在返回对象时,能够让编译器消除临时对象的成本。我们的方法是:返回所谓的constructor arguments以取代对象(命名对象和匿名对象都可以由返回值优化被消除,也就是不一定是constructor arguments了),并且可以搭配inline消除函数调用的额外开销

条款21 利用重载技术避免隐式类型转换

  • 使用函数重载来避免隐式类型转换,这样做的好处是可以消除临时对象的产生
  • 每个重载操作符必须获得至少一个用户定制类型的自变量
  • 增加一大堆重载函数不见得是好事,除非当使用重载函数后,程序的整体效率可获得重大改善

条款22 考虑使用以操作符复合形式取代其独身形式

  • 要确保操作符的复合形式和其独身形式之间的自然关系能够存在,我们应该以前者作为后者的实现基础,好处是我们只需要维护复合形式,并且如果这些操作符的复合形式是在类的public接口内,那么就不需要让其独身形式成为该类的友元
  • 如果我们将独身形式放在全局上,那么我们可以使用模板,完全消除独身形式操作符的撰写必要、有了这些模板后,只要程序针对类型T定义有一个复合操作符,对应的独身版本就会在需要的时候被自动创建出来
  • 操作符的复合版本比起对应的独身版本有着更高效率的倾向。身为一位程序库设计者,你应该两者都提供;身为一位应用软件开发者,如果性能是重要因素的话,你应该考虑以复合版本操作符取代其独身版本
使用独身版本和复合版本导致的效率问题
  1. 一般而言,复合操作符比其对应的独身版本效率高,因为独身版本通常必须返回一个新对象,因此我们必须因此负担一个临时对象的构造和析构成本。而复合版本则是直接将结果写入其左端自变量,没有临时对象的产生和销毁
  2. 如果同时提供某个操作符的复合形式和独身形式,便允许你的客户在效率与便利性之间做取舍,独身形式编写简单、容易调试和维护,并在绝大多数情况下提供可接受的性能,而复合形式的效率较高,而且对汇编程序员比较直观,如果同时供应两种选择,你便允许客户以较易理解的操作符独身版本来设计程序并调试,而同时保留了“将独身版本用效率更高的复合版本取代”的权利
  3. 独身操作符的编写应当充分考虑返回值优化问题,能使用匿名对象就不要企图使用具名对象,因为匿名对象比具名对象更容易消除并且可能产生更高的效率

条款23 考虑使用其他程序库

  • 两个程序库提供类似的机能,但是在性能表现上却可能存在不同

  • iostream程序库相较于stdio程序库来说,stdio程序库具有类型安全性并且可扩充,但是在效率方面,iostream通常表现得比stdio差,除此之外stdio的可执行文件通常也比iostream更小

  • 不同的程序库即使提供相似的机能,也往往表现出不同的性能取舍策略,所以一旦你找出程序的瓶颈,你应该思考是否有可能因为改用另一个程序库而移除那些瓶颈。


条款24 了解虚函数、多继承、虚基类、运行时类型检查的成本

虚函数实现机制
  1. 当一个虚函数被调用时,执行的代码对应于调用者的动态类型
  2. 虚函数表通常是一个由指针组成的数组,某些编译器会以链表取代数组,但其基本策略相同。程序库中的任何一个声明或者继承虚函数的类,都有自己的虚函数表,而其中的条目就是该类的各个虚函数实现体的指针
  3. 凡声明有虚函数的类,其对象都含有一个隐藏的类成员(vptr)用来指向该类的虚函数表
  4. 非虚函数(包括必定不是虚函数的构造函数)会像C函数那样被实现。所以他们的使用并没有什么特殊性能考虑
  5. 即便虚析构函数名不同,派生类中同样可以覆盖
  6. 所有的编译器都会忽略虚函数的inline指示的
使用虚函数的成本
  1. 你必须为每个拥有虚函数的类耗费一个虚函数表的空间,其大小视虚函数个数而定,每个类应该只有一个虚函数表,所以虚函数表的总空间通常并不是很大,但是如果你有大量这样的类,或者你再每个类中拥有大量的虚函数,可能会导致虚函数表占用较大的空间
  2. 你必须在每一个拥有虚函数的类对象身上额外付出一个指针(vptr)的代价。如果对象不大,这份额外开销可能形成值得注意的成本。对于一个内存不充裕的系统中,这意味着你能够产生对象的数量减少了,而对于一个内存充裕的系统而言,你的软件性能下降了,因为较大的对象意味着较难塞入一个缓存分页或者虚拟内存分页之中,也就意味着你的换页活动可能会增加
  3. 虚函数的调用过程中无非就是几个指针的偏移操作和解引用操作,因此不会产生什么效率问题,其成本类似于通过函数指针来调用函数,虚函数本身并不构成性能上的瓶颈。实际上虚函数真正的运行期成本发生在和inline互动的情况下,使用虚函数相当于放弃了inline(通过对象进行调用是可以inline的,只有当通过指针或者引用进行调用的时候才无法inline,但大部分虚函数调用动作是通过对象的指针或引用来完成的,此类行为无法内联,由于此等行为是常态,所以虚函数事实上等于无法被内联)
虚函数表在哪里?
  1. 对于整合编译器和连接器的编译器厂商来说,其做法是在每一个需要虚函数表的目标文件内都产生一个虚函数表副本,最后通过链接器剥除重复的副本,使最终可执行文件或者程序库中 只留下虚函数表的单一实体。
  2. 另一种编译器厂商的做法是将虚函数表产生在内含类第一个非内联、非纯虚函数定义式的目标文件中
虚拟继承
  • 使用虚继承的过程中涉及到了指针的使用,用来指向虚基类中的成分以消除复制行为。而对象中可能会出现一个或多个这样的指针
RTTI
  • RTTI让我们在运行时可以获得对象和类的相关信息,这些相关信息被存放在类型为type_info的对象内,我们可以利用typeid操作符取得某个类相应的type_info对象。
  • 一个类只需要一份RTTI信息就好,但是必须有某种方法让其下属的每个对象都能够取用它,C++规范中说道:只有当某种类型拥有至少一个虚函数,才保证我们能够检验该类型对象的动态类型,通常RTTI是通过虚函数表来实现的,这样实现的话,RTTI的空间成本就只需在每一个类虚函数表中增加一个条目,再加上每个类所需的一份type_info对象空间即可。

条款25 将构造函数和非成员函数虚化

将构造函数虚化
  • “虚构造函数”并不是构造函数,而是一个成员函数,只不过它可以视输入的不同,可产生不同类型的对象,“虚构造函数”在许多情况下有用,其中之一就是从磁盘读取对象信息。同理虚拷贝构造函数也不是构造函数,而是一个具有类似于clone名字的成员函数,完成对象拷贝功能的一个成员函数
  • 当派生类重新定义其基类的一个虚函数时,不再需要一定得声明与原本相同的返回类型。如果函数的返回类型是一个指向基类的指针或者引用,那么派生类的函数可以返回一个指向派生类的指针或者引用。这样做并不会导致类型系统出现问题,并且可以产生虚拷贝构造函数
将成员函数虚化
  • 就像是构造函数虚化不是真正的构造函数一样,非成员函数的虚化也并不是真正的虚化非成员函数,而是写一个虚函数做实际工作,再写一个什么都不做的非虚函数,只负责调用虚函数。这样我们就可以让非成员函数的行为视请参数的动态类型的不同而不同

条款26 限制某个类所能产生对象的数量

  • 私有继承基类中的共有成员,那么在派生类中的访问权限就是私有权限,我们可以通过using 声明来提升该成员的访问权限为共有
  • 避免具体类继承其他具体类
  • 一个对象存在的形式具有三种,1.他自己。2.派生类对象中包含的基类对象。3.内嵌于较大对象之中的成员
  • 如果一个类具有私有的构造函数又没有声明任何的友元的话,那么该类对象就不能被当做基类,也不能够被内嵌于其他对象内
阻止某个类产出对象

最简单方法就是将其构造函数声明为私有

允许一个对象产生

单例模式


条款27 要求或禁止将对象产生在堆中

要求将对象产生在堆中
  • 非堆对象会在其定义点自动构造,并在其寿命结束时自动析构,所以只要让那些被隐式调用的构造动作和析构动作不合法就可以了。我们的做法是让其析构函数私有化,但是保持构造函数共有,这样就可以保证将对象产生在堆中,但是由于析构函数成为私有的,因此我们要提供一个假的析构函数来完成对象的析构动作(delete this)
  • 如果如上动作影响到了继承和内含,那么我们可以令析构函数为保护权限(保持构造函数为共有权限),便可以解决继承问题。至于内含问题,我们可以以指针代替对象
判断某个对象是否在堆中
  • 如果你发现自己被对象是否位于堆中这个问题困住,可能是因为你想要知道对它调用delete是否安全,通常这样的删除动作是以声名狼藉的delete this来完成的,然而,此动作是否安全和“指针是否指向一个位于堆内的对象”是两回事。因为并非所有指向堆内的指针都可以被安全删除(只有new返回的地址才可以成功的delete)
  • 对于检测一个对象是否位于堆中这个问题,我们可能会利用大多数系统的特点,即程序的地址空间以线性序列阻止而成,其中栈空间从高地址往低地址成长,堆从低地址往高地址成长。来实现出一个函数来检测是否位于堆中。此方法虽然简单,但是不可移植,无法区分静态对象和堆对象,因此我们并不使用这个方法,而是使用该方法
禁止将对象产生于堆中
  • 因为堆中的对象都是以new operator产生的,所以我们可以令客户无法调用new,虽然我们无法影响new operator的能力,但是我们可以将new operator所调用的operator new私有化来完成禁止将对象产生在堆中的目的(operator new和operator delete应该具有相同的访问权限)
  • 将operator new声明为私有的,同样会影响到其派生类的构造,因为operator new和operator delete都会被继承,所以如果这些函数不在派生类中声明为共有,派生类中的版本便是从基类中继承得来的

条款28 智能指针

  • 智能指针是看起来、用起来和感觉起来都像内建指针,但提供更多机能的一种对象。他们有各式各样的用途,包括资源管理以及自动的重复写码工作
  • auto_ptr被复制或者被赋值的时候,其对象拥有权就会转移
  • assignment操作符在掌握一个新对象的拥有权之前,必须先删除它所拥有的对象,如果没有这么做,该对象就不会被删除(我不能临时保存下吗?但是按照规矩来这样做是没有问题的)
  • 以值传递的方式传递auto_ptr往往是非常糟的主意,因此STL容器中绝对不适合放置auto_ptr,我们应该使用常量引用传递auto_ptr参数
  • auto_ptr的拷贝构造和赋值操作符的非传统声明,他们原本应该需要const参数,但是上面的代码显示并非如此。因为转移所有权
  • 当我们设计智能指针的operator*函数的时候,返回值应该是一个引用,如果返回一个对象,虽然编译器允许我们这样做,产生的结果可能是我们意想不到的。因为原生指针并不一定非要指向基类对象,还可以指向一个派生类对象,如果我们返回一个对象,那么就会产生切割问题。并且调用虚函数也之能是基类的虚函数而不可能是派生类的虚函数
  • 对于operator->函数的返回值来说,在该返回值上施行->操作符都必须是合法的,因此operator->只能返回原生指针或者一个智能指针(展开一看就明白了)
  • 测试智能指针是否为空,我们可以提供一个隐式类型转换操作符,这个操作符的传统目标是void*(隐式类型转换,往往可能带来很大的副作用),并且提供一个operator!当调用者是null的情况下返回true,而operator bool总是返回operator!的反相
  • 我们不应该提供对原生指针的隐式转换操作符,他们使得客户可以轻而易举地直接对原生指针进行操作,因而回避了之智能指针当初的设计初衷。如果实现了引用计数,往往会导致数据的败坏,并且因为其并不能完全替代原生指针(编译器不允许两次用户定义的隐式类型转换的发生)
  • 当一个智能指针涉及到与继承有关的类型转换时,我们可以利用编译器提供的将非虚函数声明为模板的方法来产生智能指针的转换函数。如果对应的原生指针可以转换成功,使用该项技术就可以成功。这个技术存在的问题是,C++对任何转换函数的调用动作,认为是一样好,即转换为爷爷,和转换为爸爸是一样好的,而原生指针则会认为转换为爸爸好一些,当出现这样模棱两可的行为时,我们需要使用强制类型转换避免
  • 当我们希望实现智能指针和常量智能指针的转换时我们可以让智能指针继承常量智能指针,并在常量智能指针类中定义一个联合体来表示一个常量指针或者一个非常量指针

条款31 让函数根据一个以上的对象类型来决定如何虚化

  • 基类应该总是抽象的。
    方案一:虚函数+RTTI
void SpaceShip::collide(GameObject & otherObject)
{
	const type_info& objectType = typeid(otherObject);
	if (objectType == typeid(SpaceShip))
	{

	}
	else if (objectType == typeid(SpaceStation))
	{

	}
	else
	{
		throw exception;
	}
}

缺点:

  1. 虽然很容易写出,也可以有效运作,看起来无害,但是最后的else抛出了一个异常(因为面对未知东西不能不知道如何去处理它了)
  2. 破坏了封装性,使得每一个函数内都必须知道其所在类的兄弟类,如果有个新型对象加入游戏行列,我们必须为所有的collide函数增加一个if else判断,如果我们忘记,程序就会出错,并且错误不明显,编译器也无法帮助我们侦测这类疏失
  3. 这样的程序难以维护,很难进行扩充
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值