Effective Modern C++读书笔记

  • 本笔记主要用于记录要领、体会及摘抄书中精华

第1章 类型推导

1.1 理解模板类型推导

  1. 在模板类型推导过程中

    1. 具有引用(&)或指针(*)类型的实参会被当成非引用类型来处理。换言之,其引用或指针会被忽略。
    template
    void f(T& param);
    
    int x = 27;
    const int cx = x;
    const int& rx = x;
    
    f(x);	// 此处T的类型被推导为int,param为int&
    f(cx);	// 此处T的类型被推导为const int,param为const int&
    f(rx);	// 此处T的类型被推导为const int,param为const int&
    
    template
    void f(T* param);
    
    int x = 27;
    const int* px = &x;
    
    f(&x);	// T的类型为int,param为int*
    f(px);	// T的类型为const int,param为const int*
    
    1. 对万能引用(&&)形参进行推导时,左值实参会进行特殊处理。
      1. 若形参是左值,则T及ParamType 都会被推导为左值引用。
      2. 若形参为右值,则同 1
    template
    void f(T&& param)
    
    int x = 27;
    const int cx = x;
    const int& rx = x;
    
    f(x);	// x为左值,因此T的类型为int&,param为int&
    f(cx);	// cx为左值,因此T的类型为const int&,param为const int&
    f(rx);	// rx为左值,因此T的类型为const int&,param为const int&
    f(27);	// 27为右值,因此T的类型为int,param为int&&
    
    1. 对按值传递的形参进行推导时,若实参类型中带有const或volatile修饰,则它们还是会被当作不带const或volatile修饰的类型来处理。
    template
    void f(T param);	// param按值传递
    
    const char* const ptr = "F";	// ptr是指向const对象的const指针
    
    f(ptr);	// T的类型为const char*,param为const char*,此时按值传递,将存在拷贝过程,拷贝过程中指针的const或volatile特性会被忽略,此处针对的是指针的修饰
    
    1. 数组或函数类型的实参会退化成对应的指针,除非它们被用来初始化引用。
    void fun(int param[]) // 函数形参无法被声明为真正的数组类型,此处声明仍然会按照指针形式处理,同void fun(int* param)
    void fun(int& param) // 即int (&)[]
    

1.2 理解auto类型推导

  1. 编译器会利用表达式来推导类型和参数类型。

  2. 一般情况下,auto类型推导和模板类型推导相同,但auto类型推导会假定用大括号内的初始化表达式表示一个std::initializer_list,但模板类型推导却不会。

    auto x = 27;
    // 等价于
    // template
    // void func_for_x(T param);
    // func_for_x(27);
    const auto cx = x;
    // 等价于
    // template
    // void func_for_cx(const T param);
    // func_for_cx(x);
    const auto& rx = x;
    // 等价于
    // template
    // void func_for_rx(const T& param);
    // func_for_rx(x);
    
    const char name[] = "R.N.Briggs";
    auto arr1 = name;	// arr1 类型为const char*
    auto& arr2 = name;	// arr2 类型为const char (&)[13]
    void someFunc(int, double);	// 函数类型为void(int, double)
    auto func1 = someFunc;	// func1类型为void (*)(int, double)
    
  3. 在函数返回值或lambda式形参中使用auto,意思是使用模板类型推导而非auto类型推导。

    auto x = {11, 23, 9};	// x类型为 std::initializer_list
    template	// 带有形参的模板,与x的声明等价
    void f(T param);
    f({11, 23, 9});			// 此处无法推导T的类型,报错
    
    template
    void f(std::initializer_list initlist);
    f({11, 23, 9});	// T的类型推导为int,从而initlist类型为std::initializer_list
    
    auto createInitList() { return {1, 2, 3}; } // 无法为{1, 2, 3}完成类型推导
    
    std::vector v;
    auto resetV = [&V](const auto& newValue) { v = newValue; };
    resetV({1, 2, 3});	// 无法为{1, 2, 3}完成类型推导
    

1.3 理解decltype

  • 得出的结果就是表达式的无修改直接类型。
  1. 绝大多数情况下,decltype会得出变量或表达式的类型而不作任何修改。

    const int i = 0;	// decltype(i)为const int
    bool f(const Widget& w);	// decltype(w)为const Widget&,decltype(f)为bool(const Widget&)
    Widget w;
    f(w);	// decltype(f(w))为bool
    vector v; // decltype(v[0])为int&
    
  2. 对于类型为T的左值表达式,除非该表达式仅有一个名字,decltype总是得出类型T&

    • C++11允许对单表达式的lambda式的返回值类型实施推导
    • C++14允许将这种范围扩展到一切lambda式、多表达式及函数。
    template
    auto authAndAccess(Container& c, Index i) -> decltype(c[i])
    {
        authenticateUser();
        return c[i];
    }
    
    // 改进:C++14
    template
    auto authAndAccess(Container& c, Index i)
    {
        authenticateUser();
        return c[i];
    }
    
    std::deque d;
    authAndAccess(d, 5) = 10;	// 此处编译无法通过,原因为此函数模板返回类型推导结果为int,此结果的原因是因为auto推导时会忽略引用,则此处右值赋值右值无法通过编译
    
    // 改进:于C++14基础上,将auto替换为decltype(auto)
    template
    decltype(auto) authAndAccess(Container& c, Index i)
    {
        authenticateUser();
        return c[i];
    }
    // 无法向该函数传递右值容器
    
    // 改进:于C++14基础上,增加万能引用,允许Container进行左值或右值绑定
    template
    decltype(auto) authAndAccess(Container&& c, Index i)
    {
        authenticateUser();
        return c[i];
    }
    
    // 改进:于C++14基础上,在return上增加std::forward的使用
    template
    decltype(auto) authAndAccess(Container& c, Index i)
    {
        authenticateUser();
        return std::forward(c)[i];
    }
    
  3. C++14 支持decltype(auto),和auto一样,它会从其初始化表达式推导类型,但是它的类型推导使用的是decltype的规则。

    • **decltype(auto)**不限于在函数返回值类型处推导使用,在变量声明上也可使用,原理相同。
    int x = 0; // decltype(x)为int,decltype((x))为int&
    

1.4 掌握查看类型推导结果的方法

  1. 利用IDE编辑器、编译器错误消息和Boost.TypeIndex库常常能够查看到推导而得的类型。
    1. IDE编辑器
      • 移动光标查看类型
    2. 编译器诊断信息
      • 直接编译,看报错
    3. 运行时输出
      1. 打印出std::typeid(x).name()
      2. 打印出boost::typeindex::type_id_with_cvr().pretty_name()
  2. 有些工具产生的结果可能会无用,或者不准确。因此,理解C++类型推导规则是必要的。

第2章 auto

2.1 优先选用auto,而非显式类型声明

  • 使用auto声明定义变量或函数的优点:

    • 能够避免未初始化变量及复杂的变量声明

      • auto声明的变量必须在声明时定义,原因为类型推导均来自于定义值。
    • 能够直接定义闭包。使用std::function<>声明的闭包和auto声明的区别:

      • auto声明的、存储的闭包的变量和该闭包是同一类型,且所占内存栈大小与闭包相同;**std::function<>**声明的、存储的闭包的变量是std::function<>的一个实例,无论给定的函数签名是什么,它的内存栈大小固定,若大小不够,则会从堆上再行分配以存储。
      • 编译器的实现细节一般会限制内联,并会产生间接函数调用,因此通过std::function<>调用闭包一定比通过auto声明的变量来调用同一闭包要慢。
      • auto声明相比于**std::function<>**要简洁。
    • 能够避免“类型捷径”。

      std::vector v;
      
      unsigned sz = v.size();	// 此声明可能导致sz内存不足
      auto sz = v.size();	// sz的类型是std::vector::size_type
      
  • 使用auto声明定义的问题:

    • 代码可读性降低。
    • 详见1.22.2

2.2 当auto推导的型别不符合要求时,使用带显式类型定义的初始化习惯用法

  1. 隐式代理类型可以导致auto根据初始化表达式推导出错误的类型。

    • std::vector 的operator[]返回值问题。
    • 隐式转换带来的精度降低问题。
  2. 带显式类型定义的初始化习惯用法强制auto推导出目标类型。

    auto index = static_cast(d * c.size());
    

第3章 转向现代C++

3.1 在创建对象时注意区分()及{}

  1. {}初始化可以应用的语境最为广泛,可以阻止隐式窄化类型转换,还对最令人苦恼的解析(most vexing parse)语法免疫。

    // 非静态成员指定默认初始值
    class Widget
    {
        ...
        private:
        	int x{0};	// 可行
        	int y = 0;	// 可行
        	int z(0);	// 不可行
    };
    
    // 不可拷贝对象
    std::atomic ai1{0};	// 可行
    std::atomic ai2(0);	// 可行
    std::atomic ai3 = 0;	// 不可行
    
    • {}初始化禁止内建类型之间进行隐式窄化类型转换;()不会

      double x, y, z;
      
      int sum1{x + y + z};	// 报错,double类型之和无法用int表示
      int sum2(x + y + z);	// 没问题,等同于sum2 = x + y + z
      
    • {}对最令人苦恼解析(most vexing parse)语法免疫

      Widget w1(10);	// 调用Widget构造函数,传入形参10
      Widget w2();	// 这个语句声明了一个名为w2,返回一个Widget类型对象的函数
      Widget w3{};	// 调用没有形参的Widget构造函数
      
  2. 在构造函数重载判定的时候,{}初始化会强烈地优先与带有std::initializer_list类型的形参的构造函数相匹配,即使其他重载版本有着更加匹配的形参列表。

    // 此类构造函数的形参中没有任何一个具备std::initializer_list类型
    class Widget
    {
        public:
        	Widget(int, bool);
        	Widget(int, double);
        ...
    };
    
    Widget w1(10, true);	// 调用的是第一个构造函数
    Widget w2{10, true};	// 调用的是第一个构造函数
    Widget w3(10, 5.0);		// 调用的是第二个构造函数
    Widget w3{10, 5.0};		// 调用的是第二个构造函数
    
    // 此类反之
    class Widget
    {
        public:
        	Widget(int, bool);
        	Widget(int, double);
        	Widget(std::initializer_list);
        ...
    };
    
    Widget w1(10, true);	// 调用的是第一个构造函数
    Widget w2{10, true};	// 调用的是第三个构造函数,10以及true被强制转换为double类型
    
    // 常规会执行复制或移动的构造函数也可能被带有std::initializer_list类型形参的构造函数替代
    class Widget
    {
        public:
        	Widget(int, bool);
        	Widget(int, double);
        	Widget(std::initializer_list);
        
        	operator float() const;
        	...
    };
    
    Widget w0{10, 5.};
    Widget w1(w0);	// 调用的是复制构造函数
    Widget w2{w0};	// 调用的是带有std::initializer_list类型形参的构造函数(w0的返回值被强制转换为float,后float又被强制转换为long double)
    Widget w3(std::move(w0));	// 调用的是移动构造函数
    Widget w4{std::move(w0)};	// 过程及结果同w2
    
    // 窄化类型转换错误
    class Widget
    {
        public:
        	Widget(int, bool);
        	Widget(int, double);
        	Widget(std::initializer_list);
        	...
    };
    Widget w{10, 5.};	// 报错,std::initializer_list<>中禁止窄化类型转换
    
    // 找不到
    
  3. 使用()还是{},会造成结果大相径庭的一个例子是:使用两个实参来创建一个std::vector<类型> 对象。std::make_unique及std::make_shared

    std::vector v1(10, 20);	// 调用了形参中没有任何一个具备std::initializer_list类型的构造函数,结果是创建了一个
    								// 含有10个元素的std::vector所有的元素的值都是20
    std::vector v2{10, 20};	// 调用了形参中含有std::initializer_list类型的构造函数,结果是创建了一个含有2个元素
    								// 的std::vector元素的值分别是10和20
    
  4. 在模板内容进行对象创建时,使用()或是{}需要做出选择。如std::vector<>逐个定义值时使用{}

