上周看完了这本大名鼎鼎的《Effective C++》,属实学到了很多技巧,本文是我阅读途中做的记录。尽管这本书出版于十多年前,且并没有对应C++11进行改进,但是其中介绍的很多技巧至今仍然适用,希望每个目标是用好C++的人都好好看一看这本书。
这一节先记录原书条款1-25的部分, 全文7.0k字, 余下部分下一篇文章放出。不熟悉C++的话阅读本文可能有些困难。本文同步存于我的Github仓库, 点击底部"阅读原文"可跳转(https://github.com/ZFhuang/Study-Notes/blob/main/Content/%E3%80%8AEffective%20C++%E3%80%8B%E7%AC%94%E8%AE%B0/README.md)
0 导读
size_t是一个typedef, 一般指无符号整型unsigned- 除非有一个好理由令构造函数用于隐式类型转换, 否则声明为explict
- C++有大量未定义(undefined)的行为, 一定要小心. 这些行为结果并非报错, 而是与编译器和执行环境相关的无法估计的结果
- "接口"通常指函数的签名
1 让自己习惯C++
1 视C++为一个语言联邦
- 将C++看作是以多个相关语言组成的结合体而不是一个关系紧密的单一语言:
- C语言: C++的基础结构
- 面向对象部分: C++创造时的目标
- 模板C++: 为了支持泛型编程而创建的语法, 并实现了模板元编程(TMP)这种奇技淫巧
- 标准模板库: 充分运用了模板C++编写的程序库, 每个开发者都应该学习STL
- C++各个部分功能上有很大的重叠, 使用的时候要视乎自己使用目标来选用合适的分部
2 尽量以const, enum, inline 替换 #define
#define并不被视作语言的一部分, 因为它属于预处理器, 是运行在编译阶段之前的- 现代C++不建议使用预处理器, 因为它无法进行复杂的计算, 只能机械地对代码进行预处理, 且在处理后就与编译器断开了联系, 无法debug
- 尽量别用
#define, 因为你这里define的变量/函数仅仅是机械地进行了替换, 不会进入编译器的记号表因此编译器无法看到define的变量名, 会出现很多难以追踪的常量 - 需要define常数时, 改用const变量可以解决绝大多数问题
- 对于需要在编译期使用的常量, 可以用如下的enum hack来实现. 这样声明出来的元素可以在编译期使用, 且不会消耗额外的内存. 由于是编译期技术, 这个技巧在TMP中也很常用
- 宏函数由于避免了函数调用开销因此可以带来很高的执行效率, 但是要记得宏函数中每个变量最好都加上小括号, 保证生成出的代码不会那么容易遇到优先级问题
- 对于宏函数我们还应该用inline模板函数来代替, 通过模板操作我们可以让函数接收任何类型的值, 且获得各种提前检测和访问控制. 通过inline的特性模板函数能够达到和宏一样的效率
3 尽可能使用const
- 只要某个对象事实上是不变的, 那么就应该标记const, 这样能获得更大的使用范围
- const的特性: 默认作用于左边的对象, 如果左边没有对象, 那么作用于右边. 因此
const T * const Foo() const;第一个const由于左边没有对象, 作用于右边的T上, 表示函数返回的指针指向的值不能改变. 第二个const左边有对象, 是指针符, 因此表示这个指针本身的值(地址)也是不能改变的. 第三个const左边是函数名, 表示这个函数也是const, 不会改变其内部对象的值. - const迭代器: 直接给STL迭代器标记const代表这个迭代器自身地址不能改变, 也就是不能++iter之类的, 但指向的对象依然可以改变. 如果要封锁指向的对象应该使用const_iterator.
- 除非需要改变函数的参数, 否则保持一个好习惯就是让函数参数都是const的, 这样能获得更大的使用范围.
- 改善C++效率的一大方法就是以
const引用方式传递参数 - C++的const函数不可以更改对象内的非static变量, 但是依然可以从指针修改内容, 要小心
- 有些时候我们希望写一个const函数但是令其在某些特殊时候能修改某些变量, 那么我们可以将那些变量声明为mutable使其脱离const的掌控
- 一个函数如果只有常量性不同, 依然可以被重载, 那么为了我们代码的清洁高效, 最好先实现其const版本, 然后对其用
常量性转除包装出非const版本, 要注意这里的写法比较特别(傻): 由于我们需要明确调用const版本的自己的重载函数, 因此需要先将自己用static_cast转型为const, 从而令编译器调用const版本的函数, 随后再用const_cast去掉返回值上的const标记.
- 除了这种特殊情况外, 尽量不要使用
const_cast, 去除const标记可能造成危险的后果
4 确认对象被使用前已先被初始化
- 对于内置类型, 一定要在使用前对其初始化, 最好放在很近的地方才不会忘记
- 对于自定类型, 则应该在构造函数完善地初始化
- 对于类的成员, 尽可能不要在构造函数内再初始化自己的元素, 因为在进入构造函数之前自定类型就会被调用默认初始化了, 构造函数内进行的实际上是拷贝构造, 但又要注意内置类型并不会调用默认初始化, 所以内置类型应该用类内初始化优化.
- 如果希望自定成员不要默认初始化, 那么应该在初值列中进行初始化, 这会覆盖掉编译器的设置, 即便是默认初始化我们也可以不传递参数从而显式进行默认初始化
- 因此为了防止遗漏, 我们可以对每个类成员都在初值列中进行初始化.
- 使用初值列初始化的时候还要注意成员依赖的问题, 时刻记得: 成员在初值列中初始化的顺序至于其在类内的声明顺序相同, 为了防止混乱我们应该也按照声明顺序在初值列进行排列
- 以上可能导致初值列本身产生重复, 那么对于那些赋值和直接初始化效果一样好(例如那些需要借助数据库进行真正的初始化的成员)则应该移进一个统一的特殊函数中(Setup函数), 然后在构造函数中调用这个函数
- 对于static成员又要注意, C++对于全局的static对象的初始化并没有顺序约束, 因此类初始化的时候有可能static成员还未初始化, 为了解决这个问题, 更好的方式是将static成员写在专属的函数内(单例模式), 等到调用那个函数的时候再进行懒初始化.
- 但是在多线程环境中又有问题, 所有static成员之间可能会产生竞速关系. 为了解决这个问题, 最好在程序最开始还是单线程启动的时候在一个函数中有顺序地集中初始化所需的所有static成员, 然后再启动多线程
2 构造/析构/赋值运算
5 了解C++默默编写并调用哪些函数
- 编译器会在类的构造函数被调用但是我们没有自己声明时自动创建, 所有自动创建的函数都是public, non-virtual且inline的, 只会简单地将非static成员拷贝到目标对象中.
- 只有当编译器能够确认自己可以合法进行操作时编译器才会自动生成默认函数, 否则编译器拒绝生成相关的函数. 例如包含了引用成员的类和包含const成员的类都不会生成默认的拷贝构造函数
6 若不想使用编译器自动生成的函数, 就该明确拒绝
- 拒绝编译器自动生成函数的传统方法是自己声明一个private的对应函数然后固定不去实现它.
- C++11后加入了
=delete操作, 让我们可以明确删除某些函数的生成
7 为多态基类声明virtual析构函数
- 对所有多态基类来说, 声明虚析构函数非常重要, 这是因为派生类如果被基类指针指着, 然后被delete, 此时基类没有虚析构函数, 那么此时这个对象实际上会调用基类的析构函数(因为没有多态), 于是整个内存的回收是不完全的, 会导致一个局部销毁的对象从而引发内存泄漏
- 最好的解决方法就是对于任何一个将要用来继承的类都实现虚析构函数
- 判断的方法: 如果一个类没有虚函数, 那就代表它本身不愿意被用作一个基类. STL的所有容器都没有虚函数
- C++11后引入了final关键字可以用来中断类后续的继承行为
- 当程序在析构的时候, 会从最深处开始析构, 逐步调用析构函数, 因此基类的虚析构需要一个定义, 可以是空定义
8 别让异常逃离析构函数
- 由于在C++中两个异常同时存在会导致未定义行为, 因此我们不应该让析构函数上报异常, 这是因为析构函数是会被自动调用的, 当一个对象析构而抛出异常时, 同个作用域的其它对象的析构也会被自动执行, 此时有可能继续抛出异常导致多异常未定义
- 因此我们应该将所有析构函数用try-catch包裹起来, 可以选择吞掉异常然后继续执行, 也可选择记录后结束程序
- 更合理的方法是额外写一个close函数, 用户可以主动调用close函数来执行和析构同样的行为, 这样用户就能自己处理可能发生的析构的异常, 同时依然要给真正的析构用try-catch包裹起到双保险的作用
9 绝不在构造和析构过程中调用virtual函数
- 派生类构造期间, 是先构造基类, 然后逐级向上构造的, 底层构造完成前, 派生类的相关变量都是未定义的.
- 因此如果在构造函数中调用了虚函数, 那么会根据当前正在构造的类型来调用相应的虚函数版本, 而非派生类的版本. 析构函数尽管调用顺序相反但是思路一致
- 所以不要在析构/构造过程中调用虚函数
- 补偿方法是将需要让派生类执行的函数以非虚函数的形式写好, 然后将其所需的变量通过构造函数在构造初值列中进行传递. 传递变量时如果需要进行计算的话最好使用static函数, 这样能够保证不会使用到派生类中还未初始化的变量
10 令operator=返回一个reference to *this
- 为了实现连锁赋值的惯例操作, 最好令重载的赋值运算符以引用的形式返回*this
- 这只是个协议, 但最好遵守
11 在operator=中处理"自我赋值"
- 当对象中存在指针申请的空间时, 在赋值运算符中我们一般都会释放旧空间, 然后创建一份和待复制内存相同的内存, 再指向新内存. 但是这里的问题是如果目标和当前对象是相同的(也就是别名), 则会提前将目标内存释放.
- 处理自我赋值问题有几个方式:
- 证同测试: 在拷贝构造函数的最前面用if判断目标对象与当前对象地址是否相同, 相同则直接返回*this. 这种方法的缺点是如果new的时候发生异常, 此时当前对象的指针已经被释放, 那么这个对象就会留下一个错误的指针
- 备份指针: 在一开始对指针进行备份, 然后new一个复制的内存, 当没有异常发生时才去释放原先的内存, 否则这里指针不会被修改. 这个方法顺便解决了证同问题
- 证同+备份: 如果对效率要求很高的话可以在备份指针的方法最前面再引入证同测试, 但是要衡量引入额外的if语句是否值得
- 复制构造并交换: 用赋值构造的方法构造一个当前类型的副本, 然后将自己的数据与副本交换. 如果拷贝构造是以值形式传入参数的话, 还可以直接将当前对象数据与传值进入的对象进行交换
12 复制对象时勿忘其每一个成分
- 当你选择实现自己的拷贝构造函数时, 一定要手动复制所有的局部变量, 同时调用所有基类合适的拷贝函数
- 如果有一些成员变量没有在初值列中进行拷贝, 那么此时对象将会进入局部拷贝状态
- 如果只拷贝了成员变量而没有调用基类的拷贝函数, 那么此时基类部分将会被默认初始化, 也属于一种局部拷贝
- 很多时候基类的拷贝函数与派生类的拷贝函数会有很多代码的重叠部分, 但是千万不能用拷贝函数调用拷贝函数, 这相当于试图构造一个已经存在的对象, 很容易导致对象被破坏. 正确的做法是额外写一个init函数包含通用代码, 然后让两个拷贝函数都调用它来初始化
3 资源管理
13 以对象管理资源
- 资源获取时机便是初始化管理对象的时机(Resource Acquisition Is Initialization; RAII)
- 在堆上分配内存后就要记得在控制流离开的时候回收它. 但是手动回收很不方便, 很容易忘记释放, 例如一个提早的return, 或者跳出的异常
- 最好的方法是通过在栈上分配的智能指针来管理对象, 因为智能指针是栈上分配的模板类, 所以会在控制流离开的时候自动调用析构里的delete
- 养成习惯: 获得堆资源后立刻放入管理对象中(构造与new写于同一语句), 这样最大程度确保自己不会遗漏释放
- 智能指针的思路类似其它语言的垃圾回收机制, 区别是垃圾回收器是在后台运行的另一套系统, 而智能指针是语言范围内的一种结构而已
- 智能指针在内存上与原始指针几乎相同(顶多多一个引用计数器), 速度上也相差无几
- 三种标准库智能指针, 都在memory里:
unique_ptr: 独占指针, 相当于以前的auto_ptr, 只能移动而不可复制(复制操作默认是移动语义的), 没有其它成员变量, 可以用make_unique来初始化数组shared_ptr: 有引用计数的指针, 因此可以复制weak_ptr: 不参与引用计数, 与shared_ptr一起使用
14 在资源类中小心Copying行为
- 这一条是当你不得不自己建立资源管理类的时候要注意的
- 如果对RAII进行复制是不合理的, 那么就应该禁止它, 参考
unique_ptr - 对于需要保留资源在多个对象手上的情况, 参考
shared_ptr设置底层的引用计数器, 额外写一个删除器(deleter)在引用计数为0时调用 - 复制资源管理对象的时候要确保是深拷贝的, 同时需要维护好各自的引用计数和析构
- copy函数会被编译器自动创建, 记得注意
15 在资源管理器中提供对原始资源的访问
- 很多函数API要求访问原始指针, 因此自己写的资源管理器一定要写好访问原始指针的接口方法, 同时在日常中通过重载调用运算来模拟指针方便开发
- 隐式类型转换可以写, 比较自然, 但是有时有危险
- 更常见的情景是写一个显式的get函数来获取原始指针
- 牢记RAII类不是为了封装数据存在的, 而是为了给原始指针加上资源回收的功能, 所以不必给RAII类加太多功能
16 成对使用的new和delete时要采取相同的形式
- 由于下图的内存排列特性, 不要混淆不同的new和delete, 否则容易引发未定义行为. delete[]会对内存最前面进行大小读取, 从而确认析构的次数并进行多次析构
- 因此new和delete一定要成对使用
- 尽量不要对数组形式使用typedef, 容易让自己忘掉delete[]
17 以独立语句将newed对象置入智能指针
- 写出上面那种在一个语句(尤其是函数参数列)中执行new, 构造智能指针, 和其它操作的代码很危险. 因为编译器会自由安排函数不同参数的求值顺序, 有可能顺序变为new->调用函数->构造智能指针. 一旦这里调用函数的时候跳出异常, 那么new的返回值无法交给构造函数, 就无法delete从而产生很奇怪的内存泄露
- 由于编译器对跨语句调用顺序没有排列的自由, 因此一定要记得在独立语句中将new置入智能指针, 然后再进行别的
4 设计与声明
18 让接口容易被正确使用, 不易被误用
- 理想上通过了定义的代码都应该能得到正确的结果, 出现问题应该属于我们的接口没有足够方便客户使用的原因
- 为了限制客户输入的元素类型, 可以额外建立一些专门用于输入处理的外覆类型, 然后限制客户对于那些类型可以进行的操作
- 设计接口和行为的时候应该最大程度与内置类型一致
- 接口应该尽力提供一致的接口, 一致性比其它的很多属性都要重要
- 不要假设你的用户能记得一些使用规范
- 需要返回指针时, 不如返回智能指针, 这样能避免内存回收的难题, 并通过自定义删除器来解决跨DLL销毁问题
19 设计class犹如设计type
- 应该像"语言设计者设计基本类型时"一样审慎地研究class的设计
- 有以下几点一定要在创立前进行思考:
- 如何创建和销毁
- 初始化和赋值的区别
- 如果被按值传递的话会有什么区别
- 有哪些是"合法值"
- 需要继承/被继承么
- 需要哪些类型转换
- 需要哪些操作符
- 哪些编译器函数需要拒绝
- 成员的访问性
- 哪些接口需要实现
- 设计有多么一般化
- 真的需要这个type么
20 宁以pass-by-reference-to-const替换pass-by-value
- 缺省情况下C++默认传值方式将对象传到函数, 也就是由拷贝构造函数生成
- 因此如果只是简单地传入对象会浪费很多的构造/析构操作, 最好的做法是传const引用
- 传const是为了让调用的人放心传入, 同时传入引用还能避免对象切割问题(派生类传入声明为基类的参数时派生类会退化为基类), 起到多态的效果
- 传引用的底层实现是指针, 因此对于内置类型和STL迭代器与STL函数对象, 传值的效率会高于传引用, 这是底层决定的.
- 其它情况尽量都使用传引用, 因为大多数时候copy的代价都大于指针, 且难以被编译器优化
21 必须返回对象时, 别妄想返回其reference
- 返回对象引用时, 一定要记住引用只是别名, 底层是指针, 所以返回局部对象的引用的行为绝对是致命的
- 看到static变量的时候要注意多线程中可能遇到的问题和求值顺序可能带来的问题
- 当用拷贝来返回局部变量是最简单最安全的实现方法时, 那就直接用, 让编译器去处理效率问题
22 将成员变量声明为private
- 将成员变量声明为private然后用public接口来控制可以为系统带来约束, 并保留日后变更具体实现的空间, 降低维护代价
- 使用protected在实际项目中同样会影响大量调用了这个protected的衍生类, 因此封装效果本质上和public接近
23 宁以no-member, non-friend替换member函数
- 有点反直觉的条目, 编写非成员也非友元的工具函数来操作一个类比编写这个类的成员函数能带来更大的封装性. 这是因为工具函数只能访问目标的公有部分, 因此与目标只有很低的依赖关系, 而成员函数可以访问目标的所有内容, 反而获得了最大的依赖
- 因此当这个工具函数可以完全依赖目标类的公有方法来实现时, 那么这个函数就不需要是一个成员函数
- 而为了整合这样的工具函数, 最好将它们放到同个"工具"命名空间中(标准库std就是这样的设计思路), 这样用户也方便扩展出自己的工具函数放到命名空间中整理, 同时我们也可也将不同的工具写在不同的头文件中只要它们处于同一个命名空间即可
24 若所有参数皆需类型转换, 请为此采用non-member函数
- 只有当参数被列于参数列中时, 这个参数才能参与隐式类型转换(*this不算在内)
- 当一个函数的所有参数都需要进行类型转换时(时常发生在运算符重载函数上, 因为大多数运算符都需要符合交换律, 而此时如果是成员函数, 调用函数的对象本身并不处于参数列中, 这会导致调用错误), 应该使用非成员函数然后写入所有所需的参数
- member的反面是non-member而不是friend, friend时常带来麻烦最好还是谨慎使用
25 考虑写出一个不抛异常的swap函数
- swap函数非常重要, 在编程中很常使用, 因此一定要适当地实现它. 然而泛用实现的std::swap函数仅仅是用temp和copy来交换两个引用对象, 除非效率可以接受否则还是应该自定义
- 好的swap函数的核心是借助pimpl(指针指向实现)手法, 利用交换双方的实现指针来完成交换, 所以当自己的类型符合pimpl可以进行以下设计:
- 首先在类中定义一个公有的swap函数, 它负责交换指针的实现, 被其它函数调用. 这个成员swap千万不能抛出异常, 这是条款29的基石协议.
- 然后在我们自己的命名空间中提供一个非成员的swap函数调用类内的成员swap. 这个版本的swap可以有复杂的偏特化
- 再在std命名空间中提供一个只关于类本身(不允许是模板类)的swap特化, 同样调用我们的成员swap. 不允许是模板类是因为std的设计协议就是不要为std添加任何额外的模板, 函数, 类等等, 我们只允许添加新的模板特化版本
- 最后在我们需要调用swap的时候, 先
using std::swap;暴露出std的swap, 这样编译器进行函数搜索的时候会优先查找当前命名空间的swap, 找不到时会再去找std的swap