Skip to content

《Effective Modern C++》阅读笔记

条款1:模板类型推导

c++模板类型推导经常用在模板函数类型推导,和auto类型推导

函数模板一般是以下形式

template<typename T> 
void f(ParamType param); 

ParamType和T的区别是ParamType会包含一些饰词,例如const,引用符&等

T的推导结果要分三种情况讨论:

1.ParamType是指针或引用,但不是万能引用

这种情况下,T中只会包含ParamType中没有的饰词,ParamType中已经有的饰词会被忽略,例如

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

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

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

2.ParamType是万能引用

什么是万能引用?右值引用形参+模板类型推导就是万能引用(不在模板中使用右值引用就不能叫万能引用),例如:

template<typename T> 
void f(T && param);

此时模板类型推导的规则如下所示

template<typename T>
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&&

这也是为什么这种形式的声明叫做万能引用,当函数入参是右值时,param的类型是右值引用,当函数入参是左值时,param的类型就是左值引用

3.ParamType既非引用也非指针

这种情况也就是按值传递参数,类型推导时会忽略传入参数的引用符号&、const和volatile,如果传入参数是个指针,会忽略顶层const,但不会忽略底层const

template<typename T>
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

为什么要忽略const,因为c++认为,既然是按值传递,传入参数不能修改不意味着副本不能修改

数组和函数实参推导

简单来说,ParamType中的类型饰词非引用时,数组和函数类型都会退化为指针

数组实参推导

const char name[] = "J. P. Briggs";     //name的类型是const char[13]

template<typename T>
void f(T param);                        //传值形参的模板

f(name);                                //T被推导为const char*
const char name[] = "J. P. Briggs";     //name的类型是const char[13]

template<typename T>
void f(T &param);                        //传值形参的模板

f(name);                                //T被推导为const char (&)[13]

当模板函数的参数是按值传递时,数组入参会退化为指针,当模板函数的参数是按引用传递时,数组入参会按数组的引用传递

这一特性可以用来实现一个利用模板获取数组大小的功能

template<typename T, std::size_t N>
constexpr std::size_t arraySize(T (&)[N]) noexcept
{                                                       
    return N;
}

函数实参推导

void someFunc(int, double);         //someFunc是一个函数,
                                    //类型是void(int, double)

template<typename T>
void f1(T param);                   //传值给f1

template<typename T>
void f2(T & param);                 //传引用给f2

f1(someFunc);                       //param被推导为指向函数的指针,
                                    //类型是void(*)(int, double)
f2(someFunc);                       //param被推导为指向函数的引用,
                                    //类型是void(&)(int, double)

和数组实参类型推导类似,函数实参的推导取决于模板形参的类型,模板形参为按值传递时,函数类型会退化为函数指针吗,模板形参为按引用传递时,函数入参会按照函数引用传递

条款2:auto类型推导

C++11 新增了auto类型声明

auto类型推导和模板类型推导类似,像auto x = f(2);这样一个变量定义,auto对应的是模板类型推导中的ParamType,即类型T+饰词。

也分三种情况:

  1. 类型饰词是指针或引用,但不是万能引用。
  2. 类型饰词是万能引用。
  3. 类型饰词既非指针也非引用。

和模板类型推导结果是相同的。

类型饰词非引用的情况下,数组和函数类型也会退化成指针。

auto推导和模板推导唯一不同之处,是对于大括号初始化表达式的处理方式,如果一个auto变量以这种方式声明,auto x={27};,x会被推导为std:: initializer_ list类型,如果大括号里的值类型不一,则类型推导失败,代码通不过编译,例如auto xs = { 1, 2, 3.0 };,而模板推导不了{27}这种形式的入参

C++14中,函数返回值可以声明为auto,lambda表达式中的函数形参也可以声明为auto,这两种auto推导实际上是模板推导

条款3:decltype

decltype可以在编辑器计算表达式的类型,可以这么用

template<typename Container, typename Index> 
auto authAndAccess(Container& c, Index i ) -> decltype(c[i])
{
    authenticateUser(); 
    return c[i]; 
} 

这里使用了C++尾置返回值(trailing return type),尾置返回值的好处是可以使用形参列表中的变量

decltype会返回给定名字或表达式的确切类型,不会忽略或转换任何东西,这一点和模板推导和auto推导不同,这有个好处,如果auto返回值声明不符合预期,可以使用decltype(auto)让auto的推导采用decltype的规则,例如:

 template<typename Container, typename Index> 