3.2 优先选用nullptr,而非0或NULL

  • 字面常量0以及NULL的类型是int,而非指针。
  • nullptr实际类型为std::nullptr_t,可以隐式转换到所有的裸指针类型。
  1. 相对于0NULL,优先选用nullptr

    void f(int);
    void f(bool);
    void f(void*);
    f(0);	// 调用的是f(int),而不是f(void*)
    f(NULL);	// 可能通不过编译,但一般会调用f(int),必不会调用f(void*)
    f(nullptr);	// 调用的是f(void*)
    
  2. 避免在整型和指针类型之间重载。

3.3 优先选用别名声明,而非typedef

  • C++11支持别名声明
  1. typedef不支持模板化,但别名声明支持

    • 编译器处理别名模板时,认定其为一个类型名字;而处理typedef时,则不会认定其为一个类型名字,而是变量或其他
    // 使用别名声明
    template
    using MyAllocList = std::list>;
    MyAllocList lw;
    
    // 使用类型定义
    template
    struct MyAllocList
    {
        typedef std::list> type;
    };
    MyAllocList::type lw;
    // 则在模板内使用此定义创建一个链表,且容纳的对象类型由模板形参指定的话,需要加上typename
    template
    class Widget
    {
        private:
        	typename MyAllocList::type list;	// 带依赖类型
    };
    // 反之若使用别名模板定义,则不需要加上typename
    template
    class Widget
    {
        private:
        	MyAllocList list;
    };
    
  2. 别名模板可以让人免写“::type”后缀,并且在模板内,对于内嵌typedef的引用经常要求加上typename前缀,表示其为一个类型名字

    // 使用C++11类型特征变换工具时,由于这些工具是由嵌套在模板化的struct里的typedef实现的,因此在使用如std::remove_const::type时需要加上前缀typename
    std::remove_const::type;	// 由const T生成T
    std::remove_reference::type;	// 由T&或T&&生成T
    std::add_lvalue_reference::type;	// 由T生成T&
    
    // C++14中对于此做了别名声明优化
    std::remove_const::type;	// 由const T生成T
    std::remove_const_t;	// 等价于
    						// template
    						// using remove_const_t = typename remove_const::type;
    						// 下同
    std::remove_reference::type;	// 由T&或T&&生成T
    std::remove_reference_t;
    std::add_lvalue_reference::type;	// 由T生成T&
    std::add_lvalue_reference_t;
    

3.4 优先选用限定作用域的枚举类型(强枚举类型),而非不限作用域的枚举类型

  1. C++98风格的枚举类型,称为不限范围的枚举类型

  2. 限定作用域的枚举类型仅在枚举类型内可见。它们只能通过强制类型转换以转换至其他类型

    // C++98风格的枚举类型中定义的枚举量的名字属于包含着这个枚举类型的作用域,这就表示在相同作用域内不能有其他实体去取用相同名字
    enum color { black, white, red };	// black、white、red所在作用域和Color相同
    auto white = false;	// 报错,white已在范围内被声明过
    
    // C++11强枚举类型语法
    enum class Color { black, white, red };	// black、white、red所在作用域被限定在Color内
    auto white = false; // 编译通过
    Color c = white; // 报错,Color中无white枚举量
    Color c = Color::white;	// 编译通过
    auto c = Color::white;	// 编译通过,且符合2.1
    
  3. 限定作用域的枚举类型和不限范围的枚举类型都支持底层类型指定。限定作用域的枚举类型的默认底层类型是int,而不限范围的枚举类型没有默认底层类型(编译器会根据最大枚举量选择最小底层类型进行取值)

  4. 限定作用域的枚举类型总是可以进行前置声明,而不限范围的枚举类型却只有在制定了默认底层类型的前提下才可以进行前置声明。

3.5 优先选用删除函数,而非private未定义函数

  • C++会在需要时自动生成特别成员函数,如拷贝、移动构造函数和操作符函数
  1. 优先选用删除函数,而非private未定义函数

    • C++98为了阻止这些函数被使用,采取的做法是声明其为private,并且不去定义它们。但是这种做法存在弊端,在其类成员函数或友元函数中仍然可以访问这些声明为private的函数,导致在链接阶段缺少函数定义而失败

      • 举例,一个istream对象表示的是一个输入值流,其中可能有一部分已被读取,而有一部分未来有要读取的可能。若对istream对象进行拷贝,那是不是需要拷贝所有已经读取过的值以及所有未来将要读取的值呢?

        template>
        class basic_ioss : public ios_base
        {
            public:
            	...
            private:
            	basic_ios(const basic_ios&);
            	basic_ios& operator=(const basic_ios&);
        };
        
    • C++11使用”=delete“将拷贝构造函数及拷贝赋值运算符标识为删除函数。习惯上,将删除函数声明为public。任何函数都能成为删除函数,但只有成员函数能声明成private

      template>
      class basic_ioss : public ios_base
      {
          public:
          	...
          	basic_ios(const basic_ios&) = delete;
          	basic_ios& operator=(const basic_ios&) = delete;
      };
      
  2. 任何函数都可以删除,包括非成员函数和模板具现

    1. 能够过滤掉一些期望删除的重载版本
    2. 模板特化必须在命名空间作用域而非类作用域内撰写

3.6 为意在改写的函数添加override声明

  • 改写两要素:
    1. 基类中的函数必须是虚函数
    2. 基类和派生类中的函数名字必须完全相同(析构函数除外)
    3. 基类和派生类中的函数形参类型必须完全相同
    4. 基类和派生类中的函数常量性必须完全相同
    5. 基类和派生类中的函数返回值和异常规格必须兼容
    6. C++11限制:基类和派生类中的函数引用饰词必须完全相同
  • override可显式标明派生类中的函数为基类的改写版本
  1. 为意在改写的函数添加override声明

  2. 成员函数引用饰词使得对于左值和右值对象(*this)的处理能够区分开来

    class Widget
    {
        public:
        	using DataType = std::vector;
        	...
            DataType& data() & { return values; } // 对于左值Widgets类型,返回左值。此版本仅在*this为左值时调用
        	DataType data() && { return std::move(values); } // 对于右值Widgets类型,返回右值。此版本尽在*this为右值时调用
        	...
        private:
        	DataType values;
    };
    
    Widget w;	// 对象
    Widget makeWidget(); // 工厂函数
    
    auto vals1 = w.data();	// 调用Widget::data左值重载版本vals1采用拷贝构造完成初始化
    auto vals2 = makeWidget().data();	// 调用Widget::data右值重载版本vals2采用移动构造完成初始化
    

3.7 优先选用const_iterator,而非iterator

  • C++98中取得容器对应的const的版本有两种方法
    1. 容器强制转换为const
    2. 将容器绑定到一个引用到const的变量,再在相应位置使用该变量
  • C++98容器插入的位置只能以iterator指定,而不能是const_iterator
  • C++11仅添加了非成员函数版本的begin和end
  • C++14补充了短板,cbegin、cend、rbegin、rend、crbegin、crend
  1. (C++11)优先选用const_iterator,而非iterator

  2. 在最通用的代码中,优先选用(自己实现的)非成员函数版本的begin、end及rbegin等,而非其成员函数版本,因为有些容器不支持cbegin等成员

    // 非成员函数版本cbegin
    template
    auto cbegin(const C& container) -> decltype(std::begin(container))
    {
        return std::begin(container);
    }
    

3.8 只要确定函数不会抛出异常,就为其加上noexcept声明

  • C++98必须梳理初一个函数可能抛出的所有异常类型,若函数实现发生改动,则异常规则也需要相应改动
  1. noexcept声明是函数接口的组成部分,这意味着调用方可能会对它有所依赖
  2. 相对于不带noexcept声明的函数,带有noexcept声明的函数有更多机会得到优化
    1. C++98异常规则下,调用栈会开解至f的调用处,之后执行一些与本条款无关的动作后,程序执行中止;C++11异常规则下,程序执行中止之前,栈只是可能会开解
    2. 在带有noexcept声明的函数中,优化器不需要在异常传出函数的前提下,将执行期栈保持在可开解状态;也不需要在异常逸出函数的前提下,保证所有其中的对象以其被构造顺序的逆序完成析构,而**throw()**声明的则不会如此灵活
  3. noexcept性质对于移动操作、swap、内存释放函数和析构函数最有价值
    1. 对于向std::vector类型的对象添加新元素且空间不足(size=capacity)
      1. C++98中的做法是,先把元素逐个从旧内存块拷贝至新内存,然后将旧内存中的对象析构。若在拷贝元素的过程中抛出异常,则std::vector类型对象会保持原样不变。强异常安全保证。
      2. C++11支持移动操作,做法是将上述拷贝操作改成移动操作。这样的话,若在移动元素时抛出异常,则push_back操作无法完成,此时由于已经移动了n个元素,复原可能会产生异常。因此若在已知移动操作不会抛出异常的前提下,有策略(move if you can, but copy if you must)
  4. 大多数函数都是异常中立的,不具备noexcept性质
    1. 此类函数自身并不抛出异常,但它们调用的函数则可能会抛出异常,并且传至调用栈更深的一层
    2. 默认地,内存释放函数和所有的析构函数都隐式地具备noexcept性质
    3. 带有宽松契约的函数,是没有前置条件的,要调用这样的函数,无须关心程序状态,对于调用方传入的实参也没有限制。不可能展现出未定义行为
    4. 带有狭隘契约的函数,若前置条件被违反,则结果为未定义,若加上了noexcept关键字,则程序中止,因此一般把noexcept关键字声明于带有宽松契约的函数

