【读书笔记】Effective Modern Cpp(二)
右值引用、移动语义和完美转发
23 std::move和std::forward只是一种强制类型转换
- std::move会保留cv限定符号,但是会导致传入右值却执行拷贝操作。所以如果希望移动std ::move 的值,传值类型不能是const
- C++11引入了右值引用,但原有的模板无法转发左值,因此引入std::forward
void f(int&) { std::cout << 1; }
void f(const int&) { std::cout << 2; }
void f(int&&) { std::cout << 3; }
// 用多个重载转发给对应版本比较繁琐
void g(int& x)
{
f(x);
}
void g(const int& x)
{
f(x);
}
void g(int&& x)
{
f(std::move(x));
}
// 同样可以用一个模板来替代上述功能
template<typename T>
void h(T&& x)
{
f(std::forward<T>(x)); // 注意std::forward的模板参数是T
}
int main()
{
int a = 1;
const int b = 1;
g(a); h(a); // 11
g(b); h(b); // 22
g(std::move(a)); h(std::move(a)); // 33
g(1); h(1); // 33
}
- std::forward可以取代std ::move,但是后者更清晰简单
h(std::forward<int>(a)); // 3
h(std::move(a)); // 3
24 转发引用与右值引用的区别
- 带右值引用符号不一定就是右值引用,这种不确定类型的引用称为转发引用
- 转发引用必须严格按 T&& 的形式涉及类型推断
template<typename T>
void f(std::vector<T>&&) {} // 右值引用而非转发引用
std::vector<int> v;
f(v); // 错误
template<typename T>
void g(const T&&) {} // 右值引用而非转发引用
int i = 1;
g(i); // 错误
- auto&& 都是转发引用,因为一定涉及类型推断
- lambda中也可以使用完美转发。
25 对右值引用使用std::move,对转发引用使用std::forward
- 右值引用只会绑定到可移动对象上,因此应该使用std::move;转发引用用右值初始化时才是右值引用,用std ::forward
class A{
public:
//右值引用
A(A&& rhs) : s(std::move(rhs.s)),p(std::move(rhs.p)){}
template<typename T>
void f(T&& x){
s = std::forward<T>(x); //转发引用
}
private:
std::string s;
std::shared_ptr<int> p;
- 如果想只有在移动构造函数保证不抛出异常时才能转为右值,用std::move_ if_noexcept 替代std :: move
- 如果返回对象传入时是右值引用或转发引用,在返回时用std::move或std ::forward转换。
- 局部变量会直接创建在返回值分配的内存上,从而避免拷贝。std::move并不满足RVO的要求。
26 避免重载使用转发引用的函数
- 如果函数参数接受左值引用,则传入右值时执行的仍然是拷贝
void f(const std::string& s){
v.emplace_back(s);
}
//传入右值,执行的仍然是拷贝
f(std::string (“hi”));
f("hi");
//但是让函数接受转发引用就可以解决问题
void f(T&& s){
v.emplace_back(std::forward<T>(s));
}
//现在就是移动操作
f(std::string(“hi”));
f("hi");
- 转发引用几乎可以匹配任何类型。但是如果重载就会引起问题。
27 重载转发引用的替代方案
- 标签分派:额外引入一个参数来打破转发引用的万能匹配
template<typename T>
//额外引入参数
void g(T&& s, std::false_type){
v.emplace_back(std::forward<T>(s));
}
std::string makeString(int n){
return std::string("hi");
}
void g(int n, std::true_type){
v.emplace_back(makeString(n));
}
template<typename T>
void f(T&& s){
g(std::forward<T>(s), std::is_integral<std::remove_reference_t<T>>());
}
unsigned i = 1;
f(i); // OK:调用int版本
- 使用std::enable_if在特定条件下禁用模板
- 标签分派用在构造函数不方便,因此可以使用std::enable_if
- 在派生类调用基类的构造函数时,派生类和基类是不同类型,不会禁用模板,因此还需要使用std::is_base _of
- 为了方便调试,可以用static_assert预设错误信息。
28 引用折叠
- 出现的语境:模板实例化,auto类型推断,decltype类型推断,typedef/using别名声明
//引用的引用非法。
int a = 1;
int& & b = a; //错误
//当左值传给接受转发引用的模板,模板参数会推断成引用的引用
template<typename T>
void f(T&&);
int i = 1;
f(i); //T是int&,T& &&变成引用的引用。引入折叠
- 引用折叠的机制规则如下:
& + & → &
& + && → &
&& + & → &
&& + && → &&
29 移动不比拷贝快的情况
- 无移动操作:待移动对象不提供移动操作,移动请求将变为拷贝请求
- 移动不比拷贝快:待移动对象虽然有移动操作,但不比拷贝操作快
- 移动不可用:本可以移动时,要求移动操作不能抛异常,但未加上 noexcept 声明
- 有些特殊场景无需使用移动语义。比如RVO
- 移动不一定比拷贝快:array,string(小型字符串15字节时)
30 无法完美转发的类型
- 用相同实参调用原函数和转发函数,如果两者执行不同的操作,则称完美转发失败。
- 大括号初始化
- 作为空指针的0/NULL
- 只声明但未定义的static const整型数据成员
class A {
public:
static const int n = 1; // 仅声明
};
void f(int) {}
template<typename T>
void fwd(T&& x){
f(std::forward<T>(x));
}
f(A::n); // OK:等价于f(1)
fwd(A::n); // 错误:fwd形参是转发引用,需要取址,无法链接
- 重载函数的名称和函数模板名称
- 如果转发的是函数指针,可以直接将函数名作为参数,函数名会转换为函数指针
- 但如果要转发的函数名对应多个重载函数,则无法转发,因为模板无法从单独的函数名推断出函数类型
- 位域
- 转发引用是引用,要取址,但是位域不允许
struct A {
int a : 1;
int b : 1;
};
void f(int) {}
template<typename T>
void fwd(T&& x){
f(std::forward<T>(x));
}
A x{};
f(x.a); // OK
fwd(x.a); // 错误
lambda表达式
31 捕获的潜在问题
- 值捕获只保存捕获时的对象状态
- 引用捕获会保持与被捕获对象状态一致
- 引用捕获时,在捕获的局部变量析构后调用 lambda,将出现空悬引用
- C++14提供了广义lambda捕获
struct A {
auto f(){
return [i = i] { std::cout << i; };
};
int i = 1;
};
auto g(){
auto p = std::make_unique<A>();
return p->f();
}
// 或者直接写为
auto g = [p = std::make_unique<A>()]{ return p->f(); };
g()(); // 1
32 用初始化捕获将对象移入闭包
- move-only 类型对象不支持拷贝,只能采用引用捕获
- 初始化捕获则支持把 move-only 类型对象移动进 lambda 中
auto p = std::make_unique<int>(42);
auto f = [p = std::move(p)]() {std::cout << *p; };
assert(p == nullptr);
-
直接在捕获列表中初始化 move-only 类型对象
auto f = [p = std::make_unique<int>(42)]() {std::cout << *p; };
-
如果要在 C++11 中使用 lambda 并模拟初始化捕获,需要借助std::bind
auto f = std::bind(
[](const std::unique_ptr<int>& p) { std::cout << *p; },
std::make_unique<int>(42));
- bind对象包含对所有实参的拷贝。左值实参:拷贝构造,右值实参:移动构造
- 默认情况下,lambda生成的闭包类的operator()默认为const,成员变量也是
- bind对象的生命期和闭包相同
33 用decltype获取auto&&参数类型以std::forward
- 对于泛型lambda可以使用完美转发。用decltype:
- 如果传递给auto&&的实参是左值,则x为左值引用类型,decltype(x)为左值引用类型
- 如果传递给auto&&的实参是右值,则x为右值引用类型,decltype(x)为右值引用类型
auto f = [](auto&& x) { return g(std::forward<decltype(x)>(x)); };
34 用lambda替代std::bind
- 对比
auto f = [l, r] (const auto& x) { return l <= x && x <= r; };
// 用std::bind实现相同效果
using namespace std::placeholders;
// C++14
auto f = std::bind(
std::logical_and<>(),
std::bind(std::less_equal<>(), l, _1),
std::bind(std::less_equal<>(), _1, r));
// C++11
auto f = std::bind(
std::logical_and<bool>(),
std::bind(std::less_equal<int>(), l, _1),
std::bind(std::less_equal<int>(), _1, r));
- lambda可以指定值捕获和引用捕获,但是bind总会按值拷贝
- lambda 中可以正常使用重载函数,但是bind无法区分重载版本
- C++14不需要使用std::bind,C++11有两个场景需要:
- 模拟 C++11 缺少的移动捕获
- 函数对象的 operator() 是模板时,若要将此函数对象作为参数使用,用 std::bind 绑定才能接受任意类型实参
并发API
35 用std::async替代std::thread
- 异步运行函数的一种选择是:创建一个std::thread;另一种选择是用std ::async,它返回一个有计算结果的std ::future
int f();
std::thread t(f);
std::future<int> ft = std::async(f);
- 函数如果有返回值,thread无法直接获取,但是async可以用future的get获取。有异常时,get能访问,thread是直接终止程序。
- 并发的C++中,线程定义:
- hardware thread 是实际执行计算的线程,计算机体系结构中会为每个CPU内核提供一个或多个硬件线程
- software thread(OS thread或system thread)是操作系统实现跨进程管理,并执行硬件线程调度的线程
- std::thread 是 C++ 进程中的对象,用作底层 OS thread 的 handle
- std::async能把 oversubscription 的问题丢给库作者解决
- std::async分担了手动管理线程的负担,并提供了检查异步执行函数的结果的方式
- 以下情况仍然需要用std::thread
- 需要访问底层线程 API
- 需要为应用优化线程用法
- 实现标准库未提供的线程技术,比如线程池
36 用std::launch::async指定异步求值
- std::async有两种启动策略:
- std::launch ::async:必须异步,运行在不同线程
- std::launch ::deferred:函数只在返回future调用get/wait时运行。
- 默认启动方式:允许异步或同步
auto ft1 = std::async(f); // 意义同下
auto ft2 = std::async(std::launch::async | std::launch::deferred, f);
- std::async使用默认启动策略创建要满足的条件:
- 任务不需要与对返回值调用 get 或 wait 的线程并发执行
- 读写哪个线程的 thread_local 变量没有影响
- 要么保证对返回值调用 get 或 wait,要么接受任务可能永远不执行
- 使用 wait_for 或 wait_until 的代码要考虑任务被推迟的可能
- 以上只要一点不满足,就要用std::launch ::async
template<typename F, typename... Ts>
inline
auto // std::future<std::invoke_result_t<F, Ts...>>
reallyAsync(F&& f, Ts&&... args){
return std::async(std::launch::async,
std::forward<F>(f),
std::forward<Ts>(args)...);
}
37 RALL线程管理
- 每个std::thread对象都处于可合并或不可合并的状态。不可合并:
- 默认构造的std::thread
- 已移动的std::thread
- 已join或已join的thread
- 如果可合并的std::thread对象的析构函数被调用,则程序的执行将终止
void f() {}
void g(){
std::thread t(f); // t.joinable() == true
}
int main(){
g(); // g运行结束时析构t,导致整个程序终止
...
}
- 销毁一个可合并的 std::thread将导致终止程序。要避免程序终止,只要让可合并的线程在销毁时变为不可合并状态即可,使用RAII手法就能实现这点
class A {
public:
enum class DtorAction { join, detach };
A(std::thread&& t, DtorAction a) : action(a), t(std::move(t)) {}
~A(){
if (t.joinable()){
if (action == DtorAction::join) t.join();
else t.detach();
}
}
A(A&&) = default;
A& operator=(A&&) = default;
std::thread& get() { return t; }
private:
DtorAction action;
std::thread t;
};
void f() {}
void g(){
A t(std::thread(f), A::DtorAction::join); // 析构前使用join
}
int main(){
g(); // g运行结束时将内部的std::thread置为join,变为不可合并状态
// 析构不可合并的std::thread不会导致程序终止
// 这种手法带来了隐式join和隐式detach的问题,但可以调试
...
}
38 std::future的析构行为
- 销毁 std::future有时表现为隐式 join,有时表现为隐式 detach,有时表现为既不隐式 join 也不隐式 detach,但它不会导致程序终止。
std::promise<int> ps;
std::future<int> ft = ps.get_future();
-
callee结果存储在外部某个位置:shared state
-
shared state 通常用堆上的对象表示,决定了future的析构函数行为:
- 采用std:: launch:: async 启动策略的 std :: async返回的std:: future中,最后一个引用 shared state的,析构函数会保持阻塞至任务执行完成。本质上,这样一个 std::future 的析构函数是对异步运行的底层线程执行了一次隐式 join
- 其他所有 std:: future的析构函数只是简单地析构对象。对底层异步运行的任务,这相当于对线程执行了一次隐式 detach。对于被推迟的任务来说,如果这是最后一个 std::future],就意味着被推迟的任务将不会再运行
-
析构函数满足以下条件时发生特殊行为:
- future引用的shared state由调用std::async创建
- 任务启动策略是std:: launch:: async
- 这个future是最后一个引用shared state的
-
只有在async调用时出现的shared state才可能出现特殊行为
-
析构行为正常的原因
{
std::packaged_task<int()> pt(f);
auto ft = pt.get_future(); // ft可以正常析构
std::thread t(std::move(pt));
... // t.join() 或 t.detach() 或无操作
} // 如果t不join不detach,则此处t的析构程序终止
// 如果t已经join了,则ft析构时就无需阻塞
// 如果t已经detach了,则ft析构时就无需detach
// 因此std::packaged_task生成的ft一定可以正常析构
39 用std::promise和std::future之间的通信实现一次性通知
- 让一个任务通知另一个异步任务发生了特定事件,一种实现方法是使用条件变量,另一种是用 std:: promise:: set_value通知 std:: future::wait
std::promise<void> p;
void f(){
p.get_future().wait(); // 阻塞至p.set_value
std::cout << 1;
}
int main(){
std::thread t(f);
p.set_value(); // 解除阻塞
t.join();
}
//但是promise和future之间的shared state是动态分配的
//存在堆上的分配和回收成本。promise只能设置一次。
//一般用来创建暂停状态的thread
- std:: condition_ variable:: notify_ all]可以一次通知多个任务,这也可以通过 std:: promise 和[std::shared_future 之间的通信实现
std::promise<void> p;
void f(int x){
std::cout << x;
}
int main()
{
std::vector<std::thread> v;
auto sf = p.get_future().share();
for(int i = 0; i < 10; ++i) v.emplace_back([sf, i]{ sf.wait(); f(i); });
p.set_value();
for(auto& x : v) x.join();
}
40 std::atomic提供原子操作,volatile禁止优化内存
- std::atomic的原子操作
std::atomic<int> i(0);
void f(){
++i; // 原子自增
++i; // 原子自增
}
void g(){
std::cout << i;
}
int main(){
std::thread t1(f);
std::thread t2(g); // 结果只能是0或1或2
t1.join();
t2.join();
}
- volatile 变量是普通的非原子类型,则不保证原子操作
volatile int i(0);
void f(){
++i; // 读改写操作,非原子操作
++i; // 读改写操作,非原子操作
}
void g(){
std::cout << i;
}
int main(){
std::thread t1(f);
std::thread t2(g); // 存在数据竞争,值未定义
t1.join();
t2.join();
}
- std::atomic可以限制重排序以保证顺序一致性。volatile不会。
- volatile是告诉编译器正在处理的事特殊内存,不要对此内存上的操作优化。
其他轻微调整
41 对于可拷贝的形参,如果移动成本低且一定会被拷贝则考虑传值
- 一些函数的形参本身就是用于拷贝的,对左值实参应该执行拷贝,对右值实参应该执行移动
- C++98 中,按值传递一定是拷贝构造,但在 C++11 中,只在传入左值时拷贝,如果传入右值则移动
- 重载和模板的成本是:左值一次拷贝,右值一次移动。传值对左值一次拷贝一次移动,对右值两次移动。传值多一次移动但是避免麻烦。
- 可拷贝的形参才考虑传值,因为 move-only 类型只需要一个处理右值类型的函数
- 只有当移动成本低时,多出的一次移动才值得考虑,因此应该只对一定会被拷贝的形参传值
42 用emplace操作替代insert操作
- vector中的push_back对左值和右值的重载
template<class T, class Allocator = allocator<T>>
class vector {
public:
void push_back(const T& x);
void push_back(T&& x);
};
- 直接传入字面值,会创建临时对象,但是emplace_back不会。所有insert都有对应的emplace。
- emplace 不一定比 insert 快:
- 添加值到已有对象占据的位置
- set和map检查值是否存在时,值如果存在。
- emplace 函数在调用 explicit 构造函数时存在一个隐患
std::vector<std::regex> v;
v.push_back(nullptr); // 编译出错
v.emplace_back(nullptr); // 能通过编译,运行时抛出异常,难以发现此问题