Item 17: Understand special member function generation

C++ 的官方说法中,特殊成员函数是指C++ 会自动生成的那些成员函数。C++ 98中,这样的函数有四个:默认构造函数、析构函数、拷贝构造函数和拷贝赋值运算符。这些函数仅会在需要时才会生成(即,某些代码使用了它们,它们却没有被显示的声明)。仅当类没有声明任何构造函数,才会自动生成默认构造函数(只要指定了一个要求传参的构造函数,就会阻止编译期自动生成默认构造函数)。这些被自动生成的特殊成员函数都是public、inline且nonvirtual的,但是对于析构函数,如果在一个继承体系中,基类的析构函数被声明为virtual的,那么继承类中自动生成的析构函数也是virtual的。

时代在变化,C++ 也在进步,特殊的成员函数生成规则也在变化。因此,想要编码高效的C++ 程序,知道编译器何时悄无声息的在你的类中插入这些特殊的成员函数是非常重要的。

从C++ 11开始,特殊成员函数又多了两个:移动构造函数和移动赋值运算符。函数签名如下:

class Widget {
public:Widget(Widget&& rhs); // move constructor
    Widget& operator=(Widget&& rhs); // move assignment operator};

这两种特殊成员函数的生成规则和行为表现与它们的拷贝版本(拷贝构造函数和拷贝赋值运算符)相似。移动操作也仅在需要时生成,并且一旦生成,它们也是在非静态成员上执行“按成员移动”操作。也就是说:1,移动构造函数将根据其形参rhs的各个非静态成员对本类的对应成员执行移动构造操作;2,移动赋值运算符将根据其形参rhs的各个非静态成员对本类的对应成员执行移动赋值操作。移动构造函数/移动赋值运算符同时还会移动构造/移动赋值他的基类部分(如果有的话)。

但是,在类成员或者基类部分的移动操作(move-constructing or move-assigning),并不能保证真的发生。实际上,“按成员移动”更像是按成员的移动请求,因为有些不可移动的类型(即,并未为移动操作提供特殊支持的类型,包括了大多数c++ 98中遗留的类)将通过它们的拷贝操作实现“移动”。每个成员“移动”的核心是对要移动的对象应用std::move,其返回值被用于函数重载决议,并最终决定是执行移动操作还是拷贝操作,Item 23将会详细介绍这一过程。在本条款中,只需记住,按成员移动,会在支持移动操作的成员上执行移动操作,如果不支持移动操作就会执行拷贝操作。

笼统的说,与对应的拷贝操作一样,如果我们自己声明了移动操作,则编译器就不会再为我们自动生成了。但是,移动构造函数和移动赋值运算符的生成条件还是与赋值类生成条件略有不同。

