Effective Modern C++ 条款17 理解特殊成员函数的生成

理解特殊成员函数的生成

在C++的官方说法中,有一条是C++愿意自己生成特殊成员函数(special member functions)。在C++98,特殊成员函数有四个:默认构造函数,析构函数,拷贝构造函数,拷贝赋值运算符。这四个函数只有当它们被需要时才会自动生成,也就是一些代码使用了这些函数,但是用户类中没有声明它们。默认构造函数只有在类中没有声明一个构造函数时才生成。(当你声明了带参的构造函数时,这样可以防止编译器创建默认构造。)生成的特殊成员函数是隐式publicinline的,它们不是虚函数,除了一种例外:某个类继承了析构函数是虚函数的基类,那么这个类的析构函数是虚函数。

不过你已经知道这些东西了。是的,这已经有很悠久的历史了:Mesopotamia,the Shang dynasty,,FORTANT,C++98。不过时代变了,C++特殊成员函数产生的规则变了。理解新规则是很重要的,因为这些东西知道编译器什么时候会在类中默默插入函数。

在C++11,特殊成员函数多了两个:移动构造函数和移动赋值运算符。它们的前面是这样的:

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

它们的产生和行为与拷贝相像。移动操作会在需要它们时产生,它们表现的行为是把类中non-static成员变量“逐一移动”(memberwise move)。那意味着移动构造函数会以rhs类的每一个non-static成员变量作为参数进行移动构造,移动赋值操作符会以每一个non-static成员变量作为参数进行移动赋值。移动构造函数还会移动构造基类的部分(如果有的话),移动赋值操作符也移动基类的部分。

现在我想说,对于一个成员变量或者基类的移动操作,不敢保证移动真的会发生。实际上呢,“逐一移动”(memberwise move)更像是请求逐一移动,那些不是能够移动(move-enable)的类型(即一些不支持移动操作的类型,例如,大部分C++98的类)会借助它们的拷贝操作进行“移动”。每个逐一“移动”的内部都使用了std::move来移动需要移动的对象,结果呢,通过重载函数决策来决定std::move表示为拷贝还是移动。条款23会详解这个过程,在本条款呢,只需简单地记住:在移动操作中,如果成员变量和基类支持移动操作,那么就逐一移动它们,否则拷贝它们。

与拷贝操作一样,如果你声明了移动操作,编译器就不会帮你生成了。但是呢,它们的规则与拷贝操作又有点区别。

类中的两个拷贝操作是独立的:声明了其中一个不会阻止编译器生成另一个。所以,如果你声明了拷贝构造函数,但没有声明拷贝赋值运算符,然后写代码的时候需要拷贝赋值运算符,那么编译器会为你生成一个拷贝赋值运算符。同样地,如果你声明了拷贝移动运算符,没有声明拷贝构造函数,然后你的代码使用到拷贝构造函数,编译器会为你生成拷贝构造函数。这在C++98中是正确的,在C++11依然正确。

类中的两个移动操作不是独立的。如果你声明了其中一个,那会阻止编译器生成另一个。这里的根据是:如果你声明了一个移动构造函数,暗示着你的移动构造函数实现与编译器产生的默认逐一移动实现不同,那么如果逐一移动的构造函数是有问题的,那么逐一移动的赋值运算可能也有问题。所以声明了移动构造函数会阻止移动赋值运算符的生成,声明移动赋值也会阻止移动构造的生成。

而且,显式声明拷贝操作的类不能生成移动操作。正当的理由是:声明了拷贝操作(构造或赋值)暗示着正常的拷贝对象的方法(成员逐一拷贝)是不适合这个类的,然后编译器认为如果成员逐一拷贝不适合操作操作,成员逐一移动可能也不会适合移动操作。

反过来说吧。在类中声明一个移动操作(构造或赋值)会导致拷贝操作无法生成(拷贝操作会被delete,看条款11)。归根到底,如果成员逐一移动不是对象移动的合适方式,那么没有理由相信成员逐一拷贝是拷贝对象的合适方式。听起来这会破坏C++98的代码,因为C++11中使能拷贝操作的条件比C++98要苛刻,但并非如此。C++98没有移动操作,所以C++98中没有可移动对象。旧代码想要拥有用户声明的移动操作的唯一办法就是在C++11中添加它们,即为了使用移动语义,修改旧的类,那么这个类必须服从C++11的特殊成员函数生成的规则。

