[TOC]

第3章 移步现代C++

Item 7: Distinguish between () and {} when creating objects

  • 区别赋值运算符和初始化
Widget w1;              //调用默认构造函数

Widget w2 = w1;         //不是赋值运算,调用拷贝构造函数

w1 = w2;                //是赋值运算,调用拷贝赋值运算符(copy operator=)

统一初始化

  • C++11使用统一初始化(uniform initialization)来整合这些混乱且不适于所有情景的初始化语法

统一初始化是指在任何涉及初始化的地方都使用单一的初始化语法,基于花括号

//使用花括号,创建并指定一个容器的初始元素
std::vector<int> v{ 1, 3, 5 };  //v初始内容为1,3,5

//为非静态数据成员指定默认初始值
class Widget{
    …

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

一方面,不可拷贝的对象(例如std::atomic——见Item40)可以使用花括号初始化或者圆括号初始化,但是不能使用"="初始化:

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

括号表达式还有一个少见的特性:不允许内置类型间隐式的变窄转换(narrowing conversion),而使用圆括号和"="的初始化不检查是否转换为变窄转换。

double x, y, z;

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

另一个值得注意的特性:免疫解析问题(C++规定任何可以被解析为一个声明的东西必须被解析为声明)

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

//函数声明中形参列表不能带花括号,所以使用花括号初始化表明你想调用默认构造函数构造对象
Widget w3{};

括号初始化的缺点

Item2解释了当auto声明的变量使用花括号初始化,变量类型会被推导为std::initializer_list,但是使用相同内容的其他初始化方式会产生更符合直觉的结果。

  • 你越喜欢用auto,你就越不能用括号初始化。

如果有一个或者多个构造函数的声明包含一个std::initializer_list形参,那么使用括号初始化语法的调用更倾向于选择带std::initializer_list的那个构造函数

class Widget { 
public:  
    Widget(int i, bool b);                              //同之前一样
    Widget(int i, double d);                            //同之前一样
    Widget(std::initializer_list<long double> il);      //同之前一样
    operator float() const;                             //转换为float
    …
};

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的构造函数劫持
Widget w5(w4);                  //使用圆括号,调用拷贝构造函数

Widget w6{w4};                  //使用花括号,调用std::initializer_list构造
                                //函数(w4转换为float,float转换为double)

Widget w7(std::move(w4));       //使用圆括号,调用移动构造函数

Widget w8{std::move(w4)};       //使用花括号,调用std::initializer_list构造
                                //函数(与w6相同原因)
  • 就算带std::initializer_list的构造函数不能被调用,它也会硬选。
class Widget { 
public: 
    Widget(int i, bool b);                      //同之前一样
    Widget(int i, double d);                    //同之前一样
    Widget(std::initializer_list<bool> il);     //现在元素类型为bool//没有隐式转换函数
};

Widget w{10, 5.0};              //错误!要求变窄转换

只有当没办法把括号初始化中实参的类型转化为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<std::string> il);
    …                                                   //没有隐式转换函数
};
//没有办法把int和bool转换为std::string:
Widget w1(10, true);     // 使用圆括号初始化,调用第一个构造函数
Widget w2{10, true};     // 使用花括号初始化,现在调用第一个构造函数
Widget w3(10, 5.0);      // 使用圆括号初始化,调用第二个构造函数
Widget w4{10, 5.0};      // 使用花括号初始化,现在调用第二个构造函数
  • 空的花括号意味着没有实参,不是一个空的std::initializer_list
class Widget { 
public:  
    Widget();                                   //默认构造函数
    Widget(std::initializer_list<int> il);      //std::initializer_list构造函数//没有隐式转换函数
};

Widget w1;                      //调用默认构造函数
Widget w2{};                    //也调用默认构造函数
Widget w3();                    //最令人头疼的解析!声明一个函数

//想用空std::initializer来调用std::initializer_list构造函数
Widget w4({ });                  //使用空花括号列表调用std::initializer_list构造函数
Widget w5{ { } };                  //同上

[!warning]

  1. 如果一堆重载的构造函数中有一个或者多个含有std::initializer_list形参,用户代码如果使用了括号初始化,可能只会看到你std::initializer_list版本的重载的构造函数。

    最好把你的构造函数设计为不管用户是使用圆括号还是使用花括号进行初始化都不会有什么影响

  2. 认真的在花括号和圆括号之间选择一个来创建对象

    默认使用花括号初始化的开发者主要被适用面广、禁止变窄转换、免疫C++最令人头疼的解析这些优点所吸引。


总结

[!note]

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

Item 8: Prefer nullptr to 0 and NULL

0 and NULL

  • 一般来说C++的解析策略是0看做int而不是指针
  • 0NULL都不是指针类型。
void f(int);        //三个f的重载函数
void f(bool);
void f(void*);

f(0);               //调用f(int)而不是f(void*)

f(NULL);            //可能不会被编译,一般来说调用f(int),
                    //绝对不会调用f(void*)

f(NULL)的不确定行为是由NULL的实现不同造成的。


nullptr

  1. nullptr的优点是它不是整型,可以把它认为是所有类型的指针。
  2. nullptr的真正类型是std::nullptr_t(std::nullptr_t可以隐式转换为指向任何内置类型的指针),在一个完美的循环定义以后,std::nullptr_t又被定义为nullptr
void f(int);        //三个f的重载函数
void f(bool);
void f(void*);
f(nullptr);         //调用重载函数f的f(void*)版本
  1. 使代码表意明确,尤其是当涉及到与auto声明的变量一起使用时。