  • 拷贝操作(拷贝构造函数和拷贝赋值运算符)的生成是各自独立的:显示声明其中一个不会影响编译器生成另一个。假设你声明了一个拷贝构造函数,而没有声明拷贝赋值运算符,当你写出需要拷贝赋值运算符的代码时,编译器将为你生成一个拷贝赋值运算符。同样地,如果你显示声明了一个拷贝赋值运算符,而没有声明拷贝构造函数,当你写出需要拷贝构造函数的代码时,编译器也会为你生成一个拷贝构造函数。这一点,在C++ 98和C++ 11中都是成立的。
  • 移动操作(移动构造函数和移动赋值运算符)的生成却不是彼此独立的:显示声明了其中一个,就会阻止编译器生成另外一个。其底层逻辑在于,假设你声明了一个移动构造函数,你实际上是想表明这个声明的移动构造函数所要进行的移动操作与编译器生成的默认按成员移动的移动构造函数多少有些不同。而如果按成员移动的移动构造函数有不妥的地方,那么按成员移动的移动赋值运算符大概率也是不妥的。基于这个逻辑依据,当声明移动构造函数时(move constructor),会阻止编译器生成移动赋值运算符(move assignment operator),而声明一个移动赋值运算符(move assignment operator)时会阻止编译器生成移动构造函数(move constructor)。
  • 此外,任何一个类显示声明了拷贝操作,都不会自动生成移动操作了。这个判断基于如果显示声明了一个拷贝操作(拷贝构造或者拷贝赋值),则表明对象的常规拷贝(按成员拷贝)不适用于该类。编译器进而判定,既然按成员拷贝不适用于拷贝操作,那么按成员移动很可能也不适用于移动操作。
  • 反之亦然,如果一个类声明了移动操作(construction or assignment),编译器就会废除拷贝操作(废除的方式就是delete它们,见Item 11)。毕竟如果按成员移动不是对象认为合适的方式,也就没有理由期望按成员拷贝是对象认为合适的拷贝方式。乍听起来,这种规定可能会破坏C++ 98的代码,因为C++ 11中生成拷贝操作的限制条件更多了,但实际情况并非如此。C++ 98中没有移动操作,因为C++ 98中就根本没有可“移动”的对象。唯一往遗留代码的类中添加用户声明的移动操作的可能,就是它要升级为C++ 11代码,而若要对一个类加以修改以享用移动语义的好处,它们就得遵从C++ 11的特殊成员函数的生成机制了。

也许你听过一条叫大三律的指导原则(Rule of Three)。它是指,如果你显示声明了拷贝构造、拷贝赋值或者析构中的任何一个,你就应该同时显示声明另外两个。这产生于这样一种观察:接管一个拷贝操作的诉求,几乎总是源自这个类想要执行一些资源管理的工作,而这也就意味着:(1) 在任何一个拷贝操作中进行资源管理,则在另外的拷贝操作中也极有可能需要资源管理工作; (2) 析构函数也将参与资源的管理(通常是释放资源)。需要管理的典型资源就是内存,这就是为什么所有管理内存的标准库类(例如执行动态内存管理的STL容器)都声明了“三大”:两个拷贝操作和析构函数。

大三律的推论是,如果存在用户自己声明的析构函数,则普通的按成员拷贝也不适用于拷贝操作,也就是说,如果声明了析构函数,就不应该再自动生成拷贝操作了。但是在C++ 98标准被接受的时代,这样的论证没有得到充分的重视,所以在C++ 98中,即使用户声明了析构函数,也不会影响编译器自动生成拷贝操作。这种情况在C++ 11中仍然得到了保持,原因仅仅在于,如果像这样对拷贝操作施加严格的限制,就会破坏太多的遗留代码。

大三律背后的理由仍然有效,再结合声明拷贝操作就会阻止隐式生成移动操作的事实,就促使C++ 11中的这样一个规定:只要用户声明了析构函数,就不会隐式的自动生成移动操作。

这么一来,一个类想要隐式的自动生成移动操作,仅在以下三个条件同时满足时:

  • 该类未显示地声明任何拷贝操作(拷贝构造或拷贝赋值运算符);
  • 该类未显示地声明任何移动操作;
  • 该类未显示地声明析构函数;

总有一天,这样的机制也会延申到拷贝操作中,因为C++ 11标准已经废弃掉了在已经存在拷贝操作或析构函数的条件下,仍然可以自动生成拷贝操作的规定。这意味着,如果你有一些代码在已经存在任一拷贝操作或析构函数的条件下,仍然依赖拷贝操作自动生成的话,你就得考虑升级这些类,以消除这样的依赖。假定编译器生成这些函数有着正确的行为(即,按成员拷贝类的非静态成员正是你想要的行为),那事情就简单了,因为C++ 11可以通过“= default”来显示的表达这个想法:

class Widget {
public:~Widget();                          // user-declared dtor// default copy ctor
    Widget(const Widget&) = default;    // behavior is OK
    
                                                // default copy assign
    Widget& operator=(const Widget&) = default; // behavior is OK};

这种方法在多态基类中通常很有用。多态基类通常拥有虚基类,因为如果不这样,一些操作(例如,通过基类指针或引用对派生类对象使用delete或typeid)将产生未定义或误导性的结果。除非基类的析构函数已经是虚的,否则使继承类中的析构函数为虚的唯一方法就是显式声明析构函数为virtual。通常,虚析构函数的默认实现就是正确的,而“= default”则是表示这一点的很好方式。然而,一旦用户显示声明了析构函数,移动操作就不会自动生成了,而如果可移动性事能够支持的,加上“default”就能够再次让编译器自动生成移动操作。又因为声明了移动操作会废除拷贝操作,所以如果还要可拷贝性,就再加一轮“default”来完成工作:

class Base {
public:
    virtual ~Base() = default;           // make dtor virtual
    
    Base(Base&&) = default;              // support moving
    Base& operator=(Base&&) = default;
    
    Base(const Base&) = default;         // support copying
    Base& operator=(const Base&) = default;};

事实上,即使编译器能够为类自动生成拷贝和移动操作,并且这些生成的函数也能满足你的需求,但你最好还是自己声明这些函数并且应用“default”作为它们的定义。这也许会多花些功夫,但它使您的意图更明确,并可以帮助您避开一些相当微妙的bug。假设你又一个表示字符串表格的类(即一个允许通过整数ID快速查找字符串值的数据结构):

class StringTable {
public:
    StringTable() {}// functions for insertion, erasure, lookup,
                        // etc., but no copy/move/dtor functionality
private:
    std::map<int, std::string> values;
};

该类没有声明拷贝操作、移动操作和析构函数。当然,编译器将在需要这些函数的时候自动生成它们。

假设过了一段时间,你决定在默认构造和析构函数中记录日志:

class StringTable {
public:
    StringTable()
    { makeLogEntry("Creating StringTable object"); }   // added
    
