大家好!我是大聪明-PLUS

我们继续推出“C++ 深度解析”系列文章。本系列旨在尽可能详细地介绍各种语言特性,其中一些可能非常具体。本文是该系列的第九篇文章;之前的文章列表请参见第六部分。本系列面向具有一定 C++ 编程经验的程序员。本文重点介绍特殊的成员函数。

在 C++ 中,类/结构体/联合体实例(代码中用变量表示的对象)的生命周期始于初始化,即设置其初始状态。接下来,对象几乎总是会被复制,并执行赋值或等价的移动操作。这些操作对于函数、容器和算法的运行至关重要。对象生命周期的最后一步是销毁。C++ 设计了特殊的成员函数来支持这些操作。

特殊成员函数的一个重要特性是编译器参与了它们的实现。特殊成员函数的实现可以部分(在某些情况下甚至是全部)由编译器生成。此外,在许多情况下,程序员不会在代码中显式调用特殊成员函数;这些调用是由编译器在特定上下文中生成的。

那么,让我们尽可能详细地讨论一下特殊成员函数。

1. 声明和使用特殊成员函数

1.1 公告

共有六个特殊成员函数:默认构造函数、析构函数、复制构造函数、复制赋值运算符、移动构造函数和移动赋值运算符。后两个函数是在 C++11 中引入的。(有关移动语义的更多信息,请参阅 Scott Meyers 的著作 [Meyers]。)

特殊成员函数的名称和签名遵循一定的规则。首先,我们来看一个示例声明:

class Demo
{
public:
    Demo();  
    ~Demo(); 

    Demo(const Demo& src); 
    Demo& operator=(const Demo& src); 
                      
    Demo(Demo&& src) noexcept; 
    Demo& operator=(Demo&& src) noexcept; 
                        

};

构造函数与类名相同,析构函数以 `.` 为前缀~。构造函数和析构函数都没有返回值。默认构造函数和析构函数没有参数。对于赋值运算符,默认返回值是对赋值结果的引用(通常使用 `return` 指令return *this;)。这使得可以在单个指令中链式赋值,这也是内置赋值运算符的工作方式。对于复制成员函数,参数类型必须是对同一类常量的引用;对于移动函数,参数类型必须是右值引用。建议将移动成员函数声明为 ` noexcept.`;这样编译器可以更好地优化代码。

可以在类、结构体和联合体中声明特殊成员函数。联合体具有一些特殊特性,将在第 5 节单独讨论,因此第 2-4 节的内容主要适用于类和结构体。

1.2 调用上下文

特殊成员函数的使用方式与普通成员函数不同;在某些情况下,程序员不能在代码中显式调用它们;这些调用是由编译器在特定上下文中生成的。

1.2.1. 构造函数

编译器在实现变量声明语句或使用 `.` 时会调用构造函数(任何构造函数,而不仅仅是默认构造函数)new。编译器在实现按值传递函数参数以及实现函数返回值时会调用复制构造函数或移动构造函数。以下是一些示例:

Demo x{};             
Demo *p = new Demo(); 
Demo a[8];            
Demo y{x};            
Demo z{std::move(x)}; 

Demo Foo()
{
    Demo x;

    return x; 
}
1.2.2 赋值运算符

编译器使用赋值运算符来实现对象赋值。以下是一些示例:

Demo x, y, z;
x = y;            
z = std::move(x); 

要执行赋值操作,可以使用赋值运算符的函数式形式。在这种情况下,x=y使用表达式(函数式形式)代替表达式x.operator=(y)(中缀形式)。函数式形式可以解决使用中缀形式时出现的一些歧义,例如调用基类运算符。

注意赋值和初始化的区别:初始化发生在对象创建之时,而赋值则是应用于先前已创建并已初始化的对象。(由于有时也用符号表示初始化,因此可能会造成一些混淆=。)

1.2.3. 销毁器