decltype(auto) 
authAndAccess(Container& c, Index i)
{
    authenticateUser(); 
    return c[i]; 
}

上面这段代码中,如果不使用decltype(auto),只使用auto,按模板推导的规则,返回类型会将引用去掉,实际返回的是值类型

decltype有一些比较复杂的情况,例如,对于这种用法,decltype(Expr)(Expr是个左值表达式),如果Expr只是个名字,那这个名字对应的类型是啥,decltype返回的就是啥,如果Expr稍微复杂一点,decltype就会返回引用类型,例如:

int x = 5;
decltype(x);    //返回int
decltype((x));  //返回int &

条款4:如何查看类型推导结果

构造一个空模板,利用编译错误输出模板推导结果

//比方说,想知道x和y的推导结果
const int theAnswer; 42; 
auto x = theAnswer; 
auto y = &theAnswer;

//声明一个模板,不实现
template<typename T> 
class TD; 

//只要试图实例化该模板,就会诱发一个错误消息,编译器输出的错误信息就会把x和y的类型打印出来
TD<decltype(x)> xType; 
TD<decltype(y)> yType; 

还可以使用typeid(x).name()在运行时打印类型信息,但是这种方法打印出来的不一定准

IDE中显示的类型也不一定准。

boost库的Boost.Typelndex是准的。

条款8:nullptr

nullptr比起NULL,有利于模板类型推导,有利于消除重载函数调用的二义性,原因是NULL实际上是个int类型

条款9:using 和 typedef

尽量使用using,using可以模板化

template<typename T>
using MyAllocList = std:: list<T, MyAlloc<T>>;

MyAllocList<Widget> lw; //用户代码

如果用typedef,必须新定义一个struct,然后写在struct内部

template<typename T>                            //MyAllocList<T>是
struct MyAllocList {                            //std::list<T, MyAlloc<T>>
    typedef std::list<T, MyAlloc<T>> type;      //的同义词  
};

MyAllocList<Widget>::type lw;                   //用户代码

用户代码要加个::type

如果要在模板内部使用这个typedef定义

template<typename T>
class Widget {                              //Widget<T>含有一个
private:                                    //MyAllocLIst<T>对象
    typename MyAllocList<T>::type list;     //作为数据成员
    …
}; 

前面要加typename,后面要加::type

条款10:优先使用 enum class 而不是 enum

条款11:优先使用 = delete 而不是 private声明

条款14:不抛出异常的函数,尽量声明为noexcept的

stl容器采用移动操作的前提是,移动操作声明为noexcept的,这样stl容器才能在满足异常安全保证的前提下使用移动操作

其他略

条款17:特殊成员函数的生成规则

  1. 默认构造函数:用户没有定义任何构造函数的时候,编译器才会去生成默认构造函数
  2. 拷贝构造函数和拷贝赋值运算符
    • 相互独立,如果用户只定义了其中一个,编译器会去生成另外一个
  3. 移动构造函数和移动赋值运算符
    • 只要用户定义了其中一个,编译器就不去生成另外一个了,只有两个都没有定义的时候,编译器才会去生成
  4. 拷贝,移动和析构的关系
    • 如果用户定义了拷贝操作,那么编译器不会去生成默认的移动操作
    • 反过来,如果用户定义了移动操作,那么编译器也不会去生成默认的拷贝操作
    • 如果定义了析构函数,编译器也不会去生成移动构造函数

条款18:unique_ptr

指针所有权只能转移,不能共享

默认情况下,销毁将通过delete进行,也可以自定义删除器

unique_ptr可以直接类型转换为shared_ptr

自定义删除器的写法

auto delInvmt = [](Investment* pInvestment)         //自定义删除器
                {                                   //(lambda表达式)
                    makeLogEntry(pInvestment);
                    delete pInvestment; 
                };

std::unique_ptr<Investment, decltype(delInvmt)> //应返回的指针
        pInv(nullptr, delInvmt);

条款19:shared_ptr

std::shared_ptr使用引用计数(reference count)确定有多少个对原始指针的引用

引用计数的性能问题

  1. 引用计数必须动态分配,make_shared比直接用shared_ptr构造开销要小一些,它将控制块的构造和对象的构造过程合并了
  2. 引用计数的增减是原子的,因此,对shared_ptr进行移动构造比拷贝构造快,移动构造不需要增减引用计数

shared_ptr自定义删除器时,和unique_ptr不同,删除器不是类型的一部分

