Effective Modern C++¶
前言¶
第一章 类型推导¶
条款一:理解模板类型推导¶
- 对于函数模板分别考虑
- 函数参数类型
- 模板类型
- 情景一:ParamType 是一个指针或引用,但不是通用引用
- 如果 expr 的类型是一个引用,忽略引用部分
- 然后 expr 的类型与 ParamType 进行模式匹配来决定 T
- 注意 const 在类型推导时是否保留
- 情景二:ParamType 是一个通用引用
- 如果 expr 是左值,T 和 ParamType 都会被推导为左值引用。
- 第一,这是模板类型推导中唯一一种 T 被推导为引用的情况。
- 第二,虽然 ParamType 被声明为右值引用类型,但是最后推导的结果是左值引用。
- 如果 expr 是右值,就使用正常的(也就是情景一)推导规则
- 如果 expr 是左值,T 和 ParamType 都会被推导为左值引用。
- 情景三:ParamType 既不是指针也不是引用
- 通过传值(pass-by-value)的方式处理
- 如果 expr 的类型是一个引用,忽略这个引用部分
- 如果忽略 expr 的引用性(reference-ness)之后,expr 是一个 const,那就再忽略 const。如果它是 volatile,也忽略 volatile
- 数组实参
- 数组会退化为指针
- 传值形参的模板,会将数组视为指针进行推导
- 传引用的模板,可以正常推导,获得数组的引用
- 函数实参
- 函数类型会退化为一个函数指针
- 传值形参的模板,会将函数视为指针进行推导
- 传引用的模板,可以正常推导,获得函数的引用
- 总结
- 在模板类型推导时,有引用的实参会被视为无引用,他们的引用会被忽略
- 对于通用引用的推导,左值实参会被特殊对待
- 对于传值类型推导,const 和/或 volatile 实参会被认为是 non-const 的和 non-volatile 的
- 在模板类型推导时,数组名或者函数名实参会退化为指针,除非它们被用于初始化引用
条款二:理解 auto 类型推导¶
- 十分类似函数模板的推导,大部分情景都是相同的
- 情景一:类型说明符是一个指针或引用但不是通用引用
- 情景二:类型说明符一个通用引用
- 情景三:类型说明符既不是指针也不是引用
- 不同之处
- auto 类型推导假定花括号表示 std::initializer_list
- 模板类型推导不会这样(确切的说是不知道怎么办)。
- C++14 中 auto 允许出现在函数返回值或者 lambda 函数形参中
- 但是它的工作机制是模板类型推导那一套方案,而不是 auto 类型推导
- auto 类型推导假定花括号表示 std::initializer_list
- 总结
- auto 类型推导通常和模板类型推导相同,但是 auto 类型推导假定花括号初始化代表 std::initializer_list,而模板类型推导不这样做
- 在 C++14 中 auto 允许出现在函数返回值或者 lambda 函数形参中,但是它的工作机制是模板类型推导那一套方案,而不是 auto 类型推导
条款三:理解 decltype¶
- 通常行为
- decltype 最主要的用途就是用于声明函数模板
- 函数名称前面的 auto 不会做任何的类型推导工作。相反的,他只是暗示使用了 C++11 的尾置返回类型语法
- C++14 扩展到允许自动推导所有的 lambda 表达式和函数,甚至它们内含多条语句。
- C++ 期望在某些情况下当类型被暗示时需要使用 decltype 类型推导的规则,C++14 通过使用 decltype(auto) 说明符
- c++14 版本
- c++11 版本
- 特殊行为
- decltype(x) 与 decltype((x))
- 总结
- decltype 总是不加修改的产生变量或者表达式的类型。
- 对于 T 类型的不是单纯的变量名的左值表达式,decltype 总是产出 T 的引用即 T&。
- C++14 支持 decltype(auto),就像 auto 一样,推导出类型,但是它使用 decltype 的规则进行推导。
条款四:学会查看类型推导结果¶
- IDE 编辑器
- 编译器诊断
- template
//只对 TD 进行声明 - class TD;
- TD
xType;
- template
- 运行时输出
- std::cout << typeid(y).name() << '\n';
- 可能存在问题与不可靠的输出
- Boost.TypeIndex
- std::cout << typeid(y).name() << '\n';
- 总结
- 类型推断可以从 IDE 看出,从编译器报错看出,从 Boost TypeIndex 库的使用看出
- 这些工具可能既不准确也无帮助,所以理解 C++ 类型推导规则才是最重要的
第二章 auto¶
条款五:优先考虑 auto 而非显式类型声明¶
- auto 可以省略冗长的声明类型
- auto 可以直接保存闭包
- auto 变量从初始化表达式中推导出类型,所以我们必须初始化。
- 使用 std::function 比 auto 声明变量会消耗更多的内存。并且通过具体实现我们得知通过 std::function 调用一个闭包几乎无疑比 auto 声明的对象调用要慢。
- auto 可以避免与类型快捷方式(type shortcuts)有关的问题。
- 总结
- auto 变量必须初始化,通常它可以避免一些移植性和效率性的问题,也使得重构更方便,还能让你少打几个字。
- 正如 Item2 和 6 讨论的,auto 类型的变量可能会踩到一些陷阱。
条款六:auto 推导若非己愿,使用显式类型初始化惯用法¶
- std::vector
对象,调用 operator[],会得到一个不可见的代理类 std::vector ::reference - 总结
- 不可见的代理类可能会使 auto 从表达式中推导出“错误的”类型
- 显式类型初始器惯用法强制 auto 推导出你想要的结果
第三章 移步现代 C++¶
条款七:区别使用 () 和 {} 创建对象¶
- 花括号初始化让你可以表达以前表达不出的东西。使用花括号,创建并指定一个容器的初始元素变得很容易
- 花括号初始化也能被用于为非静态数据成员指定默认初始值。
- 不可拷贝的对象(例如 std::atomic)可以使用花括号初始化或者圆括号初始化
- 花括号初始化不允许内置类型间隐式的变窄转换
- 可避免 most vexing parse
- 会出现在
- C 风格强制类型转换
- 未命名的临时对象
- 会出现在
- 越喜欢用 auto,你就越不能用括号初始化
- 当 auto 声明的变量使用花括号初始化,变量类型会被推导为 std::initializer_list
- 使用花括号初始化语法的调用更倾向于选择带 std::initializer_list 的那个构造函数。如果编译器遇到一个括号初始化并且有一个带 std::initializer_list 的构造函数,那么它一定会选择该构造函数。
- 总结
- 花括号初始化是最广泛使用的初始化语法,它防止变窄转换,并且对于 C++ 最令人头疼的解析有天生的免疫性
- 在构造函数重载决议中,编译器会尽最大努力将括号初始化与 std::initializer_list 参数匹配,即便其他构造函数看起来是更好的选择
- 对于数值类型的 std::vector 来说使用花括号初始化和圆括号初始化会造成巨大的不同
- 在模板类选择使用圆括号初始化或使用花括号初始化创建对象是一个挑战。
条款八:优先考虑 nullptr 而非 0 和 NULL¶
- 用 nullptr 代替 0 和 NULL 可以避开了那些令人奇怪的函数重载决议。
- 使代码表意明确,尤其是当涉及到与 auto 声明的变量一起使用时
- 模板出现时 nullptr 就更有用了
- 总结
- 优先考虑 nullptr 而非 0 和 NULL
- 避免重载指针和整型
条款九:优先考虑别名声明而非 typedef s¶
- 使用别名声明吸引人的理由是存在的:模板
- 别名声明可以被模板化
- 总结
- typedef 不支持模板化,但是别名声明支持。
- 别名模板避免了使用“::type”后缀,而且在模板中使用 typedef 还需要在前面加上 typename
- C++14 提供了 C++11 所有 type traits 转换的别名声明版本
条款十:优先考虑限域 enum 而非未限域 enum¶
- 未限域枚举
- 枚举名的名字泄漏进它们所被定义的 enum 在的那个作用域
- 限域 enum 是通过“enum class”声明,所以它们有时候也被称为枚举类
- 限域 enum 可以减少命名空间污染
- 在它的作用域中,枚举名是强类型
- 限域 enum 可以被前置声明
- 默认情况下,限域枚举的底层类型是 int
- 底层类型说明
- 总结
- C++98 的 enum 即非限域 enum。
- 限域 enum 的枚举名仅在 enum 内可见。要转换为其它类型只能使用 cast。
- 非限域/限域 enum 都支持底层类型说明语法,限域 enum 底层类型默认是 int。非限域 enum 没有默认底层类型。
- 限域 enum 总是可以前置声明。非限域 enum 仅当指定它们的底层类型时才能前置。
条款十一:优先考虑使用 deleted 函数而非使用未定义的私有声明¶
- 在 C++98 中防止调用这些函数的方法是将它们声明为私有(private)成员函数并且不定义
- 在 C++11 中有一种更好的方式达到相同目的:用“= delete”将拷贝构造函数和拷贝赋值运算符标记为 deleted 函数
- 通常,deleted 函数被声明为 public 而不是 private
- 任何函数都可以标记为 deleted
- 其中一种方法就是创建 deleted 重载函数,其参数就是我们想要过滤的类型
- bool isLucky(int number); //原始版本
- bool isLucky(char) = delete; //拒绝 char
- bool isLucky(bool) = delete; //拒绝 bool
- bool isLucky(double) = delete; //拒绝 float 和 double
- 其中一种方法就是创建 deleted 重载函数,其参数就是我们想要过滤的类型
- 另一个 deleted 函数用武之地是禁止一些模板的实例化
- 使用 delete 标注模板实例
- 总结
- 比起声明函数为 private 但不定义,使用 deleted 函数更好
- 任何函数都能被删除(be deleted),包括非成员函数和模板实例(译注:实例化的函数)
条款十二:使用 override 声明重写函数¶
- 重写一个函数,必须满足下列要求:
- 基类函数必须是 virtual
- 基类和派生类函数名必须完全一样(除非是析构函数)
- 基类和派生类函数形参类型必须完全一样
- 基类和派生类函数常量性 constness 必须完全一样
- 基类和派生类函数的返回值和异常说明(exception specifications)必须兼容
- 函数的引用限定符(reference qualifiers)必须完全一样。
- 成员函数引用限定
- 总结
- 为重写函数加上 override
- 成员函数引用限定让我们可以区别对待左值对象和右值对象(即*this)
条款十三:优先考虑 const_iterator 而非 iterator¶
- const_iterator 在 C++98 中会有很多问题,不如它的兄弟(译注:指 iterator)有用。最终,开发者们不再相信能加 const 就加它的教条,而是只在实用的地方加它,C++98 的
const_iterator
不是那么实用 - 由于标准化的疏漏,C++11 只添加了非成员函数 begin 和 end,但是没有添加 cbegin,cend,rbegin,rend,crbegin,crend。C++14 修订了这个疏漏。
- 总结
- 优先考虑
const_iterator
而非 iterator - 在最大程度通用的代码中,优先考虑非成员函数版本的 begin,end,rbegin 等,而非同名成员函数
- 优先考虑
条款十四:如果函数不抛出异常请使用 noexcept¶
- 只有在知晓移动不抛异常的情况下用 C++11 的移动操作替换 C++98 的复制操作才是安全的
- 交换高层次数据结构是否 noexcept 取决于它的构成部分的那些低层次数据结构是否 noexcept,这激励你只要可以就提供 noexcept swap 函数
- 异常中立函数决不应该声明为 noexcept
- 内存释放函数和析构函数——不管是用户定义的还是编译器生成的——都是隐式 noexcept
- 总结
- noexcept 是函数接口的一部分,这意味着调用者可能会依赖它
- noexcept 函数较之于 non-noexcept 函数更容易优化
- noexcept 对于移动语义,swap,内存释放函数和析构函数非常有用
- 大多数函数是异常中立的(译注:可能抛也可能不抛异常)而不是 noexcept
条款十五:尽可能的使用 constexpr¶
- 编译期可知的值“享有特权”,它们可能被存放到只读存储空间中。
- 所有 constexpr 对象都是 const,但不是所有 const 对象都是 constexpr。
- C++11 中,constexpr 函数的代码不超过一行语句:一个 return。
- 在 C++11 中,有两个限制使得 Point 的成员函数 setX 和 setY 不能声明为 constexpr。第一,它们修改它们操作的对象的状态, 并且在 C++11 中,constexpr 成员函数是隐式的 const。第二,它们有 void 返回类型,void 类型不是 C++11 中的字面值类型。这两个限制在 C++14 中放开了,所以 C++14 中 Point 的 setter(赋值器)也能声明为 constexpr
- “尽可能”的使用 constexpr 表示你需要长期坚持对某个对象或者函数施加这种限制。
- 总结
- constexpr 对象是 const,它被在编译期可知的值初始化
- 当传递编译期可知的值时,constexpr 函数可以产出编译期可知的结果
- constexpr 对象和函数可以使用的范围比 non-constexpr 对象和函数要大
- constexpr 是对象和函数接口的一部分
条款十六:让 const 成员函数线程安全¶
- 从概念上讲,roots 并不改变它所操作的 Polynomial 对象。但是作为缓存的一部分,它也许会改变 rootVals 和 rootsAreValid 的值。这就是 mutable 的经典使用样例,这也是为什么它是数据成员声明的一部分。
- 问题就是 roots 被声明为 const,但不是线程安全的。const 声明在 C++11 中与在 C++98 中一样正确(检索多项式的根并不会更改多项式的值),因此需要纠正的是线程安全的缺乏。
- 使用 mutex(互斥量)
- 在某些情况下,互斥量的副作用显会得过大
- 使用 std::atomic 修饰的计数器
- 对于需要同步的是单个的变量或者内存位置,使用 std::atomic 就足够了。不过,一旦你需要对两个以上的变量或内存位置作为一个单元来操作的话,就应该使用互斥量。
- 总结
- 确保 const 成员函数线程安全,除非你确定它们永远不会在并发上下文(concurrent context)中使用。
- 使用 std::atomic 变量可能比互斥量提供更好的性能,但是它只适合操作单个变量或内存位置。
条款十七:理解特殊成员函数的生成¶
- C++98 有四个
- 默认构造函数
- 析构函数
- 拷贝构造函数
- 拷贝赋值运算符
- C++11 添加
- 移动构造函数
- 移动构造函数
- 两个拷贝操作是独立的:声明一个不会限制编译器生成另一个。
- 两个移动操作不是相互独立的。如果你声明了其中一个,编译器就不再生成另一个。
- 声明移动构造函数阻止移动赋值运算符的生成,声明移动赋值运算符同样阻止编译器生成移动构造函数。
- 一个类显式声明了拷贝操作,编译器就不会生成移动操作。
- 声明移动操作(构造或赋值)使得编译器禁用拷贝操作
- Rule of Three 规则。这个规则告诉我们如果你声明了拷贝构造函数,拷贝赋值运算符,或者析构函数三者之一,你应该也声明其余两个。
- 有用户定义的析构函数的类不会生成移动操作
- 生成移动操作的条件
- 类中没有拷贝操作
- 类中没有移动操作
- 类中没有用户定义的析构
- 成员函数模版不会阻止编译器生成特殊成员函数
- 总结
- 特殊成员函数是编译器可能自动生成的函数:默认构造函数,析构函数,拷贝操作,移动操作。
- 移动操作仅当类没有显式声明移动操作,拷贝操作,析构函数时才自动生成。
- 拷贝构造函数仅当类没有显式声明拷贝构造函数时才自动生成,并且如果用户声明了移动操作,拷贝构造就是 delete。拷贝赋值运算符仅当类没有显式声明拷贝赋值运算符时才自动生成,并且如果用户声明了移动操作,拷贝赋值运算符就是 delete。当用户声明了析构函数,拷贝操作的自动生成已被废弃。
- 成员函数模板不抑制特殊成员函数的生成。
第四章 智能指针¶
- 导引
- 原始指针很难被爱的原因
- 它的声明不能指示所指到底是单个对象还是数组。
- 它的声明没有告诉你用完后是否应该销毁它,即指针是否拥有所指之物。
- 如果你决定你应该销毁指针所指对象,没人告诉你该用 delete 还是其他析构机制(比如将指针传给专门的销毁函数)。
- 如果你发现该用 delete。 原因 1 说了可能不知道该用单个对象形式(“delete”)还是数组形式(“delete[]”)。如果用错了结果是未定义的。
- 假设你确定了指针所指,知道销毁机制,也很难确定你在所有执行路径上都执行了恰为一次销毁操作(包括异常产生后的路径)。少一条路径就会产生资源泄漏,销毁多次还会导致未定义行为。
- 一般来说没有办法告诉你指针是否变成了悬空指针(dangling pointers),即内存中不再存在指针所指之物。在对象销毁后指针仍指向它们就会产生悬空指针。
- 智能指针
- C++11 中存在四种智能指针
std::auto_ptr
std::unique_ptr
std::shared_ptr
std::weak_ptr
- C++11 中存在四种智能指针
条款十八:对于独占资源使用 std::unique_ptr¶
- std::unique_ptr 是一种只可移动类型
- 自定义删除器可以实现为函数或者 lambda 时,尽量使用 lambda
- 总结
- std::unique_ptr 是轻量级、快速的、只可移动(move-only)的管理专有所有权语义资源的智能指针
- 默认情况,资源销毁通过 delete 实现,但是支持自定义删除器。有状态的删除器和函数指针会增加 std::unique_ptr 对象的大小
- 将 std::unique_ptr 转化为 std::shared_ptr 非常简单
条款十九:对于共享资源使用 std::shared_ptr¶
- std::shared_ptr 通过引用计数(reference count)来确保它是否是最后一个指向某种资源的指针,引用计数关联资源并跟踪有多少 std::shared_ptr 指向该资源。
- std::shared_ptr 大小是原始指针的两倍
- 引用计数的内存必须动态分配
- 控制块的创建会遵循下面几条规则
- std::make_shared(参见 Item21)总是创建一个控制块。
- 当从独占指针(即 std::unique_ptr 或者 std::auto_ptr )上构造出 std::shared_ptr 时会创建控制块。独占指针没有使用控制块,所以指针指向的对象没有关联控制块。(作为构造的一部分,std::shared_ptr 侵占独占指针所指向的对象的独占权,所以独占指针被设置为 null)
- 当从原始指针上构造出 std::shared_ptr 时会创建控制块。
- 从原始指针上构造超过一个 std::shared_ptr 就会让你走上未定义行为的快车道
- 一个尤其令人意外的地方是使用 this 指针作为 std::shared_ptr 构造函数实参的时候可能导致创建多个控制块。
- std::enable_shared_from_this
- 奇异递归模板模式(The Curiously Recurring Template Pattern(CRTP)
- 从 std::unique_ptr 升级到 std::shared_ptr 也很容易,反之则不行
- 总结
- std::shared_ptr 为有共享所有权的任意资源提供一种自动垃圾回收的便捷方式。
- 较之于 std::unique_ptr,std::shared_ptr 对象通常大两倍,控制块会产生开销,需要原子性的引用计数修改操作。
- 默认资源销毁是通过 delete,但是也支持自定义删除器。删除器的类型是什么对于 std::shared_ptr 的类型没有影响。
- 避免从原始指针变量上创建 std::shared_ptr。
条款二十:当 std::shared_ptr 可能悬空时使用 std::weak_ptr¶
- std::weak_ptr 通常从 std::shared_ptr 上创建。当从 std::shared_ptr 上创建 std::weak_ptr 时两者指向相同的对象,但是 std::weak_ptr 不会影响所指对象的引用计数
- 有两种形式可以从 std::weak_ptr 上创建 std::shared_ptr
- 一种形式是 std::weak_ptr::lock,它返回一个 std::shared_ptr,如果 std::weak_ptr 过期这个 std::shared_ptr 为空
- 另一种形式是以 std::weak_ptr 为实参构造 std::shared_ptr。这种情况中,如果 std::weak_ptr 过期,会抛出一个异常
- 用途
- 可缓存的工厂函数
- 观察者设计模式
- 打破 std::shared_ptr 循环
- 总结
- 用 std::weak_ptr 替代可能会悬空的 std::shared_ptr。
- std::weak_ptr 的潜在使用场景包括:缓存、观察者列表、打破 std::shared_ptr 环状结构。
条款二十一:优先考虑使用 std::make_unique 和 std::make_shared,而非直接使用 new¶
- 为什么要使用
- make 函数的声明语句只需要写一次 Widget。
- 使用 make 函数的原因和异常安全有关
- std::make_shared 的一个特性(与直接使用 new 相比)是效率提升
- 两次分配变为一次分配
- 问题
- make 函数都不允许指定自定义删除器
- 当构造函数重载,有使用 std::initializer_list 作为参数的重载形式和不用其作为参数的的重载形式,用花括号创建的对象更倾向于使用 std::initializer_list 作为形参的重载形式,而用小括号创建对象将调用不用 std::initializer_list 作为参数的的重载形式。make 函数会将它们的参数完美转发给对象构造函数,但是它们是使用小括号还是花括号?对某些类型,问题的答案会很不相同。
- 对于开发者
- 使用 make 函数去创建重载了 operator new 和 operator delete 类的对象是个典型的糟糕想法。
- 控制块和对象被放在同一块分配的内存块中,直到控制块的内存也被销毁,对象占用的内存才被释放。
- 通过 std::shared_ptr 的 make 函数分配的内存,直到最后一个 std::shared_ptr 和最后一个指向它的 std::weak_ptr 已被销毁,才会释放。
- 为了使异常安全代码达到非异常安全代码的性能水平,我们需要用 std::move 将 spw 转换为右值
- 总结
- 和直接使用 new 相比,make 函数消除了代码重复,提高了异常安全性。对于 std::make_shared 和 std::allocate_shared,生成的代码更小更快。
- 不适合使用 make 函数的情况包括需要指定自定义删除器和希望用花括号初始化。
- 对于 std::shared_ptrs,其他不建议使用 make 函数的情况包括
- 有自定义内存管理的类
- 特别关注内存的系统,非常大的对象,以及 std::weak_ptrs 比对应的 std::shared_ptrs 活得更久
条款二十二:当使用 Pimpl 惯用法,请在实现文件中定义特殊成员函数¶
- Pimpl 惯用法是 std::unique_ptr 的最常见的使用情况之一
- 要确保在 widget.cpp 文件中,在结构体 Widget::Impl 被定义之后,再定义析构函数
- 声明一个类 Widget 的析构函数会阻止编译器生成移动操作,所以如果你想要支持移动操作,你必须自己声明相关的函数。
- 总结
- Pimpl 惯用法通过减少在类实现和类使用者之间的编译依赖来减少编译时间。
- 对于 std::unique_ptr 类型的 pImpl 指针,需要在头文件的类里声明特殊的成员函数,但是在实现文件里面来实现他们。即使是编译器自动生成的代码可以工作,也要这么做。
- 以上的建议只适用于 std::unique_ptr,不适用于 std::shared_ptr。
第五章 右值引用,移动语义,完美转发¶
- 导引
- 形参 w 是一个左值
条款二十三:理解 std::move 和 std::forward¶
- std::move 和 std::forward 仅仅是执行转换(cast)的函数(事实上是函数模板)。std::move 无条件的将它的实参转换为右值,而 std::forward 只在特定情况满足时下进行转换。
- 因为移动构造函数只接受一个指向 non-const 的 std::string 的右值引用。然而,该右值却可以被传递给 std::string 的拷贝构造函数,因为 lvalue-reference-to-const 允许被绑定到一个 const 右值上。因此,std::string 在成员初始化的过程中调用了拷贝构造函数,即使 text 已经被转换成了右值。这样是为了确保维持 const 属性的正确性。
- 不要在你希望能移动对象的时候,声明他们为 const。对 const 对象的移动请求会悄无声息的被转化为拷贝操作。
- std::move 不仅不移动任何东西,而且它也不保证它执行转换的对象可以被移动。
- 总结
- std::move 执行到右值的无条件的转换,但就自身而言,它不移动任何东西。
- std::forward 只有当它的参数被绑定到一个右值时,才将参数转换为右值。
- std::move 和 std::forward 在运行期什么也不做。
条款二十四:区分通用引用与右值引用¶
- 总结
- 如果一个函数模板形参的类型为 T&&,并且 T 需要被推导得知,或者如果一个对象被声明为 auto&&,这个形参或者对象就是一个通用引用。
- 如果类型声明的形式不是标准的 type&&,或者如果类型推导没有发生,那么 type&& 代表一个右值引用。
- 通用引用,如果它被右值初始化,就会对应地成为右值引用;如果它被左值初始化,就会成为左值引用。
条款二十五:对右值引用使用 std::move,对通用引用使用 std::forward¶
- 当把右值引用转发给其他函数时,右值引用应该被无条件转换为右值(通过 std::move),因为它们总是绑定到右值;当转发通用引用时,通用引用应该有条件地转换为右值(通过 std::forward),因为它们只是有时绑定到右值
- 应用 std::move 到一个局部对象上是一个坏主意
- 返回值优化(return value optimization,RVO)
- Widget makeWidget() //makeWidget 的移动版本
- {
- Widget w;
- …
- return std::move(w); //移动 w 到返回值中(不要这样做!)
- }
- 编译器可能会在按值返回的函数中消除对局部对象的拷贝(或者移动),如果满足
- 局部对象与函数返回值的类型相同
- 局部对象就是要返回的东西
- 返回值优化(return value optimization,RVO)
- 总结
- 最后一次使用时,在右值引用上使用 std::move,在通用引用上使用 std::forward。
- 对按值返回的函数要返回的右值引用和通用引用,执行相同的操作。
- 如果局部对象可以被返回值优化消除,就绝不使用 std::move 或者 std::forward。
条款二十六:避免在通用引用上重载¶
- 使用通用引用的函数在 C++ 中是最贪婪的函数。它们几乎可以精确匹配任何类型的实参(极少不适用的实参在 Item30 中介绍)
- 在适当的条件下,C++会生成拷贝和移动构造函数,即使类包含了模板化的构造函数,模板函数能实例化产生与拷贝和移动构造函数一样的签名,也在合适的条件范围内
- 重载规则规定当模板实例化函数和非模板函数(或者称为“正常”函数)匹配优先级相当时,优先使用“正常”函数。拷贝构造函数(正常函数)因此胜过具有相同签名的模板实例化函数。
- 总结
- 对通用引用形参的函数进行重载,通用引用函数的调用机会几乎总会比你期望的多得多。
- 完美转发构造函数是糟糕的实现,因为对于 non-const 左值,它们比拷贝构造函数而更匹配,而且会劫持派生类对于基类的拷贝和移动构造函数的调用。
条款二十七:熟悉通用引用重载的替代方法¶
- 放弃重载
- 传递 const T&
- 传值
- 使用 tag dispatch
- 约束使用通用引用的模板(针对类构造、拷贝、移动函数)
- std::enable_if, std::decay, std::is_same, std::is_base_of
- 折中
- 总结
- 通用引用和重载的组合替代方案包括使用不同的函数名,通过 lvalue-reference-to-const 传递形参,按值传递形参,使用 tag dispatch。
- 通过 std::enable_if 约束模板,允许组合通用引用和重载使用,但它也控制了编译器在哪种条件下才使用通用引用重载。
- 通用引用参数通常具有高效率的优势,但是可用性就值得斟酌。
条款二十八:理解引用折叠¶
- C++ 中引用的引用是非法的
- 引用折叠
- 如果任一引用为左值引用,则结果为左值引用。否则(即,如果引用都是右值引用),结果为右值引用。
- 出现情景
- 模板实例化
- auto 的类型生成
- typedef 和别名声明的产生和使用中
- decltype 使用的情况。如果在分析 decltype 期间,出现了引用的引用
- 通用引用不是一种新的引用,它实际上是满足以下两个条件下的右值引用:
- 类型推导区分左值和右值。T 类型的左值被推导为 T& 类型,T 类型的右值被推导为 T。
- 发生引用折叠。
- 总结
- 引用折叠发生在四种情况下:模板实例化,auto 类型推导,typedef 与别名声明的创建和使用,decltype。
- 当编译器在引用折叠环境中生成了引用的引用时,结果就是单个引用。有左值引用折叠结果就是左值引用,否则就是右值引用。
- 通用引用就是在特定上下文的右值引用,上下文是通过类型推导区分左值还是右值,并且发生引用折叠的那些地方。
条款二十九:假定移动操作不存在,成本高,未被使用¶
- std::array 本质上是具有 STL 接口的内置数组。这与其他标准容器将内容存储在堆内存不同。存储具体数据在堆内存的容器,本身只保存了指向堆内存中容器内容的指针。因为每个容器中的元素终归需要拷贝或移动一次。
- std::string 提供了常数时间的移动操作和线性时间的复制操作。这听起来移动比复制快多了,但是可能不一定。许多字符串的实现采用了小字符串优化(small string optimization,SSO)。“小”字符串(比如长度小于 15 个字符的)存储在了 std::string 的缓冲区中,并没有存储在堆内存,移动这种存储的字符串并不必复制操作更快。
- 某些看似可靠的移动操作最终也会导致复制。标准库中的某些容器操作提供了强大的异常安全保证,确保依赖那些保证的 C++98 的代码在升级到 C++11 且仅当移动操作不会抛出异常,从而可能替换操作时,不会不可运行。结果就是,即使类提供了更具效率的移动操作,而且即使移动操作更合适(比如源对象是右值),编译器仍可能被迫使用复制操作,因为移动操作没有声明 noexcept。
- C++11 的移动语义并无优势的情况
- 没有移动操作
- 要移动的对象没有提供移动操作,所以移动的写法也会变成复制操作
- 移动不会更快
- 要移动的对象提供的移动操作并不比复制速度更快
- 移动不可用
- 进行移动的上下文要求移动操作不会抛出异常,但是该操作没有被声明为 noexcept
- 源对象是左值
- 除了极少数的情况外(例如 Item25),只有右值可以作为移动操作的来源。
- 没有移动操作
- 通用代码中的典型情况,比如编写模板代码,因为你不清楚你处理的具体类型是什么。在这种情况下,你必须像出现移动语义之前那样,像在 C++98 里一样保守地去复制对象。“不稳定的”代码也是如此,即那些由于经常被修改导致类型特性变化的源代码。
- 总结
- 假定移动操作不存在,成本高,未被使用。
- 在已知的类型或者支持移动语义的代码中,就不需要上面的假设。
条款三十:熟悉完美转发失败的情况¶
- 编译器不能推导出 fwd 的一个或者多个形参类型。 这种情况下代码无法编译。
- 编译器推导“错”了 fwd 的一个或者多个形参类型。 在这里,“错误”可能意味着 fwd 的实例将无法使用推导出的类型进行编译,但是也可能意味着使用 fwd 的推导类型调用 f,与用传给 fwd 的实参直接调用 f 表现出不一致的行为。这种不同行为的原因可能是因为 f 是个重载函数的名字,并且由于是“不正确的”类型推导,在 fwd 内部调用的 f 重载和直接调用的 f 重载不一样。
- 花括号初始化器
- 编译器不准在对 fwd 的调用中推导表达式 { 1, 2, 3 } 的类型,因为 fwd 的形参没有声明为 std::initializer_list。对于 fwd 形参的推导类型被阻止,编译器只能拒绝该调用。
- 0 或者 NULL 作为空指针
- 改用 nullptr 即可
- 仅有声明的整型 static const 数据成员
- 没有定义,完美转发就会失败
- 重载函数的名称和模板名称
- fwd 的完美转发函数接受一个重载函数名或者模板名,方法是指定要转发的那个重载或者实例。比如,你可以创造与 f 相同形参类型的函数指针,通过 processVal 或者 workOnVal 实例化这个函数指针
- 位域
- non-const 引用不应该绑定到位域。
- 没有函数可以绑定引用到位域,也没有函数可以接受指向位域的指针,因为不存在这种指针。
- 位域可以传给的形参种类只有按值传递的形参,有趣的是,还有 reference-to-const
- 总结
- 在大多数情况下,完美转发工作的很好。你基本不用考虑其他问题。但是当其不工作时——当看起来合理的代码无法编译,或者更糟的是,虽能编译但无法按照预期运行时——了解完美转发的缺陷就很重要了。同样重要的是如何解决它们。在大多数情况下,都很简单。
- 当模板类型推导失败或者推导出错误类型,完美转发会失败。
- 导致完美转发失败的实参种类有
- 花括号初始化
- 作为空指针的 0 或者 NULL
- 仅有声明的整型 static const 数据成员
- 模板和重载函数的名字
- 位域。
第六章 Lambda 表达式¶
- 导引
- lambda 可以做的所有事情都可以通过其他方式完成。但是 lambda 是创建函数对象相当便捷的一种方法,对于日常的 C++ 开发影响是巨大的。
- 没有 lambda 时,STL 中的 “_if” 算法(比如,std::find_if,std::remove_if,std::count_if 等)通常需要繁琐的谓词,但是当有 lambda 可用时,这些算法使用起来就变得相当方便。用比较函数(比如,std::sort,std::nth_element,std::lower_bound 等)来自定义算法也是同样方便的
- 在 STL 外,lambda 可以快速创建 std::unique_ptr 和 std::shared_ptr 的自定义删除器
- 使线程 API 中条件变量的谓词指定变得同样简单。
- 除了标准库,lambda 有利于即时的回调函数,接口适配函数和特定上下文中的一次性函数。lambda 确实使 C++ 成为更令人愉快的编程语言。
条款三十一:避免使用默认捕获模式¶
- C++11 中有两种默认的捕获模式
- 按引用捕获
- 按值捕获
- 默认按引用捕获模式可能会带来悬空引用的问题,而默认按值捕获模式可能会诱骗你让你以为能解决悬空引用的问题(实际上并没有),还会让你以为你的闭包是独立的(事实上也不是独立的)
- 捕获只能应用于 lambda 被创建时所在作用域里的 non-static 局部变量(包括形参)。在 Widget::addFilter 的视线里,divisor 并不是一个局部变量,而是 Widget 类的一个成员变量。它不能被捕获。
- 总结
- 默认的按引用捕获可能会导致悬空引用。
- 默认的按值捕获对于悬空指针很敏感(尤其是 this 指针),并且它会误导人产生 lambda 是独立的想法。
条款三十二:使用初始化捕获来移动对象到闭包中¶
- “=”左侧的作用域不同于右侧的作用域。左侧的作用域是闭包类,右侧的作用域和 lambda 定义所在的作用域相同。
- 如果你坚持要使用 lambda(并且考虑到它们的便利性,你可能会这样做),移动捕获可以在 C++11 中这样模拟
- 将要捕获的对象移动到由 std::bind 产生的函数对象中
- 将“被捕获的”对象的引用赋予给 lambda。
- 总结
- 使用 C++14 的初始化捕获将对象移动到闭包中。
- 在 C++11 中,通过手写类或 std::bind 的方式来模拟初始化捕获。
条款三十三:对 auto&& 形参使用 decltype 以 std::forward 它们¶
- 表明用右值引用类型和用非引用类型去初始化 std::forward 产生的相同的结果。
- 所以无论是左值还是右值,把 decltype(x) 传递给 std::forward 都能得到我们想要的结果
- 总结
- 对 auto&& 形参使用 decltype 以 std::forward 它们。
条款三十四:考虑 lambda 而非 std::bind¶
- 函数重载问题
- 编译器无法确定应将两个 setAlarm 函数中的哪一个传递给 std::bind。 它们仅有的是一个函数名称,而这个单一个函数名称是有歧义的
- 用 lambda 可能会比使用 std::bind 能生成更快的代码
- 使用 std::bind 进行编码的代码可读性较低,表达能力较低,并且效率可能较低。
- 在 C++11 中,可以在两个受约束的情况下证明使用 std::bind 是合理的
- 移动捕获。C++11 的 lambda 不提供移动捕获,但是可以通过结合 lambda 和 std::bind 来模拟。
- 多态函数对象。因为 bind 对象上的函数调用运算符使用完美转发,所以它可以接受任何类型的实参。当你要绑定带有模板化函数调用运算符的对象时,此功能很有用。
- 总结
- 与使用 std::bind 相比,lambda 更易读,更具表达力并且可能更高效。
- 只有在 C++11 中,std::bind 可能对实现移动捕获或绑定带有模板化函数调用运算符的对象时会很有用。
第七章 并发 API¶
条款三十五:优先考虑基于任务的编程而非基于线程的编程¶
- 开发者想要异步执行 doAsyncWork 函数,通常有两种方式
- 通过创建 std::thread 执行 doAsyncWork,这是应用了基于线程(thread-based)的方式
- 将 doAsyncWork 传递给 std::async,一种基于任务(task-based)的策略
- 基于任务的方法通常比基于线程的方法更优
- 我们假设调用 doAsyncWork 的代码对于其提供的返回值是有需求的。
- 基于线程的方法对此无能为力
- std::async 返回的 future 提供了 get 函数(从而可以获取返回值)。
- 如果 doAsycnWork 发生了异常,get 函数就显得更为重要,因为 get 函数可以提供抛出异常的访问
- 而基于线程的方法,如果 doAsyncWork 抛出了异常,程序会直接终止
- 基于任务的抽象层次更高。基于任务的方式使得开发者从线程管理的细节中解放出来,对此在 C++ 并发软件中总结了“thread”的三种含义
- 硬件线程(hardware threads)是真实执行计算的线程。现代计算机体系结构为每个 CPU 核心提供一个或者多个硬件线程
- 软件线程(software threads)(也被称为系统线程(OS threads、system threads))是操作系统(假设有一个操作系统。有些嵌入式系统没有。)管理的在硬件线程上执行的线程。
- std::thread 是 C++执行过程的对象,并作为软件线程的句柄(handle)。
- 有些 std::thread 对象代表“空”句柄,即没有对应软件线程,因为它们处在默认构造状态(即没有函数要执行)
- 有些被移动走(移动到的 std::thread 就作为这个软件线程的句柄)
- 有些被 join(它们要运行的函数已经运行完)
- 有些被 detach(它们和对应的软件线程之间的连接关系被打断)
- 软件线程是有限的资源。如果开发者试图创建大于系统支持的线程数量,会抛出 std::system_error 异常,即使编写了不抛出异常的代码 noexcept
- 即使没有超出软件线程的限额,仍然可能会遇到资源超额(oversubscription)的麻烦
- 有了 std::async,GUI 线程中响应变慢仍然是个问题,因为调度器并不知道你的哪个线程有高响应要求。这种情况下,你会想通过向 std::async 传递 std::launch::async 启动策略来保证想运行函数在不同的线程上执行
- 最前沿的线程调度器使用系统级线程池(thread pool)来避免资源超额的问题,并且通过工作窃取算法(work-stealing algorithm)来提升了跨硬件核心的负载均衡。C++ 标准实际上并不要求使用线程池或者工作窃取,实际上 C++11 并发规范的某些技术层面使得实现这些技术的难度可能比想象中更有挑战。不过,库开发者在标准库实现中采用了这些技术,也有理由期待这个领域会有更多进展。
- 仍然存在一些场景直接使用 std::thread 会更有优势
- 你需要访问非常基础的线程 API
- 为了提供对底层系统级线程 API 的访问,std::thread 对象提供了 native_handle 的成员函数,而 std::future(即 std::async 返回的东西)没有这种能力。
- 你需要且能够优化应用的线程使用。
- 你需要实现 C++ 并发 API 之外的线程技术
- 你需要访问非常基础的线程 API
- 总结
- std::thread API 不能直接访问异步执行的结果,如果执行函数有异常抛出,代码会终止执行。
- 基于线程的编程方式需要手动的线程耗尽、资源超额、负责均衡、平台适配性管理。
- 通过带有默认启动策略的 std::async 进行基于任务的编程方式会解决大部分问题。
条款三十六:如果有异步的必要请指定 std::launch::async¶
- 你事实上要求这个函数按照 std::async 启动策略来执行。有两种标准策略,每种都通过 std::launch 这个限域 enum 的一个枚举名表示
- std::launch::async 启动策略意味着 f 必须异步执行,即在不同的线程。
- std::launch::deferred 启动策略意味着 f 仅当在 std::async 返回的 future 上调用 get 或者 wait 时才执行。
- 只要满足以下条件,std::async 的默认启动策略就可以使用
- 任务不需要和执行 get 或 wait 的线程并行执行。
- 读写哪个线程的 thread_local 变量没什么问题。
- 可以保证会在 std::async 返回的 future 上调用 get 或 wait,或者该任务可能永远不会执行也可以接受。
- 使用 wait_for 或 wait_until 编码时考虑到了延迟状态。
- 总结
- std::async 的默认启动策略是异步和同步执行兼有的。
- 这个灵活性导致访问 thread_locals 的不确定性,隐含了任务可能不会被执行的意思,会影响调用基于超时的 wait 的程序逻辑。
- 如果异步执行任务非常关键,则指定 std::launch::async。
条款三十七:使 std::thread 在所有路径最后都不可结合¶
- 每个 std::thread 对象处于两个状态之一
- 可结合的(joinable)
- 正在运行或者可能要运行的异步执行线程
- 对应于一个阻塞的(blocked)或者等待调度的线程的 std::thread 是可结合的,对应于运行结束的线程的 std::thread 也可以认为是可结合的。
- 不可结合的(unjoinable)
- 默认构造的 std::threads。这种 std::thread 没有函数执行,因此没有对应到底层执行线程上。
- 已经被移动走的 std::thread 对象。移动的结果就是一个 std::thread 原来对应的执行线程现在对应于另一个 std::thread。
- 已经被 join 的 std::thread 。在 join 之后,std::thread 不再对应于已经运行完了的执行线程。
- 已经被 detach 的 std::thread 。detach 断开了 std::thread 对象与执行线程之间的连接。
- 销毁可结合的线程如此可怕以至于实际上禁止了它(规定销毁可结合的线程导致程序终止)
- 但是标准库没有 std::thread 的 RAII 类,可能是因为标准委员会拒绝将 join 和 detach 作为默认选项,不知道应该怎么样完成 RAII。
- 可结合的(joinable)
- 总结
- 在所有路径上保证 thread 最终是不可结合的。
- 析构时 join 会导致难以调试的表现异常问题。
- 析构时 detach 会导致难以调试的未定义行为。
- 声明类数据成员时,最后声明 std::thread 对象。
条款三十八:关注不同线程句柄的析构行为¶
- 总结
- future 的正常析构行为就是销毁 future 本身的数据成员。
- 引用了共享状态——使用 std::async 启动的未延迟任务建立的那个——的最后一个 future 的析构函数会阻塞住,直到任务完成。
条款三十九:对于一次性事件通信考虑使用 void 的 futures¶
- 总结
- 对于简单的事件通信,基于条件变量的设计需要一个多余的互斥锁,对检测和反应任务的相对进度有约束,并且需要反应任务来验证事件是否已发生。
- 基于 flag 的设计避免的上一条的问题,但是是基于轮询,而不是阻塞。
- 条件变量和 flag 可以组合使用,但是产生的通信机制很不自然。
- 使用 std::promise 和 future 的 方案避开了这些问题,但是这个方法使用了堆内存存储共享状态,同时有只能使用一次通信的限制。
条款四十:对于并发使用 std::atomic,对于特殊内存使用 volatile¶
- 总结
- std::atomic 用于在不使用互斥锁情况下,来使变量被多个线程访问的情况。是用来编写并发程序的一个工具。
- volatile 用在读取和写入不应被优化掉的内存上。是用来处理特殊内存的一个工具。
第八章 微调¶
条款四十一:对于移动成本低且总是被拷贝的可拷贝形参,考虑按值传递¶
- 是否存在一种编写 addName 的方法,可以左值拷贝,右值移动,只用处理一个函数(源代码和目标代码中),且避免使用通用引用?答案是是的。你要做的就是放弃你学习 C++编程的第一条规则。这条规则是避免在传递用户定义的对象时使用传值方式。像是 addName 函数中的 newName 形参,按值传递可能是一种完全合理的策略。
- 仅考虑对于可拷贝形参使用按值传递
- 不符合此条件的的形参必须有只可移动的类型(move-only types)(的数据成员)
- 对于只可移动类型,没必要为左值实参提供重载,“重载”方案只需要一个重载函数
- 按值传递应该仅考虑那些移动开销小的形参。当移动的开销较低,额外的一次移动才能被开发者接受,但是当移动的开销很大,执行不必要的移动就类似执行一个不必要的拷贝,而避免不必要的拷贝的重要性就是最开始 C++98 规则中避免传值的原因!
- 只对总是被拷贝的形参考虑按值传递
- 如果存在检查有效性的代码,无效则不进行拷贝,那么传值就浪费了一个拷贝开销
- 即使你编写的函数对可拷贝类型执行无条件的复制,且这个类型移动开销小,有时也可能不适合按值传递。这是因为函数拷贝一个形参存在两种方式:一种是通过构造函数(拷贝构造或者移动构造),还有一种是赋值(拷贝赋值或者移动赋值)。
- 移动赋值时,利用重载的方式,有可能避免两次动态内存管理操作
- 对于需要运行尽可能快的软件来说,按值传递可能不是一个好策略,因为避免即使开销很小的移动操作也非常重要。此外,有时并不能清楚知道会发生多少次移动操作。
- 按值传递会收到切片问题的影响。
- 总结
- 对于可拷贝,移动开销低,而且无条件被拷贝的形参,按值传递效率基本与按引用传递效率一致,而且易于实现,还生成更少的目标代码。
- 通过构造拷贝形参可能比通过赋值拷贝形参开销大的多
- 按值传递会引起切片问题,所说不适合基类形参类型
条款四十二:考虑使用置入代替插入¶
- 是否存在一种方法可以获取字符串字面量并将其直接传入到步骤 2 里在 std::vector 内构造 std::string 的代码中,可以避免临时对象 temp 的创建与销毁。
- 它不叫 push_back。push_back 函数不正确。你需要的是 emplace_back。
- emplace_back 使用完美转发,因此只要你没有遇到完美转发的限制,就可以传递任何实参以及组合到 emplace_back。
- 每个支持 insert(除了 std::forward_list 和 std::array)的标准容器支持 emplace
- 插入函数接受对象去插入,而置入函数接受对象的构造函数接受的实参去插入。这种差异允许置入函数避免插入函数所必需的临时对象的创建和销毁
- 通过 benchmark 测试来确定置入和插入哪种更快
- 启发式的判断
- 值是通过构造函数添加到容器,而不是直接赋值
- 传递的实参类型与容器的初始化类型不同
- 容器不拒绝重复项作为新值
- 在决定是否使用置入函数时,需要注意另外两个问题
- 资源管理,有时创建临时对象是值得的,避免异常导致泄漏
- 与 explicit 的构造函数的交互
- 不被认为是个隐式转换要求
- 使用置入函数时,请特别小心确保传递了正确的实参
- 总结
- 原则上,置入函数有时会比插入函数高效,并且不会更差。
- 实际上,当以下条件满足时,置入函数更快
- 值被构造到容器中,而不是直接赋值
- 传入的类型与容器的元素类型不一致
- 容器不拒绝已经存在的重复值。
- 置入函数可能执行插入函数拒绝的类型转换。