当对象被销毁时,编译器会调用析构函数。对于在局部作用域中声明的对象,当变量超出作用域时,对象会被销毁。对于动态对象和动态数组元素,分别在调用delete`or`运算符时,对象会被销毁delete[]。对于静态类成员以及在全局作用域或命名空间中声明的对象,对象会在程序终止的特定阶段被销毁。在所有这些情况下,显式调用析构函数都是被禁止的。

一般来说,C++ 语法并不禁止显式调用析构函数;事实上,在某些情况下,这是必要的。例如,使用放置运算符new(`placement` new)初始化对象时,对象会被初始化到之前为其分配的内存中。这样的对象不能使用放置运算符销毁delete;要销毁它,必须显式调用析构函数,之后才能释放或重用分配的内存。

所有资源管理策略都基于析构函数的描述属性。

1.3 访问说明符

在上面的例子中,特殊成员函数被声明为 public( public)。然而,这通常并非必要;只要特殊成员函数在调用上下文中可访问即可。

例如,我们来考虑构造函数和析构函数的可访问性要求。如果变量是在全局作用域或命名空间作用域中声明的,则构造函数和析构函数必须是公共的。在类中,对于成员类型和基类,构造函数和析构函数必须在类上下文中可访问。对于局部变量,构造函数和析构函数必须在声明该变量的代码块中可访问。对于动态对象,构造函数必须在调用 `new` 运算符的上下文中可访问,析构函数必须在调用 `new`或 ` newnew` 运算符的上下文中可访问。deletedelete[]

如果您设计的类仅用作其他类的基类,您可以将特殊的成员函数声明为受保护的(protected),这样它们就可以在派生类的上下文中访问。这种解决方案能更好地体现设计意图,并防止误用。

如果您设计的类的实例仅动态创建,您还可以选择使用受保护的甚至私有的private特殊成员函数——它们的调用上下文必须在类的公共成员函数内部。以下是一个示例:

class X
{
protected:
    X();
    ~X();
public:
    static X* CreateInstance(){ return new X(); }
    void Delete() { delete this; }
};

X在这种情况下,不能在类上下文之外使用逗号运算符创建new或删除此类动态对象delete;必须使用相应的成员函数。此类解决方案通常用于开发所谓的接口类。

提醒一下,还有一个额外的访问控制特性:类可以声明友元friend函数和友元类。友元函数和友元类可以完全访问声明它们的类的所有成员和成员函数。

1.4. 虚拟会议公告

根据 C++ 规则,构造函数不能声明为虚函数。(你可能会遇到“虚构造函数”这个术语,但这只是某些设计模式的别称。)赋值运算符形式上可以声明为虚函数,但不建议这样做,因为赋值操作本身并非多态操作。然而,析构函数不仅可以声明为虚函数,而且在某些情况下应该这样做。如果正在设计一个多态类层次结构,则基类中的析构函数应该声明为虚函数。在这种情况下,该运算符delete将以多态的方式工作;可以传递指向基类的指针作为参数,并且仍然会调用该指针指向的对象的实际类型的析构函数。

2. 编译器生成特殊成员函数

特殊成员函数的一个重要特点是,在某些情况下,程序员可能不需要定义它们;它们将由编译器生成(编译器生成的成员函数)。

2.1. 已删除的特殊成员功能

首先,我们来介绍如何防止编译器生成特殊成员函数。C++11 引入了将函数或成员函数声明为已删除的功能。为此,请使用 `<deleted>` 限定符进行声明=delete。如果将特殊成员函数声明为已删除,编译器将不会生成该成员函数。以下是一个示例:

class X
{
public:
    X(const X&) = delete;
    X& operator=(const X&) = delete;
// ...
};

对于此类,将禁止生成复制构造函数和复制赋值运算符,因此,复制和赋值类实例将变得不可能。

在某些情况下,编译器会自动将某些成员函数声明为已删除。例如,如果类包含常量或引用类型的非静态成员,则复制赋值运算符和移动赋值运算符将被声明为已删除。其他示例如下所示。程序员显式定义或声明此类成员函数将覆盖其隐式的已删除声明。

2.2. 编译器生成特殊成员函数的条件

