C++特殊成員函式及其生成機制

ouyang發表於2022-02-20

在C++中,特殊成員函式指的是那些編譯器在需要時會自動生成的成員函式。C++98中有四種特殊的成員函式,分別是預設建構函式、解構函式、拷貝建構函式和拷貝賦值運算子。而在C++11中,隨著移動語義的引入,移動建構函式和移動賦值運算子也加入了特殊成員函式的大家庭。本文主要基於Klaus Iglberger在CppCon 2021上發表的主題演講Back To Basics: The Special Member Fuctions以及Scott Meyers的著作Effective Modern C++中的條款17,向大家介紹這六種特殊成員函式的特點以及它們的生成機制。

預設建構函式

當且僅當以下條件成立時,編譯器會生成一個預設建構函式:

  1. 沒有顯式宣告的建構函式
  2. 所有的資料成員和基類都擁有自己的預設建構函式

如果使用者宣告瞭自己的建構函式,那麼編譯器就不會再去生成一個預設建構函式;如果使用者沒有宣告建構函式,但是類中包含了一個沒有預設建構函式的資料成員,那麼編譯器也不會生成預設建構函式。

資料成員初始化

編譯器生成預設建構函式會初始化所有類型別的資料成員,但是並不會初始化基礎型別的資料成員。以下面的程式碼為例,第六行程式碼會呼叫預設建構函式將成員變數s初始化為空字串,但是並不會初始化整型成員變數i以及指標pi

struct Widget {
  int i;
  std::string s;
  int* pi;
};
int main() {
  Widget w1;   // Default initialization
  Widget w2{}; // Vaule initialization
  return 0;
}

如果我們想同時初始化所有的成員變數,可以使用值初始化,只需在宣告物件時新增一對大括號即可,見上述程式碼第8行。如果沒有宣告預設建構函式,值初始化會zero-initialize整個物件,然後default-initializes所有non-trivial的資料成員。以上面的程式碼為例,使用值初始化後,i被初始化為0,s仍然被初始化為空字串,而pi被初始化為nullptr。如果使用者宣告瞭預設建構函式,那麼值初始化就會按照使用者宣告來完成初始化操作。

通過預設建構函式,我們可以初始化類中的資料成員。但是需要注意賦值和初始化的區別。在下面的程式碼中,我們實現了兩個預設建構函式(僅僅為了說明賦值和初始化的區別,不代表類中能夠實現兩個預設建構函式)。在第一個預設建構函式中,所有的成員在函式體內執行賦值操作。對於基礎型別來說還好,但是對於類型別或者std::string這種,一次賦值操作帶來的開銷要比初始化的開銷大。而第二個預設建構函式使用了成員初始化列表,每次操作都是初始化,所以它的開銷會更低,效能也更好。

struct Widget {
  Widget() {
    i = 42;       // Assignment, not initialization
    s = "CppCon"; // Assignment, not initialization
    pi = nullptr; // Assignment, not initialization
  }

  Widget()
    : i{42}       // Initializing to 42
    , s{"CppCon"} // Initializing to "CppCon"
    , pi{}        // Initializing to nullptr
   {}

  int i;
  std::string s;
  int* pi;
};

對於資料成員的初始化,C++ Core Guideline定義了兩條規則。首先,我們要按照資料成員在類中的定義順序來初始化資料成員;其次,儘量在建構函式中使用初始化而非賦值。

Core Guideline C.47: Define and initialize member variables in the order of member declaration.

Core Guideline C.49: Prefer initialization to assignment in constructors.

解構函式

當使用者沒有顯式宣告解構函式時,編譯器會生成一個解構函式。編譯器生成的解構函式會呼叫類型別成員變數的解構函式,但是不會對基礎型別的成員變數執行任何操作。如果類中含有指標型別的成員變數,那麼編譯器生成的解構函式就有可能導致資源洩露,因為編譯器生成的解構函式並不會釋放掉指標所指向的那些資源。

