【读书笔记】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); // 能通过编译,运行时抛出异常,难以发现此问题 

猜你喜欢

转载自www.cnblogs.com/Asumi/p/12453025.html