如果某个特殊成员函数既未被声明删除,也未被程序员定义,编译器在特定条件下可以自动生成该成员函数。编译器生成此类成员函数的主要条件是实际需要使用该函数,即存在需要调用该函数的上下文(参见 1.2 节)。

此外,还有一些附加条件取决于其他成员函数的声明。每个特殊成员函数的这些条件各不相同。以下是这些附加条件。

默认构造函数要求不能声明其他构造函数。

析构函数、复制构造函数或复制赋值运算符没有特殊条件。

对于移动构造函数,移动赋值运算符要求类中既不能声明复制构造函数,也不能声明复制赋值运算符,更不能声明析构函数。此外,它还要求移动构造函数和移动赋值运算符不能同时声明(也就是说,只能同时为这两个成员函数生成)。

如果满足这些条件,编译器将尝试生成这样的成员函数,但此尝试不一定成功,结果取决于成员和基类,有关更多详细信息,请参阅第 2.4 节。

如果成员函数生成成功,则该成员函数将可公开访问并可内联。如果生成失败,对于移动操作,将尝试将其替换为相应的复制操作;否则,将发生错误。

2.3 声明带有编译器生成实现的特殊成员函数

C++11 引入了一个实用特性:您可以声明一个特殊的成员函数而不提供其实现,并指定由编译器生成实现。为此,必须使用限定符 `@Generate` 声明该成员函数=default。编译器仍然会在实际使用该成员函数时尝试生成它,但不再需要满足 2.2 节中的附加条件。生成尝试并非总是成功;更多详情请参见 2.4 节。

如果成员函数可以由编译器生成,则这种声明不是强制性的,但在某些情况下它很有用:它可以提高代码的清晰度,并且可以指定访问说明符protected(参见第 1.3 节)。

但更重要的好处体现在以下情况下:由于违反了第 2.2 节中的附加条件,编译器将不会生成成员函数。在这种情况下(当然,假设程序员对编译器生成的成员函数感到满意),这样的声明可以让他们免于编写例行代码,并保证不会出现错误。

我们来看一些例子。

class Point
{
    int X{}, Y{};
public:
    Point(int x, int y) : X(x), Y(y) {} 
                                     
    Point() = default; 
    Point(const Point&) = default; 
    Point& operator=(const Point&) = default; 
                              

};

这个类声明了一个带参数的构造函数,因此,如果没有使用限定符声明,编译器不会生成默认构造函数=default。如果没有这样的声明,当需要默认构造函数时,编译器会报错。在这个例子中,复制构造函数和复制赋值运算符是可选的;如果省略它们,编译器会在需要时自动生成这些成员函数。

再举一个例子。

class Dictionary
{
    std::map<int, std::string> m_Dict;
public:
    ~Dictionary() { std::cout << "Dictionary, dtor\n"; }
    Dictionary(Dictionary&&) = default; 
                                        
    Dictionary& operator=(Dictionary&&) = default; 
                                 

};

默认构造函数、复制构造函数和复制赋值运算符未声明;编译器会生成它们。我们显式定义了析构函数,因为我们希望跟踪对象的销毁过程。编译器生成的移动构造函数和移动赋值运算符是可以接受的(std::map<>它们支持移动操作),但该类声明了析构函数,这违反了 2.2 节中的附加条件,因此编译器不会生成它们。在这种情况下,我们使用 `@move` 说明符声明这些成员函数=default,请求编译器生成它们的实现。如果不这样做,该类Dictionary将不支持移动操作,并且在原本应该进行移动操作的地方,类的实例将被复制。在某些情况下,这会导致性能显著下降,而且所有这些都会在没有任何警告的情况下悄无声息地发生。

该说明符=default可以从声明中省略,并在定义特殊成员函数时使用。例如:

class Point
{
    int X{}, Y{};
public:
    Point(int x, int y) : X(x), Y(y) {} 
                                        
    Point(); 

};

Point::Point() = default; 

有时可能需要这样的解决方案来改变成员函数的生成上下文,参见[Meyers],第 4.5 节。

