天天看点

Effective Modern C++ Item 17 理解特种成员函数的生成机制

在C++官方用于中,特种成员函数指的编译器会自动生成的成员函数。

C++98中的规则

C++98中提供了4种:

默认构造函数

析构函数

复制构造函数

复制赋值运算符

生成条件

:这些函数仅在需要时才会生成。通俗说来,某些代码中使用了它们,但是在类中并未显示声明的场合。

生成特点

:默认生成的都是

public

访问层级,并且是

inline

的,而且是非虚的。除非讨论的是一个析构函数,位于一个派生类中,并且基类的析构函数是个虚函数,在那种情况下,编译器为派生类生成的析构函数也是个虚函数,否则都是非虚的。

C++11中的规则

C++11中新增了两位新成员:

移动构造函数

移动赋值运算符

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

这两个特种成员函数生成规则和行为表现的和复制版本类似。移动操作也仅在需要时候才生成,一旦生成,他们执行的是作用于

非静态成员

按“成员移动”

操作。

意思是,移动构造/赋值函数将依照其形参rhs的各个非静态成员对本类的对应成员执行移动构造。移动构造函数同时还会移动构造它的基类部分(如果有的话)。

但是,上述移动操作并不保证一定真的发生。“按成员移动”实际上更贴切的说法是

按成员的移动请求

。因为对于不可移动的型别是通过复制操作实现的移动。每个按成员进行的“移动”操作,核心在于把

std::move

应用每一个移动源对象,其返回值被用于函数重载决议,最终决定移动还是复制。一句话概括就是:

对于可移动的移动,不可移动的复制

移动构造/复制构造的不同机制——移动相互抑制

如果发生了复制操作的情况下,移动操作就不会在已有声明的前提下被生成。也就是生成移动操作的精确条件,与复制操作有所不同:

  • 复制构造和复制赋值运算符是彼此独立的:声明了其中一个,并不会阻止编译器生成另一个。也就是如果只声明了复制构造/复制赋值运算符之一,但使用过程中用到了另一个,没声明的,编译器会帮你补全。
  • 移动构造和移动赋值操作并不独立:声明了其中一个,就会阻止编译器生成另一个。这种机制的理由在于:

    假如你声明了一个移动构造函数,你实际上表明移动操作的实现方式将与编译器生成的默认按成员移动的移动函数不一样。若是按成员进行移动构造操作有不合用之处的话,那么按成员移动赋值运算符极有可能不合用

    。所以声明一个移动构造会阻止编译器生成移动赋值,而声明一个移动赋值也会阻止编译器生成移动构造。

移动构造/复制构造的不同机制——复制/移动相互抑制

一旦显示声明了复制操作,这个类不会再生成移动操作。机制理由和上述类似。

并且一旦声明了移动操作(不管是移动构造还是移动赋值),编译器就会废除复制操作(废除的方式是删除他们)。机制理由和上述类似。

指导原则——大三律

大三律:如果你声明了复制构造,复制赋值运算符,析构函数中的任何一个,你就得同时声明所有这三个。

大三律的机制理由是这样的:

  • 如果有改写复制操作的需求,往往意味着该类需要执行某种资源管理。
    • 意味着在一种复制操作中进行的任何资源管理,极有可能在另一种中也需要改写。
    • 该类的析构函数也会参与到该资源的管理中。

需要市价管理的经典资源就是内存,这就是为什么标准库中用以管理内存的类都会遵从大三律,两种复制操作和析构函数都会声明全。

大三律的一个推论

  • 推论1
如果存在用户声明的析构函数,则平凡的按成员复制也不适用于该类。
  • 推论2
如果声明了析构函数,则复制操作就不该被自动生成,因为他们的行为不可能正确。

但以上推论在C++98的时候,并未被编译器广泛接受。所以在C++98中,用户声明的析构函数即使存在,也不会影响编译器生成复制操作的意愿。这一点由于C++11需要兼容C++98,所以还是保留下来了。不过,这并不是因为这条规则合理,而是出于兼容性的原因考虑。所以对于移动操作,就符合推论了。

所以在C++11中,对于移动操作的生成条件(如果需要生成)仅当以下三者同时成立:

  • 该类未声明任何复制操作
  • 该类未声明任何移动操作
  • 该类未声明任何析构函数

