effective modern C++阅读笔记,持续更新......

effective modern C++

型别推导

条款1:理解模板型别推导

模板型别推导时有一些规则:

首先要注意,模板型别T与模板函数的参数型别ParamType,往往是不一样的

  • ParamType是一个指针或引用,但不是个万能引用

    • 这种情况下推导模板类型T时,先将引用部分忽略,然后推导T的型别

    如果传入的实参类型是const的,而模板参数并未声明为const,那么推导出的ParamTypeT也是const的类型。而如果模板参数声明为const,无论传入实参类型是否为const,推导出的ParamType总是const的,T则不是const

  • ParamType是一个万能引用 template<typename T> void f(T&& param)

    • 当传入的实参类型是右值时,表现和前面一样
    • 当传入的实参类型是左值时,推导出的ParamTypeT都会是一个左值引用(只有在这种情况下,T会被推导为引用类型) ,至于为什么会产生这样的结果,可以参见条款24
  • ParamType既不是指针,也不是引用

    • 这种情况就是按值传递,此时会为函数传入参数的一个副本,并且,推导的TParamType会忽略constvolatile修饰符(因为既然是传入的副本,这些关键词也就没有意义了)

有一种特殊的情况,在C和C++中,数组类型可以退化为指针类型,但在进行模板类型推导时,有一些奇怪的表现:

  • 将数组作为按值传递的模板实参时,模板会推导出指针类型
  • 将数组作为按引用传递的模板实参时,T会推导为实际的数组型别,ParaType会推导为数组的引用型别,这些型别中会包含数组尺寸

此外,用函数类型作为模板类型时,也会发生与数组类型类似的转化

利用声明数组引用的这个能力,可以创造出一个模板,它可以在编译期推导出数组含有的元素个数:

// 以编译期常量的形式返回数组尺寸
template<typename T, std::size_t N>
constexpr std::size_t arraySize(T (&)[N]) noexcept
{
    return N;
}

// 使用示例
int keyVals[] = {1, 3, 7, 9, 11, 22, 35};
std::array<int, arraySize(kayVals)> mappedVals; // mappedVals被指定为7个元素

条款2:理解auto型别推导

auto的型别推导规则与模板是类似的,当变量用auto来声明时,auto就扮演了模板中的T这个角色。

但是,在有一种情况下例外:

C++11后,如果我们想声明并初始化一个变量,可以有以下四种写法:

int x1 = 27;
int x2(27);
// 为了支持统一初始化,C++11增加了一下的初始化方法
int x3 = {27};
int x4{27};

如果使用auto替代上面的int,我们会发现,前两种声明方式推导出的型别为int,而后面两个语句,却声明了一个型别为std::initalizer_list<int>的变量,且含有单个值为27的元素。这是因为auto的型别推导总是会认为由大括号起的初始化表达式是一个std::initalizer_list,而模板在这种情况下则是无法通过编译的,除非在模板声明时就声明它是std::initalizer_list

在C++14中,允许使用auto说明函数返回值需要推导,也允许在lambda表达式的型参声明中使用auto,但这些地方的auto推导使用的是模板型别的推导,如果使用大括号表达式初始化,也是无法通过编译的。

在C++17中,有了新的初始化列表推导规则,对于具有单个值的大括号表达式初始化,会推导为普通的类型,而不是初始化列表,如: auto x3 = {27}。 在C++17中x3会被推导为int类型

条款3:理解decltype

  • 绝大多数情况下,decltype会得出变量或表达式的型别而不做任何修改

  • 对于型别为T的左值表达式,除非该表达式仅有一个名字,decltype总是得出型别T&

    int x = 0;
    decltype(x);    // x是一个变量名,返回的结果是int
    decltype((x));  // (x)是一个左值表达式,返回的结果是int&
    
  • C++14支持decltype(auto),和auto一样,它会从其初始化表达式出发来推导型别,但是它的型别推导使用的是decltype的规则

