Effective C++ 读书笔记04
前言
本文是阅读《Effective C++ 改善程序与设计的55个具体做法(第三版)》的心得笔记第四部分,文章也会按照原书的顺序依次记录各个条款。
第一部分的阅读笔记参见effective C++ 读书笔记01。
第二部分的阅读笔记参见effective C++ 读书笔记02。
第三部分的阅读笔记参见effective C++ 读书笔记03。
实现
条款26:尽可能延后变量定义式的出现时间
尽可能延后变量定义式的出现时间,理由如下:
-
可以避免不必要的构造和析构成本:比如在使用某对象前,因某些原因函数返回或抛出异常,若已定义了该对象,便仍需要承担构造和析构成本,即便你并未真正的使用它;
-
延后变量定义式的真正意义并不只是延后变量定义式的位置,甚至应当延后定义直到你能为其提供初值实参为止。这样可以避免无意义的default构造成本,而且用具有明显意义的初值来初始化变量,还可以附带说明变量的目的;
std::string encryptPassword(const std::string& password){ |
- 对于循环而言,变量定义在循环内还是循环外?
// 定义于循环外 成本:1次构造 + 1次析构 + n次赋值 |
如何选择取决于赋值成本和构造+析构成本的大小关系,且定义于循环外会导致变量的作用域更大,会一定程度破坏程序的可理解性和易维护性。所以除非你明确地知道赋值成本要低于构造+析构成本,且你对这段循环代码的效率非常在意,那么更应该选择的是定义于循环内。
条款27:尽量少做转型动作
转型(casts)会破坏C++的类型系统(type system)。
C风格的转型动作(旧式转型):
(new_type)expression |
C++风格的转型动作(新式转型):
const_cast<new_type> (expression) |
const_cast
:通常用来将对象的常量性转除,除了const
修饰符外,new_type
和expression
的类型一致。它是唯一有此功能的C++风格的转型操作符;dynamic_cast
:主要用来执行安全向下转型(safe downcasting),即将基类的指针或引用安全地转换成派生类的指针或引用,从而能够用派生类的指针或引用调用非虚函数。这是唯一无法用旧式语法执行的动作,也是唯一可能耗费巨大执行成本的转型动作。当指针是智能指针时候,向下转型需要使用dynamic_pointer_cast
;reinterpret_cast
:用来处理无关类型之间的低级转型,即新类型的值与原始参数expression
有完全相同的比特位,只是被强行解释为新类型。错误的使用reinterpret_cast
很容易造成程序的不安全,除非你明确的知道你在做什么;static_cast
:用来强制隐形转换(implicit conversions),即显示转型。编译时检查安全性,但没有运行时类型检查来保证转换的安全性。主要有如下几种用法:- 用于类层次结构中基类和派生类之间指针或引用的转型:
- 进行向上转型(把派生类指针或引用转换成基类表示)是安全的;
- 进行向下转型(把基类指针或引用转换成派生类表示)时,由于没有动态类型检查,所以是不安全的。
- 用于基本数据类型之间的转换,如把int转换成char,把int转换成enum等。这种转换的安全性也要开发人员来保证;
- 把void指针转换成目标类型的指针;
- 把任何类型的表达式转换成void类型;
- 可以将non-const对象转换成const对象,反之则不行(只能通过
const_cast
);
- 用于类层次结构中基类和派生类之间指针或引用的转型:
旧式转型依旧合法,但新式转型更受欢迎。理由如下:首先,新式转型更容易辨识(无论是人工还是通过工具检索),从而更容易在DEBUG时找出类型系统被破坏的位置;其次,转型动作的分工更加细化,一方面是提醒开发人员是否真的选对了转型动作,另一方面也让编译器更容易发现错误。
一些错误的转型尝试
尝试1
单一对象可能拥有一个以上的地址,即指向它的基类指针和派生类指针可能并不相同,这会随着编译器的不同而不同。所以一些尝试对对象地址进行转型的骚操作,几乎总是会导致未定义行为。
尝试2
转型操作会返回一个原参数的新类型的副本,所以如下操作会出问题:
class Window { |
上述代码的本意是希望派生类中的虚函数先调用基类的虚函数实现(通用操作),然后再执行一些派生类专属的操作。但是转型动作构建了一个*this
对象的基类类型副本,并由这个副本调用onResize
函数,那么通用操作所修改的属性是这个副本的,而不是*this
对象的。正确的写法如下:
class SpecialWindow : public Window { |
尝试3
过度的使用dynamic_cast
会导致程序效率低下。如前所述,一般我们使用dynamic_cast
的目的是为了将基类的指针或引用安全地转换成派生类的指针或引用,从而能够用派生类的指针或引用调用非虚函数。那么想要规避使用dynamic_cast
无非有下面两个思路:
- 修改代码,窄化类型,直接持有派生类的指针即可;
- 将想要调用的非虚函数实现为虚函数,基类中缺省实现(条款34有进一步讨论),派生类中有具体实现,用多态的方式去解决;
总结
- 尽量避免使用转型,尤其是在注重效率的代码中避免使用
dynamic_cast
; - 当转型不可避免时,可以将其封装在函数中供用户使用;
- 优先使用新式转型,而不是旧式转型。
条款28:避免返回 handles 指向对象内部成分
所谓的handles(号码牌)包括指针、引用和迭代器。
应当避免返回指向对象内部成分的handles,原因有2:
-
- 这样做会破坏封装性
// |
上述例子虽然能通过编译,但实际上是与设计初衷相违背的。getUpperLeft
和getLowerRight
被声明为const成员函数,即表明了它们会给用户返回相关坐标点,但并不允许用户修改它。但是这里返回的是内部private变量的引用,这一方面相当于把priavte变量的封装性降低到public,更糟糕的是用户就可以通过返回的引用间接的更改rect
对象的内部数据,即便rect
对象被声明为const。
解决方法也很简单,只要对给返回类型加上const即可。虽然仍然降低了封装性,但是是有限度的降低,即只赋予了用户读权限,而没有放开写权限。
class Rectangle { |
-
- 这样做可能会导致空悬的handles
虽然使用const能解决封装性的问题,但无法解决handles可能比其所指的内部对象更长寿的问题。即可能存在内部对象已析构,但handles还留存的问题。尤其是临时变量的析构,不太容易察觉。考虑如下情况:
class GUIObject { ... }; |
上述代码中,boundingBox
函数会返回一个临时的Rectangle
对象,然后这个临时对象调用getUpperLeft
返回一个引用(handle)指向对象内部成分,更具体的说,是表征矩形右上角点坐标的Point
成员对象的引用。然后用一个指针指向了该引用。但是随着该语句的结束,临时的Rectangle
对象将会析构,相应的,其中的Point
成员对象也会被析构,最终导致pUL
指针指向一个不复存在的对象,形成空悬/虚吊(dangling)!
所以,应当尽量避免返回 handles 指向对象内部成分,但并不意味着绝对禁止,有时候你不得不这样做。比如std::string
和std::vector
的operator[]
操作符就允许返回容器内元素的引用。但不可避免的,这些元素也会随容器的销毁而销毁,所以也要谨慎。
条款29:为“异常安全”而努力是值得的
两个条件
异常安全(Exception Safety)是指,在异常被抛出时,满足以下两个条件:
-
不泄露任何资源。这里资源包括动态内存、互斥锁等;
-
不允许数据败坏。不会因为异常抛出而导致出现空悬指针等未定义行为。
如下示例则不满足上述两个条件:
class PrettyMenu { |
在上述例子中,若new Image
时抛出异常,则unlock(&mutex)
无法被调用到,互斥锁资源泄漏;bgImage
已经被销毁却没有指向新的背景图像,出现了空悬指针。
三个等级
异常安全函数会提供如下三种等级的保证之一:
-
基本承诺:如果异常被抛出,程序内的任何事务仍然保持在有效状态下,没有资源泄漏,也没有数据败坏。在上例中,可以修改
changeBackground
函数,使PrettyMenu
在异常被抛出时,继续拥有原背景图像,或者拥有一个缺省背景图像,让程序能够继续有效运行(只要合法就行,不能强求如何实现,此所谓基本); -
强烈保证:如果异常被抛出,程序状态不改变。即在此异常安全等级保证下的函数要么完全成功执行,要么回退到函数执行之前的状态;
-
不抛掷保证:函数承诺绝不抛出异常,总是能够完成原来承诺的功能。如所有针对内置类型的操作都能够提供nothrow保证。
-
在原书中,提到了
throw()
,用法如函数声明int dosomething() throw();
并不是指不会抛出异常,而是指如果抛出异常,则将是严重错误,会调用unexpected函数进而abort; -
throw()
在不同编译器上的表现不一致,现在已经不推荐使用。详细讨论可参考[Should I use an exception specifier in C++?]和A Pragmatic Look at Exception Specifications; -
在C++11中,有了更可靠有效的关键字
noexcept
,具体用法参见noexcept 说明符和noexcept 运算符。
-
如果可能的话,我们应当尽可能的提供最强等级的nothrow保证。但很多时候这很难实现,对于大多数函数而言,往往需要在基本承诺和强烈保证之间进行选择。之前的例子提供强烈保证的实现版本如下:
class PrettyMenu { |
copy and swap 策略
copy and swap 策略的原则是:为你打算修改的对象(原件)拷贝一份副本,然后在副本上做一切必要的修改。若有任何修改动作抛出异常,原对象仍保持未改变状态。待所有修改操作都成功后,再将修改过的那个副本和原对象在一个不抛出异常的swap操作中进行置换。
基于上述示例的改写如下:
struct PMImpl { // Pretty Menu Impl |
在上述示例中,用到了pimpl手法,即将隶属于对象的数据从原对象放进另一个对象中,然后赋予原对象一个指针,指向那个所谓的实现对象(implementation object)。这里让PMImpl
成为一个struct而不是一个class,是因为在其封装性因pImpl
是private成员而得到保证的前提下,用struct实现会更方便。
异常安全的连带影响(side effects)
函数提供的“异常安全保证等级”只取决于其调用的各个子函数的“最不安全者”。
考虑如下使用了 copy and swap 策略的示例:
void someFunc { |
虽然 copy and swap 策略在尽力强烈保证异常安全,但如果f1
或f2
函数的异常安全等级比强烈保证低,则someFunc
就很难成为强烈异常安全。即便f1
和f2
都是强烈异常安全的,但f1
成功做了修改,f2
随后抛出异常而回退状态(f1
成功执行后的状态),却也无法恢复someFunc
函数被调用前的状态,故也称不上强烈异常安全。
除此之外,copy and swap 策略会为每一个即将被改动的对象创建副本,这可能会对内存和效率产生不小的挑战。
综上所述,当你在撰写代码时,应当思考如何让代码具有异常安全性:首先,以对象管理资源,可以阻止内存泄漏;其次,在三个异常安全等级中选择一个合适的实施于你写的每一个函数上,且应当选择实际可实施的最强烈等级。
但总是想要提供强烈保证的异常安全并非易事,如果你能证明实现强烈保证不切实际时,可以退而求其次,选择基本保证。若你调用了不具备异常安全性的旧代码,你才别无选择地不提供任何异常安全性的保证。
条款30:透彻了解 inlining 的里里外外
inline函数,即内联函数会在函数调用处展开代码,直接将函数体插入函数调用处,从而省去了函数调用的开销。但相应地,代价就是会导致函数无法被复用,从而导致代码膨胀,(过度的inlining)进而可能会导致额外的换页行为(paging),降低指令cache的命中率,反而降低了代码效率。
关于inline,还有如下几点需要注意:
inline只是对编译器的申请
inline只是对编译器的申请或建议,并不是强制命令,编译器有权利对其认为不适合inline的函数拒绝inline。此外,原书还提到如果编译器拒绝,通常它会给出warning信息,但在VS2019中实测并未看到。
inline的申请方式有两种:
- 显示inline:在函数定义时使用
inline
关键字; - 隐式inline:在(头文件中)类内实现的成员函数或者friend函数;
inline和template并无必然联系
inline函数和template函数通常都被定义于头文件中(这是因为inline的函数体替换操作和template的具现化操作通常都是在编译期执行的),而且不少简短的template函数都是带有inline
关键字(如下例),但这并不代表两者有着必然的因果关系。
template<typename T> |
不适合(编译器拒绝)进行inline的函数类型
- 复杂函数:如函数体内有循环或者递归;
- 虚函数:inline是编译器决定的,而虚函数需要等到运行期才能确定;
- 构造/析构函数:即便构造函数体为空,但编译器在编译时会生成调用基类或者成员对象的构造函数的代码,同时为其生成精致复杂的异常处理代码;若基类或者派生类成员对象的构造/析构函数为inline,其在派生类中的代码展开也会导致不合理的代码膨胀;
- 通过函数指针调用的函数:函数指针不可能指向一个并不存在的函数,因此通过函数指针调用的函数仍会生成一个outlined函数本体,示例如下:
inline void f() {...} // 假设编译器不拒绝 inline “对f的调用” |
过度inline会为调试和发布带来困难
inline是代码嵌入与展开,而非函数调用,所以某些编译器不支持inline的单步Debug(就像宏展开一样不支持调试),某些支持inline函数调试的编译环境也只是在Debug中禁止发生inlining来实现的。
此外,若inline函数发生修改,则(客户端)调用它的代码全都要重新编译,而non-inline函数则只需要重新链接即可。如果采用动态库的方式进行发布,non-inline函数的升级甚至都可以不被察觉。
条款31:将文件间的编译依存关系降到最低
C++并没有把将接口从实现中分离这件事做的很好。即(头文件中)类定义式中不仅包含了接口,而且可以有实现。如下示例:
|
在上例中,Person
类的定义文件(大概率也是头文件)通过#include
与date.h
和address.h
形成了编译依存关系(compilation dependency),如果这些头文件中有任何变化,或者这些头文件所依赖(include)的其他头文件有任何变化,则任何使用了Person
类的文件都得重新编译。
当然,该定义文件与标准库<string>
也存在编译依存关系,但标准头文件一般不会改动,且由于预编译头的加持,其编译不会成为瓶颈,故这里我们仅讨论自己实现定义的头文件。
Handle Class(pimpl 手法)
使用pimpl(pointer to implementation)手法可以实现接口和实现的分离:
// person.h |
在上例中,Person
类的使用者(#include "person.h"
)完全不用关心Person
类接口及其实现类PersonImpl
的实现细节(PersonImpl
与Person
有着完全相同的成员函数,两者接口完全相同),也不需要关心其他关联类Date
和Address
的实现细节。这些类的实现的任何修改也不需要使用者代码的重新编译,真正实现了接口与实现分离!像Person
这样使用pimpl手法的类通常称为Handle class。
这里做到分离的关键点在于用声明的依存性替代定义的依存性。这正是编译依存性最小化的本质:让头文件尽可能自我满足,如果做不到,则也要依赖于其他文件的声明式而非定义式。
具体到设计策略,有如下几点:
- 如果使用 object references 或 object pointers 可以完成任务,就不要使用objects。如果定义某类型的objects,就需要使用该类型的定义式;而定义该类型的指针或引用,则只需要类型声明式。
- 尽量以 class 声明式替换 class 定义式。函数声明中的 class 类型可以只用声明式,而并不需要类的定义,即使函数是以by-value的形式传递参数(虽然这样不好,参见条款20),亦是如此。
- 为声明式和定义式提供不同的头文件。比如上面提到的
Date
类,可以分为提供声明式的datefwd.h
和提供定义式的date.h
,那么就可以用声明式头文件代替前置声明,在真正调用函数的源文件中include定义式头文件。这种方式在STL库中使用较多,具体可参考<iosfwd>
与sstream
,fstream
,iostream
等。
Interface class
另一个实现Handle class的方法就是构建一个abstract base class(抽象基类),称为Interface class。
这种类的目的是一一描述派生类的接口,因此它通常不会包含成员变量,也没有构造函数, 只有一个virtual析构函数以及一组pure virtual函数,用来描述整个接口。Person
类的例子如下:
// person.h |
在上述例子中,create
函数是一个工厂函数,用于返回基类指针,指向动态分配的派生类对象。该工厂函数可以基于额外的参数值等条件,创建不同派生类的对象。