3.9 只要有可能使用constexpr,就使用它

  • 使用constexpr使代码能够在编译期就能执行完成,能大大提高运行期效率
  1. constexpr对象都具备const属性,并由编译期已知的值完成初始化

    • const并未提供和constexpr一样的保证,因为const对象不一定经由编译期已知值来初始化
    int sz;	// 非constexpr变量
    
    constexpr auto arraySize1 = sz;	// 报错,sz的值在编译期未知
    std::array data1;	// 报错,一样的问题
    constexpr auto arraySize2 = 10; // 正常,10是编译期常量
    std::array data2; // 正常,arraySize2是个constexpr
    
    const auto arraySize = sz;	// 正常,arraySize是sz的一个const副本
    std::array data; // 报错,arraySize的值非编译期已知
    
  2. constexpr函数在调用时若传入的实参值是编译期已知的,则会产出编译期结果,反之,则产出运行期结果同普通函数

  3. 比起非constexpr对象或constexpr函数而言,constexpr对象或是constexpr函数可以用在一个作用域更广的语境中

    1. C++11中constexpr函数不得包含多于一个执行语句,且void类型不能作为一个字面类型
    2. C++14放宽了上述限制
    3. 但都一样的点是,constexpr函数中拒绝所有IO语句,这也合情理的(在编译期中完成,此时无IO)

3.10 保证const成员函数的线程安全性

  1. 保证const成员函数的线程安全性,除非可以确信它们不会用在并发语境中
    1. 引入互斥量
      1. (省略实例代码)
    2. 原子操作,相较于上,开销减少
      1. (省略示例代码)
  2. 运用std::atomic类型的变量会比运用互斥量提供更好的性能,但前者仅适用对单个变量或内存区域的操作
    1. 考虑多线程同时调用同一const函数,可能会产生同线程数量倍数的函数运行开销

3.11 理解特别成员函数的生成机制

  • C++11支配特别成员函数的机制:
    1. 默认构造函数:与C++98机制相同。仅当类中不包含用户声明的构造函数时才生成。
    2. 析构函数:与C++98机制基本相同,唯一区别是析构函数默认为noexcept,仅当基类的析构函数为虚,派生类的析构函数才为虚
    3. 拷贝构造函数:运行期行为与C++98相同:按成员进行非静态数据成员的拷贝构造。仅当类中不包含用户声明的拷贝构造函数时才生成。若该类声明了移动操作,则拷贝构造函数将被删除。在已存在拷贝赋值运算符或析构函数的条件下,仍然生成拷贝构造函数已经成为了被废弃的行为。
    4. 拷贝赋值运算符:同拷贝构造函数
    5. 移动构造函数和移动赋值运算符:都按成员进行非静态数据成员的移动操作。仅当类中不包含用户声明的拷贝操作、移动操作和析构函数时才生成
  1. 特别成员函数是指那些C++会自行生成的成员函数:默认构造函数、析构函数、拷贝操作以及移动操作

  2. 移动操作仅当类中未包含用户显式声明的复制操作、移动操作以及析构函数时才生成

  3. 拷贝构造函数仅当类中不包含用户显式声明的拷贝构造函数时才生成,如果该类声明了移动操作则拷贝构造函数将被删除。拷贝赋值运算符仅当类中不包含用户显式声明的拷贝赋值运算符才生成,如果该类声明了移动操作则拷贝赋值运算符将被删除。在已经存在显式声明的析构函数的条件下,生成拷贝操作已经成为了被废弃的行为

  4. 成员函数模板在任何情况下都不会抑制特别成员函数的生成

    // 编译器会始终生成Widget的拷贝和移动操作
    class Widget
    {
        template
        Widget(const T& rhs);
        
        template
        Widget& operator=(const T& rhs);
    };
    

第4章 智能指针

  • 裸指针的缺点:
    1. 裸指针的声明没有明确表示指涉到的是单个对象还是一个数组
    2. 裸指针的声明没有明确表示使用完指涉的对象后,是否需要析构它
    3. 析构方法未知
    4. 即使知道应该使用delete,也不容易知道改用delete还是delete[]
    5. 只要析构次数少一次就会导致资源泄漏,多一次就会导致未定义行为
    6. 没有正规的方式能检测出指针是否空悬
  • C++11中有四种智能指针:std::auto_ptr(C++98)、std::unique_ptrstd::shared_ptrstd::weak_ptr

4.1 使用std::uinique_ptr管理具备专属拥有权的资源

  1. std::unique_ptr是小巧、高速的、具备只移类型的智能指针,对托管资源实施专属所有权语义

    • std::unique_ptr是个只移类型,不允许拷贝。因为若拷贝了一个std::unique_ptr,就会得到两个指涉到同一资源的std::unique_ptr,而两者都认为自己独特拥有该资源。
  2. 默认地,资源析构采用delete运算符来实现,但可以指定自定义删除器。有状态的删除器和采用函数指针实现的删除器会增加std::unique_ptr类型的对象尺寸

    • 常见用法是在对象继承谱系中作为工厂函数的返回类型,工厂函数不仅是std::unique_ptr的常用方法,且更广泛地用作实现Pimpl地常用方法机制。
      • 通常会在堆上分配一个对象并且返回一个指涉到它的指针,并当不需要该对象时,由调用者负责删除之
    class Investment {...};
    class Stock : public Investment {...};
    class Bond : public Investment {...};
    class RealEstate : public Investment {...};
    
    template
    std::unique_ptr
    makeInvestment(Ts&&... params);
    
    {
        ...
        auto pInvestment = makeInvestment(arguments);	
    }// 作用域结束后*pInvestment析构
    
    • 默认析构器和自定义析构器的区别:

      • 使用默认析构器的前提下,std::unique_ptr裸指针有相同的尺寸,4字节
      • 使用自定义析构器的前提下,情况不同:
        1. 析构器时函数指针:std::unique_ptr尺寸一般会增加到一到两个字长
        2. 析构器是函数对象:取决于该函数对象中存储了多少状态。无状态的函数对象不会浪费任何存储尺寸,如无捕获的lambda表达式
      auto delInvmt = [](Investment* pInvestment)
      {
          makeLogEntry(pInvestment);
          delete pInvestment;
      };
      
      template
      std::unique_ptr	// 若为C++14可使用函数类型推导,auto
      makeInvestment(Ts&&... params)
      {
          std::unique_ptr pInv(nullptr, delInvmt);
          if(/* 应创建一个Stock类型的对象 */)
          {
              pInv.reset(new Stock(std::forward(params)...));	// C++11禁止将裸指针直接赋值于智能指针,因为会触发隐式转换从而发生错误
          }
          else if(/* 应创建一个Bond类型对象 */)
          {
              pInv.reset(new Bond(std::forward(params)...));
          }
          else if(/* 应创建一个RealEstate类型的对象 */)
          {
              pInv.reset(new RealEstate(std::forward(params)...));
          }
          
          return pInv;
      }
      
      void delInvmt2(Investment* pInvestment)
      {
          makeLogEntry(pInvestment);
          delete pInvestment;
      }
      
      template
      std::unique_ptr
      makeInvestment(Ts&&... params);	// 返回值尺寸等于Investment*的尺寸加上至少函数指针的尺寸
      
    • 两种形式地构造,这样地区分所指涉到地对象种类不会产生二义性:

      1. 单个对象:std::unique_ptr
      2. 数组(不推荐):std::unique_ptr
  3. std::unique_ptr转换成std::shared_ptr是容易实现的,反之则不可

4.2 使用std::shared_ptr管理具备共享所有权的资源

  1. std::shared提供方便的手段,实现了任意资源在共享所有权语义下进行生命周期管理的垃圾回收

    • 当最后一个指涉到某对象的std::shared_ptr不再指涉到它时,std::shared_ptr会析构其指涉到的对象
    • std::shared_ptr可以通过访问某资源的引用计数来确定是否自己是最后一个指涉到该资源的。std::shared_ptr的构造函数会使引用计数递增,析构函数会使其递减,拷贝赋值运算符同时执行两种操作
  2. std::unique_ptr相比,std::shared_ptr的尺寸通常是裸指针尺寸的两倍,它还会带来控制块的开销,并要求原子化的引用计数操作

    • 引用计数的存在带来的性能影响:
      1. std::shared_ptr的尺寸是裸指针的两倍。因为它们内部既包含一个指涉到该资源的裸指针,也包含一个指涉到该资源的引用计数的裸指针
      2. 引用计数的内存必须动态分配。引用计数与被指涉到的对象相关联,然而被指涉到的对象对此却一无所知
      3. 引用计数的递增和递减必须是原子操作。因为在不同线程中可能存在并发的读写器,且原子操作一般都比非原子操作慢
  3. 默认的资源析构通过delete运算符进行,但同时也支持自定义析构器。析构器的类型不属于std::shared_ptr类型的一部分

    auto loggingDel = [](Widget* pW)
    {
        makeLogEntry(pW);
        delete pW;
    };
    std::unique_ptr upw(new Widget, loggingDel);
    std::shared_ptr spw(new Widget, loggingDel);	//析构器类型不是std::shared_ptr类型的一部分
    
    • 设计具有弹性。同一类型的std::shared_ptr能够相互赋值,并且可以被当作形参传递至函数内,而具有不同自定义析构器的std::unique_ptr则无法实现,因为自定义析构器的类型会影响std::unique_ptr的类型
    • 自定义析构器不会改变std::shared_ptr的尺寸。无论析构器是怎样的类型,std::shared_ptr对象的尺寸都相当于裸指针的两倍。
      • 析构器所占内存不属于std::shared_ptr对象的一部分,而是在堆上被分配器分配的位置
      • std::shared_ptr对象相关内存的组成:
        1. 指涉到T类型的对象的指针->T类型的对象
        2. 指涉到控制块的指针,指涉的内容(若使用了默认析构器和默认内存分配器,则控制块尺寸只有三个字长且分配操作实质上没有任何成本)
          • 控制块包含:
            1. 引用计数
            2. 弱计数
            3. 其他数据(如,自定义析构器,分配器等)
      • 使用std::shared_ptr也会带来控制块用到的虚函数带来的成本,但在使用默认析构器、分配器的情况下,与一个裸指针的成本相当。控制块中的虚函数机制通常只被每个托管给std::shared_ptr的对象使用一次:在对象被析构的时刻
  4. 避免使用裸指针类型的变量来创建std::shared_ptr指针

    • 一个对象的控制块由首个指涉到该对象的std::shared_ptr函数确定,遵循以下规则:
      1. std::make_shared总是创建一个控制块
      2. 从具备专属所有权的指针(std::auto_ptr或std::unique_ptr)出发构造一个std::shared_ptr时,会创建一个控制块
        • 专属所有权指针不适用控制块
      3. 当std::shared_ptr构造函数使用裸指针作为实参调用时,它会创建一个控制块
    // 这些规则会导致未定义行为
    auto pw = new Widget;
    ...
    std::shared_ptr spw1(pw, loggingDel);	// 为*pw创建一个控制块
    ...
    std::shared_ptr spw2(pw, loggingDel);	// 为*pw创建了第二个控制块
    // 这里创建了两个控制块,得到了两个引用计数,而每个引用计数最终都会归零,从而导致*pw被析构两次,第二次析构将会引发未定义行为
    
    • 替代手法:使用std::make_shared。若采用了自定义析构器,就无法使用std::make_shared

    • 若必须将一个裸指针传递给std::shared_ptr的构造函数,就直接传递new运算符的结果,而非传递一个裸指针变量

      std::shared_ptr spw1(new Widget, loggingDel);
      std::shared_ptr spw2(spw1);	// 不会产生上述问题
      
    • 托管到std::shared_ptr的类能够安全地由this指针创建一个std::shared_ptr(和this指针指涉到相同对象的std::shared_ptr)时,它将为你继承而来的基类提供一个模板,此模板设计模式为奇妙递归模板模式

      std::vector> processWidgets;
      
      class Widget : public std::enable_shared_from_this
      {
          public:
          	...
              void process();
          	...
      };
      
      void Widget::process()
      {
          ...
          processWidgets.emplace_back(shared_from_this());
          ...
      }
      // 为了实现这个,必须有一个已经存在的指涉到当前对象的std::shared_ptr。若这样的对象不存在,该行为未定义,则shared_from_this抛出异常
      
      // 为了避免调用者在std::shared_ptr指涉到该对象前就调用了引发shared_from_this的成员函数,继承自std::enable_shared_from_this的类通常会将其构造函数声明为private访问层级,并且允许调用者通过调用返回std::shared_ptr工厂函数来创建对象
      class Widget : public std::enable_shared_from_this
      {
          public:
          	template
          	static std::shared_ptr create(Ts&&... params);
          
          	...
          	void process();
          	...
          private:
          	... // 构造函数
      };
      

