《Effective Modern C++》全书内容提炼总结

个人博客地址: https://cxx001.gitee.io

《Effective Modern C++》全书内容提炼总结_第1张图片

前言

C++程序员都应该是对性能执着的人,想要彻底理解C++11和C++14,不可止步于熟悉它们引入的语言特性(例如,auto型别推导、移动语义、lambda表达式,以及并发支持)。挑战在于高效地运用这些特性,从而使你的软件具备正确性、高效率、可维护性和可移植性。这正是本书意欲达成的定位,它不只是教我们应该怎么做,更多的是告诉我们背后发生了什么。

本书电子版


一、类型推导

C++98有一套类型推导的规则:用于函数模板的规则。C++11修改了其中的一些规则并增加了两套规则,一套用于auto,一套用于decltype。C++14扩展了autodecltype可能使用的范围。

1. 理解模板类型推导

C++11的auto/decltype类型推导其实就是基于模板类型推导实现的,模板类型推导分成三个情景,下面我们分别介绍:

  1. 模板类型是指针或引用情形

举个例子,这是我们的模板,

template
void f(T& param);         

我们声明了这些变量,

int x=27;                       //x是int
const int cx=x;                 //cx是const int
const int& rx=x;                //rx是指向作为const int的x的引用

下面是不同调用T被推导出的类型,

f(x);                           //T是int,param的类型是int&
f(cx);                          //T是const int,param的类型是const int&
f(rx);                          //T是const int,param的类型是const int&

如果我们将f的形参类型T&改为const T&,情况有所变化,但不会变得那么出人意料。

template
void f(const T& param);         //param现在是reference-to-const

int x = 27;                     //如之前一样
const int cx = x;               //如之前一样
const int& rx = x;              //如之前一样

f(x);                           //T是int,param的类型是const int&
f(cx);                          //T是int,param的类型是const int&
f(rx);                          //T是int,param的类型是const int&

如果param是一个指针,本质上也一样,

template
void f(T* param);               //param现在是指针

int x = 27;                     //同之前一样
const int *px = &x;             //px是指向作为const int的x的指针

f(&x);                          //T是int,param的类型是int*
f(px);                          //T是const int,param的类型是const int*

其实规则就是模板类型与传递参数类型作模式匹配,重复部分会被忽略,得到的T类型。

  1. 模板类型是一个右值引用(&&)
template
void f(T&& param);              //param现在是一个通用引用类型
        
int x=27;                       //如之前一样
const int cx=x;                 //如之前一样
const int & rx=cx;              //如之前一样

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. 模板类型既不是指针也不是引用
template
void f(T param);                //以传值的方式处理param

int x=27;                       //如之前一样
const int cx=x;                 //如之前一样
const int & rx=cx;              //如之前一样

f(x);                           //T和param的类型都是int
f(cx);                          //T和param的类型都是int
f(rx);                          //T和param的类型都是int

这是参数传值得形式,param是实参的一个副本,推导时,参数的const,引用会被忽略。

请记住:

  • 在模板类型推导时,有引用的实参会被视为无引用,他们的引用会被忽略。
  • 对于通用引用(右值引用)的推导,左值实参会被特殊对待。
  • 对于传值类型推导,const和&或volatile实参会被认为是non-const的和non-volatile的。
  • 在模板类型推导时,数组名或者函数名实参会退化为指针,除非它们被用于初始化引用。

2. 理解auto类型推导

如果你已经读过前面item1的模板类型推导,那么你几乎已经知道了auto类型推导的大部分内容,至于为什么不是全部是因为有一个例外。

先看下面相同部分,也分三个情形:

  • 情形一:类型说明符是一个指针或引用
  • 情形二:类型说明符是一个右值引用(&&)
  • 情形三:类型说明符既不是指针也不是引用
auto x = 27;                    //情景三(x既不是指针也不是引用)
const auto cx = x;              //情景三(cx也一样)
const auto & rx=cx;             //情景一(rx是非通用引用)
// 情景二
auto&& uref1 = x;               //x是int左值,所以uref1类型为int&
auto&& uref2 = cx;              //cx是const int左值,所以uref2类型为const int&
auto&& uref3 = 27;              //27是int右值,所以uref3类型为int&&

讨论完相同点接下来就是不同点,前面我们已经说到auto类型推导和模板类型推导有一个例外使得它们的工作方式不同,接下来我们要讨论的就是那个例外。

先看下面这个简单示例,截至目前我们对一个变量的初始化有4中形式,如下:

auto x1 = 27;                   //类型是int,值是27
auto x2(27);                    //同上
auto x3 = { 27 };               //类型是std::initializer_list,值是27
auto x4{ 27 };                  //同上

当用auto声明的变量使用花括号进行初始化,auto类型推导推出的类型则为std::initializer_list。而对于模板类型推导这样行不通:

auto x = { 11, 23, 9 };         //x的类型是std::initializer_list

template            //带有与x的声明等价的
void f(T param);                //形参声明的模板

f({ 11, 23, 9 });               //错误!不能推导出T

然而如果在模板中指定Tstd::initializer_list而留下未知T,模板类型推导就能正常工作:

template
void f(std::initializer_list initList);

f({ 11, 23, 9 });               //T被推导为int,initList的类型为std::initializer_list

因此auto类型推导和模板类型推导的真正区别在于,auto类型推导假定花括号推导为std::initializer_list类型而模板类型推导不会这样(确切的说是不知道怎么办)。

知道了上面这个不同,我们接下来看C++14允许auto用于函数返回值,而且C++14的lambda函数也允许在形参声明中使用auto。但是在这些情况下auto实际上使用模板类型推导的那一套规则在工作,而不是auto类型推导。所以下面代码不会通过编译:

auto createInitList()
{
    return { 1, 2, 3 };         //错误!模板类型推导规则不能推导{ 1, 2, 3 }的类型
}
std::vector v;
…
auto resetV = 
    [&v](const auto& newValue){ v = newValue; };        //C++14
…
resetV({ 1, 2, 3 });            //错误!不能推导{ 1, 2, 3 }的类型

请记住:

  • auto类型推导通常和模板类型推导相同,但是auto类型推导假定花括号初始化代表std::initializer_list,而模板类型推导不这样做。
  • 在C++14中auto允许出现在函数返回值或者lambda函数形参中,但是它的工作机制是模板类型推导那一套方案,而不是auto类型推导。

3. 理解decltype

在C++11中,decltype最主要的用途就是用于声明函数模板,而这个函数返回类型依赖于形参类型。

// 返回值decltype(auto)实际上我们可以这样解释它的意义:auto说明符表示这个类型将会被推导,decltype说明decltype的规则将会被用到这个推导过程中。

//C++14版本
template    
decltype(auto)  
authAndAccess(Container&& c, Index i)  // 右值引用没有临时对象的拷贝
{
    authenticateUser();
    return std::forward(c)[i];
}

//C++11版本
template    
auto
authAndAccess(Container&& c, Index i)
->decltype(std::forward(c)[i])
{
    authenticateUser();
    return std::forward(c)[i];
}

注意decltype(exp) var;有个特例就是如果exp是被()包围,那么推导的类型就是exp的引用。

decltype(auto) f2()
{
    int x = 0;
    return (x);                          //decltype((x))是int&,所以f2返回int&
}

请记住:

  • decltype总是不加修改的产生变量或者表达式的类型。
  • 对于T类型的不是单纯的变量名的左值表达式,decltype总是产出T的引用即T&
  • C++14支持decltype(auto),就像auto一样,推导出类型,但是它使用decltype的规则进行推导。

4. 学会查看类型推导结果

查看类型推导结果一般是下面这几种方式:

  • 编辑器IDE断点或报错日志查看
  • 运行时输出,使用typeid(x).name()
  • 使用Boost TypeIndex库

但是有时你要注意,它们不一定都正确,看下面示例:

#include 
#include 

template 
void f(const T& param)
{
    std::cout << "T =     " << typeid(T).name() << '\n'; //  int const * __ptr64
    std::cout << "param = " << typeid(param).name() << '\n'; //  int const * __ptr64
}

int main()
{
    std::vector v;
    v.push_back(1);

    const auto vw = v;

    if (!vw.empty()) {
        f(&vw[0]);
    }

    return 0;
}

上面模板函数的T和param的类型输出都是int const *,这明显是错误的,它们不应该是相同的,T应该是int。