2.4. 编译器生成特殊成员函数的算法

编译器生成的特殊成员函数实现必须确保所有成员和基类都调用同一个成员函数。如果程序员没有为成员类型或基类定义所需的特殊成员函数,编译器将尝试自行生成一个。

调用成员函数和基类特殊成员函数的顺序如下:对于构造函数和赋值运算符,从最外层的基类到直接基类(在多重继承的情况下,按声明顺序),然​​后是类成员函数,同样按声明顺序。对于析构函数,顺序则完全相反。

所述流程能否成功完成尚无保证;结果取决于某些附加条件。让我们来看看这些条件。

  1. 所需的成员函数未被声明为已删除。

  2. 如果所需的成员函数是由程序员定义的,或者用说明符声明的,那么它必须是可访问的(成员类型、基类或友元上下文中=default必须有访问说明符)。publicpublic/protected

  3. 如果程序员声明所需的成员函数时没有使用 `@` 说明符=default,则该成员函数必须由程序员定义,否则链接器将生成错误。

  4. 如果所需的成员函数未声明,则必须满足第 2.2 节中的附加条件,编译器才能生成该成员函数。

如果在生成成员函数的描述过程中,任何步骤违反了这些条件,那么在移动操作的情况下,将尝试用相应的复制函数替换它;在其他情况下,生成尝试将失败。

我们举个例子来说明这些规则是如何运作的。为了防止复制、移动和赋值类实例,您必须阻止生成复制构造函数、移动构造函数以及相应的赋值运算符(当然,也不能定义这些成员函数)。为此,您可以使用以下类作为基类:

class NonCopyable
{
protected:
    NonCopyable() = default;
    NonCopyable(const NonCopyable&) = delete;
    NonCopyable& operator=(const NonCopyable&) = delete;
};

我们将该类的复制构造函数和复制赋值运算符声明NonCopyable为已删除,因此编译器不会生成它们,因此禁止生成这些成员函数将适用于以该类NonCopyable为基类的任何继承链类(继承可以是私有的private)。

由于违反了第 2.2 节中的附加条件(该类声明了复制构造函数和复制赋值运算符),编译器不会为该类生成移动构造函数或移动赋值运算符NonCopyable。由于复制操作已被声明删除,因此无法用复制操作替换移动操作。相应地,禁止生成这些成员函数的规则将适用于任何派生类。

我们使用 `default` 说明符声明了一个默认构造函数,=default因为如果没有这个声明,编译器会因为声明了复制构造函数而不会生成默认构造函数(这再次违反了 2.2 节中的附加条件)。因此,编译器不会在任何派生类中生成默认构造函数,这是一个完全不必要的限制。此外,在派生类中根本无法定义任何构造函数(参见 3.1 节)。

请注意,使用一个类NonCopyable作为基类不会增加派生类的实例大小,因为编译器随后会应用空基优化(EBO)。

3. 构造函数和析构函数

如果成员函数是由程序员定义的,那么对于构造函数或析构函数,编译器也会参与其实现,并可能生成一些代码。让我们更详细地探讨一下这种情况。

3.1 初始化和简单类型

3.1.1. 通用初始化方案

对于构造函数,编译器会为所有成员和基类部分提供初始化。构造函数可以是程序员定义的,也可以是编译器生成的。遍历顺序与编译器生成的构造函数相同,并且所需的访问要求也与第 2.4 节中的规则相同。构造函数的主体代码最后执行。

可以为成员和基类指定初始化器(具体方法将在 3.1.6 节和 3.1.7 节中介绍)。如果存在初始化器,编译器将使用与初始化器参数匹配的构造函数,或者使用聚合初始化(参见 3.1.8 节)。对于参数列表为空的初始化器,将使用默认构造函数。如果程序员未定义默认构造函数,则将使用编译器生成的构造函数。

对于未指定初始化器的成员和基类,编译器将使用默认构造函数。如果程序员没有定义默认构造函数,编译器将生成一个。