4.3 对于类似std::shared_ptr且有可能空悬的指针使用std::weak_ptr

  • std::weak_ptr不能够申请,也不能检查是否为空,因为它仅是std::shared_ptr的一种扩充。一般通过std::shared_ptr创建,当使用std::shared_ptr完成初始化std::weak_ptr时,两者就指涉到相同位置,且std::weak_ptr不会影响std::shared_ptr所指涉到的对象的引用计数,但会影响弱计数

    auto spw = std::make_shared();	// 构造完成后,此时指涉到Widget的引用计数为1
    std::weak_ptr wpw(spw);	// 与spw指涉到同一个对象,且spw引用计数保持为1
    
    spw = nullptr;	// 引用计数为0,Widget对象被析构,wpw空悬,即失效(expired)
    
    // 检测失效的方式,两种形式
    // 1.std::weak_ptr::lock,它会返回一个std::shared_ptr,若std::weak_ptr已失效,则std::shared_ptr为空
    std::shared_ptr spw1 = wpw.lock(); // 若wpw失效,则spw1为空
    auto spw2 = wpw.lock();	// 同上
    // 2.std::weak_ptr作为实参构造std::shared_ptr,若std::weak_ptr已失效,则抛出异常
    std::shared_ptr spw3(wpw);	// 若wpw失效,抛出std::bad_weak_ptr类型的异常
    
  1. 使用std::weak_ptr来代替可能空悬的std::shared_ptr

  2. std::weak_ptr可能的用武之地包括缓存,观察者列表,以及避免std::shared_ptr指针环路

    // 第一种:工厂模式。
    // 创建一个带缓存的工厂函数
    std::unique_ptr loadWidget(WidgetId id);
    std::shared_ptr fastLoadWidget(WidgetID id)
    {
        static std::unordered_map> cache;
        
        auto objPtr = cache[id].lock();
        if(!objPtr)
        {
            objPtr = loadWidget(id);
            cache[id] = objPtr;
        }
        return objPtr;
    }
    // 存在弊端,由于相应的Widget不再使用,缓存中失效的std::weak_ptr可能会不断积累。这个实现可被优化,但不妨考虑其他设计模式
    // 第二种:观察者模式。主要组件是主题和观察者,每个主题包含一个数据成员,该成员持有指涉到其观察者的指针。主题不会控制其观察者的生存期(不关心何时被析构),但需要确认的话,当一个观察者被析构之后,主题不会去访问它。此时可让每个主题持有一个容器来放置指涉到其观察者的std::weak_ptr,以便主题在使用某个指针之前,能够先确定他是否空悬
    

4.4 优先选用std::make_shared和std::make_unique,而非直接使用new

  • C++11加入了std::make_shared,但没有std::make_unique,C++14中加入了标准库

  • C++11实现std::make_unique。切记不要将自定义版本放入std命名空间,若版本变更为C++14,容易产生冲突

    // 此函数模板不支持数组以及自定义析构器
    template
    std::unique_ptr make_unique(Ts&&...params)
    {
        return std::unique_ptr(new T(std::forward(params)...));
    }
    
  • make系列函数总共三个:std::make_unique、std::make_shared、std::allocate_shared(与std::make_shared一样,只不过它的第一个实参是个用以动态分配内存的分配器对象)

  1. 相比于直接使用new表达式,make系列函数消除了重复代码、改进了异常安全性,并且对于std::make_sharedstd::allocated_shared而言,生成的目标代码会尺寸更小、速度更快

    1. 代码重复:源代码中的重复会增加编译次数,导致臃肿的目标代码,并且通常会产生更难上手的代码存根,通常会演化成不一致的代码,而代码存根中的不一致性经常会导致代码缺陷

      auto upw1(std::make_unique());
      std::unique_ptr upw2(new Widget); // 重复
      auto spw1(std::make_shared());
      std::shared_ptr spw2(new Widget) // 重复
      
    2. 异常安全:

      void processWidget(std::shared_ptr spw, int priority);
      
      // 使用new运算符而非std::make_shared
      processWidget(std::shared_ptr(new Widget), computePriority());	// 存在潜藏的资源泄漏问题
      // 在运行期,传递给函数的实参必须在函数调用被发起前完成评估求值,因此在调用processWidget过程中,必须执行
      // 1.表达式"new Widget"必须先完成评估求值,即一个Widget对象必须现在堆上创建
      // 2.由new产生的裸指针的托管对象std::shared_ptr的构造函数必须执行
      // 3.computePriority必须执行
      // 编译器不必按上述顺序生成代码,编译器可能会产生如下时序操作:
      // 1.new Widget
      // 2.执行computePriority
      // 3.运行std::shared_ptr构造函数
      // 若按上述执行,且在运行期computePriority产生异常,那么由第一步动态分配的Widget会被泄漏,因为它将永远不会被存储到在第三步才接管的std::shared_ptr中去
      // 使用std::make_shared可以避免上述问题
      processWidget(std::make_shared(), computePriority());
      // 在运行期std::make_shared和computePriority中肯定有一个会首先被调用。若std::make_shared首先被调用,指涉到动态分配的Widget的裸指针会在computePriority被调用前被安全存储在返回的std::shared_ptr对象中;若随后computePriority产生异常,那么std::shared_ptr的析构函数也能够知道它所拥有的Widget已被析构。若computePriority先被调用并产生异常,std::make_shared将不会被调用,因此也无须为动态分配的Widget担心
      
      // 上述示例以及分析在std::unique_ptr上同样适用
      
    3. 性能提升:make系列函数会让编译器有机会利用更简洁的数据结构,产生更小更快的代码

      std::shared_ptr spw(new Widget);	// 这段代码会引发两次内存分配。一次是new Widget,另一次是std::shared_ptr的构造函数中相关联的控制块
      auto spw(std::make_shared());	// 这段代码只进行了一次内存分配。只分配了单块内存既保存Widget对象又保存与其相关联的控制块。这种优化减小了程序的静态尺寸,因为代码只包含一次内存分配调用,同时还增加了可执行代码的运行速度,因为内存是一次性分配出来的。另外,还能避免控制块中的一些薄记信息的必要性,潜在减少了程序的内存痕迹总量。
      
      // 上述示例以及分析在std::allocate_shared同样适用
      
  2. 不适用make系列函数的场景包括需要定制析构器,以及期望直接传递{}初始值

    1. 需要定制析构器的场景需使用new表达式

      std::unique_ptr upw(new Widget, WidgetDeleter);
      std::shared_ptr spw(new Widget, widgetDeleter);
      
    2. make系列函数会对()内的参数进行完美转发,但无法对{}内的值进行完美转发

      auto upv(std::make_shared>(10, 20)); // 指涉到包含10个元素,每个元素值为20的std::vector
      // 可使用下面方式进行调用make系列函数
      auto initList{10, 20};
      auto spv(std::make_shared>(initList));
      
  3. 对于std::shared_ptr,不建议使用make系列函数的额外场景包括:1.自定义内存管理的类;2.内存紧张的系统、非常大的对象以及存在比指向到相同对象的std::shared_ptr生存期更久的std::weak_ptr

    1. 有些类会定义自身版本的operator new和operator delete。通常情况下,这两种函数被设计成处理尺寸恰好是sizeof(Widget)的内存块,此时不适用于用作std::shared_ptr所支持的那种自定义分配器(通过std::allocate_shared)和释放器(通过自定义析构器)
      • 因为std::allocate_shared所要求的内存数量并不等于动态分配对象的尺寸,而是该尺寸的基础上加上控制块的尺寸

4.5 使用Pimpl习惯用法时,将特殊成员函数的定义放到cpp文件中

  • Pimpl习惯用法,即pointer to implementation,指涉到实现的指针

  • 这种技巧就是把类的数据成员用一个指涉到某实现类(或结构体)的指针替代,之后把原来在主类中的数据成员放置到实现类中,并通过指针间接访问这些数据成员

    class Widget
    {
        public:
        	Widget();
        	...
        private:
        	std::string name;		// 必须#include 
        	std::vector data;	// 必须#include 
        	Gadget g1, g2, g3;	// 必须#include "gadget.h"
    };
    // include上述头文件,增加了编译时间
    
  1. Pimpl惯用法通过降低类的客户以及类实现者之间的依赖性,减少了构建(编译)次数

    // widget.h
    // 使用Pimpl方法,将数据成员定义于某类或结构体,并且将其放于另一个头文件中,此时本文件仅需调用一次定义头文件即可,能够加快编译时间
    class Widget
    {
        public:
        	Widget();
        	~Widget();
        private:
        	struct Impl;
        	Impl* pImpl;
    };
    
    // widget.cpp
    #include "widget.h"
    #include "gadget.h"
    #include 
    #include 
    
    // Widget::Impl的实现,包括此前在Widget中的数据成员
    struct Widget::Impl
    {
        std::string name;
        std::vector data;
        Gadget g1, g2, g3;
    };
    Widget::Widget()
        : pImpl(new Impl)
    {	}
    Widget::~Wiget()
    {
        delete pImpl;
    }
    
    // 优化为C++14形式
    class Widget
    {
        public:
        	Widget();
        	...
        private:
        	struct Impl;
        	std::unique_ptr pImpl;
    };
    // 结构体定义同之前
    Widget::Widget()
        : pImpl(std::make_unique())
    {}
    
  2. 对于采用std::unique_ptr来实现的pImpl指针,必须在类的头文件中声明特别成员函数,且在实现文件中定义它们。即使默认函数实现有着正确行为,也必须这样做

    • 本例中类未声明定义析构函数,而若定义对象,在退出作用域时,会被析构,调用类对象的析构函数,由于没有定义析构函数,此时编译器会生成一个默认析构函数,默认析构函数中将std::unique_ptr指针析构,由于使用了默认的std::unique_ptr析构器,从而使用delete运算符对裸指针实施析构。然而,在实施delete运算符前,典型的实现会使用C++11中的static_assert确保裸指针未指涉到非完整类型。因此,导致产生错误信息
      • 解决方法:只需保证在生成析构std::unique_ptr< Widget::Impl >代码处,Widget::Impl是个完整类型即可。只要类型定义能够让编译器看到(在cpp中定义),就是完整的
    • 支持移动操作。若声明了析构函数,则会阻止编译器产生移动操作,因此,若需要支持移动操作,就必须自声明定义
    • 支持拷贝操作。若数据成员支持拷贝,则类必须支持
  3. 上述建议仅适用于std::unique_ptr,不适用于std::shared_ptr

    • std::unique_ptr和std::shared_ptr这两种智能指针在实现pImpl指针行为时的不同,源自它们对于自定义析构器的支持的不同
      1. 对于std::unique_ptr而言,析构器类型是智能指针类型的一部分,这使得编译器会产生更小尺寸的运行期数据结构以及更快速的运行期代码。如此高效带来的后果是,欲使用编译器生成的特别函数(如,析构或移动),就要求其指涉到的类型必须是完整类型
      2. 对于std::shared_ptr而言,析构器的类型并非智能指针类型的一部分,这就需要更大尺寸的运行期数据结构以及更慢一些的目标代码,但在使用编译器生成的特别函数时,其指涉到的类型却并不要求是完整类型
    • 并不需要在std::unique_ptr和std::shared_ptr的特性之间做出权衡,因为本示例更适合用std::unique_ptr。