请记住:

  • 类型推断可以从IDE看出,从编译器报错看出,从Boost TypeIndex库的使用看出。
  • 这些工具可能既不准确也无帮助,所以理解C++类型推导规则才是最重要的。

二、auto

auto很简单,使用它可以存储类型,但是有时它也会犯一些错误,而且比之手动声明一些复杂类型也会存在一些性能问题。所以我们有必要知道auto的里里外外。

5. 优先考虑auto而非显式类型声明

下面列举些优先使用auto的原因:

  • auto声明变量必须初始化,不会存在未初始化的变量。
int x1;                         //潜在的未初始化的变量
auto x2;                        //错误!必须要初始化
auto x3 = 0;                    //没问题,x已经定义了
  • 更加简洁、简单。
template          
void dwim(It b, It e) 
{
    while (b != e) {
        typename std::iterator_traits::value_type  // 类型声明很长
        currValue = *b;
        …
    }
}


template           //如之前一样
void dwim(It b,It e)
{
    while (b != e) {
        auto currValue = *b;  // 简洁很多了
        …
    }
}
  • 避免一些潜在类型转换问题。
// 传统写法,这个把int类型赋值给了一个unsigned类型。
// 这在32位机器没问题,但是在64位机器,int是64位,而unsigned是32位。
std::vector v;
unsigned sz = v.size();

// 而使用auto就可以避免上面问题
auto sz =v.size();                      //sz的类型是std::vector::size_type

当然使用auto也会带来一些问题,如对源码可读性的影响和item2/6讨论的一些点。而我的观点是在理解的前提下,能用auto的地方就都用它吧。

请记住:

  • auto变量必须初始化,通常它可以避免一些移植性和效率性的问题,也使得重构更方便,还能让你少打几个字。
  • 正如Item2和6讨论的,auto类型的变量可能会踩到一些陷阱。

6. auto推导若非己愿,使用显式类型强转

auto在item5我们建议是尽可能使用,但是有个特殊情况需要注意,就是对于返回代理类的场景,auto推导就不正确了。代理类是对现有类型的一种封装,使这个原始类型在特定场景操作更加方便,如智能指针就是原始指针的代理类。

std::vector features(const Widget& w);

Widget w;
...
auto highPriority = features(w)[5]; // 这里返回的是隐式代理类型std::vector::reference
...
processWidget(w, highPriority);  //未定义行为!processWidget第二个参数要bool,而highPriority并不是

所以像上面这种代理类型就不能使用auto去推导了,一般两种方式处理:

  • 不使用auto类型推导,明确指明类型
bool highPriority = features(w)[5]; //这会隐式将std::vector::reference类型转换为bool类型
  • 使用auto,加类型强转
auto highPriority = static_cast(features(w)[5]); // highPriority也会推导为bool类型

请记住:

  • 不可见的代理类可能会使auto从表达式中推导出“错误的”类型。
  • 显式类型初始器惯用法(static_cast)强制auto推导出你想要的结果。

三、移步现代C++

说起知名的特性,C++11/14有一大堆可以吹的东西,auto,智能指针,移动语义,lambda,并发 。每个都是如此的重要,这章将覆盖这些内容。掌握这些特性是必要的,要想成为高效率的现代C++程序员需要小步迈进。

7. 区别使用()和{}创建对象

对于一个变量的初始化,有下面这么多的形式:

int x(0);               //使用圆括号初始化
int y = 0;              //使用"="初始化
int z{ 0 };             //使用花括号初始化  等效 int z = { 0 }; 

而像容器要初始化元素,使用{}很直观

std::vector v{ 1, 3, 5 };  //v初始内容为1,3,5

还有现在可以直接给非静态的数据成员指定默认初始值,但是()形式不行。

class Widget{
    …

private:
    int x{ 0 };                 //没问题,x初始值为0
    int y = 0;                  //也可以
    int z(0);                   //错误!
}

再者,std::atomic对象初始化不能用=形式。

std::atomic ai1{ 0 };      //没问题
std::atomic ai2(0);        //没问题
std::atomic ai3 = 0;       //错误!

再看一个示例,{}形式初始化还能检测类型变窄问题。

double x, y, z;

int sum1{ x + y + z };          //错误!double的和可能不能表示为int

还有如要调用不带参数构造函数,使用()会认为是一个声明,使用{}则没这种问题。

Widget w2();                    //最令人头疼的解析!声明一个函数w2,返回Widget

Widget w3{};                    //调用没有参数的构造函数构造对象

综上所述,使用{}初始化是适应性最全面,而且还能避免类型变窄和令人头疼的解析。所以大部分情况我是推荐使用统一的{}风格来初始化对象的。

不过有个场景你需要了解,{}初始化返回的其实是std::initializer_list模板类型对象,在item2我们有详细介绍。所以{}作为实参调用重载函数接口,一定会优先匹配带std::initializer_list参数的接口。

class Widget { 
public:  
    Widget(int i, bool b);
    Widget(int i, double d);
    Widget(std::initializer_list il);
    …
}; 
Widget w1(10, true);    //使用圆括号初始化,调用第一个构造函数

Widget w2{10, true};    //使用花括号初始化,调用带std::initializer_list的构造函数
                        //(10 和 true 转化为long double)

Widget w3(10, 5.0);     //使用圆括号初始化,调用第二个构造函数 

Widget w4{10, 5.0};     //使用花括号初始化,调用带std::initializer_list的构造函数
                        //(10 和 5.0 转化为long double)

不过如果{}参数类型与std::initializer_list类型无法隐式转换,则会去匹配其它重载方法。

class Widget { 
public:  
    Widget(int i, bool b);                              //同之前一样
    Widget(int i, double d);                            //同之前一样
    //现在std::initializer_list元素类型为std::string
    Widget(std::initializer_list il);
    …                                                   //没有隐式转换函数
};

Widget w1(10, true);     // 使用圆括号初始化,调用第一个构造函数
Widget w2{10, true};     // 使用花括号初始化,现在调用第一个构造函数
Widget w3(10, 5.0);      // 使用圆括号初始化,调用第二个构造函数
Widget w4{10, 5.0};      // 使用花括号初始化,现在调用第二个构造函数

最后,一个关于容器使用()与{}初始化的区别要注意,这其实是设计的缺陷,我们自己设计应该尽量避免。

std::vector v1(10, 20);    //使用非std::initializer_list构造函数
                                //创建一个包含10个元素的std::vector,
                                //所有的元素的值都是20
std::vector v2{10, 20};    //使用std::initializer_list构造函数
                                //创建包含两个元素的std::vector,
                                //元素的值为10和20

请记住:

  • 花括号初始化是最广泛使用的初始化语法,它防止变窄转换,并且对于C++最令人头疼的解析有天生的免疫性。
  • 在构造函数重载决议中,编译器会尽最大努力将括号初始化与std::initializer_list参数匹配,即便其他构造函数看起来是更好的选择。
  • 对于数值类型的std::vector来说使用花括号初始化和圆括号初始化会造成巨大的不同。
  • 在模板类选择使用圆括号初始化或使用花括号初始化创建对象是一个挑战。

8. 优先考虑nullptr而非0和NULL

这点就没太多讨论的了,传统C++98用NULL表示空指针,其实就是一个宏定义,本质就是0,这会与整型0产生歧义。在c++11我们解决了这个问题,用nullptr来表示任意类型的空指针。

请记住

优先考虑nullptr而非0NULL

9. 优先考虑别名声明而非typedef

关于为什么优先考虑使用using别名声明,我列出了下面几点原因:

  1. 更直观,便于理解
typedef
    std::unique_ptr>
    UPtrMapSS; 

using UPtrMapSS =
    std::unique_ptr>;

// 尤其是函数指针取别名时
//FP是一个指向函数的指针的同义词,它指向的函数带有
//int和const std::string&形参,不返回任何东西
typedef void (*FP)(int, const std::string&);    //typedef

//含义同上
using FP = void (*)(int, const std::string&);   //别名声明
  1. 在模板中,using可以模板化但typedef不能,这时使用using明显容易很多。
template                            //MyAllocList是
using MyAllocList = std::list>;   //std::list>
                                                //的同义词

MyAllocList lw;                         //用户代码

使用typedef,你就只能从头开始:

template                            //MyAllocList是
struct MyAllocList {                            //std::list>
    typedef std::list> type;      //的同义词  
};

MyAllocList::type lw;                   //用户代码

请记住

优先考虑使用using取别名替代typedef

10. 优先考虑限域枚举而非未限域枚举