如果使用auto对函数返回值类型进行推导,而返回值是一个左值引用,根据条款2的内容,我们知道auto推导出的类型为一个引用类型,但这显然是具有隐患的——如果返回值是一个引用类型,而该引用是一个作用域仅在函数内的引用,这就导致了一个定义行为。基于第二点和第三点,如果我们像这样使用decltype,就会导致悬垂引用的问题:

decltype(auto) f()
{
    int x = 0;
    ...
    return (x);    // decltype((x))是int&,因此f()返回的是int&
}

在C++14定义一个模板,用于获取容器中第i个元素的引用

template<typename Container, typename Index>
decltype(auto)                        // 推导模板返回值
authAndAccess(Container&& c, Index i) // 同时可以接受左值和右值
{
 authenticateUser();
 return std::forward<Container>(c)[i]; // 对万能引用,使用std::forward(条款25)
}

如果是C++11中,则需要用auto f() -> decltype(std::forward<Container>(c)[i])进行声明

auto

条款5:优先选用auto,而非显式型别声明

表面上看,auto可以让我们在有些时候少打一些字,但在某些情况下,auto关键字具有更加精妙的作用

  • 使用auto声明的变量会自动进行初始化

  • 使用auto可以解决一些兼容性和隐含的效率问题,如:

    std::vector<int> v;
    // 有时程序员会这样写,但这并不是size()返回的实际类型
    // 它的实际类型应该是std::vector<int>::size_type,它的位数根据机器可能有所不同
    unsigned sz = v.size(); 
    // 如果不想写这一长串类型名称,可以使用auto代替
    
    std::unordered_map<std::string, int> m;
    for(const std::pair<std::string, int>& p, m) {
        ...
    }
    // 上面的代码看起来没什么问题,却隐含了一个类型转换,因为unordered_map键值部分是const
    // 所以哈希表中的型别应该是std::pair<const std::string, int>
    // 如果按照上面的写法,每一次循环时,就会在这里创建一个临时的std::pair<std::string, int>变量
    // 如果使用auto,则可以避免这些因为粗心导致的效率问题
    
  • auto型别的变量都具有条款2和条款6的毛病

条款6:当auto推导的型别不符合要求时,使用带显式型别的初始化物习惯用法

  • 隐形的代理型别可以导致auto推导出错误的型别

    如当我们使用std::vector<bool>时,使用operator[]返回的是一个std::vector<bool>::reference的代理类型,而非bool&类型的变量。当使用auto时,它会返回std::vector<bool>::reference类型,而这个类型通常只是一个临时类型,在operator[]语句结束后就被析构,因此,此处会产生一个空悬指针,这是一个未定义行为。

  • 使用带显式型别的初始化可以解决这个问题。

    不赞同,这种情况下还使用auto进行声明没什么道理——既然已经显式指明了类型,说明我们已经知道应该用什么类型了

转向现代C++

条款7:在创建对象时注意区分 () 和 {}

  • C++11引入了统一初始化,它的基础是大括号的形式

    • 可以用来指定容器的初始内容std::vector<int> v {1, 2, 3};

    • 可以用来为非静态成员指定默认初始化值(C++11新增特性),使用=也可以初始化,但小括号()不行

    =不能为不可拷贝的对象进行初始化,而{}()可以,总的来说,{}的适用范围最为广泛

  • 大括号初始化禁止内建型别之间进行隐式窄化类型转换,如

    double x, y, z;
    ...
    int sum{x + y + z}; //错误,double型别之和可能无法用int表达,使用=初始化则不会指出这个错误
    

    另外,它还可以避免语法解析时的一些歧义:

    Widget w1(10); // 调用Widget的构造函数,传入形参10
    Widget w2();   // 原意是调用Widget的无参构造函数,但这个语句会声明一个函数!
    Widget w3{};   // 调用Widget的无参构造函数
    
  • 但是,大括号初始化也并非总是更好用。在重载决议阶段,如果大括号初始化物与带有std::initializer_list 的形参匹配,则总是会匹配该重载函数,即使其它函数有貌似更匹配的形参表

    class Widget {
    public:
        Widget(int i, bool b);
        Widget(std::initializer_list<long double> il);
    ...
    }
    
    Widget w1(10, true); // 使用第一个构造函数
    Widget w2{10, true}; // 使用第二个构造函数,10和true都强制转换为long double
    

    因此,std::vector并不是一个好的设计,当进行以下两种初始化时,会产生截然不同的效果

    std::vector<int> v1(10, 20); // v1含有10个元素,初始化值为20
    std::vector<int> v2{10, 20}; // v2具有两个元素,初始化为10, 20
    

