Effective Modern C++
基于 Effective C++: 42 Specific Ways to Improve Your Use of C++11 and C++14 编写
推断类型
理解模板类型推断
在模板类型推导中,引用参数被视为非引用
对通用引用 &&
参数,左值参数特别对待
在按值传递参数时,const
和 volatile
参数被视为非 const
和非 volatile
在模板类型推导中,数组或函数名参数退化为指针,除非被用于初始化引用
理解 auto
类型推断
auto
通常和模板类型推断一样,但是 auto
类型推断将大括号初始化 {}
视为 std::initializer_list
,模板类型推断不这样
函数返回值中的 auto
或 lambda 参数是函数模板推断,不是 auto
类型推断
理解 decltype
decltype
在没有任何修改的情况下,几乎总是生成变量或表达式的正确类型
对于不同于名字的类型为 T
的左值表达式,decltype
总是返回 T&
decltype(auto)
类似于 auto
,从初始化推断类型,但是使用了 decltype
规则推断类型
知道如何观察推断出的类型
- IDE
- 编译器错误信息
boost/type_index
:type_id_with_cvr<decltype(param)>().pretty_name()
但某些工具的结果可能不准确
auto
倾向于 auto
而不是显式类型声明
auto
变量必须初始化- 可以用于防止一些未知类型的问题
- 减轻重构过程
- 打的字较少
当 auto
推断出不想要的类型时,使用显式类型初始化习语
不可见的代理类型可能导致 auto
推断出“错误”的类型,如 std::vector<bool>
返回的是一个代理
显式类型初始化习语强迫 auto
推断想要的类型:auto index = static_cast<int>(d * c.size())
移动到现代 C++
区分 ()
和 {}
创建对象
大括号初始化可以广泛应用于初始化中,可以防止狭窄的转换,还能够防止 C++ 中逆天的语法设计:
|
但是大括号初始化几乎一定会匹配为 std::initializer_list
参数对应的构造函数,尽管明显有更好的匹配
std::vector<int>
初始化使用圆括号和花括号效果不同
在模板中选择是小括号还是大括号用于对象创建更具有挑战性
倾向于 nullptr
而不是 0
和 NULL
因为后两者本质上都是一个整数 0,只有前者是一个特殊的指针 std::nullptr_t
,可以隐式转化为任意指针类型
当然,为了兼容,还是应该避免重载整数和指针
同样,在模板推断中,后两者都会被推断为整数类型,从而造成麻烦
倾向于使用别名声明而不是 typedef
typedef
不支持模板化,但是别名声明可以,如
|
别名模板避免 ::type
后缀,同时在模板中,typedef
通常需要 typename
前缀
C++14 对所有 C++11 的类型特征转化提供了别名模板,例如
|
倾向于有作用域的 enum
而不是无作用域的 enum
C++98 风格的是无作用域的 enum
,如 enum Color
有作用域的 enum
的枚举成员只在 enum
中可见,必须显示 cast 才能转换为其他类型
有作用域的和无作用域的 enum
都支持潜在类型规范,默认的有作用域的 enum
的潜在类型是 int
,而无作用域的没有默认潜在类型
有作用域的总是可以前向声明,无作用域的只有在声明规范了一个潜在的类型时才可以前向声明(否则编译器无法得知要分配多少空间)
倾向于删除函数而不是私有未定义的
非成员函数也可以被删除,可以防止函数参数的隐式转换,如:
|
特定的模板实例也可以被删除
声明覆盖的函数为 override
成员函数引用限定符可以不同地对待左值和右值对象
倾向于 const_iterator
而不是 iterator
在最大化泛型代码中,倾向于 begin
等的非成员函数版本,如
|
如果某函数不会发出异常,则声明其为 noexcept
noexcept
是函数接口的一部分,意味着调用者依赖于它
noexcept
函数比非 noexcept 函数更优化
noexcept
特别有价值,如移动操作、swap
、内存重新分配函数和析构函数
大部分函数的异常中立而不是 noexcept
的
尽可能使用 constexpr
constexpr
对象同时是 const
的且用编译时已知的值初始化
constexpr
函数可以在当编译时已知的参数时,产生编译时结果
constexpr
对象和函数可能在比非 constexpr
对象和函数更广泛的上下文有用
使 const
成员函数线程安全
除非确定不会用于并发上下文中
使用 std::atomic
变量可能比 mutex
效率更改,但是只能用于操作一个变量或内存地址
理解特殊成员函数生成
编译器会自动生成的特殊成员函数:默认构造函数,析构函数,复制操作和移动操作
移动操作只会在没有显式声明移动操作、复制操作和析构函数的情况下才会生成
复制构造函数只会在没有显式声明复制构造函数的情况下生成,如果声明了移动操作,则会删除。
复制赋值操作只会在缺少显式声明的复制赋值操作的情况下生成,如果声明了移动操作,则会删除。
有显式声明的析构函数的类复制操作的生成被弃用
成员函数模板不会影响特殊成员函数的生成
智能指针
将 std::unique_ptr
用于互斥所有权的资源管理
std::unique_ptr
小、块、只能移动
默认通过 delete
资源析构,但是可以指定自定义删除器。有状态的删除器和函数指针作为删除器都会增加 std::unique_ptr
对象的大小
将 std::unique_ptr
转化为 std::shared_ptr
很容易
将 std::shared_ptr
用于共享所有权的资源管理
std::shared_ptr
提供了接近垃圾收集的方便方法
相比于 std::unique_ptr
,std::shared_ptr
两倍大,同时还有有控制块的开销,还需要引用计数操作
默认资源析构通过 delete
,也可以自定义删除器。删除器的类型对 std::shared_ptr
没有影响
避免从原始指针类型创建 std::shared_ptr
将 st::weak_ptr
用于 shared_ptr
类似的可能悬垂的指针
可能的用法:
缓存:
|
观察者列表
防止 std::shared_ptr
循环
倾向于 std::make_unique
和 std::make_shared
而不是直接使用 new
相比直接使用 new
,make
函数减少了代码重复,提高了安全性,同时对 std::make_shared
和 std::allocate_shared
来说,生成的代码更小更快
make
函数不适用的情况是当想要自定义删除器或者传递大括号初始化值时
对于 std::shared_ptr
,额外的 make
函数可能不推荐的情况是:
- 自定义内存管理的类
- 关心内存的系统,非常大的对象,
std::weak_ptr
比std::shared_ptr
活得长
使用 Pimpl 习语时,在实现文件中定义特殊成员函数
Pimpl 习语通过减少类客户和类实现的编译依赖来减少构建时间
对于 std::unique_ptr pImpl
指针,在函数头中声明成员函数,但是在实现文件中实现。即使默认函数实现是可接受的也要这么做
以上建议适用于 std::unique_ptr
,但是不适用于 std::shared_ptr
右值引用,移动语义和完美转发
理解 std::move
和 std::forward
std::move
执行无条件转化为右值,实际上不会移动任何东西
std::forward
只有在参数被绑定到右值时才会将参数转化为右值
std::move
和 std::forward
在运行时都不会做任何事
区分通用引用和右值引用
如果寒素模板参数对推断的类型 T
有类型 T&&
,或者一个对象使用 auto&&
被推断,则该参数或对象是通用引用
如果类型声明的形式不是 type&&
,或者不发生参数推断,则 type&&
代表右值引用
通用引用只有在被右值初始化时才会关联到右值引用;如果以左值初始化,则关联到左值引用
对右值引用使用 std::move
,对通用引用使用 std::forward
在最后一次使用时,对右值引用使用 std::move
,对通用引用使用 std::forward
按值返回时也一样
不要对局部变量使用 std::move
或 std::forward
,因为其已经执行了返回值优化
避免对通用引用重载
对通用引用的重载通常会导致通用引用比预期的更频繁调用
完美转发的构造函数特别有问题,因为其通常对非 const 左值的匹配比复制构造函数更好,且会劫持派生类对基类调用复制和移动构造函数
熟悉重载通用引用的替代
方法一:放弃重载,将函数命名不同
方法二:按 const T&
传递参数
方法三:按值传递参数
方法四:使用标签派遣
即通过一个标签来区分函数,如:
|
方法五:限制接受通用引用的模板
通过 std::enable_if
控制了编译器使用通用引用重载的条件
|
通用引用参数通常有效率的优势,但又有缺乏可用性的劣势
理解引用合并
引用合并出现在以下上下文中:
- 模板实例化
auto
类型生成typedef
和别名声明创建和使用decltype
当编译器在引用合并的上下文中生成对引用的引用时,结果变成一个引用
- 如果原始引用中有左值引用,则结果是左值引用
- 否则,是右值引用
在类型推断区分从右值的左值和引用合并发生时,通用引用时右值引用
假设移动操作不存在,不便宜,不被使用
移动操作并不一定是高效的,因为其可能会不存在,不便宜,不被使用
但是在已知类型或支持移动语义的代码中,不需要这些假设
熟悉完美转发失败的情况
当模板类型推断失败或推断出了错误的类型时,完美转发失败
大括号初始化
|
0
或 NULL
指针:替换为 nullptr
只声明的整数 static const
数据成员:
|
需要在 .cpp 文件中定义:
|
重载的函数名称和模板名称:
|
比特域
|
因为 fwd
的参数是一个引用,不可以将一个非 const 引用绑定到一个比特域(毕竟所谓的“引用”类似于指针,不可以指向某个位)
解决方法是复制一份:
|
Lambda 表达式
- Lambda 表达式是一个表达式
- 闭包是由 Lambda 在运行时创建的一个对象
- 闭包类是闭包从其实例化的类
避免默认捕捉模式
默认通过引用捕捉可能会导致悬垂引用
默认通过值捕捉容许悬垂指针(特别是 this
),且会让人误解 Lambda 是自我包含的
使用初始化捕捉来将对象移动到闭包中
需要指明:
- 数据成员的名称
- 用于初始化的表达式
|
对 auto&&
参数使用 decltype
来 std::forward
它们
|
倾向于 lambda 而不是 std::bind
lambda 更可读、更有表现力、可能比使用 std::bind
更高效
并发 API
倾向于基于 task 的编程而不是基于 thread 的
因为 std::thread
API 无法直接获取异步运行函数的返回值,且如果抛出了异常,程序终止
基于线程的编程要求手动管理线程枯竭,认购超额,负载平衡,适配新平台
基于 task 的编程可以通过 std::async
的默认启动策略处理绝大部分问题
使用 thread 的一些特殊的情况:
- 需要使用底层的线程实现 API
- 需要为应用优化线程使用
- 需要实现除 C++ 并发 API 的线程技术
如果异步是必须的,则指定 std::launch::async
std::async
的默认启动策略即允许异步也允许同步 task 执行
这种灵活性会导致访问 thread_local
的不确定性,造成 task 可能永远不会执行,影响程序对基于超时的 wait
调用的逻辑
使 std::thread
在所有路径上都不可合并
C++ 标准没有提供相关的 RAII,是因为:
- 析构时
join
可能导致难以调试的效率异常 - 析构时
detach
可能导致难以调试未定义行为
对于自己定义的 RAII 包装,在数据成员列表的最后声明 std::thread
对象,保证先包装,后执行
了解不同的线程处理析构函数的行为
future 的析构函数通常只析构 future 的数据成员
最终 future 引用一个非延迟的 task 的共享状态,直到 task 完成
给一次性事件通信考虑 void
future
对于简单的事件通信,基于条件变量的设计需要多余的 mutex,对相关的检测和反应 task 的过程强加了限制,需要反应 task 验证事件已经发生了
使用 flag 的设计避免了这些问题,但是是基于 polling 的,而不是 blocking
condvar 和 flag 可以一起使用,但是造成的通信机制有些僵硬
使用 std::promise
和 future 可以避免这些问题,但是该方法使用堆内存来共享状态,且受限于一次性通信
std::atomic
用于并发,volatile
用于特殊的存储
std::atomic
用于在不使用 mutex 的情况下,多个线程访问的数据,是编写并发软件的工具
volatile
是用于读和写不应该被优化的内存,是处理特殊内存的工具
Tweaks
对于移动和总是要被复制的可复制参数,考虑按值传递
|
几乎和按引用传递一样高效,更容易实现,且生成更少的对象代码。当然,代价就是一定会多一次 std::move()
通过构造复制参数可能比通过赋值复制明显更高效
按值传递会有切片问题,尤其是对基类参数类型不适合时
考虑 emplace 而不是插入
原则上,emplace
系列的函数应该有时比插入的对应部分更高效,应该永远不会更低效
实际上,只有满足以下条件时,才会更快:
- 添加的值被构造到容器中,而不是被赋值
- 传递的参数类型和容器持有的类型不同
- 容器不会因重复而拒绝添加的值
放置函数可能执行会被插入函数拒绝的类型转换