不限域枚举作用域属于包含enum的作用域,可能导致命名污染。

enum Color { black, white, red };   //black, white, red在Color所在的作用域
auto white = false;                 //错误! white早已在这个作用域中声明

再来看看限域枚举,就解决了这个问题。

enum class Color { black, white, red }; //black, white, red 限制在Color域内
auto white = false;                     //没问题,域内没有其他“white”

Color c = white;                        //错误,域中没有枚举名叫white

Color c = Color::white;                 //没问题
auto c = Color::white;                  //也没问题(也符合Item5的建议)

使用限域enum可以减少命名空间污染。而且限域enum还有第二个吸引人的优点:在它的作用域中,枚举名是强类型,不接受隐式转换。未限域enum中的枚举名会隐式转换为整型。

enum class Color { black, white, red }; //Color现在是限域enum

std::vector primeFactors(std::size_t x);

Color c = Color::red;                   
...                               

if (c < 14.5) {                         //错误!不能比较Color和double
    auto factors =                      //错误!不能向参数为std::size_t
      primeFactors(c);                  //的函数传递Color参数
    …
}

如果你非要使用,就只能显式类型强转了。

if (static_cast(c) < 14.5) {    //奇怪的代码,
                                        //但是有效
    auto factors =                                  //有问题,但是
      primeFactors(static_cast(c));    //能通过编译
    …
}

还有限域enum可以前置声明,它默认底层类型是int,当然你也可以指定修改。而非限域enum底层类型默认根据枚举值选最大的那个类型作为底层类型,需要定义了才能确认底层类型,所以它不支持前置声明。但是它也可以手动指定类型,这样之后也可以前置声明了,这里本质是要提前确定底层类型。

enum class Status;                  //前置声明,默认底层类型是int
void continueProcessing(Status s);  //使用前置声明enum
// 手动指定底层类型,非限域enum指定也是一样
enum class Status: std::uint32_t;   //Status的底层类型
                                    //是std::uint32_t
                                    //(需要包含 

请记住

  • C++98的enum即非限域enum
  • 限域enum的枚举名仅在enum内可见。要转换为其它类型只能使用cast
  • 非限域/限域enum都支持底层类型说明语法,限域enum底层类型默认是int。非限域enum没有默认底层类型。
  • 限域enum总是可以前置声明。非限域enum仅当指定它们的底层类型时才能前置。

11. 优先考虑使用deleted函数而非使用未定义的私有声明

在C++98时,我们要屏蔽某些成员函数不让调用,一般做法是声明为私有且不去实现它们。

template  >
class basic_ios : public ios_base {
public:
    …

private:
    basic_ios(const basic_ios& );           // not defined
    basic_ios& operator=(const basic_ios&); // not defined
};

如果内部或友元函数调用了它们就会在链接时引发缺少函数定义的错误。

在C++11中有一种更好的方式,用= delete将函数标记为删除,这样如果有地方调用了它,直接在编译时就会报错,比传统方式报错提前了而且报错信息更加友好

template  >
class basic_ios : public ios_base {
public:
    …

    basic_ios(const basic_ios& ) = delete;
    basic_ios& operator=(const basic_ios&) = delete;
    …
};

通常,deleted函数被声明为public而不是private,这是有原因的,C++会在检查deleted状态前检查它的访问性。当客户端代码调用一个私有的deleted函数,一些编译器只会给出该函数是private的错误,这样报错信息就不那么准确了。

deleted函数还有一个重要的优势是任何函数都可以标记为deleted,比如假设我们有这样一个函数,

bool isLucky(int number);

只有传int时才是有意思的,其它类型调用默认会隐式转换,但是可能没有意义。

if (isLucky('a')) …         //字符'a'是幸运数?
if (isLucky(true)) …        //"true"是?
if (isLucky(3.5)) …         //难道判断它的幸运之前还要先截尾成3?

如果幸运数必须是整型,我们该禁止这些调用通过编译。

bool isLucky(int number);       //原始版本
bool isLucky(char) = delete;    //拒绝char
bool isLucky(bool) = delete;    //拒绝bool
bool isLucky(double) = delete;  //拒绝float和double

请记住:

  • 比起声明函数为private但不定义,使用deleted函数更好。
  • 任何函数都能被删除,包括非成员函数和模板实例。

12. 使用override声明重写函数

所谓重写即派生类的虚函数重写基类同名函数。令人遗憾的是虚函数重写可能一不小心就错了。

比如,下面的代码是完全合法的,咋一看,还很有道理,但是它没有任何虚函数重写——没有一个派生类函数重写了基类函数。你能识别每种情况的错误吗,换句话说,为什么派生类函数没有重写同名基类函数?

class Base {
public:
    virtual void mf1() const;
    virtual void mf2(int x);
    virtual void mf3() &;
    void mf4() const;
};

class Derived: public Base {
public:
    virtual void mf1();
    virtual void mf2(unsigned int x);
    virtual void mf3() &&;
    void mf4() const;
};
  • mf1Base基类声明为const,但是Derived派生类没有这个常量限定符
  • mf2Base基类声明为接受一个int参数,但是在Derived派生类声明为接受unsigned int参数
  • mf3Base基类声明为左值引用限定,但是在Derived派生类声明为右值引用限定
  • mf4Base基类没有声明为virtual虚函数

所以从上就可以看出,我们要重写虚函数,传统方式其实很容易犯错。不过现在C++11提供一个方法让你可以显式地指定一个派生类函数是基类版本的重写:将它声明为override

class Derived: public Base {
public:
    virtual void mf1() override;
    virtual void mf2(unsigned int x) override;
    virtual void mf3() && override;
    virtual void mf4() const override;
};

这样,如果不是重写,就会编译报错。

最后,与override对应的还有一个关键字final,向虚函数添加final可以防止派生类重写。也能用于类,这时这个类不能用作基类

请记住:

  • 为重写函数显示加上override,防止想重写而实际没重写错误。

13. 优先考虑const_iterator而非iterator

const_iteratoriterator 是用于访问容器元素的两种不同类型的迭代器(其实就是指向容器元素的指针)。它们的主要区别在于是否允许修改容器中的元素。

这里优先考虑const_iteratorconst的使用目的是一样的,就是当你不需要修改容器元素时,你就应该用const_iterator,防止意外修改,这种做法有助于防止一些常见的编程错误,提高代码的可读性和可维护性。

请记住:

  • 优先考虑const_iterator而非iterator

14. 如果函数不抛异常请使用noexcept

如果你能明确函数不会抛异常,把它标记为noexcept,可以帮助编译器更好的生成优化的代码。

请记住:

  • noexcept是函数接口的一部分,这意味着调用者可能会依赖它。
  • noexcept函数较之于non-noexcept函数更容易优化。
  • noexcept对于移动语义,swap,内存释放函数和析构函数非常有用(这些操作如果失败一般是不可逆的,所以要保证它们不抛异常)。
  • 大多数函数是可能抛也可能不抛异常,所以大部分函数一般不加noexcept

15. 尽可能的使用constexpr

关键就是它保证对象或函数返回值在编译期间求值或计算,提高了运行效率。

请记住:

  • 所有constexpr对象都是const,但不是所有const对象都是constexpr,它被在编译期可知的值初始化。
  • 当传递编译期可知的值时,constexpr函数可以产出编译期可知的结果。
  • constexpr对象和函数可以使用的范围比non-constexpr对象和函数要大。
  • constexpr是对象和函数接口的一部分(这和上面noexcept一样)。

16. 让const成员函数线程安全

主要是讨论多线程环境下如何保证线程安全的话题,其实与const没太多关系。。。

这里对于保证线程安全提供了两种思路:

  1. 使用C++11引入的智能锁std::lock_guard,创建时上锁,离开创建的作用域后自动解锁。
class Polynomial {
public:
    using RootsType = std::vector;
    
    RootsType roots() const
    {
        std::lock_guard g(m);       //锁定互斥量
        
        if (!rootsAreValid) {                   //如果缓存无效
            …                                   //计算/存储根值
            rootsAreValid = true;
        }
        
        return rootsVals;
    }                                           //解锁互斥量
    
private:
    mutable std::mutex m;
    mutable bool rootsAreValid { false };
    mutable RootsType rootsVals {};
};
  1. 单一变量互斥使用std::atomic开销更小,性能更高。