auto result = findRecord( /* arguments */ );
//result的结果一定是指针类型。
if (result == nullptr) {  
    …
}
  1. 模板里的nullptr

    int    f1(std::shared_ptr<Widget> spw);     //只能被合适的
    double f2(std::unique_ptr<Widget> upw);     //已锁互斥量
    bool   f3(Widget* pw);                      //调用
    
    template<typename FuncType,
             typename MuxType,
             typename PtrType>
    decltype(auto) lockAndCall(FuncType func,       //C++14
                               MuxType& mutex,
                               PtrType ptr)
    { 
        MuxGuard g(mutex);  
        return func(ptr); 
    }
    
    auto result1 = lockAndCall(f1, f1m, 0);         //错误!
    ...
    auto result2 = lockAndCall(f2, f2m, NULL);      //错误!
    ...
    auto result3 = lockAndCall(f3, f3m, nullptr);   //没问题
    
    1. nullptr传给lockAndCall时,ptr被推导为std::nullptr_t。当ptr被传递给f3的时候,隐式转换使std::nullptr_t转换为Widget*,因为std::nullptr_t可以隐式转换为任何指针类型。
    2. 模板类型推导将0NULL推导为一个错误的类型(即它们的实际类型,而不是作为空指针的隐含意义)

    3. 想用一个空指针,使用nullptr,不用0或者NULL

总结

[!note]

  • 优先考虑nullptr而非0NULL
  • 避免重载指针和整型

Item 9: Prefer alias declarations to typedef

typedef and using

  • typedef是C++98的东西
typedef
    std::unique_ptr<std::unordered_map<std::string, std::string>>
    UPtrMapSS;
  • C++11也提供了一个别名声明alias declaration):using 声明
using UPtrMapSS =
    std::unique_ptr<std::unordered_map<std::string, std::string>>;
  1. 声明一个函数指针时别名声明更容易理解:
//FP是一个指向函数的指针的同义词,它指向的函数带有
//int和const std::string&形参,不返回任何东西
typedef void (*FP)(int, const std::string&);    //typedef

//含义同上
using FP = void (*)(int, const std::string&);   //别名声明
  1. 别名声明可以被模板化(这种情况下称为别名模板alias templates)但是typedef不能

当编译器处理Widget模板时遇到MyAllocList<T>(使用模板别名声明的版本),它们知道MyAllocList<T>是一个类型名,因为MyAllocList是一个别名模板:它一定是一个类型名。

但是如果使用typedef编译器不能确定MyAllocList<T>::type是一个类型而非特化版本的数据成员

template<typename T> 
using MyAllocList = std::list<T, MyAlloc<T>>;   //同之前一样

template<typename T> 
class Widget {
private:
    MyAllocList<T> list;                        //没有“typename”//没有“::type”
};

<type_traits>

  • C++11在type traits(类型特性)中给了你一系列工具去实现类型转换,些模板请包含头文件<type_traits>
  • C++11的type traits是通过在struct内嵌套typedef来实现的
  • C++14才提供了使用别名声明的版本
std::remove_const<T>::type          //C++11: const T → T 
std::remove_const_t<T>              //C++14 等价形式

std::remove_reference<T>::type      //C++11: T&/T&& → T 
std::remove_reference_t<T>          //C++14 等价形式

std::add_lvalue_reference<T>::type  //C++11: T → T& 
std::add_lvalue_reference_t<T>      //C++14 等价形式

总结

[!note]

  • typedef不支持模板化,但是别名声明支持。
  • 别名模板避免了使用“::type”后缀,而且在模板中使用typedef还需要在前面加上typename
  • C++14提供了C++11所有type traits转换的别名声明版本

Item 10: Prefer scoped enums to unscoped enums

(unscoped enum)and (scoped enum)

  • 通常来说,在花括号中声明一个名字会限制它的作用域在花括号之内。

  • C++98风格的enum中声明的枚举名的名字属于包含这个enum的作用域

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

    这些枚举名的名字泄漏进它们所被定义的enum在的那个作用域:未限域枚举(unscoped enum)

  • 一个相似物,限域枚举(scoped enum):

    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 class”声明,所以它们有时候也被称为枚举类(enum classes)。