因此,如果類中的資料成員擁有某些外部資源的所有權,我們就需要實現一個解構函式來正確釋放掉相關資源。如果確實沒有啥資源需要手動釋放,那麼也不要寫一個空的解構函式,最好是讓編譯器生成或者將解構函式定義成=default

拷貝操作

我們首先來看一下拷貝建構函式和拷貝賦值運算子的函式簽名。一般來說,拷貝建構函式的形參是一個常量左值引用,極少數情況下是一個非常量左值引用,但不可能是一個物件的拷貝,因為這會導致遞迴呼叫。對於拷貝賦值運算子,它的形參也是一個常量左值引用,極少數情況下是非常量左值引用,也有可能是一個物件的拷貝,因為拷貝賦值運算子可以通過拷貝建構函式實現,所以這種形參是合法的。

// copy constructor
Widget(const Weidget&); // The default
Widget(Widget&);        // Possible, but very likely not reasonable
Widget(Widget);         // Not possible, recursive call

// copy assignment operator
Widget& operator=(const Widget&); // The default
Widget& operator=(Widget&);       // Possible, but very likely not reasonable
Widget& operator=(Widget);        // Reasonable, builds on the copy constructor

當且僅當以下條件成立時,編譯器會生成拷貝操作:

  1. 不存在顯式宣告的拷貝操作
  2. 不存在顯式宣告的移動操作
  3. 所有的成員變數都能夠被拷貝構造或拷貝賦值

拷貝建構函式和拷貝賦值運算子的生成是獨立的:宣告瞭其中一個,並不會阻止編譯器生成另一個。如果使用者宣告瞭拷貝建構函式,但是沒有宣告拷貝賦值運算子,同時又編寫了要求拷貝賦值的程式碼,那麼編譯器就會自動生成拷貝賦值運算子,反之亦然。

編譯器生成的拷貝操作預設會按成員進行拷貝。對於指標型別的資料成員,如果執行按成員拷貝,那麼就只會拷貝成員的值,也就是拷貝指標的值。這樣一來,就會有兩個物件指向同一塊資源。當其中一個物件被析構以後,資源會被釋放,另一個物件中的指標就成了懸掛指標(Dangling Pointer)。當這個物件被析構時,它所指向的資源就會被析構兩次,記憶體的重複釋放會導致嚴重的錯誤。為了解決此問題,我們需要在拷貝建構函式和拷貝賦值運算子中執行深拷貝操作,也就是要拷貝指標指向的那一塊資源。

struct Widget {
  Widget(Wiget& other) noexcept
    : Base{other}
    , i{other.i}
    , s{other.s}
    , pr{other.pr ? new Resource(*ohter.pr) : nullptr}
  {}

  Widget& operator=(Widget&& other) {
    deleter pr; // cleanup current resource
    Base::operator=(std::move(other));
    i = other.i;
    s = other.s;
    pr = other.pr ? new Resource{*other.pr} : nullptr;
    return *this;
  }
  int i;
  std::string s;
  Resource* pr{};
};

注意在上述程式碼的拷貝賦值運算子中,我們首先刪除了當前物件所指向的資源,然後再執行相關的拷貝操作。然而,這會導致程式不能正確處理self-assignment的情況。形如Widget w{}; w = w;這樣的程式碼就會釋放掉物件w指向的資源,從而導致程式發生錯誤。幸運的是,我們可以用copy-and-swap的思想,通過一個臨時物件和swap函式來解決此問題。臨時物件在退出作用域是會自動呼叫解構函式,所以我們就不用擔心資源洩漏的問題。

Widget& operator=(const Widget& other) {
  Widget tmp(other);
  swap(tmp);
  return *this;
}
void swap(Widget& other) {
  std::swap(id, other.id);
  std::swap(name, other.name);
  std::swap(pr, other.pr);
}

這種做法的好處就是安全,程式碼能正確處理self-assignment的情況,但它的缺點就是效能比較一般。

移動操作

我們首先來看一下移動建構函式和移動賦值運算子的函式簽名。一般來說,移動建構函式和移動賦值運算子的形參都是一個右值引用,帶有const的形參是合法的,但是非常少見,一般也不會遇到。