auto loggingDel = [](Widget *pw)        //自定义删除器
                  {                     //(和条款18一样)
                      makeLogEntry(pw);
                      delete pw;
                  };

std::unique_ptr<                        //删除器类型是
    Widget, decltype(loggingDel)        //指针类型的一部分
    > upw(new Widget, loggingDel);

std::shared_ptr<Widget>                 //删除器类型不是
    spw(new Widget, loggingDel);        //指针类型的一部分

shared_ptr会为管理的对象建立一个控制块,控制块中包含引用计数,自定义删除器的拷贝等,多个shared_ptr会利用指针共享这个控制块

shared_ptr提供了std::enable_shared_from_thisshared_from_this这两个设施,对象可以用这两个东西获取到指向自身的shared_ptr,保证对象在处理一些异步操作时被错误释放掉,shared_from_this要求对象外必须已经有一个shared_ptr指向对象了,如果没有会抛出异常(很合理,如果shared_from_this是创建一个新的shared_ptr,这个shared_ptr一旦被释放,对象也跟着被释放了),用法如下

class Widget: public std::enable_shared_from_this<Widget> {
public:
    ...
    void process();
    ...
};

void Widget::process()
{
    ...
    auto thisPtr = shared_from_this();
    ...
}

条款20:weak_ptr

weak_ptr不是独立的智能指针,是对shared_ptr的增强,它不能解引用,也不能判空(和nullptr比较)

std::weak_ptr通常从std::shared_ptr上创建,但它不会影响shared_ptr的引用计数

基本操作:检查资源有效性

检查所指对象是否有效:

if (wpw.expired()) …  

检查并访问对象:

std::shared_ptr<Widget> spw1 = wpw.lock();  //如果wpw过期,spw1就为空

shared_ptr的控制块中除了引用计数以外还有弱计数,对应weak_ptr的引用,但weak_ptr是通过检查引用计数判断对象是否有效的,并不是弱计数。

循环引用问题

weak_ptr可以解决shared_ptr循环引用问题

有两个shared_ptr分别指向A和B,同时对象A和B各自持有对方的shared_ptr,这种情况下,释放智能指针实际上是释放不了A和B的

可以通过将A或B内部持有的shared_ptr换成weak_ptr来解决

weak_ptr的使用原则

weak_ptr和shared_ptr的最主要区别是weak_ptr不会获得资源的所有权,想要正确使用weak_ptr,要牢记这一点

条款21:优先使用 make_shared 和 make_unique

为什么要优先使用 make_shared 和 make_unique:

  1. std::shared_ptr<Widget>(new Widget)这种写法存在潜在的资源泄露,构造对象和构造智能指针分成了两步,很容易因为编译器优化在这两步中间插入别的操作,一旦中间这个操作有异常,就会造成资源泄露
  2. std::shared_ptr<Widget> spw(new Widget)会比 make_shared 多分配一次内存,第一次是new一个对象,第二次是为shared_ptr的控制块分配内存,make_shared 会把这两次内存分配合并

make系列函数的限制:

  1. 无法使用自定义析构器
  2. 对自定义new和delete的类不友好
  3. 由于控制块和对象的内存是一起分配的,因此最后一个weak_ptr不销毁的情况下,对象即使析构,内存也是不会回收的,使用new 则不会有这个问题,对象的内存和控制块的内存是分开分配的。

条款23:std::move 和 std::forward

std::move 和 std::forward 都是强制类型转换,本身不做任何移动和转发的操作

举个例子,下面这段代码实际上并不会触发value的移动构造函数,原因是std::move转换后的text是一个带const的右值,无法触发value的移动构造

const std::string text = "abc";
std::string value(std::move(text));

std::forward会将传入的右值引用参数强行转换为右值引用,这句话看起来很奇怪,是因为函数形参在函数内部使用时始终是个左值表达式,如果想保留传入参数的右值引用属性,就需要std::forward强转,std::forward一般配合模板的万能引用参数(T &&)来使用

条款24:区分右值引用和万能引用

涉及到模板类型推导的就是万能引用

例如,以下写法都是万能引用

auto&& var2 = var1;

template<typename T>
void f(T&& param);

万能引用必须是T&&这种形式

像以下这种不涉及类型推导的模板,也不是万能引用

template<class T, class Allocator = allocator<T>>
class vector
{
public:
    void push_back(T&& x);  //不是万能引用
    …
}

作为对比,以下属于万能引用

template<class T, class Allocator = allocator<T>>   //依旧来自C++标准
class vector {
public:
    template <class... Args>
    void emplace_back(Args&&... args);
    …
};