第5章 右值引用、移动语义和完美转发

5.1 理解std::move和std::forward

  1. std::move实施的是无条件的向右值类型的强制类型转换。就其本身而言,不会执行移动操作

    // C++11 std::move示例实现
    template
    typename remove_reference::type&& move(T&& param)
    {
        using ReturnType = typename remove_reference::type&&;
        return static_cast(param);
    }
    // C++14
    template
    decltype(auto) move(T&& param)
    {
        using ReturnType = remove_reference_t&&;
        return static_cast(param);
    }
    
    • 两点经验:
      1. 若需要取得对某个对象执行移动操作的能力,则不要将其声明为常量,因为针对常量对象执行的移动操作将转换成拷贝操作
      2. std::move不仅不实际移动任何,甚至不保证经过其强制类型转换后的对象具备可移动能力,唯一可以确定地是,结果必定是个右值
  2. 仅当传入的实参被绑定到右值时std::forward才针对该实参实施向右值类型的强制类型转换

    • 传递给std::forward的实参类型应当是非引用类型
    // 典型使用
    void process(const Widget& lvalArg);
    void process(Widget&& rvalArg);
    template
    void logAndProcess(T&& param)
    {
        auto now = std::chrono::system_clock::now();
        makeLogEntry("Calling 'process'", now);
        process(std::forward(param));	// process依据param是左值或是右值选择重载版本进行重载
    }
    // 使用
    Widget w;
    logAndProcess(w);	// 调用时传入左值
    logAndProcess(std::move(w));	// 调用时传入右值
    // 所有函数皆为左值,因此以上对process的调用都会取用左值类型的重载版本
    
  3. 运行期std:::movestd::forward不会做任何操作

    • 两者执行的操作仅仅是强制类型转换

5.2 区分万能引用和右值引用

void f(Widget&& param);	// 右值引用
Widget&& var1 = Widget();	// 右值引用
auto&& var2 = var1;	// 非右值引用,万能引用
template
void f(std::vector&& param);	// 右值引用
template
void f(T&& param);	// 非右值引用,万能引用
  1. 函数模板形参具备T&&类型,并且T的类型是推导而来的,如果对象使用auto&&声明其类型,则该形参或对象就是个万能引用

    • 简单来说,不涉及类型推导的T&&,就是万能引用

    • 若声明带有const关键字且涉及类型推导的T&&,不会是万能引用,而会是右值引用

    • 位于模板内,并不能保证涉及类型推导

      // C++标准
      template>
      class vector
      {
          public:
          	void push_back(T&& x);
          ...
      };
      // std::vector push_back不涉及类型推导
      std::vector v;
      class vector>
      {
      	public:
          	void push_back(Widget&& x);	// 右值引用
          	template
              void emplace_back(Args&&... args);	// 涉及类型推导,因此是万能引用
          ...
      };
      
  2. 类型声明不精确地具备type&&的形式类型推导并未发生,则type&&就代表右值引用

  3. 若采用右值初始化万能引用,就会得到一个右值引用。若采用左值来初始化万能引用,就会得到一个左值引用

5.3 针对右值引用用std::move,针对万能引用用std::forward

  1. 针对右值引用最后一次使用实施std::move,针对万能引用最后一次使用实施std::forward

    1. 为了保证在完成对实参对象操作前实参不被移动,就需要保证尽在最后一次使用移动操作

    2. 在确定移动操作必不会抛出异常时,使用std::move_if_noexcept替代

    3. 针对右值引用实施std::forward,代码啰嗦,易错,且不符合习惯用法

    4. 针对万能引用实施std::move,某些左值会遭到以外改动

      class Widget
      {
          public:
          	template
          	void setName(T&& newName) { name = std::move(newName); }
          private:
          	std::string name;
          	std::shared_ptr p;
      };
      std::string getWidgetName(); // 工厂函数
      Widget w;
      auto n(getWidgetName());
      w.setName(n);	// 此处将n值移入w,n值无条件转换为右值,调用完setName后,n值变得未知
      ...
      
  2. 作为按值返回的函数的右值引用万能引用,依上一条所属采取相同行为。

    • 由拷贝转换为引用,能够有效地提高执行效率
  3. 局部对象可能适用于返回值优化(RVO),则请针对其实施std::movestd::forward

    Widget makeWidget()
    {
    	Widget w;
        ...
        return w;
    }
    // 通过将拷贝转换为移动进行优化
    Widget makeWidget()
    {
        Widget w;
        return std::move(w);
    }
    
    1. 此处标准化委员早已考虑,即返回值优化(RVO,return value optimization)。编译器若要在一个按值返回的函数中省略对局部对象的拷贝(或移动),需要满足两个条件
      1. 局部对象类型和函数返回值类型相同
      2. 返回的就是局部对象本身
    2. RVO会使编译器在处理按值返回对象时能够省略拷贝操作,按引用返回则不支持,会在内存中进行对象重建。即RVO前提条件满足时,要么发生拷贝省略,要么隐式地对局部对象进行std::move

5.4 避免用万能引用类型进行重载

  1. 万能引用作为重载候选类型,几乎总会让该重载版本在始料未及的情况下被调用到

    std::multiset names;
    void logAndAdd(const std::string& name)
    {
        auto now(std::chrono::system_clock::now());
        log(now, "logAndAdd");
        names.emplace(name);	// 此处name为左值,无法避免拷贝操作
    }
    std::string petName("Darla");
    logAndAdd(petName);	// 传递左值std::string,在内部需要进行一次拷贝操作
    logAndAdd(std::string("Persephone"));	// 传递右值std::string,因为此处实参为右值,被绑定到左值name上,原则上是可以进行移动操作,但此处进行了一次拷贝操作,事实上仅需要付出一次移动的成本
    logAndAdd("Patty Dog");	// 传递字符串字面量,在传入时,因为是字符串字面量,所以隐式构造了std::string对象,但此处实际上连一次移动的成本都不需要付出
    
    // 为了解决第二个及第三个调用语句效率低下的问题,只需要改写为万能引用
    template
    void logAndAdd(T&& name)
    {
        auto now(std::chrono::system_clock::now());
        log(now, "logAndAdd");
        names.emplace(std::forward(name));
    }
    std::string petName("Darla");
    logAndAdd(petName);	// 同前,进行了一次拷贝操作
    logAndAdd(std::string("Persephone"));	// 对右值实施了移动操作
    logAndAdd("Patty Dog");	// 在multiset中直接构造一个std::string对象,而非拷贝
    
    // 用户提供了关于索引的重载版本,此处容易发生问题
    std::string nameFromIdx(int idx);
    void logAndAdd(int idx)
    {
        auto now(std::chrono::system_clock::now());
        log(now, "logAndAdd");
        names.emplace(nameFromIdx(idx));
    }
    logAndAdd(22); // 此处调用了形参类型为int的重载版本,及用户提供的版本
    // 但形参类型若为short,则会先进行隐式类型提升后进行类型精确匹配,调用万能引用版本,在emplace中调用std::string的构造函数,但其并没有short类型的构造版本,因此会发生错误
    
    • 综上,需要避免重载和万能引用这两者结合起来,或者撰写一个带完美转发的构造函数
  2. 完美转发 构造函数的问题尤为严重,因为对于非常量左值类型而言,它们一般都会形成相对于拷贝构造函数的更佳匹配,并且会劫持派生类中对基类的拷贝移动构造函数的调用

    class Person
    {
        public:
        	template
        	explicit Person
        		: name(std::forward(n)) {}
        	
        	explicit Person(int idx)
                : name(nameFromIdx(idx)) {}
        
        	// 此处会编译器会隐式生成拷贝构造函数和移动构造函数
        	// Person(const Person& rhs);
        	// Person(Person&& rhs);
        private:
        	std::string name;
    };
    Person p("Nancy");
    auto cloneOfP(p);	// 从p出发构建新的Person类型对象,无法通过编译!此处会调用完美转发版本而非拷贝构造版本,若需要调用拷贝构造版本,则需要对p的声明添加const关键字,因为欲拷贝的对象是一个常量,所以就形成了对拷贝构造函数形参的精确匹配
    
    • C++重载决议中的一条:若在函数调用时,一个模板实例化函数和一个非函数模板具备相当的匹配程度,则优先选用常规函数