class Point {                                   //2D点
public:
    …
    double distanceFromOrigin() const noexcept  //noexcept的使用
    {                                           //参考条款14
        ++callCount;                            //atomic的递增
        
        return std::sqrt((x * x) + (y * y));
    }

private:
    mutable std::atomic callCount{ 0 };
    double x, y;
};

请记住:

  • 确保const成员函数线程安全,除非你确定它们永远不会在并发上下文(concurrent context)中使用。
  • 使用std::atomic变量可能比互斥量提供更好的性能,但是它只适合操作单个变量或内存位置。

17. 理解特殊成员函数的生成

特殊成员函数是指C++自己生成的函数。C++98有四个:**默认构造函数,析构函数,拷贝构造函数,拷贝赋值运算符。**C++11新增了两个:移动构造函数和移动赋值运算符。

这些函数仅在需要的时候才会生成,生成的特殊成员函数是隐式public且inline

class Widget {
    public:
    Widget();                              // 用户声明的构造函数
    ~Widget();                             // 用户声明的析构函数                        
    Widget(const Widget&) = default;       // 显示告诉编译器生成默认拷贝构造函数
    Widget& operator=(const Widget&) = default; //显示告诉编译器生成默认拷贝赋值运算符
    
    Widget(Widget&& rhs);               //移动构造函数
    Widget& operator=(Widget&& rhs);    //移动赋值运算符
};
// = default 避开C++11特殊函数生成的规则,显示告诉编译器生成默认的。

请记住:

  • 特殊成员函数是编译器可能自动生成的函数:默认构造函数,析构函数,拷贝操作,移动操作。
  • 移动操作仅当类没有显式声明移动操作,拷贝操作,析构函数时才自动生成。
  • 拷贝构造函数仅当类没有显式声明拷贝构造函数时才自动生成,并且如果用户声明了移动操作,拷贝构造就是delete拷贝赋值运算符仅当类没有显式声明拷贝赋值运算符时才自动生成,并且如果用户声明了移动操作,拷贝赋值运算符就是delete。当用户声明了析构函数,拷贝操作的自动生成会被废弃。
  • 成员函数模板不抑制特殊成员函数的生成。

四、智能指针

原始指针是强大的工具,当然,另一方面几十年的经验证明,只要注意力稍有疏忽,这个强大的工具就会攻击它的主人。

智能指针是解决这些问题的一种办法。智能指针包裹原始指针,它们的行为看起来像被包裹的原始指针,但避免了原始指针的很多陷阱。你应该更倾向于智能指针而不是原始指针。几乎原始指针能做的所有事情智能指针都能做,而且出错的机会更少。

在C++11中存在四种智能指针:std::auto_ptrstd::unique_ptrstd::shared_ptr std::weak_ptr

std::auto_ptr是来自C++98的已废弃遗留物,它是一次标准化的尝试,后来变成了C++11的std::unique_ptr。要正确的模拟原生指针需要移动语义,但是C++98没有这个东西。取而代之,std::auto_ptr拉拢拷贝操作来达到自己的移动意图。这导致了令人奇怪的代码(拷贝一个std::auto_ptr会将它本身设置为null!)和令人沮丧的使用限制(比如不能将std::auto_ptr放入容器)。

std::unique_ptr能做std::auto_ptr可以做的所有事情以及更多。它能高效完成任务,而且不会扭曲自己的原本含义而变成拷贝对象。在所有方面它都比std::auto_ptr好。现在std::auto_ptr唯一合法的使用场景就是代码使用C++98编译器编译。除非你有上述限制,否则你就该把std::auto_ptr替换为std::unique_ptr而且绝不回头。

18. 对于独占资源使用std::unique_ptr

std::unique_ptr智能指针没有拷贝操作,独占资源,它很轻量级,大小等同于原始指针。在工厂函数返回指针对象的场景中常用,它可以很方便的转换为std::shared_ptr共享指针。

请记住:

  • std::unique_ptr是轻量级、快速的、只可移动的管理专有资源的智能指针。
  • std::unique_ptr转化为std::shared_ptr非常简单。

19. 对于共享资源使用std::shared_ptr

std::shared_ptr共享智能指针允许多个std::shared_ptr对象指向同一块内存资源。

std::shared_ptr通过引用计数来确保它是否是最后一个指向某种资源的指针,引用计数关联资源并跟踪有多少std::shared_ptr指向该资源。std::shared_ptr通常是构造函数递增引用计数值,析构函数递减值,拷贝赋值运算符做前面这两个工作。(如sp1sp2std::shared_ptr并且指向不同对象,赋值“sp1 = sp2;”会使sp1指向sp2指向的对象。直接效果就是sp1引用计数减1,sp2引用计数加1。)如果std::shared_ptr在计数值递减后发现引用计数值为零,没有其他std::shared_ptr指向该资源,它就会销毁资源。

std::shared_ptr相对std::unique_ptr更耗性能,它大小更大,内部多了引用计数对象,并且计数的增减必须保证是原子性的,而原子操作通常比非原子操作要慢。

下面是一个非常简单的示例,实际的 shared_ptr 实现比这要复杂得多,因为它需要考虑线程安全、循环引用等。

template 
class SimpleSharedPtr {
public:
    SimpleSharedPtr(T* ptr) : data(ptr), count(new int(1)) {}

    SimpleSharedPtr(const SimpleSharedPtr& other) : data(other.data), count(other.count)     {
        (*count)++;
    }

    ~SimpleSharedPtr()
    {
        (*count)--;
        if (*count == 0) {
            delete data;
            delete count;
        }
    }

    SimpleSharedPtr& operator=(const SimpleSharedPtr& other) 
    {
        if (this != &other) {
            (*count)--;
            if (*count == 0) {
                delete data;
                delete count;
            }
            data = other.data;
            count = other.count;
            (*count)++;
        }
        return *this;
    }

private:
    T* data;
    int* count;
};

接下来我们看一个这样的使用示例:

auto pw = new Widget;               //pw是原始指针
…
std::shared_ptr spw1(pw);   //为*pw创建控制块
…
std::shared_ptr spw2(pw);   //为*pw创建第二个控制块

用一个原始指针来创建两个shared_ptr对象,这很糟糕,*pw有两个引用计数值,每一个最后都会变成零,然后最终导致*pw销毁两次,第二个销毁会产生未定义行为。

针对这种情况我们的建议是永远不要直接使用原始指针来创建std::shared_ptr,而应该使用std::make_shared来创建,如果你非要使用原始指针创建,请使用new出来的结果

std::shared_ptr spw1(new Widget);    //直接使用new的结果

请记住:

  • std::shared_ptr为有共享所有权的任意资源提供一种自动垃圾回收的便捷方式。
  • 较之于std::unique_ptrstd::shared_ptr对象通常大两倍,控制块会产生开销,需要原子性的引用计数修改操作。
  • 避免从原始指针变量上创建std::shared_ptr

20. 当std::shared_ptr可能相互引用时使用std::weak_ptr

std::weak_ptr通常与std::shared_ptr配合一起使用,可以shared_ptr对象赋值给weak_ptr对象,它不会导致计数器加减,类似一个弱引用。主要用于解决多个std::shared_ptr对象相互引用无法释放的问题。

#include 
#include 

class B; // 前向声明

class A {
public:
    A() { std::cout << "A constructor\n"; }
    std::shared_ptr b_ptr;  // A中包含B对象的std::shared_ptr
    ~A() { std::cout << "A destructor\n"; }
};

class B {
public:
    B() { std::cout << "B constructor\n"; }
    std::weak_ptr a_weak_ptr; // B中包含A对象用weak_ptr弱引用
    ~B() { std::cout << "B destructor\n"; }
};

int main() {
    std::shared_ptr a_ptr = std::make_shared();
    std::shared_ptr b_ptr = std::make_shared();
    
    a_ptr->b_ptr = b_ptr;
    b_ptr->a_weak_ptr = a_ptr;
    
    // 不会导致循环引用,可以正常释放。
   
    return 0;
}

请记住:

  • std::weak_ptr的潜在使用场景包括:缓存、观察者列表、打破std::shared_ptr环状结构。
  • std::weak_ptr的效率和std::shared_ptr相当,它们控制块基本相同,虽然它不影响计数,但是它内部有另一个计数对象。

21. 优先考虑使用std::make_unique和std::make_shared而非直接使用new

为什么优先考虑使用make_xx,优点:

  1. 比使用new创建xx_ptr代码更简洁,消除了重复代码。