条款8:优先选用nullptr,而非0或NULL

条款9:优先选用别名声明,而非typedef

  • typedef不支持模板化,但别名声明支持
  • 别名模板可以让人免写"::type"后缀,并且在模板内,对于内嵌typedef的引用经常要求加上typename前缀

条款10:优先选用限定作用域的枚举型别

如果在公共空间直接声明enum Color {...};,那么它的作用于存在于整个空间;在C++11后,可以声明enum class Color {...};,将枚举类型的名字空间限定在Color中,也可以称它为枚举类。除了命名空间的优点之外,这种写法还有其它优点:

  • enum class的声明,可以限制枚举类型的隐式转换
  • 使用enum声明的枚举类型,没有默认底层型别,而使用enum class声明的枚举类型,其默认的底层型别为int。在C++11中,可以对枚举类型进行前向声明,但使用前向声明时,必须指明其枚举型别的尺寸。——也就是说使用enum class进行前置声明时,不需指定枚举型别底层类型,而使用enum进行前置声明,指定其底层类型则是必要的。(因为enum如果不声明其底层类型,那么这个类型的大小是不确定的,如果这个枚举类型发生了修改,则应当对所有相关文件的重新编译)

条款11:优先选用删除函数,而非private未定义函数

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

    使用= delete的好处:

    1. 有助于IDE和编译器的错误提示,将删除函数定义在public中,编译器会明确告知你该函数已被删除

    2. 可以阻止一些隐式的类型转换,例如:

      bool isLucky(int number);
      bool isLucky(double) = delete;
      

      如果只想让isLucky函数处理整数,那么可以将一些会发生隐式类型转换的型别定义为delete,例如上面将double类型的形参声明为了delete,编译时编译器就会阻止由isLucky(double)的调用。

  • 任何函数都可以删除,包括非成员函数和模板具现

    在指针中有两个异类,一个是void*指针,因为无法对其执行提领、自增、自减等操作;还有一个是char*指针,它通常用来代表C风格的字符串,而不是单个字符的指针。如果我们创建一个指针类型的模板,并且在这两种情况时拒绝对模板进行实例化,就可以这样做:

    template<typename T>
    void processPointer(T* ptr);
    
    template<> void processPointer<void>(void*) = delete;
    template<> void processPointer<char>(char*) = delete;
    // 当然,你可能还需要delete它们的const版本和const volatile版本,以及其它重载的字符型指针
    // 如std::wchar_t, std::char16_t等
    

条款12:为意在改写的函数添加override声明

  • 添加override可以避免一些声明时的错误(重载的函数参数、修饰符必须完全匹配)

  • 成员函数引用修饰词可以让左值和右值对象的处理区分开来

    class Widget {
    public:
        using DataType = std::vector<double>;
        
        DataType& data() &            // 对于左值Widget型别,返回左值
        {return values};
        
        DataType data() &&
        {return std::move(values)};   // 对于右值Widget型别,返回右值
        
    private:
        DataType values;
    }
    

条款13:优先选用 const_itertor,而非 itertor

  • 任何时候你需要你已迭代器,而其指涉的内容没有必要修改,就应该使用const_itertor

