特殊成员

[注意:本章需要对动态分配的内存有正确的理解]

特殊成员函数是在某些情况下被隐式定义为类成员的成员函数。总共有六种:

成员函数C 的典型形式:
默认构造函数C::C();
析构函数C::~C();
拷贝构造函数C::C (const C&);
拷贝赋值C& operator= (const C&);
移动构造函数C::C (C&&);
移动赋值C& operator= (C&&);

让我们逐一研究这些函数。

默认构造函数

默认构造函数是在声明类的对象但未使用任何参数进行初始化时调用的构造函数。

如果一个类定义中没有任何构造函数,编译器会假定该类有一个隐式定义的默认构造函数。因此,在声明了像下面这样的一个类之后:

1
2
3
4
5
class Example {
  public:
    int total;
    void accumulate (int x) { total += x; }
};

编译器会假定 Example 有一个默认构造函数。因此,可以通过简单地声明它们而不带任何参数来构造该类的对象:

1
Example ex;

但是,一旦一个类显式声明了任何带有任意数量参数的构造函数,编译器就不再提供隐式的默认构造函数,并且不再允许声明该类的无参数新对象。例如,对于下面的类:

1
2
3
4
5
6
class Example2 {
  public:
    int total;
    Example2 (int initial_value) : total(initial_value) { };
    void accumulate (int x) { total += x; };
};

这里,我们声明了一个带有一个 int 类型参数的构造函数。因此,下面的对象声明是正确的:

1
Example2 ex (100);   // ok: calls constructor 

但是下面的声明:
1
Example2 ex;         // not valid: no default constructor 

将会是无效的,因为该类已经声明了一个带有一个参数的显式构造函数,这取代了不带参数的隐式默认构造函数

因此,如果需要无参数地构造该类的对象,那么也应该在类中声明一个合适的默认构造函数。例如:

// classes and default constructors
#include <iostream>
#include <string>
using namespace std;

class Example3 {
    string data;
  public:
    Example3 (const string& str) : data(str) {}
    Example3() {}
    const string& content() const {return data;}
};

int main () {
  Example3 foo;
  Example3 bar ("Example");

  cout << "bar's content: " << bar.content() << '\n';
  return 0;
}
bar's content: Example

这里,Example3 有一个被定义为空代码块的默认构造函数(即一个无参数的构造函数):

1
Example3() {}

这使得类 Example3 的对象可以无参数地构造(如此例中声明的 foo)。通常情况下,对于所有没有其他构造函数的类,这样的默认构造函数会被隐式定义,因此不需要显式定义。但在本例中,Example3 有另一个构造函数:

1
Example3 (const string& str);

当任何构造函数在一个类中被显式声明时,就不会自动提供隐式的默认构造函数

析构函数

析构函数的功能与构造函数相反:它们负责在类对象的生命周期结束时进行必要的清理工作。我们在前面章节中定义的类没有分配任何资源,因此并不真正需要任何清理。

但是现在,让我们想象一下,上一个例子中的类分配了动态内存来存储其作为数据成员的字符串;在这种情况下,有一个函数在对象生命周期结束时自动被调用,负责释放这块内存,将会非常有用。为此,我们使用析构函数。析构函数是一种与默认构造函数非常相似的成员函数:它不接受任何参数,也不返回任何东西,甚至连 void 都不返回。它也使用类名作为自己的名字,但前面加上一个波浪号(~)。

// destructors
#include <iostream>
#include <string>
using namespace std;

class Example4 {
    string* ptr;
  public:
    // constructors:
    Example4() : ptr(new string) {}
    Example4 (const string& str) : ptr(new string(str)) {}
    // destructor:
    ~Example4 () {delete ptr;}
    // access content:
    const string& content() const {return *ptr;}
};

int main () {
  Example4 foo;
  Example4 bar ("Example");

  cout << "bar's content: " << bar.content() << '\n';
  return 0;
}
bar's content: Example

在构造时,Example4 为一个 string 分配存储空间。该存储空间稍后由析构函数释放。

对象的析构函数在其生命周期结束时被调用;对于 foobar,这发生在 main 函数的末尾。

拷贝构造函数

当一个对象被传入一个同类型的具名对象作为参数时,它的拷贝构造函数被调用以构造一个副本。

拷贝构造函数是一种构造函数,其第一个参数是对该类自身的引用类型(可能是 const 限定的),并且可以用这种类型的单个参数来调用。例如,对于一个类 MyClass拷贝构造函数可能具有以下签名:

1
MyClass::MyClass (const MyClass&);

如果一个类没有定义自定义的拷贝移动构造函数(或赋值运算符),则会提供一个隐式的拷贝构造函数。这个拷贝构造函数只是简单地执行其成员的拷贝。例如,对于像这样的一个类:

1
2
3
4
class MyClass {
  public:
    int a, b; string c;
};