auto upw1(std::make_unique());      //使用make函数
std::unique_ptr upw2(new Widget);   //不使用make函数, 重复写了Widget类型
auto spw1(std::make_shared());      //使用make函数
std::shared_ptr spw2(new Widget);   //不使用make函数
  1. 能避免函数多个实参因构造顺序,某个参数构造异常导致的内存泄露问题。
// 接口原型
void processWidget(std::shared_ptr spw, int priority);

// 使用new形式
// 潜在的资源泄漏!
/*
	这个接口的参数构造时的顺序是不确定的,有可能是下面这样:
	1. 执行new Widget
    2. 执行computePriority
    3. 运行std::shared_ptr构造函数
    那么如果在执行第2步时异常了,那么第1步的new内存就泄露了。
*/
processWidget(std::shared_ptr(new Widget), computePriority()); 

// 使用make_xx就不会有上面问题
processWidget(std::make_shared(), computePriority());
  1. 它效率比new更高。
// 直接使用new需要为Widget进行一次内存分配,为控制块再进行一次内存分配。
std::shared_ptr spw(new Widget);

// 只有一次内存分配。这是因为std::make_shared分配一块内存,同时容纳了Widget对象和控制块。
auto spw = std::make_shared();

当然有一些特殊场景使用make_xx并不是很好,需要注意:

  1. 它不能自定义删除器,而直接使用new可以。
// 自定义删除器
auto widgetDeleter = [](Widget* pw) { … };
std::shared_ptr spw(new Widget, widgetDeleter);
  1. vector希望用{}花括号初始化。(make_xx默认是用(),但是也可以通过auto转换后再使用)
// 创建了有10个值为20的数组
auto spv = std::make_shared>(10, 20);

// 如果想用{}创建2个值分别为10,20的数组怎么操作了?要么使用new,要么用auto转换下
//创建std::initializer_list
auto initList = { 10, 20 };
//使用std::initializer_list为形参的构造函数创建std::vector
auto spv = std::make_shared>(initList);
  1. 自定义内存管理场景,内存系统很大,需要自己控制。~当然这点很边缘,实际用到很少。

综上,我整体还是推荐优先使用make_xx来创建智能指针,只是一些特殊场景需要我们了解注意下。

请记住:

  • 和直接使用new相比,make函数消除了代码重复,提高了异常安全性。对于std::make_sharedstd::allocate_shared,生成的代码更小更快。
  • 不适合使用make函数的情况包括需要指定自定义删除器和希望用花括号初始化。
  • 对于std::shared_ptr,其他不建议使用make函数的情况包括: (1)有自定义内存管理的类;(2)特别关注内存的系统,非常大的对象,以及std::weak_ptr比对应的std::shared_ptr活得更久。

22. 用智能指针形式使用Pimpl惯用法

什么是Pimpl惯用法?**它是减少类实现和类使用者之间编译依赖的一种技巧。**通过将类数据成员替换成一个指向包含具体实现的类(或结构体)的指针,并将数据成员移动到这个实现类去,而这些数据成员的访问将通过指针间接访问。

class Widget() {                    //定义在头文件“widget.h”
public:
    Widget();
    …
private:
    std::string name;
    std::vector data;
    Gadget g1, g2, g3;              //Gadget是用户自定义的类型
};

Widget的数据成员包含有类型std::stringstd::vectorGadget, 定义有这些类型的头文件在类Widget编译的时候,必须被包含进来,这意味着类Widget的使用者必须要#include 以及gadget.h。 这些头文件将会增加类Widget使用者的编译时间,并且让这些使用者依赖于这些头文件。 如果一个头文件的内容变了,类Widget使用者也必须要重新编译。 这也就是我们为什么建议封装类时,头文件要只包含必要的include的原因:减少依赖,提高编译速度

如何解决这个问题,我们先用传统C++98方式使用Pimpl技巧:

class Widget                        //仍然在“widget.h”中
{
public:
    Widget();
    ~Widget();                      //析构函数在后面会分析
    …

private:
    struct Impl;                    //声明一个 实现结构体
    Impl *pImpl;                    //以及指向它的指针
};
#include "widget.h"             //以下代码均在实现文件“widget.cpp”里
#include "gadget.h"
#include 
#include 

struct Widget::Impl {           //含有之前在Widget中的数据成员的
    std::string name;           //Widget::Impl类型的定义
    std::vector data;
    Gadget g1,g2,g3;
};

Widget::Widget()                //为此Widget对象分配数据成员
: pImpl(new Impl)
{}

Widget::~Widget()               //销毁数据成员
{ delete pImpl; }

这样我们就把类的数据成员封装到它的实现里面了,减少了自己头文件里的包含文件,这样这个类提供给别人使用时,依赖也就更少了,提高了编译速度。

下面再来看看使用现代C++如何实现Pimple:

#pragma once

#include 

class Widget {
public:
    Widget();
    // 必须自己实现析构,并把实现放到Impl结构定义的后面,不然编译报错,Impl不是完整类型,没有找到定义
    ~Widget();
    // 由于声明了析构,会使默认移动操作失效,所以这里也一起声明,让它支持移动操作
    Widget(Widget&& rhs);
    Widget& operator=(Widget&& rhs);

private:
    struct Impl;
    std::unique_ptr pImpl;
};

#include "widget.h"
#include 
#include 

struct Widget::Impl {
    std::string name;
    std::vector data;
    Gadget g1,g2,g3;
};

Widget::Widget() : pImpl(std::make_unique()) 
{
}

// 这些特殊函数行为可以使用默认的,这里只是为了定义到Impl结构下面,让它认识这个定义
Widget::~Widget() = default;

Widget::Widget(Widget&& rhs) = default;

Widget& Widget::operator=(Widget&& rhs) = default;
#include "widget.h"

int main()
{
    Widget w;   
    return 0;
}

使用std::unique_ptr当然是自然合理的,而且效率更高,但是你也看到,需要把特殊函数都定义一遍,感觉麻烦的话,可以用std::shared_ptr,它就不需要定义特殊函数了,编译时也不会报错。(原因是std::unique_ptr与std::shared_ptr内部默认的析构行为不一样导致)

class Widget {                      //在“widget.h”中
public:
    Widget();
    …                               //没有析构函数和移动操作的声明

private:
    struct Impl;
    std::shared_ptr pImpl;    //用std::shared_ptr
};                                  //而不是std::unique_ptr
Widget w1;
auto w2(std::move(w1));     //移动构造w2
w1 = std::move(w2);         //移动赋值w1

请记住:

  • Pimpl惯用法通过减少在类实现和类使用者之间的编译依赖来减少编译时间。
  • 对于std::unique_ptr类型的pImpl指针,需要在头文件的类里声明特殊的成员函数,在实现文件里面来实现他们。即使是编译器自动生成的代码可以工作,也要这么做。
  • std::shared_ptr则不需要声明特殊函数,没有谁好,看自己选择。

五、右值引用,移动语义,完美转发

移动语义:一般用于拷贝函数,传参由原本的构造复制操作改为右值移动操作。

完美转发:传递一个对象到另外一个函数,保留它原有的左值属性或右值属性。

右值引用:它是使移动语义和完美转发变得可能的基础语言机制。

在本章的这些小节中,非常重要的一点要牢记形参永远是左值,即使它的类型是一个右值引用。比如,

void f(Widget&& w);  // 形参w是一个左值

23. 理解std::move和std::forward

std::move底层只是做类型转换,返回一个右值,无条件转换。移动语义一般就是通过它来传递右值调用特殊的移动拷贝函数,但是有种情况即使你传递了右值也不一定会调用移动函数。

class string {                  
public:                      
    …
    string(const string& rhs);  //拷贝构造函数
    string(string&& rhs);       //移动构造函数
};

const string a = "test";
string(std::move(a));   // 这里其实不会调用移动构造,而是调用拷贝函数

这是因为,移动函数只能接收非const的右值引用参数,但是这个const的右值可以绑定到const的引用上,所以会调用拷贝构造函数。

接下来,我们再看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);
    
    /*
       首先开始就讲了一个原则,不管传递的实参是左值还是右值,形参都是左值。
       所以这里外部传递一个左值时,一切正常,std::forward什么也没做,
       当外部传递一个右值时,param形参会变为左值,这时使用std::froward会把它转换为右值,保证了和外部一致。
    */
    process(std::forward(param));
}
Widget w;

logAndProcess(w);               //用左值调用
logAndProcess(std::move(w));    //用右值调用