枚举类

  1. 使用限域enum减少命名空间污染

  2. 在它的作用域中,枚举名是强类型

    • 未限域enum中的枚举名会隐式转换为整型(现在,也可以转换为浮点类型)
    enum Color { black, white, red };       //未限域enum
    
    std::vector<std::size_t>                //func返回x的质因子
      primeFactors(std::size_t x);
    
    Color c = red;
    …
    
    if (c < 14.5) {                         // Color与double比较 (!)
        auto factors =                      // 计算一个Color的质因子(!)
          primeFactors(c);
        …
    }
    
    • 不存在任何隐式转换可以将限域enum中的枚举名转化为任何其他类型
    enum class Color { black, white, red }; //Color现在是限域enum
    
    Color c = Color::red;                   //和之前一样,只是
    ...                                     //多了一个域修饰符
    
    if (c < 14.5) {                         //错误!不能比较
                                            //Color和double
        auto factors =                      //错误!不能向参数为std::size_t
          primeFactors(c);                  //的函数传递Color参数
        …
    }
    
    • 使用正确的类型转换运算符扭曲类型系统执行Color到其他类型的转换
    if (static_cast<double>(c) < 14.5) {    //奇怪的代码,
                                            //但是有效
        auto factors =                                  //有问题,但是
          primeFactors(static_cast<std::size_t>(c));    //能通过编译
        …
    }
    
  3. 限域enum可以被前置声明,减少编译依赖

    enum Color;         //错误!
    enum class Color;   //没问题
    

    在C++11中,非限域enum也可以被前置声明:在C++中所有的enum都有一个由编译器决定的整型的底层类型

    C++11中的前置声明enums可以减少编译依赖

    enum class Status;                  //前置声明
    void continueProcessing(Status s);  //使用前置声明enum
    

    即使Status的定义发生改变,包含这些声明的头文件也不需要重新编译

  4. 限域enum的底层类型总是已知的,而对于非限域enum,你可以指定它。

    enum class Status: std::uint32_t;   //Status的底层类型
                                        //是std::uint32_t
                                        //(需要包含 <cstdint>)
    

    底层类型说明也可以放到enum定义处。

  5. 限域enum并非万事皆宜

    [!warning]

    牵扯到C++11的std::tuple的时候

    UserInfo uInfo;                 //tuple对象auto val = std::get<1>(uInfo);    //获取第一个字段
    
    //非限域
    enum UserInfoFields { uiName, uiEmail, uiReputation };
    UserInfo uInfo;                         //同之前一样
    //UserInfoFields中的枚举名隐式转换成std::size_t
    auto val = std::get<uiEmail>(uInfo);    //啊,获取用户email字段的值
    
    //限域
    enum class UserInfoFields { uiName, uiEmail, uiReputation };
    
    UserInfo uInfo;                         //同之前一样auto val =
        std::get<static_cast<std::size_t>(UserInfoFields::uiEmail)>
            (uInfo);
    
    • 为避免这种冗长的表示,我们可以写一个函数传入枚举名并返回对应的std::size_t

      template<typename E>                //C++14
      constexpr auto
          toUType(E enumerator) noexcept
      {
          return static_cast<std::underlying_type_t<E>>(enumerator);
      }
      

总结

[!note]

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

Item 11: Prefer deleted functions to private undefined ones.

delete

  • 在C++98中,想要禁止使用的成员函数,几乎总是拷贝构造函数或者赋值运算符,或者两者都是。防止调用这些函数的方法是将它们声明为私有private)成员函数并且不定义

    所有istreamostream类都继承此模板类basic_ios(直接或者间接)

    basic_ios在C++98中是这样声明的(包括注释):

    template <class charT, class traits = char_traits<charT> >
    class basic_ios : public ios_base {
    public:
        …
    
    private:
        //使这些istream和ostream类不可拷贝
        basic_ios(const basic_ios& );           // not defined
        basic_ios& operator=(const basic_ios&); // not defined
    };
    

    有代码用它们(比如成员函数或者类的友元friend),就会在链接时引发缺少函数定义(missing function definitions)错误。

  • 在C++11中,用“= delete”将拷贝构造函数和拷贝赋值运算符标记为deleted\函数

    template <class charT, class traits = char_traits<charT> >
    class basic_ios : public ios_base {
    public:
        …
    
        basic_ios(const basic_ios& ) = delete;
        basic_ios& operator=(const basic_ios&) = delete;
        …
    };
    

    deleted函数不能以任何方式被调用,即使你在成员函数或者友元函数里面调用deleted函数也不能通过编译

  • 任何函数(包含普通函数和成员函数等所有可声明函数的地方)都可以标记为deleted,而只有成员函数可被标记为private

    //假如我们有一个非成员函数,它接受一个整型参数,检查它是否为幸运数
    bool isLucky(int number);
    

    能被视作数值的任何类型都能隐式转换为int,所以

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

    创建deleted重载函数,禁止这些调用通过编译。

    bool isLucky(int number);       //原始版本
    bool isLucky(char) = delete;    //拒绝char
    bool isLucky(bool) = delete;    //拒绝bool
    bool isLucky(double) = delete;  //拒绝float和double
    
  • deleted 禁止一些模板的实例化

    假如你要求一个模板仅支持原生指针(尽管第四章建议使用智能指针代替原生指针):

    template<typename T>
    void processPointer(T* ptr);
    

    指针的世界里有两种特殊情况

    1. void*指针,因为没办法对它们进行解引用,或者加加减减等
    2. char*,因为它们通常代表C风格的字符串,而不是正常意义下指向单个字符的指针

    processPointer不能被void*char*调用

    template<>
    void processPointer<void>(void*) = delete;
    
    template<>
    void processPointer<char>(char*) = delete;
    //const void*和const char*也应该无效,所以这些实例也应该标注delete:
    template<>
    void processPointer<const void>(const void*) = delete;
    
    template<>
    void processPointer<const char>(const char*) = delete;
    

    [!tip]

    做得更彻底一些,你还要删除const volatile void*const volatile char*重载版本,另外还需要一并删除其他标准字符类型的重载版本:std::wchar_tstd::char16_tstd::char32_t

    • private(经典的C++98惯例)来禁止这些函数模板实例化

      class Widget {
      public:
          …
          template<typename T>
          void processPointer(T* ptr)
          { … }
      
      private:
          //模板特例化必须位于一个命名空间作用域,而不是类作用域。
          template<>                          //错误!
          void processPointer<void>(void*);
      
      };
      

总结

[!note]

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

Item 12: Declare overriding functions override