一种完美转发函数调用的写法

auto AnyFuncWrapper =
    [](auto&& func, auto&&... params)           //C++14
    {
        std::cout << "func in" << std::endl;
        std::forward<decltype(func)>(func)(     //对params调用func
            std::forward<decltype(params)>(params)...
            );
        std::cout << "func out" << std::endl;
    };

万能引用实际上涉及到一个叫引用折叠(reference collapse)的概念

条款25:对右值引用用std::move,对万能引用用std::forward

如题所示

另外一个知识点是关于C++的返回值优化(return value optimization,RVO)的

什么是RVO呢?即对于下面这种代码,

Widget makeWidget()
{
    Widget w;
    ...
    return w;
}

编译器会考虑是否为返回值做拷贝消除(copy elision)优化,即直接把w构造到返回值对象上,而不是返回时拷贝一次,有点类似于下面的代码,

Widget makeWidget()
{
    Widget w;
    ...
    return std::move(w);
}

所以,一般不推荐手动编写上面这种代码,有可能会对编译器的优化形成干扰,做出负优化

条款26:避免用万能引用重载函数

万能引用几乎能和所有类型产生精确匹配,很容易造成误匹配

重载函数匹配一旦和模板实例化、继承等结合起来,规则会非常复杂,很容易出现让人意想不到的行为

条款27:条款26的替代方案

  1. 放弃重载
  2. 使用const T&形参代替万能引用
  3. 传值,而不是传引用
  4. 将真正需要重载的功能委托给万能引用,判断传入参数类型,例如使用is_integral<T>()判断参数类型是否为整型
    cpp std::is_integral<typename std::remove_reference<T>::type>()

    完整写法

    ```cpp //1. 判断参数类型 template void logAndAdd(T&& name) { logAndAddImpl( std::forward(name), std::is_integral::type>() ); }

    //2. 编译期区分调用哪个函数 //std::false_type 和 std::true_type 是两个不同的类型,分别对应 true 和 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::string nameFromIdx(int idx); void logAndAddImpl(int idx, std::true_type) { logAndAdd(nameFromIdx(idx)); } `` 5. 使用enable_if`在条件满足时禁用模板(过于复杂,略过)

条款28:理解引用折叠

万能引用的工作原理是引用折叠,具体工作过程如下:

  1. 对于T &&这个参数,如果传入左值,T会推导为左值引用,如果是右值,则推导为非引用
  2. 对于左值参数,推导后的结果就变成了type & &&
  3. 此时触发引用折叠机制,将双重引用变为单引用,规则是:任意一个引用为左值引用,则最终折叠为左值引用,否则,折叠为右值引用
  4. 完美转发std::forward也依赖引用折叠工作(原理略过,书里写了一大堆,懒得看了)

条款29:移动语义不好用的场景

  1. 对象没有提供移动操作
  2. 对象的移动实现很慢,比如std::array,因为std::array本质上就是一个支持stl操作的内建数组,用的是栈内存
  3. 要求移动操作不发生异常的情况下,移动操作未架上noexcept

条款30: 熟悉完美转发的失败情形

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

会导致完美转发失败的实参种类有大括号初始化物、 以值0或NULL表达的空指针、仅有声明的整型 static const 成员变量、模板或重载的函数名字,以及位域

条款32:lambda表达式初始化捕获

可以在捕获列表中写初始化表达式,可以实现移动捕获

auto func = [pw = std::move(pw)]        //使用std::move(pw)初始化闭包数据成员
            { return pw->isValidated()
                     && pw->isArchived(); };

条款33:lambda表达式万能引用

lambda可以模板化,例如 auto f = [](auto x){ return func(normalize(x)); };

万能引用+完美转发的写法

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

条款34:lambda表达式全面优于std::bind

lambda 式比起使用 std::bind 而言,可读性更好、表达力更强,可能运行效率也更高

条款35:基于任务的程序设计

从条款35开始介绍现代c++的并发api

int doAsyncWork();

//基于线程的写法
std::thread t(doAsyncWork);

//基于任务的写法
auto fut = std::async(doAsyncWork);

std::thread的缺陷:

  • 无法获取函数返回值
  • 无法在线程外部捕获线程抛出的异常
  • 无法控制线程数量
  • 多线程时存在频繁的线程上下文切换
    线程上下文切换成本高,而且线程调度会降低线程的缓存命中率

简单来说,基于任务的api提供了更高级的抽象,使开发者不必关心线程管理的问题