请记住:

  • std::move执行得到右值无条件的转换,但就自身而言,它不移动任何东西。
  • std::forward只有当它的参数被绑定到一个右值时,才将参数转换为右值。
  • std::movestd::forward在运行期什么也不做,只是转换。

24. 区别通用引用和右值引用

什么是通用引用,它表现上像右值引用(即T&&),但是它可以绑定到左值上,也可以绑定到右值上。此外,它还可以绑定到const或者non-const的对象上。它们可以绑定到几乎任何东西上。这种空前灵活的引用值得拥有自己的名字,我把它叫做通用引用。还有一些C++社区的成员已经开始将这种通用引用称之为转发引用

通用引用常见的场景:

template
void f(T&& param);                  //param是一个通用引用

auto&& var2 = var1;                 //var2是一个通用引用

这两种情况的共同之处就是都存在类型推导,它们是左值引用还是右值引用取决于它们的初始值是左值还是右值。

template
void f(T&& param);              //param是一个通用引用

Widget w;
f(w);                           //传递给函数f一个左值;param的类型
                                //将会是Widget&,即左值引用

f(std::move(w));                //传递给f一个右值;param的类型会是
                                //Widget&&,即右值引用

除此之外,如果类型声明的形式不是标准的type&&,或者如果类型推导没有发生,那么type&&代表一个右值引用。

请记住:

  • 如果一个函数模板形参的类型为T&&,并且T需要被推导得知,或者如果一个对象被声明为auto&&,这个形参或者对象就是一个通用引用。
  • 如果类型声明的形式不是标准的type&&,或者如果类型推导没有发生,那么type&&代表一个右值引用。
  • 通用引用,如果它被右值初始化,就会对应地成为右值引用;如果它被左值初始化,就会成为左值引用。

25. 对于右值引用使用std::move,对于通用引用std::forward

这条没什么说的,就是在右值引用上使用std::move,在通用引用上使用std::forward。不要反正来。

26. 避免重载通用引用

使用通用引用做参数很优雅,外部参数可以是左值,右值,它自动匹配。

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);                     //拷贝左值到multiset
logAndAdd(std::string("Persephone"));	//移动右值而不是拷贝它
logAndAdd("Patty Dog");                 //在multiset直接创建std::string
                                        //而不是拷贝一个临时std::string

但是注意不要重载通用引用形参的函数,为什么?引用通用引用参数匹配比你想象的要广泛,如果不是精准匹配你添加的重载版本,那它就会优先匹配通用引用版本。

//新的重载版本
void logAndAdd(int idx)             
{
    auto now = std::chrono::system_clock::now();
    log(now, "logAndAdd");
    names.emplace(nameFromIdx(idx));
}

logAndAdd(22);  //调用int重载版本,这没问题

如果客户是想调用整数版本,但是传参却给了一个short类型,那么就会有问题了,它会匹配通用引用。

short nameIdx;
…                                       //给nameIdx一个值
logAndAdd(nameIdx);                     //错误!

而且,构造函数中也注意不要用通用引用完美转发形式,它会劫持一些默认生成函数。

class Person {
public:
    template            //完美转发的构造函数
    explicit Person(T&& n)
    : name(std::forward(n)) {}

    explicit Person(int idx);       //int的构造函数

    Person(const Person& rhs);      //拷贝构造函数(编译器生成)
    Person(Person&& rhs);           //移动构造函数(编译器生成)
    …
};

请记住:

  • 对通用引用形参的函数进行重载,通用引用函数的调用机会几乎总会比你期望的多得多。
  • 完美转发构造函数是糟糕的实现,因为对于non-const左值,它们比拷贝构造函数更优先匹配,而且会劫持派生类对基类的拷贝和移动构造函数的调用。

27. 熟悉重载通用引用的替代品

上节讲了为什么要避免重载通用引用形参的函数,不过有些场景可能需要重载,那么我们有那些合理的替代方案了?

  1. 放弃重载,直接定义不同的函数名。

  2. 不使用通用引用参数,使用const T&替代。

  3. 使用传值方式。

  4. 使用tag dispatch

    前面都是常规方式,去掉了通用引用,如果你还是想用通用引用又要重载,可以参考本条实现,还是沿用item26例子。

// 对外还是用通用引用
template
void logAndAdd(T&& name)
{
    // 内部实现一个新接口,多加一个参数,编译期明确T是否是整数
    logAndAddImpl(
        std::forward(name),
        std::is_integral::type>()
    );
}
// 非整数版本,std::false_type是编译期的false类型
template                           
void logAndAddImpl(T&& name, std::false_type)	
{
    auto now = std::chrono::system_clock::now();
    log(now, "logAndAdd");
    names.emplace(std::forward(name));
}
// 整数版本,std::true_type是编译期的true类型
std::string nameFromIdx(int idx);           
void logAndAddImpl(int idx, std::true_type) 
{
  logAndAdd(nameFromIdx(idx)); 
}
  1. 约束使用通用引用的模板

    使用tag dispatch方式无法应对带通用引用形参的构造函数,因为有些对构造函数的调用也被编译器自动生成的函数处理,绕过了分发机制。这时我们可以使用std::enbale_if来有条件的约束通用引用模板。

class Person {
public:
    // 在编译器检测,如果模板参数T是非Person的派生对象同时非int整数类型,那么模板有效。
    // 否则,模板被禁用。
    template<
        typename T,
        typename = std::enable_if_t<
            !std::is_base_of>::value
            &&
            !std::is_integral>::value
        >
    >
    explicit Person(T&& n)          //对于std::strings和可转化为
    : name(std::forward(n))      //std::strings的实参的构造函数
    { … }
	
    explicit Person(int idx)        //对于整型实参的构造函数
    : name(nameFromIdx(idx))
    { … }

    …                               //拷贝、移动构造函数等

private:
    std::string name;
};

请记住:

  • 通用引用和重载的组合替代方案包括使用不同的函数名,通过const T&传递形参,按值传递形参,使用tag dispatch
  • 通过std::enable_if约束模板,允许组合通用引用和重载使用,但它也控制了编译器在哪种条件下才使用通用引用重载。
  • 通用引用参数通常具有高效率的优势,但是可用性就值得斟酌。

28. 理解引用折叠

概念性东西,没啥实际指导意义,了解即可。

29. 认识移动操作的缺点

存在几种情况,C++11的移动语义并无优势:

  • 没有移动操作:要移动的对象没有提供移动操作,所以移动的写法也会变成复制操作。
  • 移动不会更快:要移动的对象提供的移动操作并不比复制速度更快(短字符串string)。
  • 移动不可用:进行移动的上下文要求移动操作不会抛出异常,但是该操作没有被声明为noexcept(容器操作)。

请记住:

  • 假定移动操作不存在,成本高,未被使用。
  • 在已知的类型或者支持移动语义的代码中,就不需要上面的假设。

30. 熟悉完美转发失败的情况

请记住:

  • 当模板类型推导失败或者推导出错误类型,完美转发会失败。
  • 导致完美转发失败的实参种类有花括号初始化,作为空指针的0或者NULL,仅有声明的整型static const数据成员,模板和重载函数的名字,位域。

六、Lambda表达式

这个题在以前的C++11语言特性中已经列举比较清楚了,这里不再详细探讨,就列举下注意建议。

31. 避免使用默认捕获模式

请记住:

  • 默认的按引用捕获可能会导致悬空引用。
  • 默认的按值捕获对于悬空指针很敏感(尤其是this指针),并且它会误导人产生lambda是独立的想法。

32. 使用初始化捕获来移动对象到闭包中

在某些场景下,按值捕获和按引用捕获都不是你所想要的。如果你有一个只能被移动的对象(例如std::unique_ptrstd::future)要进入到闭包里,如果你要复制的对象复制开销非常高,但移动的成本却不高。

c++14:

std::vector data;               //要移动进闭包的对象

…                                       //填充data

auto func = [data = std::move(data)]    //C++14初始化捕获
            { /*使用data*/ };

c++11:

std::vector data;               //同上

…                                       //同上

auto func =
    std::bind(                              //C++11模拟初始化捕获
        [](const std::vector& data) 
        { /*使用data*/ },
        std::move(data)                     
    );

请记住:

  • 使用C++14的初始化捕获将对象移动到闭包中。
  • 在C++11中,通过手写类或std::bind的方式来模拟初始化捕获。

33. 对于std::forward的auto&&形参使用decltype

auto f =
    [](auto&&... params)
    {
        return
            func(normalize(std::forward(params)...));
    };