override

  • 最基本的概念是派生类的虚函数重写基类同名函数

    class Base {
    public:
        virtual void doWork();          //基类虚函数
        …
    };
    
    class Derived: public Base {
    public:
        virtual void doWork();          //重写Base::doWork//(这里“virtual”是可以省略的)
    }; 
    
    std::unique_ptr<Base> upb =         //创建基类指针指向派生类对象
        std::make_unique<Derived>();    //关于std::make_unique//请参见Item21
    
    
    upb->doWork();                      //通过基类指针调用doWork,
                                        //实际上是派生类的doWork
                                        //函数被调用
    

    重写一个函数:

    1. 基类函数必须是virtual

    2. 基类和派生类函数名必须完全一样(除非是析构函数)

    3. 基类和派生类函数形参类型必须完全一样

    4. 基类和派生类函数常量性constness必须完全一样

    5. 基类和派生类函数的返回值和异常说明exception specifications)必须兼容

    6. 函数的引用限定符reference qualifiers)必须完全一样(C++11)。

      class Widget {
      public:
          …
          void doWork() &;    //只有*this为左值的时候才能被调用
          void doWork() &&;   //只有*this为右值的时候才能被调用
      }; 
      …
      Widget makeWidget();    //工厂函数(返回右值)
      Widget w;               //普通对象(左值)
      …
      w.doWork();             //调用被左值引用限定修饰的Widget::doWork版本
                              //(即Widget::doWork &)
      makeWidget().doWork();  //调用被右值引用限定修饰的Widget::doWork版本
                              //(即Widget::doWork &&)
      
  • 所有重写函数后面加上override

    class Base {
    public:
        virtual void mf1() const;
        virtual void mf2(int x);
        virtual void mf3() &;
        virtual void mf4() const;
    };
    
    class Derived: public Base {
    public:
        virtual void mf1() const override;
        virtual void mf2(int x) override;
        virtual void mf3() & override;
        void mf4() const override;          //可以添加virtual,但不是必要
    };
    
    1. 给你的派生类重写函数全都加上override
    2. override还可以帮你评估后果
    3. 对于override,它只在成员函数声明结尾处才被视为关键字。

final

  • 向虚函数添加final可以防止派生类重写。
  • final也能用于类,这时这个类不能用作基类