你可能听过三大法则的指导方针。三大法则规定:如果你声明了拷贝构造、拷贝复制、析构函数中的其中一个,你应该把这三个都声明。这是从观察中得到的:需要自定义拷贝构造通常是由于某种资源管理,这几乎暗示着(1)一个拷贝操作进行的资源管理操作在另一个拷贝操作也需要进行,(2)析构函数也需要参与资源管理(通常是释放资源)。通常需要管理的资源是内存,这也是为什么所有标准库中涉及资源管理的类(例如STL容器)都声明了“三大”:两个拷贝操作和一个析构函数。

三大法则的一条法则是:出现用户声明的析构函数,暗示着简单的成员逐一拷贝不适合类的拷贝操作。响应地,表明如果一个类声明了析构函数,拷贝操作不应该自动生成,因为生成的是不对的。在采用C++98的时候,这条法则不被完全接受,所以在C++98,用户声明的析构函数的存在不会影响到编译器自动生成拷贝操作。这情况在C++11仍然存在,因为如果改了会破坏大量旧代码。

三大法则的道理仍然是有效的,不过呢,因为声明了拷贝操作会阻止移动操作的生成,导致在C++11中,类中出现了用户声明的析构函数就不会生成移动操作。(本来应该是声明了析构就不会生成拷贝和移动,但是为了兼容旧代码,免除了拷贝。)

所以一个类生成移动操作(当要用时)需要满足以下3点:

  • 类中没有声明拷贝操作。
  • 类中没有声明移动操作。
  • 类中没有声明析构函数。

在某种意义上,类似的规则可以延伸到拷贝操作,因为C++11反对在一个声明了拷贝操作或析构函数的类中自动生成拷贝操作。这意味着如果你的代码中一个声明了析构函数或者某个拷贝操作的类还依赖编译器生成的拷贝操作,你应该考虑修改这个类来消除依赖。假如编译器生成的函数是正确的(即你想要的就是逐一拷贝non-static成员变量),你的工作就很简单啦,因为C++11的“=default”可以让你显示说明:

class Widget {
public:
    ...
    ~Widget();      // 用户声明的析构函数
    ...
    Widget(const Widget&) = default;    // 使用默认拷贝构造

    Widget& operator=(const Widget&) = default;  // 使用默认拷贝复制操作
    ...
};

这个方法在多态基类中很有用,即通过派生类来定义接口。多态基类通常有虚析构函数,如果不是这样,一些操作(例如,派生类对象通过基类指针或引用使用deletetypeid)会导致未定义或者误导的结果。让析构函数成为虚函数的唯一办法就是把它显式声明为虚函数,通常,默认的实现是正确的,然后“=default”是表达它的好办法。但是,用户声明的析构函数会抑制移动操作的生成,所以如果这个类支持移动,“=defalut”就可以用第二次啦。声明了移动操作就会使拷贝操作无效,所以如果该类是可拷贝的,多用一次“=default”就行:

class Base {
public:
    virtual ~Base() = default;   // 虚析构函数

    Base(Base&&) = default;     // 支持移动操作
    Base& operator(Base&&) = default;

    Base(const Base&) = default;    // 支持拷贝
    Base& operator(const Base&) = default;
    ...
};

事实上,如果你有个类想要用编译器生成的拷贝操作和移动操作,你可以像这样声明它们并使用“=default”定义它们。这好像有点多余,但这可以让你避免一些诡异的bug。例如,你有一个表示字符串表的类,即一个通过ID快速查询字符串的数据结构:

class StringTable {
public:
    StringTable() {}
    ...             // 插入,删除,查询函数,但是没有拷贝/移动/析构函数
private:
    std::map<int, std::string> values;
};

假定这个类没有声明拷贝操作,移动操作,析构函数,那么当需要它们的时候编译器会自动生成,这好方便呀。