一个隐式的拷贝构造函数被自动定义。该函数假定的定义执行的是浅拷贝,大致等同于:

1
MyClass::MyClass(const MyClass& x) : a(x.a), b(x.b), c(x.c) {}

这个默认的拷贝构造函数可能满足许多类的需求。但是,浅拷贝只拷贝类本身的成员,对于像我们上面定义的 Example4 这样的类,这可能不是我们所期望的,因为它包含了它自己管理存储的指针。对于那个类,执行浅拷贝意味着指针的值被拷贝了,但内容本身没有;这意味着两个对象(副本和原始对象)将共享同一个 string 对象(它们都将指向同一个对象),并且在某个时刻(在析构时)两个对象都将尝试删除同一块内存,这很可能导致程序在运行时崩溃。这可以通过定义一个执行深拷贝的自定义拷贝构造函数来解决:

// copy constructor: deep copy
#include <iostream>
#include <string>
using namespace std;

class Example5 {
    string* ptr;
  public:
    Example5 (const string& str) : ptr(new string(str)) {}
    ~Example5 () {delete ptr;}
    // copy constructor:
    Example5 (const Example5& x) : ptr(new string(x.content())) {}
    // access content:
    const string& content() const {return *ptr;}
};

int main () {
  Example5 foo ("Example");
  Example5 bar = foo;

  cout << "bar's content: " << bar.content() << '\n';
  return 0;
}
bar's content: Example

这个拷贝构造函数执行的深拷贝为新字符串分配了存储空间,并将其初始化为原始对象内容的副本。通过这种方式,两个对象(副本和原始对象)都拥有存储在不同位置的内容的独立副本。

拷贝赋值

对象不仅在构造和初始化时被拷贝,它们也可以在任何赋值操作中被拷贝。看下面的区别:

1
2
3
4
MyClass foo;
MyClass bar (foo);       // object initialization: copy constructor called
MyClass baz = foo;       // object initialization: copy constructor called
foo = bar;               // object already initialized: copy assignment called 

请注意,baz 是在构造时使用等号初始化的,但这并不是一个赋值操作!(尽管它可能看起来像):对象的声明不是赋值操作,它只是调用单参数构造函数的另一种语法。

foo 的赋值是一个赋值操作。这里没有声明任何对象,而是在一个已存在的对象 foo 上执行一个操作。

拷贝赋值运算符operator= 的一个重载,它接受类本身的引用作为参数。返回值通常是对 *this 的引用(尽管这不是必需的)。例如,对于一个类 MyClass拷贝赋值运算符可能具有以下签名:

1
MyClass& operator= (const MyClass&);

拷贝赋值运算符也是一个特殊函数,如果一个类没有定义自定义的拷贝移动赋值运算符(或移动构造函数),它也会被隐式定义。

但是,隐式版本执行的是浅拷贝,这对于许多类是合适的,但对于那些拥有指向其管理存储的对象的指针的类则不合适,比如 Example5。在这种情况下,该类不仅面临着两次删除所指向对象的风险,而且赋值操作还会因未在赋值前删除对象所指向的对象而造成内存泄漏。这些问题可以通过一个拷贝赋值运算符来解决,它会删除旧的对象并执行深拷贝

1
2
3
4
5
6
Example5& operator= (const Example5& x) {
  delete ptr;                      // delete currently pointed string
  ptr = new string (x.content());  // allocate space for new string, and copy
  return *this;
}

或者更好的是,由于其 string 成员不是常量,它可以重用同一个 string 对象:

1
2
3
4
Example5& operator= (const Example5& x) {
  *ptr = x.content();
  return *this;
}


移动构造函数和移动赋值

与拷贝类似,移动也使用一个对象的值来设置另一个对象的值。但是,与拷贝不同,内容实际上是从一个对象(源)转移到另一个对象(目标):源对象失去了该内容,而该内容被目标对象接管。这种移动只在值的来源是未命名对象时发生。

未命名对象是那些本质上是临时的,因此甚至没有被赋予名字的对象。未命名对象的典型例子是函数的返回值或类型转换。

使用像这样的临时对象的值来初始化另一个对象或赋给它值,并不真的需要一次拷贝:该对象永远不会被用于其他任何事情,因此,它的值可以被移动到目标对象中。这些情况会触发移动构造函数移动赋值

当一个对象在构造时使用一个未命名的临时对象进行初始化时,会调用移动构造函数。同样,当一个对象被赋予一个未命名的临时对象的值时,会调用移动赋值

1
2
3
4
5
6
MyClass fn();            // function returning a MyClass object
MyClass foo;             // default constructor
MyClass bar = foo;       // copy constructor
MyClass baz = fn();      // move constructor
foo = bar;               // copy assignment
baz = MyClass();         // move assignment 