条款14:只要函数不会发射异常,就将上noexcept声明

  • 使用noexcept有助于程序优化:

    在C++98中,以vector为例,使用std::vector::push_back时,如果数组需要扩容,则会开辟一片新的内容,然后对原数组的内容进行拷贝,这个过程时异常安全的。在C++11中,理所当然的对此进行了优化——使用移动语义可以减少拷贝的开销,但是,如果移动过程中出现了异常,数组想恢复到原始状态也是难以做到的,因此不能保证这个操作的异常安全。C++11的做法是,如果函数声明了noexcept,则说明这个函数不会产生异常,此时使用移动操作,否则使用复制操作。

    需要注意的是,noexcept固然有优点,但只有你保证函数不会抛出异常时才能使用它

  • C++11中,析构函数都隐式的具备noexcept的性质,除非它的某些数据成员的析构函数显式的声明了noexcept(false)

条款15:只要有可能使用 constexpr,就使用它

  • constexpr对象都具备const属性,并由编译期已知的值完成优化
  • constexpr函数在调用时若传入的实参值是编译期已知的,则会产出编译期结果

C++11中,constexpr只可用于包含一条可执行语句的函数,即函数内必须是return 表达式;的形式,而在C++14里则没有这个限制

条款16:保证 const 成员函数的线程安全性

多线程情景下,使用const函数时,一般情况可以直接使用,因为const意味着不会修改类中的成员变量,但如果函数中使用了mutable变量时,这个约束就无效了,这种情况下,应当由函数实现者保证它的线程安全性。

你可以选择使用std::mutex来保证线程安全,这样做会使这个类失去可复制性(因为mutex不可复制)。一个更轻量级的方法是使用std::atomic ,在仅限单个互斥量的情况下,它会提供比std::mutex更好的性能,但多个互斥量联动时,atomic并不能保证并行操作的正确性。(atomic同样不可复制)

条款17:理解特种成员函数的生成机制

  • C++会自动生成一些类成员函数,这些函数仅在需要时才会生成,并且仅当一个类没有声明任何构造函数时,才会生成默认构造函数
class Widget {
    Widget();                    // 默认构造函数
    ~Widget();                   // 默认析构函数
    Widget(Widget&);             // 复制构造函数
    Widget& operator=(Widget&);  // 复制赋值运算符
    // C++11中,新增了两种新的特种成员函数
    Widget(Widget&&);            // 移动构造函数
    Widget& operator=(Widget&&); // 移动赋值运算符
}

对于移动构造函数和移动赋值运算符,对于其内部可移动的数据成员,会使用它相应的移动构造函数或移动赋值运算符,对于不可移动的数据成员,则使用它的复制构造函数或复制赋值运算符。其核心在于将std::move应用于每一个移动源对象,其返回值被用于函数重载决议,最终决定是执行一个移动还是复制操作。

  • 特种成员函数的机制如下:(默认为C++11中)
    • 默认构造函数:仅当类中不包含用户声明的构造函数时才生成
    • 析构函数:C++11中,默认析构函数为noexcept,除非它或它的内部成员的析构函数被显式声明为noexcept(false);当基类的析构函数为虚函数时,派生类的析构函数才是虚函数。析构函数只能有一个,但在C++20中,一个类可以有一个或多个预期的析构函数,其中之一会在重载决议时被选为类的析构函数。
    • 复制构造函数:按成员进行非静态数据成员的复制构造,仅当类中不包含用户声明的复制构造函数时才生成。如果该类声明了移动操作,则复制构造函数将被删除
    • 复制赋值运算符:类似复制构造函数
    • 移动构造函数和移动赋值运算符:都按成员进行非静态数据成员的移动操作。仅当类中不包含用户声明的赋值操作、移动操作和析构函数时才生成
    • 成员函数模板在任何情况下都不会抑制特种成员函数的生成

智能指针

条款18:使用 std::unique_ptr 管理具备专属所有权的资源

  • 可以认为unique_ptr具有与裸指针相同的尺寸,并且大多数操作的值令与直接使用裸指针并无不同。unique_ptr可以移动但不能复制。
  • 资源的析构默认采用delete运算符来实现,但可以指定自定义删除器,有状态的删除器和采用函数指针实现的删除器会增加unique_ptr对象的尺寸。
  • std::unique_ptr转换成std::shared_ptr是容易实现的

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

条款20: 对于类似 std::shared_ptr 但有可能空悬的指针使用 std::weak_ptr

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

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

lambda表达式