因此,构造函数是一种上下文,它可能需要为成员和基类使用默认构造函数。然而,生成默认构造函数的规则需要对所谓的平凡类型进行一些澄清。我们将在 3.1.2 节讨论平凡类型,并在 3.1.4 节讨论生成默认构造函数的规则。

3.1.2 平凡类型

引入平凡类型是为了解决与 C 语言的兼容性问题。它们包括数值类型、bool枚举类型、指针类型,以及由平凡类型构成的数组、类、结构体和联合体。类、结构体和联合体必须满足一些附加条件:不能包含程序员自定义的特殊成员函数、不能包含已删除的特殊成员函数和虚成员函数。在不违反平凡性条件的前提下,可以使用限定符声明特殊成员函数=default,也可以定义普通成员函数、静态成员、成员函数和带参数的构造函数。

C++11 标准库包含名为类型属性(头文件<type_traits>)的模板。其中一个属性允许您判断一个类型是否为平凡类型。如果类型为平凡类型,则表达式std::is_trivial<Т>::value的值为 true ;否则为 false。trueTfalse

平凡类型的主要问题在于,在初始化过程中,平凡数据可能会变为未初始化状态,这意味着它可能被随机置位,该置位对应于为该对象分配的内存。平凡类型也可能被零初始化,这意味着所有数据都被赋予对应类型的零值。让我们来探讨一下零初始化和平凡数据何时变为未初始化状态。

值得注意的是,编译器有时会检测到未初始化的变量,从而发出警告甚至报错。静态代码分析器在检测未初始化的变量方面更加有效。

3.1.3. 静态内存

如果变量是全局声明的、位于命名空间作用域内的,或是静态类成员,则未初始化的平凡数据将被按位清零。因此,以下讨论同样适用于局部变量和数组,以及动态对象和数组。

3.1.4 编译器生成的默认构造函数

对于平凡类型,编译器会生成两个默认构造函数变体。
第一个变体不进行初始化,会在以下情况下生成:

  1. 声明变量时没有初始化器;

  2. 对于没有初始化的构造函数上下文中没有初始化器的成员和基类;

  3. 对于程序员定义的构造函数中没有初始化器的类成员和基部分。

在这样的构造函数中,没有初始化器的平凡类型的成员和基本部分将未初始化。

第二个初始化版本在以下情况下生成:

  1. 当声明一个带有初始值设定项的变量时;

  2. 对于带有初始化器的成员和基本部件;

  3. 对于类的成员和基本部分,如果构造函数没有初始化器,则初始化器的效果会向下传播到对象图的更下方。

在这种构造函数中,没有初始化器的平凡类型的成员和基本部分将获得零初始化。

假设某个类型T没有程序员定义的构造函数。以下是一个声明示例,其中T将生成一个没有初始化的构造函数。

T x;
T a[N];
T* p = new T;
T* q = new T[N];

以下是一个声明示例,其中T将为生成带有初始化的构造函数。

T x{};
T a[N]{};
T* p = new T{};
T* q = new T[N]{};
3.1.5 程序员自定义构造函数

从上一节的描述可知,对于没有初始化器的成员和基类构造函数,编译器将生成一个没有初始化的默认构造函数。

请注意,程序员定义的构造函数还具有为平凡类型的成员和基本部分指定值的额外能力:这可以在构造函数的主体中完成,例如通过赋值。

3.1.6. 构造函数初始化列表

可以使用构造函数的初始化列表来指定成员或基类部分的初始化器。以下是一个示例:

class B
{
protected:
    B(int x);
    
};

class G : public B
{
    std::string m_M1;
    std::string m_M2;
public:
    G(int b, const char* m1)
    : B{b}, m_M1{m1}, m_M2{} 
    { /* ... */}             
   
};

与构造函数初始化列表相关的一条重要规则是初始化顺序。此顺序并非由列表中元素的顺序决定,而是由编译器在初始化期间遍历成员和基类的顺序决定(参见 3.1.1 节)。构造函数初始化列表中的元素顺序无关紧要。这条规则可能会让程序员感到意外,如果初始化列表中的元素引用了同一列表中的其他元素,则可能导致错误。

