Effective Modern C++
笔记
导言
- 本书涉及 C++98, C++03, C++11, and C++14。 C++03与C++98相比只有技术细节差异,本书统称为C++98; C++14是对C++11的补充,本书统称为C++11; 本书提及“C++14”时则仅指C++14, 提及“C++“时则通含这四个版本。
- 本书的条目是指导方针,而不是硬性规则,“指导方针”意味着有例外。所以掌握条目背后的原理是必要的。
- 可用能否取到地址来判断一个表达式是不是左值,能取到地址就是左值,而不是看表达式的类型。比如,所有参数在内部(形式参数)都是左值,哪怕它的类型是右值引用。
- 基本异常保障:符合基本异常保障的函数确保调用触发异常后,程序仍然保持正常,没有数据结构被损坏,没有资源泄露;
- 强异常保障符合强异常保障的函数会确保调用触发异常后,程序的运行状态和调用这个函数之前的状态是一样的。
- 函数对象:指重载了
operator()
的类(仿函数) - 本书把函数签名定义为函数的参数与返回值,不包括函数名与其它声明(比如 noexcept、constexpr等),即相同参数与相同返回值类型的函数是同一个函数签名。这与官方定义不同,官方定义不包括返回值类型。
- 要规避标准提及的未定义行为。
- 内置指针:new 返回的原始指针; 与之对应的,则有智能指针的概念。
章节1. 类型推导(Deducing types)
» 条目1. 理解模板的类型推导
auto
的类型推导是基于模板的类型推导。- 当模板函数形参为指针或非通值引用,例如
f(T* param)
,f(T& param)
或f(const T& param)
等,根据传入的实参来推导类型时,会先省略实参的引用属性,即此时实参const int i
与const int& i
是等价的。 - 当模板函数形参为通值引用
f(T&& param)
时, 如果实参为左值,那么 T 会被推导为左值引用(比如int&
), 这是 T 被推导为左值引用的唯一情况; 如果实参为右值,则与上条相同。 - 当模板函数形参为非指针非引用
f(T param)
时,即按值传递,类型推导时会忽略实参的引用属性、const 属性和 volatile 属性。只有按值传递时类型推导才忽略实参的 const 属性。 - 数组实参为例外情况:模板函数形参按值传递时,实参如果为数组,形参会被推导为指针;模板函数形参为引用时,则保留数组类型不会退化为指针
- 函数类型退化为指针与数组情况一样.
- 列个表:
形参 | 实参 | 推导结果 |
---|---|---|
f(T*), f(T&), f(const T&) | int i; | T ⇒ int |
f(T*), f(T&), f(const T&) | int& ri=i; | T ⇒ int |
f(T*), f(T&) | const int& ci=i; | T ⇒ const int |
f(const T&) | const int& ci=i; | T ⇒ int |
f(T&& param) | lvalue 左值i,ri | T ⇒ int&; param ⇒ int& |
f(T&& param) | rvalue 右值 f(27); f(std::move(i)) | T ⇒ int; param ⇒ int&& |
f(T param) | int i; volatile int i; const int ci=i; const int& ri=i; | T ⇒ int; param ⇒ int |
f(T param) | const char name[]=“oakfire” | T ⇒ const char* |
f(T& param) | const char name[]=“oakfire” | T ⇒ const char[8] |
f(T param) | void someFunc(int, double) | T ⇒ void (*)(int,double) |
f(T& param) | void someFunc(int, double) | T ⇒ void (&)(int,double) |
» 条目2. 理解 auto 的类型推导
auto
的类型推导与模板类型推导基本一致,但对于花括号初始化值,比如auto i = {1};
,i
会被推导为std::initializer_list
类型,而模板不会推导为此类型,它会继续推导,类型不能确定即报告错误。auto
在作为函数返回值以及 lambda 参数时,则和模板类型推导完全一致。
» 条目3. 理解 decltype 的类型推导
decltype
基本是给出变量或表达式本身的类型,不会做修改;- 对于
T
类型的左值表达式(一般表达式都是右值),decltype
给出的是引用类型T&
;- 对于
int x=0
,decltype(x)
的结果是int
,decltype( (x) )
的结果则是int&
, 因为 C++ 规定带括号的表达式为左值。
- C++14 支持
decltype(auto)
,它和auto
一样,从其初始化器中推导出一个类型,但它使用decltype
规则来执行类型推导。
» 条目4. 了解推导类型结果的查看方法
- 使用 IDE 提示、编译错误信息或使用
Boost.TypeIndex
库来运行时打印信息通常可以看到推导出的类型。 - 但以上工具的结果可能既无帮助也不准确,因此了解类型推导规则是必要的。
章节2. auto 关键字
» 条目5. 优先使用 auto 而非显式类型声明
auto
声明的变量必须被初始化,可规避初始化遗忘问题;auto
可规避因为显式类型声明不匹配导致的移植性问题或性能问题;auto
可简化重构过程;auto
可节省打字输入时间!- 但是
auto
类型要受制于条目2 与条目6所提到的陷阱。
» 条目6. 当 auto 推导不出期望类型时,使用显式类型初始化
std::vector<bool>
的[]
返回类型不是bool&
而是std::vector<bool>::reference
.(因为早期C++专门针对std::vector<bool>
做过优化),此时auto
并不能推导出期望的bool
类型。- 类似
std::vector<bool>
的”不可见”代理类,会导致auto
不能推导出期望类型。 - 也可使用类型转换来让
auto
推导出期望类型:std::vector<bool> f(const W& w);auto a = static_cast<bool>(f(w)[5]);
章节3. 转向现代C++
» 条目7. 创建对象(定义变量初始化)时区分 ''()'' 和 ''{}''
int i = {0}
等号加大括号的初始化方式等同于只用大括号int i{0}
- 花括号初始化方式适用性最广,比如在类atomic成员声明时初始化就只能用花括号方式;、
- 花括号初始化可规避内置类型的隐式转换
- 花括号初始化可避免语法解析的歧义,比如
Widget w();
既可解析为初始化对象 w, 也可解析为声明一个返回 Widget 对象的函数 w,但Widget w{}
就没有这种歧义。 - 但是,在构造函数重载解析时,花括号初始化会尽可能匹配到
std::initializer_list
参数,即使其他构造函数提供了看似更好的匹配。 - 括号初始化与花括号初始化之间产生巨大差异的一个例子是带有两个参数的
std::vector<numeric-type>
:std::vector<int> v1(10, 20)
表示初始化10个元素的 vector,其中每个值都为 20;std::vector<int> v1{10, 20}
表示初始化两个元素的 vector, 值分别为 10 与 20
- 在模板对象创建时,用括号还是花括号是可能具有挑战性的事情(由于模板可变参数的存在)
» 条目8. 相比 0 或 NULL优先选择使用 nullptr
- 优先使用 nullptr 可避免歧义
- 要避免同时重载整形与指针类型
» 条目9. 相比 typedefs 优先选择使用别名
- 别名用于声明函数指针时更直观:
// FP is a synonym for a pointer to a function taking an int and // a const std::string& and returning nothing typedef void (*FP)(int, const std::string&); // typedef same meaning as above using FP = void (*)(int, const std::string&); // alias declaration
- 别名可直接用于模板,而
typedef
用于模板时需要typename
与后缀::type
想配合。 - 由于历史原因,C++11 保留有许多 typedef 的类型,例如
std::remove_const<T>::type
, c++14 提供了这些类型的别名版本, 例如:std::remove_const_t<T>
» 条目10. 使用枚举时优先使用限定域枚举
- c++98 的枚举是非限定域的,会污染全局变量命名
- 限定域的枚举类
enum class
, 则只在域内可见,也只可通过显式类型转换来转换类型 - c++98 enum 不限定默认基础类型,编译器在定义枚举时才确定枚举的基础类型;
enum class
则是有基础类型int
- 限定域枚举可以前置声明
enum class Color;
, 非现定域则需要指定基础类型才可前置声明enum Color: std::uint8_t;
» 条目11. 阻止调用时,相比私有域声明不定义方式,优先使用函数删除的方式
- 删除默认构造函数与默认赋值重载函数:
Widget(const Widget& ) = delete; Widget& operator=(const Widget&) = delete;
- 任何函数都可以被删除,包括非成员函数与模板。
» 条目12. 声明覆盖函数时,使用修饰词 override
- 区分覆盖和重载的区别。
- c++11 比 c++98 对虚函数覆盖多了一条限定:覆盖函数的引用限定词必须与基类相同。
class Widget { public: … void doWork() &; // this version of doWork applies only when *this is an lvalue void doWork() &&; // this version of doWork applies only when *this is an rvalue }; … Widget makeWidget(); // factory function (returns rvalue) Widget w; // normal object (an lvalue) w.doWork(); // calls Widget::doWork for lvalues makeWidget().doWork(); // calls Widget::doWork for rval
override
修饰词可规避因细微声明差异导致未覆盖。
» 条目13. 优先使用 const 迭代器 const_iterator
- 相比c++98, c++11 对
const_iterator
支持更好,并提供了cbegin, cend
等返回 const 迭代器的函数。 - 在编写最泛化代码时,更偏好非成员版本的
begin,end,rbegin
等,而不是其对应的成员函数。 - c++14 相比 c++11 添加了
std::cbegin, std::cend
等非成员函数版本。
» 条目14. 如果函数不会抛出异常,则声明为 noexpect
noexpect
属于接口声明的一部分, 调用者依赖它。noexpect
利于编译期优化。noexpect
应用于移动操作,swap,内存释放函数与析构函数最有价值。- 大多数函数都是异常中性(exception-neutral), 它们内部代码调用的函数可能抛出异常,不能声明为 noexcept。
- 从实现复杂度考虑,只对有不抛异常自然实现的函数来声明
noexpect
。
» 条目15. 尽可能使用 constexpr
constexpr
对象在编译期初始化值,初始化后为 const, 可认为是常量;const
对象则可能是在运行期才初始化值,保证值不变。constexpr
函数在编译期就能产生结果值,如果实参在编译期就能得到。- 由于在编译期变成了常量,constexpr的对象或函数可用在仅允许常量的语境下。
constexpr
属于接口声明的一部分,“尽可能”包括表示尽量不要在接口声明了constexpr又去掉。
» 条目16. 确保 const 成员函数线程安全
- 确保 const 成员函数的线程安全,除非保证不会使用在并发环境。
std::atomic
性能可能优于互斥量,但仅适用于单个变量或单个内存地址的操作。
» 条目17. 理解特制成员函数的生成机制
- 特制成员函数(special member function)是指编译器可自行生成的:
- 默认构造函数,析构函数,拷贝构造函数,拷贝赋值运算,
- 移动构造函数:
Widget(Widget&& rhs);
- 移动赋值运算:
Widget& operator=(Widget&& rhs);
- 特制成员函数仅在需要时才会生成,具有public, inline, 非虚函数属性(除了父析构为虚,此时子类默认析构也为虚)。
- 移动操作(指移动构造与移动赋值运算,下同)仅生成于该类未显式声明任何拷贝操作、移动操作与析构函数时。
- 拷贝构造函数仅生成于该类未显式声明拷贝构造函数,并在声明了移动操作后会被删除。
- 拷贝赋值运算仅生成于该类未显式声明拷贝赋值运算,并在声明了移动操作后会被删除。
- 类显示声明析构函数后,生成的拷贝操作被废弃。
- 模板成员函数的声明(比如
template<typename T> Widget(const T& rhs);
)不会限制特制成员函数的生成。
章节4. 智能指针
- C++ 11 存在四种智能指针:
std::auto_ptr
c++98 遗留,已废弃,可用std::unique_str
代替std::unique_ptr
std::shared_ptr
std::weaked_ptr
» 条目18 使用 std::unique_ptr 管理独占资源
std::unique_ptr
轻量,快速,只可移动(move-only), 不可复制;- 很适合作为工厂设计模式的返回:工厂自定义资源删除方法, 并让调用者自主管理资源释放;
std::unique_ptr
释放资源默认使用delete
, 也可自定义删除器(deleter);自定义删除器的状态与函数指针可增加std::unique_ptr
对象的大小;std::unique_prt
可轻易得转为std::shared_prt
» 条目19 使用 std::shared_ptr 管理共享资源
std::shared_ptr
使用引用计数(reference count)来管理资源,当引用计数为0(最后一个指向该资源的shared_ptr析构)时释放资源,实现资源的生命周期管理;- 引用计数的读写是原子性的,存在额外开销;
std::shared_ptr
对象析构、构造时修改引用计数,但是移动构造与移动赋值直接省略了减一加一过程,比拷贝构造拷贝赋值要更有效率;std::shared_ptr
可自定义删除器,但删除器不作为其类型的一部分,而std::unique_ptr
把删除器作为其类型的一部分:// 自定义删除器 auto loggingDel = [](Widget *pw) { makeLogEntry(pw); delete pw; }; //unique_ptr指针声明包含了删除器类型 std::unique_ptr<Widget, decltype(loggingDel)> upw(new Widget, loggingDel); //shared_ptr指针声明不包含删除器类型 std::shared_ptr<Widget> spw(new Widget, loggingDel);
- 控制块与共享资源要确保一一对应,创建时机:
std::make_shared
, 此时能确保是首次创建资源对象;- 从
std::unique_ptr
(或std::auto_ptr
)对象构造std::shared_ptr
,此时能确保独占对象不存在控制块,并且所有权已从独占指针转移; - 从原生指针构造
std::shared_ptr
也会创建控制块,但此时不能确保原始指针指向的对象不存在控制块,使用同一个原生指针构造两次std::shared_ptr
就会产生未定义行为
- 避免从原生指针构造
std::shared_ptr
; std::enable_shared_from_this
基类模板(The Curiously Recurring Template Pattern(CRTP))可安全得使用this
指针构造std::shared_ptr
- 和
std::unique_ptr
不同,std::shared_ptr
设计之初就是针对单个对象的, 没有办法std::shared_ptr<T[]>, 不适用于直接指向数组, 应使用标准容器。
» 条目20 使用 std::weak_ptr 来指向可能悬空的共享资源
std::weak_ptr
从std::shared_ptr
创建,但不参与共享资源的所有权引用计数auto spw = std::make_shared<Widget>(); // 引用计数+1 std::weak_ptr<Widget> wpw(spw); // 引用计数不变 spw = nullptr; // 引用计数-1,资源被释放, wpw 悬空 wpw.reset(); // 引用计数不变, 置空
wpw.expired()
可判断弱指针指向的资源是否已释放,即指针本身是否悬空(dangle);std::weak_ptr
本身不能解引用,要使用资源时,需使用wpw.lock()
或直接构造std::shared_ptr
来使用:std::shared_ptr<Widget> spw1 = wpw.lock(); // 如果wpw过期,则 spw1 为 null std::shared_ptr<Widget> spw3(wpw); // 如果wpw过期,抛异常std::bad_weak_ptr
std::weak_ptr
的适用场景:- 缓存:资源过期就创建新的,否则就使用老的,缓存里的指针不参与生命周期;
- 观察者列表:被观察者不关心观察者的死活,观察者注销不需要关心被观察者的列表注销;
- 避免std::shared_ptr环状结构:A、B两对象互相存有对方的shared_ptr指针,则永远不会被释放。
» 条目21 比起new, 优先使用 std::make_unique 和 std::make_shared
make
方式 比new
方式提高了异常安全性:- 使用new 的版本
std::shared_ptr<Widget> pw(new Widget)
包含了两步操作:1.new 对象;2.构建指针 - 考虑这个代码
processWidget(std::shared_ptr<Widget>(new Widget), computePriority());
由于没有规定函数实参构建顺序,编译器可能按如下顺序构建实参:
- 执行 new Widget
- 执行 computePriority()
- 构造 std::shared_ptr
- 此时,如果第二步 执行computePriority() 时产生了异常导致第三步未执行,那么第一步的 new 就产生了泄露。
- 所以,如果使用 new 方式,应把 new 结果直接传递给智能指针构建,确保这中间没有其它语句。
std::make_shared
和std::allocate_shared
方式生成的代码更快更小:对象与控制快内存一起分配、一起销毁。- 不适合使用
make
方式的情况:- 需要自定义删除器;
- 希望用花括号初始化。
- 不建议使用
std::shared_ptr
的其它情况- 有自定义内存管理的类;
- 特别关注内存的系统;
- 非常大的对象并且
std::weak_ptr
比对应的std::shared_ptr
更长久(弱指针存在导致控制块不能销毁,导致make产生的对象内存块也没有销毁)。
» 条目22 使用 Pimpl 惯用法时,定义特制成员函数需要放在实现文件里
- Pimpl(pointer to implementation)惯用法,为了缩减编译时间而减少头文件依赖
- 对于
std::unique_ptr
pImpl 指针, 在头文件声明特制成员函数,实现则要在实现文件里定义,即使编译器默认实现是可接受的:// 以下都在实现文件 widge.cpp 中: Widget::~Widget() = default; // 防止报错 // 自定义析构导致拷贝与移动构造不能默认生成,手动生成需定义在实现文件 Widget::Widget(Widget&& rhs) = default; // 注意默认实现是浅拷贝,需深拷贝自己实现 Widget& Widget::operator=(Widget&& rhs) = default;
std::shared_ptr
指针没有上述限制,但不适用于 Pimpl 独享占有权语义。
章节5. 右值引用、移动语义和完美转发
- 形参永远是左值,即使它的类型是右值引用(参考上方导言第三行)
» 条目23 理解 std::move 与 std::forward
std::move
无条件转换为一个右值,其本身并不移动任何东西:// std::move 的简单实现: template<typename T> // C++14; in namespace std decltype(auto) move(T&& param) { using ReturnType = remove_reference_t<T>&&; return static_cast<ReturnType>(param); }
- 如果想移动实参,就不要把实参声明为
const
, 因为 const 实参会默认进入拷贝构造而不是移动构造 std::forward
仅当形参来源于右值实参时,才把它转为右值(作为下个函数的实参);std::move
与std::forward
在运行时 do nothing。
» 条目24 区分通值引用与右值引用
- 通值引用,两种情况:
- 函数模板参数为
T&&
并且T
需类型推导; - 变量声明为
auto&&
。
- 如果类型声明格式不是标准的
type&&
,或者不存在类型推导,那么type&&
为右值引用:void f(Widget&& param); // rvalue reference Widget&& var1 = Widget(); // rvalue reference auto&& var2 = var1; // not rvalue reference template<typename T> void f(std::vector<T>&& param); // rvalue reference template<typename T> void f(T&& param); // not rvalue reference
- 通值引用如果是被右值初始化,那么就转为右值引用,左值则转为左值引用(详见类型推导)。
» 条目25 std::move 使用在右值引用,std::forward 使用在通值引用
- 要利用形参的右值性时,右值引用的形参使用
std::move
, 通值引用的形参使用std::forward
。 - 一个函数内想多次利用同一个对象的右值性时,只在最后一次使用
std::move
,std::forward
(因为使用之后该对象就会失效)。 - 按值返回的函数要返回右值引用或通值引用时,同样使用
std::move
或std::forward
。 - 由于标准规定编译器存在返回值优化(return value optimization,RVO)以及在不优化场景下将
std::move
隐式应用于返回的局部对象,所以,不需要对要返回的局部对象(或值形参)手动使用std::move
或std::forward
。
» 条目26 避免重载通值引用
- 相比
int
重载,通值引用的函数会更匹配short
参数:template<typename T> void logAndAdd(T&& name){} // origon_func void logAndAdd(int idx){} // overload_func short index; logAndAdd(index); //Error! it match origon_func, not overload_func
通值引用的重载,会让通值引用比预期中更频繁得被调用。
- 完美转发构造函数尤其如此,因为对于非const左值来说,它们通常比复制构造函数(复制构造函数参数声明为const)更匹配,而且它们可以劫持派生子类调用基类的复制与移动构造函数,转而让子类调用完美转发构造函数。
» 条目27 熟悉「重载通值引用」之外的替代方法
翻译对照
英 | 汉 | 备注 |
---|---|---|
argument | 实参 | |
parameter | 形参/参数 | |
basic guarantee | 基本异常保障 | basic exception safty guarantee |
strong guarantee | 强异常保障 | strong exception safty guarantee |
built-in pointer | 内置指针 | 相对于智能指针的原指针概念 |
raw pointer | 原生指针 | |
universal reference | 通值引用 | 相对“左值引用”、“右值引用”来说 |
scoped enums | 限定域枚举 | |
unscoped enums | 非限定域枚举 | |
forward-declared | 前置声明 | |
override | 覆盖 | |
overload | 重载 | |
maximally generic code | 最泛化代码 | |
special member function | 特制成员函数 | |