特殊成員

[注意:本章需要對動態分配的記憶體有正確的理解]

特殊成員函式是在某些情況下被隱式定義為類成員的成員函式。總共有六種:

成員函式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
目錄