条款36:std::async的启动模式

std::async有两种启动模式

  • std::launch::async:异步启动
  • std::launch::deferred:延迟启动,只会在std::async返回值上调用get或wait时启动,启动后同步执行,会阻塞调用线程

默认的启动模式是async或deferred,由并发api自己调度

判断async的启动策略

auto fut = std::async(f);               //同上

if (fut.wait_for(0s) ==                 //如果task是deferred(被延迟)状态
    std::future_status::deferred)
{
    …                                   //在fut上调用wait或get来异步调用f
} else {                                //task没有deferred(被延迟)
    while (fut.wait_for(100ms) !=       //不可能无限循环(假设f完成)
           std::future_status::ready) {
        …                               //task没deferred(被延迟),也没ready(已准备)
                                        //做并行工作直到已准备
    }
    …                                   //fut是ready(已准备)状态
}

手动指定启动策略

auto fut = std::async(std::launch::async, f);   //异步启动f的执行

默认以异步模式启动的函数封装

template<typename F, typename... Ts>
inline
auto                                        // C++14
reallyAsync(F&& f, Ts&&... params)
{
    return std::async(std::launch::async,
                      std::forward<F>(f),
                      std::forward<Ts>(params)...);
}

条款37:thread join和detach

thread析构之前,要么join,要么detach,否则崩溃

条款38:std::future的析构

这里涉及std::futurestd::promisestd::shared_future等概念

什么是future?

future用来保存一个未来的结果

std::async返回的就是一个future

future的get和wait操作都是阻塞等待,他们的区别如下:

  • get:能获取到future中的数据,会消耗掉future的结束状态,只能调用一次
  • wait:只能等待,不能获取到future中的数据,能调用多次

future和promise配合使用

future和promise是线程间异步通信的一种手段,一个线程持有promise,另一个线程持有future,持有promise的线程set数据,持有future的线程get数据

创建promise的时候,他会同时创建一个future和他绑定,通过promise.get_future()能获取到future,必须通过这个future获取到promise的值

future和promise在底层实现上,他们之间还有一个称为“共享状态的buffer”,用于保存promise set的数据

shared_future允许多个线程同时访问共享状态,每个shared_future都能访问到相同的结果

future是独占的,不能拷贝,只能移动,shared_future可以拷贝

用法示例:

int func(std::promise<std::string> promise)
{
    std::cout << "Hello world." << std::endl;
    promise.set_value("Hi, I'm Alice");
    return 0;
}

int testAsync()
{
    std::promise<std::string> promise;
    std::future<std::string> future = promise.get_future();

    auto fuRet =std::async(std::launch::async, func, std::move(promise));

    std::cout << future.get() << std::endl;
    std::cout << fuRet.get() << std::endl;

    return 0;
}

shared_future用法:

//用法1,通过独占future的share成员函数获得
std::future<std::string> future = promise.get_future();
std::shared_future<std::string> future1= future.share();
std::shared_future<std::string> future2 = future1;

//用法2,把promise绑定的future直接转换成shared_future,然后拷贝
std::shared_future<std::string> future = promise.get_future();
std::shared_future<std::string> future1 = future;
std::shared_future<std::string> future2 = future;

注意,第一种写法中,调用future.share()之后,future本身会失效,所以创建shared_future最好采用第二种写法,不会出错

future的析构规则

  • future的正常析构行为就是销毁future本身
  • 有多个共享future时,最后一个future的析构函数会阻塞住,直到任务完成,相当于一个隐式的join操作。

条款39:用promise和future代替条件变量和bool型flag

条件变量和bool型flag都是线程同步的常用手段

条件变量存在虚假唤醒的问题,解决虚假唤醒通常需要在while循环内wait,或者轮询一个bool型flag,对开发者造成额外的负担,并且轮询代价高昂

可以给条件变量的wait操作设置一个检查条件的函数,但是在实际开发过程中,执行wait的线程通常没法去检查条件,不然还用什么条件变量

条款40:volatile不能乱用

volatile的作用是,阻止编译器对其进行内存优化,即,对volatile变量的读写永远是直接操作对应的内存,编译器不会将其加载到寄存器中。只有当程序控制之外的东西可能修改变量的值时(一般是和硬件打交道时),才会考虑将其声明为volatile。

跟并发没有一毛钱关系。

有些编译器对这个关键字的实现可能不一样,但还是不要乱用为好

条款41:volatile不能乱用

条款42:并不是所有场景移动都比拷贝快

条款43:用emplace代替insert