成员函数引用限定(reference qualifiers

//写一个函数只接受左值实参,声明一个non-const左值引用形参
void doSomething(Widget& w);    //只接受左值Widget对象

//只接受右值实参,声明一个右值引用形参
void doSomething(Widget&& w);   //只接受右值Widget对象
  • 引用限定可以很容易的区分一个成员函数被哪个对象(即*this)调用s

  • 指明当data被右值Widget对象调用的时候结果也应该是一个右值。现在就可以使用引用限定,为左值Widget和右值Widget写一个data的重载函数来达成这一目的:

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

总结

[!note]

  • 为重写函数加上override
  • 成员函数引用限定让我们可以区别对待左值对象和右值对象(即*this)

Item 13: Prefer const_iterators to iterators

const_iterator

  • STL const_iterator等价于指向常量的指针(pointer-to-const
  • 实践是能加上const就加上

假如你想在std::vector<int>中查找第一次出现1983(C++代替C with classes的那一年)的位置,然后插入1998(第一个ISO C++标准被接纳的那一年)。如果vector中没有1983,那么就在vector尾部插入。

  1. C++98

    typedef std::vector<int>::iterator IterT;               //typedef
    typedef std::vector<int>::const_iterator ConstIterT;
    
    std::vector<int> values;
    …
    //用const_iterator重写这段代码
    ConstIterT ci =
        std::find(static_cast<ConstIterT>(values.begin()),  //cast
                  static_cast<ConstIterT>(values.end()),    //cast
                  1983);
    
    values.insert(static_cast<IterT>(ci), 1998);    //可能无法通过编译,
                                                    //原因见下
    
  2. C++11:容器的成员函数cbegincend产出const_iterator,甚至对于non-const容器也可用

    std::vector<int> values;                                //和之前一样auto it =                                               //使用cbegin
        std::find(values.cbegin(), values.cend(), 1983);//和cend
    values.insert(it, 1998);
    
  • C++14支持但是C++11的时候还没:

    • 想写最大程度通用的库,并且这些库代码为一些容器和类似容器的数据结构提供beginend(以及cbegincendrbeginrend等)作为非成员函数而不是成员函数
    • 原生数组,还有一种情况是一些只由自由函数组成接口的第三方库
  • 非成员函数cbegin的实现:

    template <class C>
    auto cbegin(const C& container)->decltype(std::begin(container))
    {
        return std::begin(container);   //解释见下
    }
    
    1. 这个cbegin模板接受任何代表类似容器的数据结构的实参类型C
    2. 通过reference-to-const形参container访问这个实参
    3. const容器调用非成员函数begin(由C++11提供)将产出const_iterator
  • 非成员函数cend的实现:同理

    template <class C>
    auto cend(const C& container)->decltype(std::end(container))
    {
        return std::end(container);   //解释见下
    }
    

总结

[!note]

  • 优先考虑const_iterator而非iterator
  • 在最大程度通用的代码中,优先考虑非成员函数版本的beginendrbegin等,而非同名成员函数

Item 14: Declare functions noexcept if they won’t emit exceptions

noexcept

  • 在C++98中,异常说明(exception specifications)是喜怒无常的野兽

    不得不写出函数可能抛出的异常类型

    如果函数实现有所改变,异常说明也可能需要修改

    同时改变异常说明会影响客户端代码

  • 在C++11标准化过程中,异常说明真正有用的信息是一个函数是否会抛出异常

    一个函数可能抛异常,或者不会

  • 在C++11中,无条件的noexcept保证函数不会抛出任何异常。

    1. 一个函数是否已经声明为noexcept是接口设计的事
    2. 函数的异常抛出行为是客户端代码最关心的
    3. 调用者可以查看函数是否声明为noexcept,这个可以影响到调用代码的异常安全性(exception safety)和效率
  • 给不抛异常的函数加上noexcept的动机:它允许编译器生成更好的目标代码

    //函数f,它保证调用者永远不会收到一个异常
    int f(int x) throw();   //C++98风格,没有来自f的异常
    int f(int x) noexcept;  //C++11风格,没有来自f的异常
    
    • 在运行时,f出现一个异常
      • C++98的异常说明中,用栈(the call stack)会展开至f的调用者,在一些与这地方不相关的动作后,程序被终止
      • C++11异常说明中,调用栈只是可能在程序终止前展开

展开调用栈和可能展开调用栈

展开调用栈和可能展开调用栈两者对于代码生成(code generation)有非常大的影响

  • 在一个noexcept函数中,当异常可能传播到函数外时
    • 优化器不需要保证运行时栈(the runtime stack)处于可展开状态
    • 不需要保证当异常离开noexcept函数时,noexcept函数中的对象按照构造的反序析构
std::vector<Widget> vw;
…
Widget w;
…                   //用w做点事
vw.push_back(w);    //把w添加进vw
  • std::vector::push_back受益于“如果可以就移动,如果必要则复制”策略
    • std::vector的大小(size)等于它的容量(capacity)。这时候,std::vector会分配一个新的更大块的内存用于存放其中元素,然后将元素从老内存区移动到新内存区,然后析构老内存区里的对象。
    • 这种方法使得push_back可以提供很强的异常安全保证:如果在复制元素期间抛出异常,std::vector状态保持不变
    • 在C++11中,一个很自然的优化就是将上述复制操作替换为移动操作,这会破坏push_back的异常安全保证

swap

  • swap函数是noexcept的另一个绝佳用地。
  • swap是STL算法实现的一个关键组件,它也常用于拷贝运算符重载中
  • 标准库的swap是否noexcept有时依赖于用户定义的swap是否noexcept

数组和std::pairswap声明如下

template <class T, size_t N>
void swap(T (&a)[N],
          T (&b)[N]) noexcept(noexcept(swap(*a, *b)));  //见下文

template <class T1, class T2>
struct pair {
    …
    void swap(pair& p) noexcept(noexcept(swap(first, p.first)) &&
                                noexcept(swap(second, p.second)));
    …
};

这些函数视情况noexcept:它们是否noexcept依赖于noexcept声明中的表达式是否noexcept

事实上交换高层次数据结构是否noexcept取决于它的构成部分的那些低层次数据结构是否noexcept


异常中立

  • 仅当你保证一个函数实现在长时间内不会抛出异常时才声明noexcept

  • 大多数函数都是异常中立(exception-neutral)的

    这些函数自己不抛异常,但是它们内部的调用可能抛出异常

    异常中立函数允许那些抛出异常的函数在调用链上更进一步直到遇到异常处理程序,而不是就地终止。

  • 异常中立函数决不应该声明为noexcept

  • 为了noexcept而扭曲函数实现来达成目的是本末倒置

    为了讨好调用者隐藏了这个(比如捕获所有异常,然后替换为状态码或者特殊返回值),这不仅会使你的函数实现变得复杂,还会让调用点的代码变得复杂

    调用者可能不得不检查状态码或特殊返回值

  • 一些函数,使其成为noexcept是很重要的

    在C++98,允许内存释放(memory deallocation)函数(即operator deleteoperator delete[])和析构函数抛出异常是糟糕的代码设计

    C++11,默认情况下,内存释放函数和析构函数——不管是用户定义的还是编译器生成的——都是隐式noexcept

    [!tip]

    析构函数非隐式noexcept的情况:

    仅当类的数据成员(包括继承的成员还有继承成员内的数据成员)明确声明它的析构函数可能抛出异常(如声明“noexcept(false)”)


宽泛契约(wild contracts)和严格契约(narrow contracts

  • 有宽泛契约的函数没有前置条件

    这种函数不管程序状态如何都能调用,它对调用者传来的实参不设约束

    “不管程序状态如何”和“不设约束”对已经行为未定义的程序无效:

    宽泛契约的函数决不表现出未定义行为。

  • 没有宽泛契约的函数就有严格契约

    这些函数,如果违反前置条件,结果将会是未定义的。

  • 区分严格/宽泛契约库设计者一般会将noexcept留给宽泛契约函数


总结

[!note]

  • noexcept函数接口的一部分,这意味着调用者可能会依赖它
  • noexcept函数较之于non-noexcept函数更容易优化
  • noexcept对于移动语义,swap,内存释放函数和析构函数非常有用
  • 大多数函数是异常中立的(译注:可能抛也可能不抛异常)而不是noexcept

Item 15: Use constexpr whenever possible

constexpr

  • 当用于对象上面,constexpr本质上就是const的加强形式

  • 从概念上来说,constexpr表明一个值不仅仅是常量,还是编译期可知的。

    你不能假设constexpr函数的结果是const,也不能保证它们的(译注:返回)值是在编译期可知的。

  • 关于constexpr函数返回的结果不需要是const,也不需要编译期可知这一点是良好的行为!


constexpr对象

  • 这些constexpr对象,实际上,和const一样,它们是编译期可知的。

    技术上来讲,它们的值在翻译期(translation)决议,所谓翻译不仅仅包含是编译(compilation)也包含链接(linking)

  • 编译期可知的值“享有特权”,它们可能被存放到只读存储空间中。

    “其值编译期可知”的常量整数会出现在需要“整型常量表达式(integral constant expression)的上下文中

    包括数组大小,整数模板参数(包括std::array对象的长度),枚举名的值,对齐修饰符(译注:alignas(val)),等等

    int sz;                             //non-constexpr变量constexpr auto arraySize1 = sz;     //错误!sz的值在
                                        //编译期不可知
    std::array<int, sz> data1;          //错误!一样的问题
    constexpr auto arraySize2 = 10;     //没问题,10是
                                        //编译期可知常量
    std::array<int, arraySize2> data2;  //没问题, arraySize2是constexpr
    
  • 所有constexpr对象都是const,但不是所有const对象都是constexpr

    想编译器保证一个变量有一个值可以放到那些需要编译期常量(compile-time constants)的上下文的地方,你需要的工具是constexpr而不是const


constexpr函数

  • 如果实参是编译期常量,这些函数将产出编译期常量

  • 如果实参是运行时才能知道的值,它们就将产出运行时值

    1. constexpr函数可以用于需求编译期常量的上下文。如果你传给constexpr函数的实参在编译期可知,那么结果将在编译期计算。
    2. 当一个constexpr函数被一个或者多个编译期不可知值调用时,它就像普通函数一样,运行时计算它的结果。
  • pow

    1. 存所有实验结果的所有组合需要足够存放3n个值的数据结构。假设每个结果都是int并且n是编译期已知的(或者可以被计算出的)
    2. 我们需要一个方法在编译期计算3n,但是这里有两个问题
      1. std::pow是为浮点类型设计的,我们需要整型结果。
      2. std::pow不是constexpr(即,不保证使用编译期可知值调用而得到编译期可知的结果),所以我们不能用它作为std::array的大小
    constexpr                                   //pow是绝不抛异常的
    int pow(int base, int exp) noexcept         //constexpr函数
    {
     …                                          //实现在下面
    }
    constexpr auto numConds = 5;                //(上面例子中)条件的个数
    std::array<int, pow(3, numConds)> results;  //结果有3^numConds个元素
    

    pow不止可以用于像std::array的大小这种需要编译期常量的地方,它也可以用于运行时环境


    constexpr函数限制

    • C++11中,constexpr函数的代码不超过一行语句:一个return

      有两个技巧可以扩展constexpr函数的表达能力

      1. 使用三元运算符“?:”来代替if-else语句
      2. 使用递归代替循环
      constexpr int pow(int base, int exp) noexcept
      {
          return (exp == 0 ? 1 : base * pow(base, exp - 1));
      }
      
    • 在C++14中,constexpr函数的限制变得非常宽松

      constexpr int pow(int base, int exp) noexcept   //C++14
      {
          auto result = 1;
          for (int i = 0; i < exp; ++i) result *= base;
      
          return result;
      }
      
    • constexpr函数限制为只能获取和返回字面值类型

      在C++11中,除了void外的所有内置类型,以及一些用户定义类型都可以是字面值类型,因为构造函数和其他成员函数可能是constexpr

      class Point {
      public:
          constexpr Point(double xVal = 0, double yVal = 0) noexcept
          : x(xVal), y(yVal)
          {}
      
          constexpr double xValue() const noexcept { return x; } 
          constexpr double yValue() const noexcept { return y; }
      
          void setX(double newX) noexcept { x = newX; }
          void setY(double newY) noexcept { y = newY; }
      
      private:
          double x, y;
      };
      

      类似的,xValueyValuegetter(取值器)函数也能是constexpr,这使得我们可以写一个constexpr函数,里面调用Pointgetter并初始化constexpr的对象:

      constexpr
      Point midpoint(const Point& p1, const Point& p2) noexcept
      {
          return { (p1.xValue() + p2.xValue()) / 2,   //调用constexpr
                   (p1.yValue() + p2.yValue()) / 2 }; //成员函数
      }
      constexpr auto mid = midpoint(p1, p2);      //使用constexpr函数的结果
                                                  //初始化constexpr对象
      

      意味着以前相对严格的编译期完成的工作和运行时完成的工作的界限变得模糊,一些传统上在运行时的计算过程能并入编译时。越多这样的代码并入,你的程序就越快。(然而,编译会花费更长时间)

      [!warning]

      在C++11中,有两个限制使得Point的成员函数setXsetY不能声明为constexpr

      1. 它们修改它们操作的对象的状态, 并且在C++11中,constexpr成员函数是隐式的const
      2. void类型不是C++11中的字面值类型。

总结

[!note]

  • constexpr对象是const,它被在编译期可知的值初始化
  • 当传递编译期可知的值时,constexpr函数可以产出编译期可知的结果
  • constexpr对象和函数可以使用的范围比non-constexpr对象和函数要大
  • constexpr是对象和函数接口的一部分

Item 16: Make const member functions thread safe

const成员函数

class Polynomial {
public:
    using RootsType =           //数据结构保存多项式为零的值
          std::vector<double>;  //(“using” 的信息查看条款9)RootsType roots() const;
    …
};
  • 这样的一个函数它不会更改多项被声明为const函数。

  • 缓存多项式的根,然后实现roots来返回缓存的值

    class Polynomial {
    public:
        using RootsType = std::vector<double>;
    
        RootsType roots() const
        {
            if (!rootsAreValid) {               //如果缓存不可用//计算根
                                                //用rootVals存储它们
                rootsAreValid = true;
            }
    
            return rootVals;
        }
    
    private:
        //mutable的经典使用样例,在被const修饰的函数里面也能被修改。
        mutable bool rootsAreValid{ false };    //初始化器(initializer)的
        mutable RootsType rootVals{};           //更多信息请查看条款7
    };
    

    [!warning]

    mutable关键词

    1. mutable只能作用在类成员上,指示其数据总是可变的。
    2. const修饰的方法(常成员函数)中,mutable修饰的成员数据可以发生改变
  • 假设现在有两个线程同时调用Polynomial对象的roots方法:

Polynomial p;
…

/*------ Thread 1 ------*/      /*-------- Thread 2 --------*/
auto rootsOfp = p.roots();      auto valsGivingZero = p.roots();

这些线程中的一个或两个可能尝试修改成员变量rootsAreValidrootVals

没有同步的情况下,这些代码会有不同的线程读写相同的内存,这就是数据竞争(data race)的定义

问题就是roots被声明为const,但不是线程安全的。

  • 解决这个问题最普遍简单的方法就是——使用mutex(互斥量):
class Polynomial {
public:
    using RootsType = std::vector<double>;

    RootsType roots() const
    {
        //自动管理互斥锁的机制,确保互斥锁在作用域结束时自动释放
        std::lock_guard<std::mutex> g(m);       //锁定互斥量

        if (!rootsAreValid) {                   //如果缓存无效//计算/存储根值
            rootsAreValid = true;
        }

        return rootsVals;
    }                                           //解锁互斥量

private:
    mutable std::mutex m;
    mutable bool rootsAreValid { false };
    mutable RootsType rootsVals {};
};
  • std::mutex 既不可移动,也不可复制。因而包含他们的类也同时是不可移动和不可复制的。
  • 如果你所做的只是计算成员函数被调用了多少次,使用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<unsigned> callCount{ 0 };
        double x, y;
    };
    

    实际上 std::atomic 既不可移动,也不可复制

    因为对std::atomic变量的操作通常比互斥量的获取和释放的消耗更小,所以你可能会过度倾向与依赖std::atomic

  • 在一个类中,缓存一个开销昂贵的int,你就会尝试使用一对std::atomic变量而不是互斥量。

    class Widget {
    public:
        …
        int magicValue() const
        {
            if (cacheValid) return cachedValue;
            else {
                auto val1 = expensiveComputation1();
                auto val2 = expensiveComputation2();
                cachedValue = val1 + val2;              //第一步
                cacheValid = true;                      //第二步
                return cachedValid;
            }
        }
    
    private:
        mutable std::atomic<bool> cacheValid{ false };
        mutable std::atomic<int> cachedValue;
    };
    

    难以避免有时出现重复计算的情况:

    1. 一个线程调用Widget::magicValue,将cacheValid视为false,执行这两个昂贵的计算,并将它们的和分配给cachedValue
    2. 第二个线程调用Widget::magicValue,也将cacheValid视为false,因此执行刚才完成的第一个线程相同的计算。(这里的“第二个线程”实际上可能是其他几个线程。)

    3. cachedValueCacheValid的赋值顺序交换可以解决这个问题,但结果会更糟:

    class Widget {
    public:
        …
        int magicValue() const
        {
            if (cacheValid) return cachedValue;
            else {
                auto val1 = expensiveComputation1();
                auto val2 = expensiveComputation2();
                cacheValid = true;                      //第一步
                return cachedValue = val1 + val2;       //第二步
            }
        }
        …
    }
    
    1. 一个线程调用Widget::magicValue,刚执行完将cacheValid设置true的语句。
    2. 在这时,第二个线程调用Widget::magicValue,检查cacheValid。看到它是true,就返回cacheValue,即使第一个线程还没有给它赋值。因此返回的值是不正确的。

    [!warning]

    对于需要同步的是单个的变量或者内存位置,使用std::atomic就足够了。

    一旦你需要对两个以上的变量或内存位置作为一个单元来操作的话,就应该使用互斥量

    所以对于Widget::magicValue是这样的:

    class Widget {
    public:
        …
        int magicValue() const
        {
            std::lock_guard<std::mutex> guard(m);   //锁定m
    
            if (cacheValid) return cachedValue;
            else {
                auto val1 = expensiveComputation1();
                auto val2 = expensiveComputation2();
                cachedValue = val1 + val2;
                cacheValid = true;
                return cachedValue;
            }
        }                                           //解锁mprivate:
        mutable std::mutex m;
        mutable int cachedValue;                    //不再用atomic
        mutable bool cacheValid{ false };           //不再用atomic
    };
    
  • const成员函数应支持并发执行,这就是为什么你应该确保const成员函数是线程安全的。


总结

[!note]

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

Item 17: Understand special member function generation

特殊成员函数

  • 特殊成员函数是指C++自己生成的函数

  • C++98有四个:默认构造函数,析构函数,拷贝构造函数,拷贝赋值运算符

  • 这些函数仅在需要的时候才生成,比如某个代码使用它们但是它们没有在类中明确声明

  • 特殊成员函数的默认特性

    • 隐式public且inline:编译器生成的特殊成员函数默认是公开的(public)并且是内联的(inline),这意味着它们可以在类定义中直接定义,而不需要在类外单独定义。
    • 非虚:这些函数默认是非虚的(non-virtual),即它们不会参与多态。
    • 特殊情况:虚析构函数,派生类继承了有虚析构函数的基类。在这种情况下,编译器为派生类生成的析构函数是虚的。

移动构造函数和移动赋值运算符

  • C++11两个新的特殊成员函数:移动构造函数和移动赋值运算符

    class Widget {
    public:
        …
        Widget(Widget&& rhs);               //移动构造函数
        Widget& operator=(Widget&& rhs);    //移动赋值运算符
        …
    };
    

    不可移动类型(即对移动操作没有特殊支持的类型,比如大部分C++98传统类)使用“移动”操作实际上执行的是拷贝操作

    逐成员移动的核心是对对象使用std::move

    支持移动就会逐成员移动类成员和基类成员,如果不支持移动就执行拷贝操作

  • 生成默认移动构造或者赋值函数的精确条件与拷贝操作的条件有点不同。

  1. 两个拷贝操作是独立的:声明一个不会限制编译器生成另一个。
  2. 两个移动操作不是相互独立的。如果你声明了其中一个,编译器就不再生成另一个。
  3. 如果一个类显式声明了拷贝操作,编译器就不会生成移动操作
  4. 同样,声明移动操作(构造或赋值)使得编译器禁用拷贝操作。

Rule of Three

  • 如果你声明了拷贝构造函数,拷贝赋值运算符,或者析构函数三者之一,你应该也声明其余两个。

  • 用户接管拷贝操作的需求几乎都是因为该类会做其他资源的管理

    1. 无论哪种资源管理如果在一个拷贝操作内完成,也应该在另一个拷贝操作内完成
    2. 类的析构函数也需要参与资源的管理(通常是释放)。通常要管理的资源是内存
  • 只要出现用户定义的析构函数就意味着简单的逐成员拷贝操作不适用于该类。

    如果一个类声明了析构,拷贝操作可能不应该自动生成,因为它们做的事情可能是错误的

  • Rule of Three规则背后的解释依然有效,再加上对声明拷贝操作阻止移动操作隐式生成的观察

    C++11不会为那些有用户定义的析构函数的类生成移动操作。

    所以仅当下面条件成立时才会生成移动操作(当需要时):

    • 类中没有拷贝操作
    • 类中没有移动操作
    • 类中没有用户定义的析构
  • 类似的规则也会扩展至拷贝操作上面

    如果你的某个声明了析构或者拷贝的类依赖自动生成的拷贝操作,你应该考虑升级这些类,消除依赖。

    假设编译器生成的函数行为是正确的(即逐成员拷贝类non-static数据是你期望的行为),C++11的= default就可以:

    class Widget {
        public:
        … 
        ~Widget();                              //用户声明的析构函数//默认拷贝构造函数
        Widget(const Widget&) = default;        //的行为还可以
    
        Widget&                                 //默认拷贝赋值运算符
            operator=(const Widget&) = default; //的行为还可以
        … 
    };
    
  • 除非类继承了一个已经是virtual的析构函数,否则要想析构函数为虚函数的唯一方法就是加上virtual关键字。

    用户声明的析构函数会抑制编译器生成移动操作,所以如果该类需要具有移动性,就为移动操作加上= default

    声明移动会抑制拷贝生成,所以如果拷贝性也需要支持,再为拷贝操作加上= default

    class Base {
    public:
        virtual ~Base() = default;              //使析构函数virtual
    
        Base(Base&&) = default;                 //支持移动
        Base& operator=(Base&&) = default;
    
        Base(const Base&) = default;            //支持拷贝
        Base& operator=(const Base&) = default;
        … 
    };
    

    应该手动声明它们然后加上= default,让你的意图更明确


C++11对于特殊成员函数处理的规则

[!tip]

  • 默认构造函数:和C++98规则相同。仅当类不存在用户声明的构造函数时才自动生成。
  • 析构函数:基本上和C++98相同;稍微不同的是现在析构默认noexcept(参见Item14)。和C++98一样,仅当基类析构为虚函数时该类析构才为虚函数。
  • 拷贝构造函数:和C++98运行时行为一样:逐成员拷贝non-static数据。仅当类没有用户定义的拷贝构造时才生成。如果类声明了移动操作它就是delete的。当用户声明了拷贝赋值或者析构,该函数自动生成已被废弃。
  • 拷贝赋值运算符:和C++98运行时行为一样:逐成员拷贝赋值non-static数据。仅当类没有用户定义的拷贝赋值时才生成。如果类声明了移动操作它就是delete的。当用户声明了拷贝构造或者析构,该函数自动生成已被废弃。
  • 移动构造函数移动赋值运算符:都对非static数据执行逐成员移动。仅当类没有用户定义的拷贝操作,移动操作或析构时才自动生成。
  • 注意没有“成员函数模版阻止编译器生成特殊成员函数”的规则

    class Widget {
        …
        template<typename T>                //从任何东西构造Widget
        Widget(const T& rhs);
    
        template<typename T>                //从任何东西赋值给Widget
        Widget& operator=(const T& rhs);
        …
    };
    

    编译器仍会生成移动和拷贝操作(假设正常生成它们的条件满足),即使可以模板实例化产出拷贝构造和拷贝赋值运算符的函数签名。


总结

[!note]

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

©OZY all right reserved该文件修订时间: 2025-09-20 05:42:10

评论区 - CHAPTER_3_Moving_to_Modern_C++

results matching ""

    No results matching ""