5.5 熟悉用万能引用类型进行重载的其他方案

  1. 若不使用万能引用和重载的组合,则替代方案包括使用彼此不同的函数名字传递const T&类型的形参、传值和标签分派

    • 舍弃重载:使用彼此不同的函数名字,缺点:需要增加不必要的维护,增加代码繁琐程度

    • 传递const T&类型的形参:缺点是必须有一次拷贝操作的成本

    • 传值:仅在个别情况下,拷贝成本小于移动成本时,可考虑

    • 标签分派

      template
      void logAndAdd(T&& name)
      {
          // logAndAddImpl(std::forward(name), std::is_integral());	// 不完全正确,若被推导为int&,则此处is_integral将判断为false
          // 应改为
          logAndAddImpl(std::forward(name), std::integral::type>());
      }
      
      // 需要提供std::false_type及std::true_type版本模板重载对象
      template
      void logAndAddImpl(T&& name, std::false_type)	// 此处std::false_type为编译期值
      {
          auto now(std::chrono::system_clock::now());
          log(now, "logAndAdd");
          names.emplace(std::forward(name));
      }
      
      std::string nameFromIdx(int idx);
      void logAndAddImpl(int idx, std::true_type)
      {
          logAndAdd(nameFromIdx(idx));
      }
      
      • 但此方式依然无法避免编译器自动生成拷贝及移动构造函数而绕过了标签分派系统的行为
  2. 经由std::enable_if对模板施加限制,就可以将万能引用和重载一起使用,不过这种技术控制了编译器可以调用到接受万能引用的重载版本的条件。

    • std::enable可以强制编译器表现出来的行为如同特定的模板不存在

      // 此处用于避免除Person类型外的对象传入,调用模板构造函数,产生错误
      class Person
      {
          public:
          	template::type>::value>::type>
              explicit Person(T&& n);
      };
      // 此处std::is_same用于判断类型是否一致,std::decay用于将类型值中的const及volatile修饰省略,std::enable_if::type返回的两种结果分别为std::true_type或std::false_type
      
    • 派生类对象作为形参传递给基类形参时,由于派生类和基类类型不一,若存在万能引用形参构造函数,则会被优先调用,为解决这个问题,则需要通过std::is_base_of作为模板类型进行判断

      class Person
      {
          public:
          	template::type>::value>::type>
              explicit Person(T&& n);
      };
      // C++14中可省略typename
      
  3. 万能引用形参通常在性能方面具备优势,但在易用性方面一般会有劣势。

    • 完美转发的效率高,能够避免在传递形参时创造临时对象,但存在失效的情况

    • 可在模板函数的实现中增加static_assert用于在编译期提示是否能够进行模板实例化,若无法进行,则编译报错

      class Person
      {
          public:
          	template::type>::value>::type>
              explicit Person(T&& n)
      			: name(std::forward(n))
              {
              	static_assert(std::is_constructible::value, "Parameter n can't be used to construct a std::string");	// 断言可以从T类型的对象构造一个std::string类型对象        
              }
      };
      

5.6 理解引用折叠

  1. 引用折叠会在四种语境中发生:模板实例化auto类型生成创建和运用typedef、别名声明using 以及 decltype
  2. 当编译器在引用折叠的语境下生成引用的引用时,结果会变成单个引用若原始的引用中有任一引用为左值引用,则结果为左值引用。否则,结果为右值引用
  3. 万能引用就是在类型推导的过程会区别左值和右值,以及会发生引用折叠的语境中的右值引用

5.7 假定移动操作不存在、成本高、未使用

  1. 假定移动操作 不存在成本高未使用
    1. 待移动的对象若未能提供移动操作,则移动请求将变成拷贝请求
    2. 待移动的对象虽有移动操作,但并不比拷贝操作快
    3. 移动本可以发生的语境下,要求移动操作不可抛出异常,但该操作未加上noexcept声明
      • 标准库中一些容器操作提供了强异常安全保证,并且为了确保依赖于这样保证的那些C++98遗留代码在升级到C++11时不会破坏这样的保证,底层的拷贝操作只有在已知移动操作不会抛出异常的前提下才会使用移动操作将其替换。这将导致即使某个类型移动操作比对应拷贝操作更高效,甚至在代码的某个特定位置移动操作一般不会有问题,编译器仍会强制去调用一个拷贝操作,只要对应移动操作未加上noexcept声明
    4. 移动的源对象是个左值的情况下(除极少数情况),只有右值可以作为移动操作源
  2. 对于那些类型或对于移动语义的支持情况已知的代码,则无需作以上假定
    • 尽管如此,仍需要根据代码的API手册或是注释进行移动成本评估

5.8 熟悉完美转发的失败情况

  1. 完美转发的失败情形,是源于模板类型推导失败推导结果是错误的类型

  2. 会导致完美转发失败的实参种类有**{}以0或NULL表达的空指针仅有声明的整型static const成员变量模板或重载的函数名字,以及位域**。

    1. {}:此种初始化方式在万能引用函数模板推导类型且不在非推导语境时,会将其推导为初始化列表std::initializer_list,之后若将此传入(完美转发)形参为std::vector的函数中,则会出现错误。解决:先声明后传入

      • 完美转发出现失败的两种情况:
        1. 编译器无法为一个或多个函数模板的形参推导出类型结果。这种情况下,代码无法通过编译
        2. 编译器为一个或多个函数模板的形参推导出了”错误的“类型结果。这里的错误既指类型推导结果的实例化无法通过编译,也指类型推导实例化结果调用与同类型直接调用产生的结果不同
    2. 0或NULL用作空指针:此种传递类型推导结果将会是整型。解决:使用nullptr

    3. 仅有声明的整型static const成员变量:解决:增加定义即可

      class Widget
      {
          public:
          	static const std::size_t MinVals = 28;
          	...
      };
      
      std::vector widgetData;
      widgetData.reserve(Widget::MinVals);
      
      • 对于static const 成员变量仅需给出声明,因为编译器会根据这些成员的值实施常数传播,从而不必为其保留内存
        • 若产生了对此成员变量实施取址的需求,其就得要求存储(指针能够指涉),因此虽然代码能够通过编译,但若不为其提供定义,在链接期就会失败
    4. 重载的函数名字和模板名字:解决:手动指定需要转发的那个重载版本或实例

      void f(int (*pf)(int));
      void f(int pf(int));
      
      int processVal(int value);
      int processVal(int value, int priority);
      
      f(processVal);	// 没问题
      fwd(processVal);	// 错误,无法得知processVal的重载版本,编译器重载决议失败
      
      template
      T workOnVal(T param) { ... }
      fwd(processVal);	// 错误,无法得知需要workOnVal的模板实例版本
      
      using ProcessFuncType = int (*)(int);
      ProcessFuncType processValPtr = processVal;
      fwd(processValPtr);	// 没问题
      fwd(static_cast(workOnVal));	// 没问题
      
    5. 位域:位域由机器字的若干任意部分组成的,这样的实体不可能有办法对其直接取址。在硬件层次,指针和引用属于同一事物,因此无法创建指涉到任意比特的指针(C++可以指涉的最小实体是单个char),自然无法引用绑定到任意比特。能够传递位域值的仅有的形参种类只有按值传递以及常量引用。解决:将位域值转换为普通类型副本,之后转发

      struct IPv4Header
      {
          std::uint32_t version:4,
          			  IHL:4,
          			  DSCP:6,
          			  ECN:2,
          			  totalLength:16;
          ...
      };
      
      auto length(static_cast(h.totalLength));
      fwd(length);
      

第6章 lambda式

  • 闭包是lambda式创建的运行期对象,根据不同捕获模式,会持有数据的副本或引用
  • 闭包类就是实例化闭包的类。每个lambda式都会触发编译器生成一个唯一的闭包类。而闭包中的语句会变成它的闭包类成员函数的可执行指令
  • 一般来说,闭包可以拷贝
  • C++11中,lambda式有两种默认捕获模式:按引用、按值。只能针对于在创建lamda式内的作用域内可见的非静态局部变量(包括形参)

6.1 避免默认捕获模式

  1. 按引用的默认捕获会导致空悬指针问题。

    • 按引用捕获会导致闭包包含指涉到局部变量的引用,或者指涉到定义lambda式的作用域内的形参的作用。一旦由lambda式所创建的闭包越过了该局部变量或形参的生命期,那么闭包的引用就会空悬
    using FilterContainer = std::vector>;
    FilterContainer filters;
    // 在运行期内动态计算除数
    void addDivisorFilter()
    {
        auto cal1 = computeSomeValue1();
        auto cal2 = computeSomeValue2();
        auto divisor = computeDivisor(cal1, cal2);
        
        // 这一步对divisor的指涉可能空悬。因为在addDivisorFilter函数返回时,divisor就不再存在,此时可能刚好添加至容器内
        filters.emplace_back([&](int value)
                             {
                                 return value % divisor == 0;
                             });
    	// 按值捕获是解决此问题的一种方法,但按值捕获只能捕获非静态局部变量
        filters.emplace_back([=](int value)
                             {
                                 return value % divisor == 0;
                             });
    }
    
  2. 按值的默认捕获极易受空悬指针影响(尤其是this),并会误导人们认为lambda式是独立的。

    class Widget
    {
        public:
        	...
            void addFilter() const;
        private:
        	int divisor;
    };
    
    // 此处divisor并非非静态局部变量,而是类成员变量,无法被捕获
    void Widget::addFilter() const
    {
        filters.emplace_back([=](int value){
            return value % divisor == 0;
        });
    }
    
    // 此处代码等同于,因为每个非静态成员函数都持有一个this指针,然后每当调用该类的成员变量时都会用到这个指针
    void Widget::addFilter() const
    {
        auto currentObjectPtr = this;
        filters.emplace_back([currentObjectPtr](int value)
                             {
                                 return value % currentObjectPtr->divisor == 0;
                             });
    }
    
    • 使用智能指针

      void doSomeWork()
      {
          auto pw = std::make_unique();
          pw->addFilter();
      }
      // 在此函数返回后,filters将持有空悬指针
      // 解决
      void Widget::addFilter() const
      {
          auto divisorCopy = divisor;
          filters.emplace_back([divisorCopy](int value){
              return value % divisorCopy == 0;
          });
      }
      
      // C++14中支持广义lambda捕获
      // 此处将divisor拷贝入闭包,使用副本进行操作
      void Widget::addFilter() const
      {
          filters.emplace_back([divisor = divisor](int value){
              return value % divisor == 0;
          });
      }
      

6.2 使用初始化捕获将对象移入闭包

  • 初始化捕获即广义lambda捕获
  1. 使用C++14的初始化捕获将对象移入闭包。

    1. 使用初始化捕获,得到机会指定:
      1. 由lambda生成的闭包类中的成员变量的名字
      2. 一个表达式,用以初始化该成员变量
  2. 在C++11中,经由手工实现的类或std::bind去模拟初始化捕获。

    1. 把需要捕获的对象移动到std::bind产生的函数对象中
    2. 给到lambda式一个指涉到欲“捕获”的对象的引用
    // 手工实现类
    class IsValAndArch
    {
        public:
        	using DataType = std::unique_ptr;
        
        	explicit IsValAndArch(DataType&& ptr)
                : pw(std::move(ptr)) {}
        	bool operator()() const
            {
                return pw->isValidated() && pw->isArchived();
            }
        private:
        	DataType pw;
    };
    auto func = IsValAndArch(std::make_unique());
    
    // std::bind模拟初始化捕获
    // C++14
    std::vector data;
    auto func = [data = std::move(data)]
    {  
    };
    // C++11
    auto func = std::bind([](const std::vector& data)
                          {
                              
                          }, std::move(data));
    // 绑定对象含有传递给std::bind所有实参的副本。对于每个左值实参,在绑定对象内的对应的对象内对其实施的是拷贝构造;而对于每个右值实参,实施的则是移动构造。data在绑定对象中实施的是移动构造,而该移动构造动作正是实现模拟移动捕获的核心所在,因为把右值移入绑定对象,正式绕过C++11无法将右值移入闭包的手法
    // 当一个绑定对象被调用时,它所存储的实参会传递给原先传递给std::bind的那个可调用对象,即data的副本作为实参传递给lambda式
    
    // 针对const operator()()
    auto func = std::bind([](std::vector& data) mutable
                          {}, std::move(data));
    