// move constructor
Widget(Widget&&) noexcept;      // The default
Widget(const Widget&&) noexcept // Possible, but uncommon

// move assignment operator
Widget& operator=(Widget&&) noexcept;      // The default
Widget& operator=(const Widget&&) noexcept // Possible, but uncommon

當且僅當以下條件成立時,編譯器會生成移動操作:

  1. 不存在顯式宣告的移動操作
  2. 不存在顯式宣告的解構函式和拷貝操作
  3. 所有的資料成員都是可以被拷貝或移動

移動建構函式和移動賦值運算子的生成並不獨立:宣告瞭其中一個,編譯器就不會生成另一個。這樣做的原因是,如果使用者宣告瞭一個移動建構函式,那麼這就表明移動操作的行為將會與編譯器所生成的移動建構函式不一致。而若是按成員進行的移動操作有不合理之處,那麼按成員移動的賦值運算子極有可能同樣有不合理之處。因此,宣告移動建構函式會阻止編譯器生成移動賦值運算子,反之亦然。

與拷貝操作類似,編譯器生成的移動操作預設會按成員進行移動。顯然,如果資料成員是一個指標型別,那麼按成員移動同樣將會導致懸掛指標。所以,對於包含指標型別的類,我們需要按照下面的方式實現移動建構函式和移動賦值運算子,其中std::exchange(a, b)的作用是用b的值去替換a的值並返回a的舊值。

struct Widget {
  Widget(Wiget&& other) noexcept
    : Base{std::move(other)}
    , i{std::move(other.i)}
    , s{std::move(other.s)}
    , pr{std::exchange(other.pr, {})}
  {}

  Widget& operator=(Widget&& other) {
    deleter pr;
    Base::operator=(std::move(other));
    i = std::move(other.i);
    s = std::move(other.s);
    pr = std::exchange(other.pr, {});
  }
  int i;
  std::string s;
  Resource* pr{};
};

然而,上面這種實現方式同樣無法處理self-assignment的問題。雖然移動一個物件到它本身是一件非常奇怪的事情,一般也不會有人去寫這種程式碼,但是作為類的提供者,我們必須要儘量考慮到所有可能出現的情況。對於self-assignment這個問題,我們可以藉助copy-and-swap思想,利用一個臨時物件來解決,程式碼如下。

Widget& operator=(Widget&& other) noexcept {
  Widget tmp(std::move(other));
  swap(tmp);
  return *this;
}
~Widget() { delete pr; }

使用原生指標來管理資源會讓我們的程式碼寫起來比較困難和繁瑣。如果我們用智慧指標替換掉原生指標,那麼程式碼寫起來將會容易很多。如果我們使用unique_ptr替換掉上例中的原生指標,因為unique_ptr只能被移動不能被拷貝,所以我們只需要實現拷貝建構函式和拷貝賦值運算子(如果我們真的需要拷貝操作的話),並將預設建構函式、解構函式和移動操作宣告為=default即可。如果我們使用shared_ptr,那麼連拷貝操作也不用寫了,六個特殊成員函式群都定義成=default就完事了,不過shared_ptr會改變整個類的語義,因為所有的指標都會指向同一個資源,所以在用它的時候要多加小心。C++ Core Guideline就指出,儘量用unique_ptr而非shared_ptr,除非你是真的想共享資源的所有權。

Core Guideline R.21: Prefer unique_ptr over shared_ptr unless you need to share ownership.

最後,我們再來看下C++ Core Guideline中的The Rule of Zero以及The Rule of Five。這兩條規則的意思非常簡單,就是說我們在定義一個類的時候,如果能避免定義所有的預設操作,那就儘量不定義;如果定義或刪除了某個預設操作,那麼就定義或刪除所有的預設操作。

Core Guideline C.20: If you can avoid defining default operation, do (aka The Rule of Zero).

Core Guideline C.21: If you define or =delete any default operation, define or =delete them all (aka The Rule of Five).

相關文章