请记住:

  • auto&&形参使用decltypestd::forward它们。

34. 优先考虑lambda表达式而非std::bind

std::bind常规使用:

#include 
#include 

// 一个简单的函数
void greet(const std::string& name, const std::string& greeting) {
    std::cout << greeting << ", " << name << "!" << std::endl;
}

int main() {
    // 使用 std::bind 绑定 greet 函数的第一个参数
    auto greetFunction = std::bind(greet, std::placeholders::_1, "Hello");

    // 调用绑定后的函数对象
    greetFunction("Alice");  // 输出:Hello, Alice!

    return 0;
}

请记住:

  • 与使用std::bind相比,lambda更易读,更具表达力并且可能更高效。
  • 只有在C++11中,std::bind可能对实现移动捕获或绑定带有模板化函数调用运算符的对象时会很有用。

七、并发API

C++11开始把并发整合到语言标准库中了,开发者首次通过标准库可以写出跨平台的多线程程序。

35. 优先考虑基于任务的编程而非基于线程的编程

我们要异步执行一个函数,通常有两种方式:

其一,基于线程的方式,通过创建std::thread执行,

int doAsyncWork();
std::thread t(doAsyncWork);

其二,基于任务的方式,通过std::async

auto fut = std::async(doAsyncWork);

那么为什么优先考虑基于任务的编程而非基于线程的编程了?

下面列举几点观点:

  1. 基于任务的方式有返回值,返回一个std::future对象,有get方法,可以使用它来获取异步操作的返回值,而基于线程的方式则不行。
  2. 基于线程的方式创建线程,系统资源是有限的,需要手动管理好线程,可能会遇到资源超额的麻烦,而使用std::async则可以很大程度避免这类问题,它不需要我们手动管理,C++标准库的开发者已经帮我们很好的考虑了。

不过,仍然存在一些场景直接使用std::thread会更有优势:

  • 你需要访问非常基础的线程API。C++并发API通常是通过操作系统提供的系统级API(pthreads或者Windows threads)来实现的,系统级API通常会提供更加灵活的操作方式(举个例子,C++没有线程优先级和亲和性的概念)。为了提供对底层系统级线程API的访问,std::thread对象提供了native_handle的成员函数,而std::future(即std::async返回的东西)没有这种能力。
  • 你需要且能够优化应用的线程使用。举个例子,你要开发一款已知执行概况的服务器软件,部署在有固定硬件特性的机器上,作为唯一的关键进程。
  • 你需要实现C++并发API之外的线程技术,比如,C++标准中未实现的线程池技术。

请记住:

  • std::thread API不能直接访问异步执行的结果,如果执行函数有异常抛出,代码会终止执行。
  • 基于线程的编程方式需要手动处理线程耗尽、资源超额、负责均衡、平台适配性管理。
  • 通过带有默认启动策略的std::async进行基于任务的编程方式会解决大部分问题。

36. 如果有异步的必要请指定std::lauch::async

std::async异步执行有两种方式,通过std::launch这个限域enum枚举名来指定,原型:

auto future = std::async(std::launch::async | std::launch::deferred, func);
  • std::launch::async启动策略意味着f必须异步执行,即在不同的线程。
  • std::launch::deferred启动策略意味着f仅当在std::async返回的future上调用get或者wait时才执行。

而如果直接使用的默认方式auto fut = std::async(f); 不指定,那么它的行为这两种都有可能,所以这样会存在不可预测的结果。

请记住:

  • std::async的默认启动策略是异步和同步执行兼有的。
  • 这个灵活性导致访问thread_locals的不确定性,隐含了任务可能不会被执行的意思,会影响调用基于超时的wait的程序逻辑。
  • 如果异步执行任务非常关键,则指定std::launch::async

37. std::threads最后一定要调用join或detach

至于为什么要在std::threads创建使用完后调用joindetach,这与底层设计相关,这里不深入讨论。

我们只需要知道这个原则就好,那么怎么让你一定能遵守这个规则不犯错了?那就只能把这个操作交给RAII对象实现:

class ThreadRAII {
public:
    enum class DtorAction { join, detach };     //enum class的信息见条款10
    // 传递右值,移到进来
    ThreadRAII(std::thread&& t, DtorAction a)   //析构函数中对t实行a动作
    : action(a), t(std::move(t)) {}             //注意这里得形参与初始化列表顺序是精心安排的

    ~ThreadRAII()
    {                                           //可结合性测试
        if (t.joinable()) {
            if (action == DtorAction::join) {
                t.join();
            } else {
                t.detach();
            }
        }
    }
    // 参考Item17
    ThreadRAII(ThreadRAII&&) = default;             //支持移动
    ThreadRAII& operator=(ThreadRAII&&) = default;  //支持复制

    std::thread& get() { return t; }            

private:
    DtorAction action;
    std::thread t;
};

这样对象析构时就会自动调用了。

请记住:

  • 在所有路径上保证thread最终是不可结合的(即析构前都调用了join或detach)。
  • 声明类数据成员时,最后声明std::thread对象。(因为std::thread对象可能会在初始化结束后就立即执行函数了,所以在最后声明是一个好习惯。)

38. 关注不同线程句柄析构行为

了解下差不多了~没啥实质用!

39. 考虑对于一次性的事件通信使用std::promise

类似这种场景:某个线程等待另一个线程事件发生才继续执行。通常的方式有:

  1. 使用条件变量
#include 
#include 
#include 
#include 
#include 

std::mutex m;
std::condition_variable cv;
std::string data;
bool ready = false;
bool processed = false;

void worker_thread()
{
    // Wait until main() sends data
    std::unique_lock lk(m);
    //子进程的中wait函数对互斥量进行解锁,同时线程进入阻塞或者等待状态。
    cv.wait(lk, [] {return ready; });

    // after the wait, we own the lock.
    std::cout << "Worker thread is processing data\n";
    data += " after processing";

    // Send data back to main()
    processed = true;
    std::cout << "Worker thread signals data processing completed\n";

    // Manual unlocking is done before notifying, to avoid waking up
    // the waiting thread only to block again (see notify_one for details)
    lk.unlock();
    cv.notify_one();
}

int main()
{
    std::thread worker(worker_thread);

    data = "Example data";
    // send data to the worker thread
    {
        //主线程堵塞在这里,等待子线程的wait()函数释放互斥量。
        std::lock_guard lk(m);
        ready = true;
        std::cout << "main() signals data ready for processing\n";
    }
    cv.notify_one();

    // wait for the worker
    {
        std::unique_lock lk(m);
        cv.wait(lk, [] {return processed; });  // 第二个参数lambda返回true时才能执行,否则继续阻塞
    }
    std::cout << "Back in main(), data = " << data << '\n';

    worker.join();
}
main() signals data ready for processing
Worker thread is processing data
Worker thread signals data processing completed
Back in main(), data = Example data after processing

在很多情况下,使用条件变量进行任务通信非常合适,不过条件变量的使用必须配合互斥锁。

  1. 使用std::atomic原子锁
// 检测任务
std::atomic flag(false);          //共享的flag
…                                       //检测某个事件
flag = true;                            //告诉反应线程
// 反应任务
…                                       //准备作出反应
while (!flag);                          //等待事件
…                                       //对事件作出反应

这种方法不存在基于条件变量的设计的缺点。不需要互斥锁,比互斥锁高效。不好的一点是反应任务中轮询的开销。在任务等待flag被置位的时间里,任务基本被阻塞了,但是一直在运行。这样,反应线程占用了可能给另一个任务使用的硬件线程,每次启动或者完成它的时间片都增加了上下文切换的开销,并且保持核心一直在运行状态,本来可以停下来省电。一个真正阻塞的任务不会发生上面的任何情况。这也是基于条件变量的优点,因为wait调用中的任务真的阻塞住了。

  1. 使用std::promisestd::future

这种应该是最优雅的,通过std::promiseset_value发送信号,std::futureget阻塞等待信号。不过注意这对收发只能使用一次就释放了。

#include 
#include 
#include 

void Thread_Fun1(std::promise& p)
{
	//为了突出效果,可以使线程休眠5s
	std::this_thread::sleep_for(std::chrono::seconds(5));

	int iVal = 233;
	std::cout << "传入数据(int):" << iVal << std::endl;

	//传入数据iVal
	p.set_value(iVal);
}