6.3 对auto&&类型的形参使用decltype,后用std::forward转发

  • C++14特性,lambda可以在形参规则中使用auto

    auto f = [](auto x) { return func(normalize(x)); };
    // 闭包类的函数调用运算符
    class SomeCompilerGeneratedClassName
    {
        public:
        	template
        	auto operator()(T x) const
        	{
            	return func(normalize(x));	    
            }
    };
    
    // 若normalize区别对待左值和右值,则该lambda式撰写存在问题,需要将其改写为完美转发形式
    auto f = [](auto&& x) { return func(normalize(std::forward(x))); };
    // 此处类型无法确认。但根据条款28,若把左值传递给万能引用的形参,则该形参的类型会成为左值引用,若为右值,则同理。传入decltype(x),若x绑定了左值,则将产生左值引用类型,符合惯例;反之将产生右值引用惯例,而非符合惯例的非引用,虽然如此,但产生的结果殊途同归
    auto f = [](auto&& param)
    {
        return func(normalize(std::forward(param)));
    };
    // C++14lambda式能够接受可变长形参,改进如下
    auto f = [](auto&&... params)
    {
        return func(normalize(std::forward(params)...));
    };
    // std::forward C++14实现
    template
    T&& forward(remove_reference_t& param)
    {
        return static_cast(param);
    }
    // 左值结果已知;若完美转发右值,则会发生引用折叠将Widget&& && 变为Widget&&
    Widget&& forward(Widget& param)
    {
        return static_cast(param);
    }
    

6.4 优先选用lambda式,而非std::bind

  1. lambda式比起使用std::bind而言,可读性更好、表达力更强、可能运行效率也更高。
  2. 仅仅在C++11中,std::bind在实现移动捕获,或是绑定到具备模板化的函数调用运算符的对象的场合中,可能尚有余热可以发挥。

第7章 并发API

7.1 优先选用基于任务而非基于线程的程序设计

  1. std::thread未提供直接获取异步运行函数返回值的途径,而且若那些函数抛出异常,程序就会终止。

    • doAsyncWork以异步方式运行,有两种方式:

      1. std::thread

        int doAsyncWork();
        std::thread t(doAsyncWork);
        
      2. std::async

        auto fut = std::async(doAsyncWork);	// 此处auto因推导为std::future,易于捕获异常
        
  2. 基于线程的程序设计要求手动管理线程耗尽超订负载均衡,以及新平台适配

    • 基于线程和基于任务的区别在于,居于任务的程序设计表现为更高阶的抽象

    • 线程在带有并发C++软件中的三种意义

      1. 硬件线程是实际执行计算的线程。现代计算机体系结构会为每个CPU内核提供一个或多个硬件线程
      2. 软件线程(又称操作系统线程或系统线程)是操作系统用以实施跨进程的惯例,以及进行硬件线程调度的线程。通常,能够创建的软件线程会比硬件线程要多,因为当一个软件线程阻塞了(如,阻塞在IO操作上,或等待互斥量或条件变量等),运行另外的非阻塞线程能够提升吞吐率
      3. std::thread是C++进程里的对象,用作底层软件线程的句柄。有些std::thread对象标识为“null“句柄,对应于”无软件线程“,可能的原因有:它们处于默认构造状态(因此没有待执行函数),或者被移动了(作为移动目的的std::thread对象成为了底层线程的句柄),或者被联结(join)了(待运行的函数已运行结束),或者被分离了(std::thread对象与其底层软件线程的连接被切断了)
    • 线程耗尽:软件线程是一种有限的资源,若试图传教的线程数量多余系统能够提供的数量,则无论情况如何(即便声明了关键字noexcept)会抛出std::system_error异常

      int doAsyncWork() noexcept; 
      std::thread t(doAsyncWork);	// 若无可用线程,则抛出异常
      
    • 超订问题:就绪状态的软件线程超过了硬件线程数量的时候

      • 这种情况,线程调度器会为软件线程在硬件线程之上分配CPU时间片。当一个线程时间片用完,另一个线程启动时,就会执行上下文切换,此时会增加系统的总体线程管理开销,尤其在一个软件线程的这一次和下一次被调度器切换到不同的CPU内核上的硬件线程时会发生高昂的计算成本
        1. 那个软件线程通常不会命中CPU缓存
        2. CPU内核运行的新软件线程还会污染CPU缓存上为旧线程所准备的数据
      • 避免超订是困难的,因为软件线程和硬件线程的最佳比例取决于软件线程变成可运行状态的频繁程度,而这是会动态改变的。无法保证平台或设备的兼容
        • 软件线程和硬件线程的最佳比例也依赖于上下文切换成本,以及软件线程使用CPU缓存时的命中率
        • 硬件线程的数量和CPU缓存细节取决于计算机体系结构
  3. 经由应用了默认启动策略的std::async进行基于任务的程序设计,大部分这类问题都能找到解决之道。

    • std::async允许调度器把指定函数运行在请求函数结果的线程中,若系统发生超订或线程耗尽,合理的调度器就可以利用这个自由度
    • 大多数情况下应该选择基于任务的程序设计,以下情况直接使用基于线程的程序设计更合适:
      1. 需要访问底层线程实现的API
        • C++并发API通常会采用特定平台的低级API实现,经常使用的有pthread或Windows线程库
        • 为了访问底层线程实现的API,std::thread通常会提供native_handle成员函数,而std::future则没有该功能的对应
      2. 需要且有能力为你的应用优化线程用法
      3. 需要实现超越C++并发API的线程技术
        • 如,在C++实现中未提供的线程池的平台上实现线程池

7.2 如果异步是必要的,则指定std::launch::async

  1. std::async的默认启动策略既允许任务以异步方式执行,也允许任务以同步方式执行。

    • 有两种标准策略,它们都是用限定作用域的枚举类型std::launch中的枚举量来表示的:

      1. std::launch::async:意味着函数必须以异步方式运行
      2. std::launch::deferred意味着函数只会在std::async所返回的期值的get或wait得到调用时才运行,且调用方会阻塞至函数运行结束为止
    • 若未指定启动策略,则采用默认策略,即上述两种策略的或运算的结果,以异步或同步的方式运行皆可。使得std::async与标准库的线程管理组件能够承担得起线程的创建和销毁、避免超订以及负载均衡的责任。但这种方法会存在一些未知性

      1. 无法预知f是否会和t并发运行,因为f可能会被调度为推迟执行
      2. 无法预知f是否运行在与调用fut的get或wait函数的线程不同的某线程之上。若那个线程是t,那就是说无法预知f是否会运行在与t不同的某线程之上
      3. 无法预知f是否会运行,因为无法保证在程序的每条路径上,fut的get或wait都会得到调用
      // 以下两种写法具有相同的意义
      auto fut1(std::async(f));
      auto fut2(std::async(std::launch::async | std::launch::deferred, f));
      
  2. 如此弹性会导致使用thread_local变量时的不确定性,隐含着任务可能永远不会执行,还会影响运行了基于超时的wait调用的程序逻辑。

    • 这意味着若f读或写此线程级局部存储(thread-local storage,TLS)时,无法预知会取到的是哪个线程的局部存储

    • 对任务调用wait_for或wait_until会产生std::launch::deferred一值

      // 循环至f完成运行,但可能永远不会完成
      using namespace std::literals;
      void f()
      {
          std::this_thread::sleep_for(1s);
      }
      auto fut(std::async(f));
      while(fut.wait_for(100ms)) != std::future_status::ready)
      {
          ...
      }
      // 若f与调用std::async的线程是并发执行的,不会发生异常;若f被推迟执行,则fut.wait_for将总返回std::future_status::deferred,而那永远也不会取值std::future_status::ready,所以循环也就永远不会终止
      // 优化:判断std::async返回的期值,确定任务是否被推迟,若确实被推迟,则避免进入基于超时的循环
      auto fut(std::async(f));
      if(fut.wait_for(0s) == std::future_status::deferred)
      {
          // 此处使用fut的wait或get以异步方式调用f
          ...
      }
      else
      {
          while(fut.wait_for(100ms) != std::future_status::ready)
          {
              // 任务既未被推迟,也未就绪,则继续并发工作,直至任务就绪
              ...
          }
          // fut就绪
          ...
      }
      
    • 以默认启动策略对任务使用std::async能正常工作需要满足以下所有条件,只要其中一个条件不满足,就很有可能要确保任务以异步方式执行:

      1. 任务不需要与调用get或wait的线程并发执行
      2. 读/写哪个线程的thread_local变量并无影响
      3. 可以给出保证在std::async返回的期值上调用get或wait,或者可以接受任务可能永不执行
      4. 使用wait_for或wait_until的代码会将任务被推迟的可能性纳入考量
  3. 异步是必要的,则指定std::launch::async

    auto fut(std::async(std::launch::async, f));
    // 以下函数能像std::async那样运作,且会自动地使用std::launch::async作为启动策略
    // 该函数接受一个可调用对象f,以及零个或多个形参params,并将后者完美转发给std::async,同时传递std::launch::async作为启动策略
    // C++11版本
    template
    inline std::future::type> reallyAsync(F&& f, Ts&&... params)
    {
        return std::async(std::launch::async, std::forward(f), std::forward(params)...);
    }
    // C++14版本
    template
    inline auto reallyAsync(F&& f, Ts&&... params)
    {
        return std::async(std::launch::async, std::forward(f), std::forward(params)...);
    }
    

7.3 使std::thread类型对象在所有路径都不可join

  • std::thread类型对象都处于两种状态其中之一:可联结、不可联结(join)
    • 可联结:对应底层以异步方式已运行或可运行地线程,即std::thread类型对象对应地底层线程若处于阻塞或等待调度或已运行结束
    • 不可联结:不属于上述均是,即std::thread类型对象包括
      • 默认构造:没有可执行函数,没有对应底层执行线程
      • 已移动:对应的底层线程不再对应原来的std::thread对象,而转移至另一个对象
      • 已联结:不再对应至已结束运行的底层线程
      • 已分离:std::thread对象与它对应的底层执行线程之间的连接已断开
  1. 使std::thread类型对象在所有路径皆不可联结(join)。

    • 处于可联结状态调用std::thread对象析构函数会导致程序终止,发生此情况的两种选项:
      1. 隐式join:这种情况下,std::thread的析构函数会等待底层异步执行线程完成。难以追踪性能异常
      2. 隐式detach:在这种情况下,std::thread析构函数会分离std::thread类型对象与底层执行线程之间的连接。而该底层执行线程会继续执行。此时,若执行函数中存在lambda式,且lambda式引用执行函数中的元素,则此时会出现未定义行为
    • 路径包括:return、continue、break、goto或异常跳出作用域等路径
  2. 在析构时调用join可能导致难以调试的性能异常。

    • 含有此行为的对象称为RAII对象,来自RAII类(即资源获取即初始化),关键在于析构而非初始化

      • RAII类在标准库中很常见,如STL容器、标准智能指针、std::fstream类型对象等

      • 没有std::thread类型对象对应的RAII类,可自行实现,如下:

        class ThreadRAII
        {
            public:
            	enum class DtorAction { join, detach };
            	ThreadRAII(std::thread&& t, DtorAction a)
                    : action(a), t(std::move(t)) { }
            	~ThreadRAII()
                {
                    if(t.joinable())
                    {
                        if(action == DtorAction::join)
                        {
                            t.join();
                        }
                        else
                        {
                            t.detach();
                        }
                    }
                }
            	std::thread& get() { return t; }
            private:
            	DtorAction action;
            	std::thread t;
        };
        
  3. 在析构时调用detach可能导致难以调试的未定义行为。

  4. 在成员列表的最后声明std::thread类型对象。