总有一天,这样的机制会延伸到复制操作,因为C++11标准规定了,在已经存在复制操作或者析构函数的条件下,仍然自动生成复制操作已经成为了被废弃的行为。假定编译器生成的这些函数有着正确的行为,那么事情就简单了,因为C++11可以通过“=default”来显示表达这个想法。例如:

class Widget {
public:
...
~Widget();                          //用户定义的析构函数
...
Widget(const Widget&) = default;    //默认复制构造函数的行为是正确的
Widget&                             //默认复制赋值运算符的行为是正确的
    operator=(const Widget&) = default;
...
};
           

default的经典使用场景

这种手法(

=default

)往往对于多态基类会很有用。

所谓多态基类,是指定义接口的类,而派生类的操作则通过这些接口来完成。

多态基类往往会有虚析构函数,因为若不然,有些操作(比如通过基类指针或者引用来对派生类执行的

delete

或者

typeid

等)就会产生未定义或者误导性的结果。在这种通常情况下,默认的实现就是正确的,而

=default

手法正是表达这一点的很好的方式。

但如果声明了析构,那么移动操作就被抑制了,那么又要default移动。声明移动又会抑制复制,又要default复制。那么大概的多态基类会是这样的类似写法:

class Base {
public:
    virtual ~Base() = default;      //使析构函数成为虚函数
    Base(Base&&) = default;         //提供移动操作的支持性
    Base& operator=(Base&&) = default;

    Base(const Base&) = default;    //提供复制操作的支持
    Base& operator=(const Base&) = default;
    ...
};
           

即使编译器能够自动生成,还是推荐用default写明。

其实即便是编译器能够在不写default的时候,自动生成对应函数,还是推荐写明,这样能够防止一些非常微妙的缺陷。举个栗子,假设有一个表示字符串表格的类,即允许通过整型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::map<int, std::string>

的复制操作可能比移动操作慢若干个数量级。这么一来,向类中加入一个析构函数的简单动作,就可能引发客观的性能问题!

但如果使用

=default

,就不会有问题了。

C++11中特种函数的机制

  • 默认构造函数:与C++98机制相同。仅当类中不包含用户声明的构造函数时,才生成。
  • 析构函数:与C++98机制基本相同,唯一的区别在于析构函数默认为noexcept。与C++98机制相同,仅当基类的析构函数为虚函数,派生类的析构函数才为虚的。
  • 复制构造函数:运行期行为与C++98相同:按成员进行非静态数据成员的复制构造。仅当类中不包含用户声明的

    复制构造函数

    时才生成。如果该类声明了移动操作,则

    复制构造函数

    将被删除。在已经存在

    复制赋值运算符

    或者析构函数的条件下,仍然生成

    复制构造函数

    已经成为被废弃的行为。
  • 复制赋值运算符:运行期行为与C++98相同:按成员进行非静态数据成员的复制构造。仅当类中不包含用户声明的

    复制赋值运算符

    时才生成。如果该类声明了移动操作,则

    复制赋值运算符

    将被删除。在已经存在

    复制构造函数

    或者析构函数的条件下,仍然生成

    复制赋值运算符

    已经成为被废弃的行为。
  • 移动构造函数和移动赋值运算符:都按成员进行非静态数据成员的移动操作。仅当类中不包含用户声明的

    复制操作

    移动操作

    析构函数

    时才生成。

特例:模板函数不会抑制编译器自动生成

class Widget {
    ...
    template<typename T>        //以任意型别构造Widget
    Widget(const T& rhs);

    template<typename T>        //以任意型别赋值Widget
    Widget& operator=(const T& rhs);
    ...
}
           

在上述代码中,编译器会始终生成Widget的复制和移动操作。即便这些模板具现结果生成了复制构造函数或者复制赋值运算符的签名。这一点特例将在

Item 26

进行展开,需要记住这一点多么重要。

要点速记
1. 特种成员函数是指编译器会自动生成的成员函数:默认构造函数、析构函数、复制操作、以及移动操作。
2. 移动操作仅当类中未包含显式声明的复制操作,移动操作和析构函数时才生成。
3. 复制构造函数仅当类中不包含用户显式声明的复制构造函数时才生成,如该类声明了移动操作,则复制构造函数将被删除。复制赋值运算符规则也一样。如果已经显式存在析构函数,生成复制操作已经是被废弃的行为。
4. 成员函数模板在任何情况下都不会抑制特种函数生成。

继续阅读