fn 返回的值和用 MyClass 构造的值都是未命名的临时对象。在这些情况下,没有必要进行拷贝,因为未命名对象的生命周期非常短,当这是一个更高效的操作时,它的资源可以被另一个对象获取。

移动构造函数和移动赋值是接受一个对该类自身的右值引用类型参数的成员:

1
2
MyClass (MyClass&&);             // move-constructor
MyClass& operator= (MyClass&&);  // move-assignment 

一个右值引用通过在类型后跟两个与号(&&)来指定。作为参数,一个右值引用匹配该类型的临时对象参数。

移动的概念对于管理其使用的存储的对象最为有用,例如使用 new 和 delete 分配存储的对象。在这样的对象中,拷贝和移动是真正不同的操作:
- 从 A 拷贝到 B 意味着为 B 分配新内存,然后将 A 的全部内容拷贝到为 B 分配的这块新内存中。
- 从 A 移动到 B 意味着已经分配给 A 的内存被转移给 B,而无需分配任何新的存储空间。它仅涉及拷贝指针。

例如:
// move constructor/assignment
#include <iostream>
#include <string>
using namespace std;

class Example6 {
    string* ptr;
  public:
    Example6 (const string& str) : ptr(new string(str)) {}
    ~Example6 () {delete ptr;}
    // move constructor
    Example6 (Example6&& x) : ptr(x.ptr) {x.ptr=nullptr;}
    // move assignment
    Example6& operator= (Example6&& x) {
      delete ptr; 
      ptr = x.ptr;
      x.ptr=nullptr;
      return *this;
    }
    // access content:
    const string& content() const {return *ptr;}
    // addition:
    Example6 operator+(const Example6& rhs) {
      return Example6(content()+rhs.content());
    }
};


int main () {
  Example6 foo ("Exam");
  Example6 bar = Example6("ple");   // move-construction
  
  foo = foo + bar;                  // move-assignment

  cout << "foo's content: " << foo.content() << '\n';
  return 0;
}
foo's content: Example

编译器已经对许多形式上需要移动构造调用的情况进行了优化,这被称为返回值优化(Return Value Optimization)。最值得注意的是,当函数的返回值被用来初始化一个对象时。在这些情况下,移动构造函数可能实际上永远不会被调用。

请注意,尽管右值引用可以用于任何函数参数的类型,但除了用于移动构造函数之外,它很少有用。右值引用很棘手,不必要的使用可能会成为难以追踪的错误源头。

隐式成员

上面描述的六个特殊成员函数是在某些情况下在类上隐式声明的成员:

成员函数隐式定义默认定义
默认构造函数如果没有其他构造函数什么都不做
析构函数如果没有析构函数什么都不做
拷贝构造函数如果没有移动构造函数和移动赋值运算符拷贝所有成员
拷贝赋值如果没有移动构造函数和移动赋值运算符拷贝所有成员
移动构造函数如果没有析构函数、拷贝构造函数、拷贝或移动赋值运算符移动所有成员
移动赋值如果没有析构函数、拷贝构造函数、拷贝或移动赋值运算符移动所有成员

请注意,并非所有特殊成员函数都在相同的情况下被隐式定义。这主要是为了向后兼容C结构体和早期的C++版本,并且实际上一些情况包含了已弃用的情形。幸运的是,每个类都可以使用关键字 defaultdelete 来显式选择这些成员中的哪些存在其默认定义,或者哪些被删除。语法是以下之一:


function_declaration = default;
function_declaration = delete;


例如:
// default and delete implicit members
#include <iostream>
using namespace std;

class Rectangle {
    int width, height;
  public:
    Rectangle (int x, int y) : width(x), height(y) {}
    Rectangle() = default;
    Rectangle (const Rectangle& other) = delete;
    int area() {return width*height;}
};

int main () {
  Rectangle foo;
  Rectangle bar (10,20);

  cout << "bar's area: " << bar.area() << '\n';
  return 0;
}
bar's area: 200

这里,Rectangle 可以用两个 int 参数构造,也可以被默认构造(无参数)。然而,它不能从另一个 Rectangle 对象进行拷贝构造,因为这个函数已经被删除了。因此,假设有上一个例子中的对象,下面的语句将是无效的:

1
Rectangle baz (foo);

然而,通过将其拷贝构造函数定义为以下形式,可以使其显式地有效:

1
Rectangle::Rectangle (const Rectangle& other) = default;

这基本上等同于:

1
Rectangle::Rectangle (const Rectangle& other) : width(other.width), height(other.height) {}

请注意,关键字 default 并不定义一个与默认构造函数(即无参数构造函数)相等的成员函数,而是定义一个与(如果未被删除)本应被隐式定义的构造函数相等的函数。

总的来说,为了将来的兼容性,鼓励那些显式定义了一个拷贝/移动构造函数或一个拷贝/移动赋值运算符但没有定义全部两者的类,在它们没有显式定义的其他特殊成员函数上指定 deletedefault
Index
目录