void Thread_Fun2(std::future& f)
{
	//阻塞函数,直到收到相关联的std::promise对象传入的数据
	auto iVal = f.get();		//iVal = 233

	std::cout << "收到数据(int):" << iVal << std::endl;
}

int main()
{
	//声明一个std::promise对象pr1,其保存的值类型为int
	std::promise pr1;
	//声明一个std::future对象fu1,并通过std::promise的get_future()函数与pr1绑定
	std::future fu1 = pr1.get_future();

	//创建一个线程t1,将函数Thread_Fun1及对象pr1放在线程里面执行
	std::thread t1(Thread_Fun1, std::ref(pr1));
	//创建一个线程t2,将函数Thread_Fun2及对象fu1放在线程里面执行
	std::thread t2(Thread_Fun2, std::ref(fu1));

	//阻塞至线程结束
	t1.join();
	t2.join();

	return 1;
}
传入数据(int):233
收到数据(int):233

请记住:

  • 对于简单的事件通信,基于条件变量的设计需要一个多余的互斥锁,对检测和反应任务的相对进度有约束,并且需要反应任务来验证事件是否已发生。
  • 基于flag的设计避免的上一条的问题,但是是基于轮询,而不是阻塞。
  • 条件变量和flag可以组合使用,但是产生的通信机制很不自然。
  • 使用std::promisefuture的方案避开了这些问题,但是这个方法使用了堆内存存储共享状态,同时有只能使用一次通信的限制。

40. 对于并发使用std::atomic,volatile用于特殊内存区

对于std::atomic的使用,上一节已经有使用示例了,用于在不使用互斥锁情况下,来使变量被多个线程访问的情况。是用来编写并发程序的一个工具。

volatile则是告诉编译器不要做内存优化,如:

auto y = x;                             //读x
y = x;                                  //再次读x
x = 10;                                 //写x
x = 20;                                 //再次写x

编译器会对这种的代码做优化,最终生成如:

auto y = x;                             //读x
x = 20;                                 //写x

但是有时我们有这样的场景,x是对应外边IO通信的内存映射,每个值都是一条独立有含义的指令,如果优化掉了,相当于指令丢失了,这时可以用volatile告诉编译器“不要对这块内存执行任何优化”。

volatile int x;

这样,最后生成代码如:

auto y = x;                             //读x
y = x;                                  //再次读x(不会被优化掉)

x = 10;                                 //写x(不会被优化掉)
x = 20;                                 //再次写x

请记住:

  • std::atomic用于在不使用互斥锁情况下,来使变量被多个线程访问的情况。是用来编写并发程序的一个工具。
  • volatile用在读取和写入不应被优化掉的内存上。是用来处理特殊内存的一个工具。

八、微调

41. 对于那些可移动总是被拷贝的形参使用传值方式

这个题本质就是分析拷贝构造、移除构造、模板实例化膨胀这些效率的综合考虑。

class Widget {                                  //方法1:对左值和右值重载
public:
    void addName(const std::string& newName)
    { names.push_back(newName); } // rvalues
    void addName(std::string&& newName)
    { names.push_back(std::move(newName)); }
    …
private:
    std::vector names;
};

class Widget {                                  //方法2:使用通用引用
public:
    template
    void addName(T&& newName)
    { names.push_back(std::forward(newName)); }
    …
};

class Widget {                                  //方法3:传值
public:
    void addName(std::string newName)
    { names.push_back(std::move(newName)); }
    …
};

考虑这两种调用方式:

Widget w;
…
std::string name("Bart");
w.addName(name);                                //传左值
…
w.addName(name + "Jenne");                      //传右值

方式1:左值一次拷贝,右值一次移动。

方式2:左值一次拷贝,右值一次移动。

方式3:左值一次拷贝一次移动,右值两次移动。

方式3不管是左值还是右值都多了一次移动,但是它的代码更简洁,只需要一个接口。对于这种移动开销小的可以考虑。

请记住:

  • 对于可拷贝,移动开销低,而且无条件被拷贝的形参,按值传递效率基本与按引用传递效率一致,而且易于实现,还生成更少的目标代码。
  • 按值传递会引起切片问题,所说不适合基类形参类型。(就是派生类对象传递给基类形参,派生类特有部分数据会截断丢失)

42. 容器添加考虑优先使用emplace_back而非push_back

一般emplace_back不会比push_back效率低,因为某些场景它可以少了临时对象的构造与析构。

std::vector vs;        //std::string的容器
vs.push_back("xyzzy");              //添加字符串字面量

下面我们看看这个push_back都发送了什么:

  1. 一个std::string的临时对象从字面量“xyzzy”被创建。这个对象没有名字,我们可以称为temptemp的构造是第一次std::string构造。因为是临时变量,所以temp是右值。
  2. temp被传递给push_back的右值重载函数,绑定到右值引用形参x。在std::vector的内存中一个x的副本被创建。这次构造也是第二次构造——在std::vector内部真正创建一个对象。(将x副本拷贝到std::vector内部的构造函数是移动构造函数,因为x在它被拷贝前被转换为一个右值,成为右值引用)
  3. push_back返回之后,temp立刻被销毁,调用了一次std::string的析构函数。

而使用emplace_back,它内部使用的完美转发,直接执行的第2步,没有临时对象的生成。

vs.emplace_back("xyzzy");           //直接用“xyzzy”在vs内构造std::string

所以建议容器相关操作优先使用emplace_xx的方式,原则上,它不会比push_xx方式效率差。

请记住:

  • 原则上,置入函数有时会比插入函数高效,并且不会更差。

tile`用在读取和写入不应被优化掉的内存上。是用来处理特殊内存的一个工具。


八、微调

41. 对于那些可移动总是被拷贝的形参使用传值方式

这个题本质就是分析拷贝构造、移除构造、模板实例化膨胀这些效率的综合考虑。

class Widget {                                  //方法1:对左值和右值重载
public:
    void addName(const std::string& newName)
    { names.push_back(newName); } // rvalues
    void addName(std::string&& newName)
    { names.push_back(std::move(newName)); }
    …
private:
    std::vector names;
};

class Widget {                                  //方法2:使用通用引用
public:
    template
    void addName(T&& newName)
    { names.push_back(std::forward(newName)); }
    …
};

class Widget {                                  //方法3:传值
public:
    void addName(std::string newName)
    { names.push_back(std::move(newName)); }
    …
};

考虑这两种调用方式:

Widget w;
…
std::string name("Bart");
w.addName(name);                                //传左值
…
w.addName(name + "Jenne");                      //传右值

方式1:左值一次拷贝,右值一次移动。

方式2:左值一次拷贝,右值一次移动。

方式3:左值一次拷贝一次移动,右值两次移动。

方式3不管是左值还是右值都多了一次移动,但是它的代码更简洁,只需要一个接口。对于这种移动开销小的可以考虑。

请记住:

  • 对于可拷贝,移动开销低,而且无条件被拷贝的形参,按值传递效率基本与按引用传递效率一致,而且易于实现,还生成更少的目标代码。
  • 按值传递会引起切片问题,所说不适合基类形参类型。(就是派生类对象传递给基类形参,派生类特有部分数据会截断丢失)

42. 容器添加考虑优先使用emplace_back而非push_back

一般emplace_back不会比push_back效率低,因为某些场景它可以少了临时对象的构造与析构。

std::vector vs;        //std::string的容器
vs.push_back("xyzzy");              //添加字符串字面量

下面我们看看这个push_back都发送了什么:

  1. 一个std::string的临时对象从字面量“xyzzy”被创建。这个对象没有名字,我们可以称为temptemp的构造是第一次std::string构造。因为是临时变量,所以temp是右值。
  2. temp被传递给push_back的右值重载函数,绑定到右值引用形参x。在std::vector的内存中一个x的副本被创建。这次构造也是第二次构造——在std::vector内部真正创建一个对象。(将x副本拷贝到std::vector内部的构造函数是移动构造函数,因为x在它被拷贝前被转换为一个右值,成为右值引用)
  3. push_back返回之后,temp立刻被销毁,调用了一次std::string的析构函数。

而使用emplace_back,它内部使用的完美转发,直接执行的第2步,没有临时对象的生成。

vs.emplace_back("xyzzy");           //直接用“xyzzy”在vs内构造std::string

所以建议容器相关操作优先使用emplace_xx的方式,原则上,它不会比push_xx方式效率差。

请记住:

  • 原则上,置入函数有时会比插入函数高效,并且不会更差。

你可能感兴趣的:(个人博客导入,c++)