    ~StringTable()                                     // also
    { makeLogEntry("Destroying StringTable object"); } // added// other funcs as before

private:
    std::map<int, std::string> values;  // as before
};

这看起来很合理,但是显示声明析构函数会产生一个明显的副作用:它会阻止移动操作的生成。但是拷贝操作不受影响。更改后的代码很可能可以编译、运行并通过功能测试。就连针对它的移动功能测试也会通过,因为即使这个类不再支持移动,移动它的请求也会通过编译和运行。正如前面所讲,移动请求拷贝操作来完成。这意味着,那些“移动”StringTable对象的代码,实际上执行的是StringTable对象的赋值,也就是类里面的std::map<int, std::string>对象的拷贝。而std::map<int, std::string>对象的拷贝操作,可能比其移动操作慢上几个数量级。如此一来,仅仅是添加了一个析构函数,就引起了不小的性能问题!但如果拷贝和移动操作都显示的用“=default”定义,这个问题就根本不会出现。
所以,这其实给了我们一个指导性建议:如果类中的成员本身支持移动操作,而我们自定义的类如果也可能需要支持移动操作,那么最好用“=default”定义默认的移动行为
我们一直在讨论C++ 11中的拷贝操作和移动操作生成的机制,但是一直没有提默认构造函数和析构函数。其实是因为这两个函数的生成规则与C++ 98中的规则是一样的,没有任何改变。

C++ 11中,特殊成员函数的生成规则概括如下:

  • 默认构造函数(Default constructor):与C++98机制一样,仅当类中不包含用户自己声明的构造函数时才生成;
  • 析构函数(Destructor):与C++ 98机制一样,仅当基类的析构函数为虚的,派生类的析构函数才是虚的。另外,比C++ 98中多了一条,就是析构函数默认是noexcept的(Item 14)
  • 拷贝构造函数(Copy constructor):与c++ 98相同的运行时行为:默认对非静态数据成员按成员进行拷贝构造。仅当类中不含用户自己声明的拷贝构造函数时才自动生成。如果该类声明了移动操作,则拷贝构造函数将被删除。在具有用户声明的拷贝赋值运算符或析构函数的条件下,任然可以生成拷贝构造函数的行为已经被废弃了。
  • 拷贝赋值运算符(Copy assignment operator):与c++ 98相同的运行时行为:默认对非静态数据成员按成员进行拷贝赋值。仅当类中不含用户自己声明的拷贝赋值运算符时才自动生成。如果该类声明了移动操作,则拷贝赋值运算符将被删除。在具有用户声明的拷贝构造函数或析构函数的条件下,任然可以生成拷贝赋值运算符的行为已经被废弃了。
  • 移动构造函数和移动赋值运算符(Move constructor and move assignment operator):都按成员进行非静态数据成员的移动操作。仅当类中不含用户自己声明的拷贝操作、移动操作和析构函数时才能自动生成。

注意,这些规则中,从来没说成员函数模板的存在会阻止编译器生成任何特殊的成员函数。这意味着,如下的代码:

class Widget {template<typename T>         // construct Widget
    Widget(const T& rhs);        // from anything
    
    template<typename T>              // assign Widget
    Widget& operator=(const T& rhs);  // from anything};

编译器仍然会为Widget生成拷贝和移动操作(假设控制它们生成的通常条件已经满足),尽管可以实例化这些模板来为拷贝构造函数和生成签名。(当T是Widget时就是这样)。Item 26将会介绍一个特殊场景。

Things to Remember

  • 特殊成员函数是指编译器可以自动生成的:默认构造函数、析构函数、拷贝构造函数、拷贝赋值运算符、移动构造函数和移动赋值运算符;
  • 移动操作仅当类中不包含用户显示声明的拷贝操作、移动操作和析构函数才自动生成;
  • 拷贝构造函数仅当类中不包含用户显示声明的拷贝构造函数时才自动生成,如果该类中声明了移动操作,则拷贝构造函数将被删除。拷贝赋值运算符仅当类中不包含用户显示声明的拷贝赋值运算符才自动生成,如果该类中声明了移动操作,则拷贝赋值运算符将被删除。在已经存在显示声明的析构函数的条件下,自动生成拷贝操作的行为已被废弃了;
  • 成员函数模板在任何情况下都不会阻止特殊成员函数的生成;
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值