不过在之后,它要在创建对象和析构对象时记录日志,这很有用,然后添加这些功能也是很容易的:

class StringTable {
public:
    StringTable()
    { makeLogEntry("Creating StringTable Object"); }  // 新添加

    ~StringTable()
    { makeLogEntry("Destroying StringTable Object"); }  // 新添加
    ...    // 如前
private:
    std::map<int, std::string> values;  // 如前
};

这看起来合情合理,但是声明了析构函数有个很大的副作用:阻止移动操作的生成。但是类的拷贝操作不受影响,因此这代码依旧可编译、可运行、可通过测试,这包括移动语义的测试,尽管这个类不再可移动,但是请求移动它依旧可以编译和运行。在本条款有讲到(移动内部使用std::move),这样的请求会进行拷贝操作,意味着代码“移动”StringTable对象实际上只是拷贝它,即拷贝内在的std::map<int, std::string>对象。拷贝std::map<int, std::string>对象可能会比移动它慢一个数量级,在类中添加析构函数这个小小的动作竟然会导致严重的性能问题!用“=default”显式定义拷贝和移动操作,这么问题就不会出现了。

现在呢,忍耐完我没完没了的废话——关于C++11管理拷贝和移动操作的规则,你可能想知道我什么时候才会讲另外两个特殊成员函数(默认构造函数,析构函数),嗯,就现在讲吧,但只有一句话,因为这两个成员函数几乎没有发生改变:C++11的规则基本和C++98的规则相同。

因此C++11管理特殊成员函数是这样的:

  • 默认构造函数:和C++98的规则相同,类中没有用户声明的构造函数才会生成。
  • 析构函数:本质上C++98的规则相同,唯一的区别就是析构函数默认声明为noexcept(看条款14)。C++98的规则是基类的析构函数的虚函数的话,生成的析构函数也是虚函数。
  • 拷贝构造函数:运行期间的行为和C++98一样:逐一拷贝构造non-static成员变量。只有在类中缺乏用户声明的拷贝构造时才会生成。如果类中声明了移动操作,拷贝构造会被删除(delete)。当类中存在用户声明的拷贝赋值操作符或析构函数时,反对生成拷贝构造函数。
  • 拷贝赋值运算符:运行期间的行为和C++98一样:逐一拷贝复制non-static成员变量。只有在类中缺乏用户声明的拷贝赋值运算符时才会生成。如果类中声明了移动操作,拷贝赋值运算符会被删除。当类中存在用户声明的拷贝构造函数或析构函数时,反对生成拷贝赋值运算符。
  • 移动构造函数和移动赋值运算符:每个都是逐一移动non-static成员变量。只有在类中没有用户声明的拷贝操作、移动操作、析构函数时才会自动生成。

请注意没有规则说明成员函数模板会阻止编译器生成特殊成员函数。这意味着如果Widget是这样的:

class Widget {
    ...
    template <typename T>  // 可以用任何对象构造Widget
    Widget(const T& rhs);   

    template <typename T>    // 可以把任何对象赋值给Widget
    Widget& operator=(const T& rhs);
    ...`
};

编译器还是会为Widget生成拷贝构造和拷贝赋值(假如条件满足),尽管这些模板可以被实例化来产生拷贝构造和拷贝赋值的签名(当T是Widget的时候)。你十有八九觉得这仅仅是值得了解的边缘情况,但我提到它是有原因的,在条款26中我会展示它导致的重大后果。

总结

需要记住的4点:

  • 特殊成员函数是编译器可自动生成的函数:默认构造函数,析构函数,拷贝操作,移动操作。
  • 移动操作只有在那些没有显式声明移动操作、拷贝操作、析构函数的类中生成。
  • 拷贝构造只有在那些没有显式声明拷贝构造的类中生成,如果类中声明了移动操作它就会被删除。拷贝复制操作符只有在那些没有显式声明拷贝操作运算符的类中生成,如果类中声明了移动操作它会被删除。在显式声明析构函数的类中生成拷贝操作是被反对的。
  • 成员函数模板从来不会抑制特殊成员函数的生成。
©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页