3.1.7. 声明成员时的初始化器

C++11 引入了在声明类或结构体时为非静态成员指定初始化器的功能。在这种情况下,类型将不再是平凡的,并且初始化将在任何构造函数中执行,无论是程序员定义的还是编译器生成的。因此,带有初始化器的平凡成员将保证被初始化。初始化顺序由成员的声明顺序决定。强烈建议在设计类型时使用此功能。让我们来看一个例子:

struct Point
{
    int X, Y;
    Point() = default;
};

struct Point2
{
    int X{}, Y{};
    Point2() = default;
};

Point声明局部变量时,类型很简单。

Point p;

该变量p将未初始化,p.X其成员p.Y将具有随机值。

声明局部变量时,类型Point2并非微不足道。

Point2 p2;

成员p2.Xp2.Y具有零值。

3.1.8. 非空初始化器

现在让我们来考虑带参数的初始化器的特点。

T x{param_list};
T a[N]{param_list};
T* p = new T{param_list};
T* q = new T[N]{param_list};

这里可能有两种选择。

第一种情况T是声明了一个聚合类型或数组。(聚合类型的要求是:所有非静态成员必须是公共的,不能声明构造函数或虚成员函数,也不能有基类。类型属性std::is_aggregate<>可以用来判断一个类型是否为聚合类型。)在这种情况下,将使用从 C 语言继承的聚合初始化。元素param_list必须是按声明顺序排列的成员T或数组元素的有效初始化器。聚合初始化的一个重要特性是,元素的数量param_list可以少于类成员或数组元素的数量。在这种情况下,缺少元素的类成员或数组元素param_list将使用默认构造函数进行初始化。

第二种情况下,类型T不是聚合类型,也没有声明数组。在这种情况下,类型T必须有一个接受参数的构造函数param_list,并且初始化将按照 3.1.5 节中的规则执行。

3.2. 销毁器

析构函数的实现,无论是程序员自定义的还是编译器生成的,都必须确保所有成员和基类的析构函数都被调用。此功能完全由编译器提供。如果析构函数是程序员自定义的,那么它的代码不能包含对成员和基类析构函数的调用。可以假设,在析构函数体执行完毕后,编译器生成的析构函数总是会被调用。析构函数是资源管理策略的关键要素,而此方案保证了不会发生资源泄漏。因此,第 2.4 节中讨论的所有关于成功生成特殊成员函数的规则都必须始终适用于析构函数。

例如,如果正在设计多态类层次结构,基类中的虚函数通常使用 `<virtual>` 说明符声明为纯虚函数=0。派生类必须重写此类函数。析构函数也必须声明为虚函数,但不能使用 `<virtual>` 说明符声明为纯虚函数,=default因为纯虚析构函数需要按照 2.4 节中的规则进行定义。C++ 允许定义纯虚函数,但对于析构函数而言,这通常是完全不必要的,而且还可能导致与链接相关的其他问题。

4. 程序员自定义的特殊成员函数

在某些情况下,编译器生成的特殊成员函数并不适用;程序员必须自行定义它们。例如,拥有资源的类。通常,这类类会有一个名为“原始资源句柄”的成员。它通常是指针,但也可能是其他类型。类通过这个句柄访问资源。拥有资源的类最终必须释放资源,而句柄正是用于此目的。这通常在析构函数中使用依赖于资源类型的代码来完成;无法通过原始资源句柄自动释放资源(这就是它被称为“原始”的原因)。

除了析构函数之外,程序员还必须定义复制构造函数和复制赋值运算符。使用编译器生成的复制构造函数会在复制类实例时生成同一资源句柄的两个副本。如果一个对象释放了资源,另一个对象就可以访问已删除的资源,或者再次删除已删除的资源,这通常会导致未定义行为(UB)。编译器生成的赋值运算符也不合适,因为它会简单地覆盖目标对象的句柄而不释放资源,从而导致资源泄漏。这里适用著名的“三法则”:如果程序员定义了复制构造函数、复制赋值运算符或析构函数这三个操作中的至少一个,那么他们必须定义所有这三个操作。