7.4 对变化多端的线程句柄析构函数行为保持关注

  • 被调方发送std::promise对象作为结果,调用方调用std::future作为期值
  • 调用方、被调方中存在被调方结果,此结果为调用方以及被调方所共享,即共享状态,期值析构函数的行为由与其关联的共享状态决定:
    • 指涉到经由std::async启动的未推迟任务的共享状态的最后一个期值会保持阻塞,直至该任务结束
      • 本质上,这样一个期值的析构函数对底层异步执行任务的线程实施了一次隐式join
    • 其他所有期值对象的析构函数只仅仅将期值对象析构就结束了
      • 对于底层异步运行的任务,相对于实施了一次隐式detach
  1. 期值的析构函数在常规情况下,仅会析构期值的成员变量

    • 在常规情况下,还有一个操作是,针对共享状态里的引用计数实施了一次自减
    • 相对于常规情况外,只有在期值满足以下全部条件时才会发挥作用,即阻塞直到异步运行的任务结束(相当于一次隐式join):
      1. 期值所指涉的共享状态是由于调用了std::async才创建的
      2. 该任务的启动策略是std::launch::async
      3. 该期值是指涉到该共享状态的最后一个期值
        • 对于std::future对象来说,这点总是成立;而对于std::shared_future对象来说,在析构时若不是最后一个指涉到共享状态的期值,则它会遵循常规行为准则,即仅析构其成员变量
  2. 指涉到经由std::async启动的未推迟任务共享状态的最后一个期值会保持阻塞,直至该任务结束

    • 标准委员会想要避免隐式detach相关的问题,为了不使程序直接终止,妥协而实施了一次隐式join
    • 当期值所对应的共享状态是std::packaged_task产生的,则通常无须采用特别析构方法。因为关于终止、联结还是分离,会由操作std::thread的代码作出决定,而std::packaged_task通常运行在该线程上
    // 该容器的析构函数可能会在其析构函数中阻塞,因为它所持有的期值中可能会有一个或多个指涉到经由std::async启动未推迟任务所产生的共享状态
    std::vector> futs;
    // Widget对象可能会在其析构函数中阻塞
    class Widget
    {
        public:
        	...
        private:
        	std::shared_future fut;
    };
    
    // 判定给定的期值不满足触发特殊析构行为的条件,std::packaged_task,此类型对象会准备一个函数(或其他可调用对象)以供异步执行,手法是将它奖赏一层包装,将其结果置入共享状态,而指涉到该共享状态的期值则可以经由std::packaged_task的get_future函数得到
    {
        int calcValue();
        std::packaged_task pt(calcValue);	// 创建后便以异步方式开始执行
        
        // 该期值对象fut未指涉到由std::async调用产生的共享状态,因此它的析构函数将表现出常规行为
        auto fut = pt.get_future();
        std::thread t(std::move(pt)); 	// 控制句柄。std::packaged_task对象不可拷贝,因此在此处需要将其强制转型为右值
        
        ...
        // ...中对t做处理的三种可能:
        // 1.未对t实施任何操作。由于此时t在作用域结束前可联结,将导致程序终止
        // 2.针对t实施join。调用后由于t变为不可联结,此时fut无须在析构函数中阻塞
        // 3.针对t实施detach。此时fut无须在析构函数中实施detach
    }
    

7.5 考虑针对一次性事件通信使用以void为模板类型实参的期值

// 条件变量使用,在多线程中需要搭配互斥量使用
std::condition_variable cv;	// 事件条件变量
std::mutex m;

// 执行线程
std::unique_lock lk(m);
cv.wait(lk, [] { return; }); // wait可搭配测试等待条件的lambda式或其他函数对象
// 另一个线程,检测事件,通知反应任务
...
cv.notify_one(); // 通知一个反应任务
cv.notify_all(); // 通知多个反应任务
  1. 若仅为了实现平凡事件通信,基于条件变量的设计会要求多余的互斥量,这会给相互关联的检测和反应任务带来约束,并要求反应任务校验事件确已发生。

    • 此方法优点为在等待事件时,会阻塞等待,不会消耗系统资源
    1. 若检测任务在反应任务调用wait之前就通知了条件变量,则反应任务将失去响应。
      • 调用wait后,则阻塞,直至被notify
    2. 反应任务的wait无法应对虚假唤醒。
      • 即使没有通知条件变量,针对该条件变量等待的代码也可能被唤醒,即虚假唤醒
  2. 使用标志位的设计可以避免上述问题,但这一设计基于轮询而非阻塞

    // 共享布尔标志位
    std::atomic flag(false);
    ...
    // 检测事件
    flag = true;
    // 反应任务轮询标志位
    while(!flag);	// 等待事件
    ... // 针对事件作出反应
    
    • 此方法反应任务的轮询成本大。因为在任务等待标志位被设置的时候,它实质上应该被阻塞,但却仍然运行
  3. 条件变量标志位可以一起使用,但这样的通信机制设计结果不甚自然。

    std::condition_variable cv;
    std::mutex m;
    bool flag(false);
    // 检测任务
    ...	// 检测事件
    {
        std::lock_guard g(m);
        flag = true;
    }
    cv.notify_one();
    // 反应任务
    ... // 准备反应
    {
        std::unique_ptr lk(m);
        cv.wait(lk, [] { return flag; });	// 使用lambda式应对虚假唤醒
        ...	// 针对事件作出反应(m被锁定)
    }
    ...	// 继续等待反应(m已解锁)
        
    // 以上仍然需要在通知条件变量才能让反应任务被唤醒去检测标志位,不够干净利落
    
  4. 使用std::promise类型对象期值(std::future或std::shared_future)就可以回避这些问题,但是第一:这个途径为了共享状态需要使用堆内存;第二:仅限于一次性通信

    std::promise p;
    // 检测任务
    ...
    p.set_value();
    // 反应任务
    ...
    p.get_future().wait();
    ...
    
    1. std::promise和期值之间是共享状态,而共享状态通常是动态分配的,因此就必须考虑在堆上进行分配和回收的成本

    2. std::promise类型对象只能设置一次。

      // 假设只暂停线程一次,则使用void期值的设计即为合理
      std::promise p;
      void react();	// 反应任务函数
      void detect()
      {
      	std::thread t([]
                        {
                            p.get_value().wait();	// 暂停线程t直至期值被设置
                            react();
                        });
          ...
          p.set_value(); // 取消暂停t(调用react)
          ...
          t.join();	// 使t处于不可联结状态
      }
      // detect使用RAII方法
      void detect()
      {
          ThreadRAII tr(std::thread([]
                                    {
                                        p.get_future().wait();
                                        react();
                                    })), ThreadRAII::DtorAction::join); // 此处存在风险
          ...	// 此处若抛出异常,程序中止,react将永远不会被执行,lambda式将永远不能完成,函数失去响应,因为tr的析构函数永远无法执行
          p.set_value();
          ...
      }
      // 将std::future换为std::shared_future,使得每个反应线程都需要自己的那份std::shared_future副本去指涉到共享状态
      std::promise p;
      void detect()
      {
          auto sf = p.get_value().share();
      	std::vector vt;	// 反应任务池
          for(int i = 0; i < threadsToRun; ++i)
          {
              vt.emplace_back([sf]{
                  st.wait();
                  react();
              });
          }
          ...	// 若此处抛出异常,则detect会失去响应
          p.set_value();	// 使所有线程取消暂停
          ...
          for(auto& t : vt)
          {
              t.join();
          }
      }
      

7.6 对并发使用std::atomic,对特别内存使用volatile

  1. std::atomic用于多线程访问的数据,且不用互斥量。它是撰写并发软件的工具。
  2. volatile用于读写操作不可以被优化掉的内存。它是在面对特别内存时使用的工具。
    1. volatile和std::atomic可一并使用
    2. 使用auto时,需要注意auto的特性

第8章 微调

8.1 针对可复制的形参,在移动成本低且一定会被复制的前提下,考虑将其按值传递

  1. 对于可拷贝,移动成本低廉并且一定会被拷贝的形参而言,按值传递可能会和按引用传递的具备相近的效率,并可能生成更少量的目标代码。
    1. 在函数中,对左值实参实施拷贝,对右值实参实施移动
    2. 按引用传递及按值传递忽略编译器优化掉拷贝及移动操作的可能性,如下:
      1. 重载:无论传入左值还是右值,调用方的实参都会绑定到实参的引用上。这样做不会在拷贝或移动时带来任何成本。成本合计:对于左值对应一次拷贝,对于右值对应一次移动
      2. 使用万能引用:除了导致头文件膨胀的影响外,由于使用了std::forward,左值实参被拷贝,右值实参被移动。成本合计同重载,甚至可能无消耗
      3. 按值传递:无论传入的是左值还是右值,针对形参都必须实施一次构造。若传入的是左值,成本是一次拷贝构造。若传入的是右值,成本是一次移动构造。成本合计:对于左值是一次拷贝及一次移动,对于右值是两次移动。与按引用传递相比,两种值类型均存在一次额外的移动操作
  2. 经由构造拷贝形参的成本可能比经由赋值拷贝形参高出很多。
  3. 按值传递肯定会导致切片问题,所以基类类型特别不适用于按值传递
    • 若传入子类,则尺寸会被截为与基类相当

8.2 考虑置入而非插入

  1. 从原理上说,置入函数有时应该比对应的插入函数高效,不应该有更低效的可能。
    • 除std::forward_list及std::array的所有标准容器都支持置入操作
    • 插入函数接收的是待插入对象,而置入函数接收的则是待插入对象的构造函数实参。这一区别使置入函数得以避免临时对象的创建和析构
  2. 从实践上说,置入函数在以下几个前提成立时,极有可能会运行更快:
    1. 待添加的值是以构造而非赋值方式加入容器
    2. 传递的实参类型与容器内的值的类型不同
    3. 容器不会由于存在重复值而拒绝待添加的值
  3. 置入函数可能会执行在插入函数中会被拒绝的类型转换

你可能感兴趣的:(读书笔记,c++,开发语言)