对于拥有资源的类,定义复制操作的方式多种多样,具体取决于资源类型以及复制后资源的预期行为。其中一些方法可能非常消耗资源。然而,有两种相对简单的方法。第一种方法是直接禁止复制和移动,从而得到一个所谓的不可复制类,其实例既不能被复制也不能被移动。第二种方法是禁止复制,但定义移动操作,即移动句柄。在这种情况下,我们得到一个所谓的仅移动类,其实例不能被复制但可以移动。在这两种情况下,句柄始终是单个实例,因此不会出现上述问题。

为防止复制和赋值类实例,必须将复制构造函数和复制赋值运算符声明为已删除,即使用 `deleted` 说明符=delete(参见 2.1 节)。如果未声明移动构造函数和移动赋值运算符,则移动类实例也会被禁止,从而得到一个不可复制的类。如果这些成员函数定义正确,则得到的是一个仅允许移动的类。

仅移动类的实例可以存储在标准容器中,几乎没有任何限制,并且对使用标准算法也没有任何限制。标准库中包含相当多的仅移动类,例如 `Integer` std::unique_ptr、`Integer`std::threadstd::fstream其他 I/O 类。仅移动类的另一个优点是,移动操作永远不会被复制操作所取代。

例如,考虑一个简单的仅用于移动的类,该类拥有一个内存缓冲区。

class SBuff
{
    using Byte = std::uint8_t;
    Byte* m_Buff{}; 
public:
    SBuff(std::size_t size) : m_Buff(new Byte[size]) {}
    ~SBuff() { delete[] m_Buff; }
    SBuff(const SBuff&) = delete;
    SBuff& operator=(const SBuff&) = delete;
    SBuff(SBuff&& src) noexcept;
    SBuff& operator=(SBuff&& src) noexcept;
};

SBuff::SBuff(SBuff&& src) noexcept
    : m_Buff(src.m_Buff)
{
    src.m_Buff = nullptr;
}
SBuff& SBuff::operator=(SBuff&& src) noexcept
{
    SBuff tmp{ std::move(src) };
    std::swap(m_Buff, tmp.m_Buff);
    return *this;
}

赋值运算符在赋值之前释放目标对象拥有的资源,这是通过众所周知的“复制和交换”惯用法实现的。

有时可以避免手动资源管理。当原始资源描述符可以被聚合到一个合适的类中时,就可以实现这一点。在这种情况下,所有必要的资源管理工作都将由编译器生成的特殊成员函数来完成。以下是如何使用模板数组特化重写前面的示例std::unique_ptr<>

class SBuff
{
    using Byte = std::uint8_t;
    std::unique_ptr<Byte[]> m_Buff;
public:
    SBuff(std::size_t size)
    : m_Buff(std::make_unique<Byte[]>(size)){}
};

我们现在有了一个功能与先前版本完全相同的仅用于移动操作的类。除默认构造函数外,所有特殊成员函数都将在需要时由编译器生成。

5. 协会

在联合体(关键字 `union` union)中,所有成员的零偏移量相同,这意味着它们相互重叠。更改一个成员几乎总是会更改所有其他成员的值。在 C++98 中,联合体直接从 C 语言迁移过来,因此所有联合体成员都必须是平凡类型,并且禁止使用初始化器、成员函数、访问说明符和继承。C++11 对此进行了更改:联合体成员现在可以具有非平凡类型,允许初始化,可以定义成员函数(包括特殊函数),并且可以指定访问说明符。静态成员、虚成员函数和继承仍然被禁止。然而,使用这些新特性有一些与类和结构体不同的特殊之处。让我们更详细地了解这些特性。

5.1 初始化

在初始化过程中,需要制定规则来选择要初始化的成员。这里有两种选择。

如果联合体没有定义构造函数,则初始化器将应用于第一个成员。因此,初始化器的类型必须与第一个成员的类型匹配。例如:

union U1
{
    double V;
    struct { int X, Y; } P;
};

U1 u1{ {3.14} };  
U1 u2{};          
U1 u3{ "meow" };  
U1 u4;            
                  

如果联合体包含非平凡成员,则声明变量时必须使用初始化器。

如果联合体定义了构造函数,则必须显式指定要初始化的成员,并且初始化列表中只能指定一个成员。例如:

union U2
{
    double V;
    struct { int X, Y; } P;

    U2(double v) : V{v}{} 
    U2(int x, int y) : P{x, y}{} 
};

U2 u5{3.14};   
U2 u6{1, 2};   
U2 u7{"meow"}; 
U2 u8{};       

=default只有当成员类型没有程序员定义的构造函数时,才允许使用带有指定符的默认构造函数。

5.2. 复制构造函数、复制赋值运算符、析构函数

如果联合体中的某个成员的类型具有程序员定义的“三大”操作,那么为了支持该成员,程序员必须显式定义该操作,否则该成员将被视为已删除。此定义可以对任何成员调用相应的操作,也可以留空。以下是一个示例:

union U3
{
    double X;
    std::string S;
    U3& operator=(const U3& s)
    {
        S = s.S;
        return *this;
    }
};

如果对某个成员调用析构函数,则必须确保该成员已初始化。对于复制构造函数,必须确保其对应的参数成员已初始化。对于复制赋值运算符,则这些要求合并在一起。联合体本身并不提供此信息,如果选择不当,则可能导致未定义行为。更实际的做法是将成员初始化信息的责任交给外部代码。因此,外部代码应该调用该成员的赋值操作,从而完全不需要为联合体定义成员函数。

5.3 结论

联合体的新语言特性褒贬不一。初始化规则相当合理:要初始化的成员是根据初始化器参数选择的。然而,为了使“三大”操作正确运行,它们需要知道哪个成员被初始化(否则,可能会出现未定义行为)。联合体本身并不提供此信息,因此这些操作的实现必须在外部代码中完成,该外部代码需要存储有关已初始化成员的信息。C++17 引入了 [模式] std::variant<>,它解决了所有这些问题。

6. 结果

程序员必须了解编译器在什么条件下以及如何生成特殊成员函数。利用此功能可以使代码更简洁、更可靠。

声明类或结构的非静态成员时,建议指定初始化器;这样可以确保它们在任何构造函数中都被初始化。

如果编译器可以生成一个特殊的成员函数,并且生成的版本能够满足程序员的需求,那么问题就来了:是完全不声明它,还是用特殊说明符声明它=default?答案可能因成员函数的具体情况而异。

建议将默认构造函数声明为 `default`。此声明不会影响编译器生成其他成员函数的方式。您还可以使用访问说明符 `@ protectedclass`,这对于只能用作基类的类非常有用。此外,没有构造函数的类看起来略显不完整。

至于“三大”成员——复制构造函数、复制赋值运算符和析构函数——最好不要声明它们,因为它们并没有带来多少好处。此外,这样的声明会阻止生成移动成员函数。唯一的例外是多态层次结构的基类,在这种情况下,析构函数必须使用限定符声明为虚函数=default

应特别注意移动操作(如果类需要移动操作)。生成移动成员函数的条件相当复杂,并且在开发过程中很容易发生变化。如果编译器没有生成移动成员函数,移动操作将被静默地替换为复制操作,这会导致效率降低。因此,如有疑问,最好声明移动成员函数。如果同时声明删除复制成员函数,则最终得到的将是一个只包含移动操作的类。

如果某个类需要手动管理资源,建议为此创建一个单独的、最小的类。资源应在该类的析构函数中释放。根据复制和移动操作期间所需的资源行为,还应在该类中定义(或声明删除)实现这些操作的特殊成员函数。这样的类可以聚合到另一个类中,或用作基类。在这种情况下,无需编写资源管理代码:所有必要的工作都将由编译器生成的特殊成员函数完成。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