C++ 學習筆記之(13) - 拷貝控制
本文將學習類如何通過一組函數控制對象拷貝、賦值、移動和銷燬,這組函數分別是拷貝構造函數、移動構造函數、拷貝賦值運算符、移動賦值運算符以及析構函數。若類沒有顯示定義這些拷貝控制成員,則編譯器會自動定義。
拷貝、賦值與銷燬
拷貝構造函數
如果一個構造函數的第一個參數是自身類類型的引用(幾乎總爲const
引用),且任何額外參數都有默認值,則爲拷貝構造函數,拷貝構造函數會被隱式使用,故不應爲explicit
合成拷貝構造函數
若沒有爲類定義拷貝構造函數,則編譯器會定義,即使定義了其他構造函數,編譯器也會合成拷貝構造函數
- 合成拷貝構造函數:從給定對象中依次將每個非
static
成員拷貝到正在創建的對象中 - 對類類型,使用其拷貝構造函數來拷貝;對內置類型,直接拷貝;對數組,則逐元素拷貝
拷貝初始化
- 直接初始化:要求編譯器使用普通的函數匹配來選擇最匹配的構造函數,包括拷貝構造函數
- 拷貝初始化:要求編譯器將右側運算對象拷貝到正在創建的對象中,若需要可進行類型轉換
- 拷貝初始化通常使用拷貝構造函數完成,有時也依靠移動構造函數完成
- 拷貝初始化發生情況
- 使用
=
定義變量時 - 將對象作爲實參傳遞給一個非引用類型的形參
- 從一個返回類型爲非引用類型的函數返回一個對象
- 用花括號列表初始化一個數組中的元素或一個聚合類中的成員
- 使用
- 拷貝構造函數自身參數必須是引用類型,因爲爲了調用拷貝構造函數,必須拷貝實參,就又會調用拷貝構造函數,死循環
class Sales_data{
public:
Sales_data(const Sales_data& orig): bookNo(orig.bookNo), units_sold(orig.units_sold)) {} // 拷貝構造函數,第一個參數爲引用,且通常爲const
private:
std::string bookNo;
int units_sold = 0;
}
string dots(10, '.'); // 直接初始化
string s(dosts); // 直接初始化,因爲是調用最匹配的構造函數,包括拷貝構造函數
string s2 = dots; // 拷貝初始化
參考
拷貝賦值運算符
與拷貝構造函數類似,若類未定義自己的拷貝賦值運算符,編譯器會生成一個合成拷貝賦值運算符
- 重載運算符本質上是函數,其名字由
operator
關鍵字後接運算符符號 - 若一個運算符爲成員函數,其左側對象就綁定到隱式的
this
參數。若爲二元運算符,其右側運算對象作爲顯示參數傳遞 - 賦值運算符通常應該返回一個指向其左側運算對象的引用
析構函數
析構函數釋放對象使用的資源,並銷燬對象的非static
數據成員
析構函數爲成員函數,名字由波浪號組成,無返回值,不接受參數,不能被重載,在類中唯一
~Foo(); // 析構函數
析構函數中,首先執行函數體,然後銷燬成員,按初始化順序逆序銷燬,且釋放對象在生存期分配的所有資源
隱式銷燬一個內置指針類型的成員不會
delete
它所指向的對象,智能指針在析構階段被自動銷燬析構函數調用時間(對象被銷燬時)
- 變量在離開其作用域時被銷燬
- 當一個對象被銷燬時,其成員被銷燬
- 容器(無論是標準庫容器還是數組)被銷燬時,其元素被銷燬
- 對於動態分配的對象,當對指向它的指針應用
delete
運算符時被銷燬 - 對於臨時對象,當創建它的完整表達式結束時被銷燬
當指向一個對象的引用或指針離開作用域時,析構函數不會執行
析構函數體自身並不直接銷燬成員,成員是在析構函數體之後隱含的析構階段被銷燬的
三/五法則
- 如果一個類需要自定義析構函數,幾乎可以肯定它也需要自定義拷貝賦值運算符和拷貝構造函數(比如簡單拷貝指針成員,導致多個類對象指向相同內存,
delete
會出錯) - 如果一個類需要一個拷貝構造函數,幾乎可以肯定也需要一個拷貝賦值運算符,反之亦然。但並不意味之需要頁析構函數
使用=default
- 通過將拷貝控制成員定義爲
=default
顯示要求編譯器生成合成版本,只能對具有合成版本的成員函數使用,即默認構造函數或拷貝控制成員 - 類內使用
`=default
修飾成員聲明時,隱式表示爲內聯,若不希望合成成員爲內聯函數,可在類外定義使用
阻止拷貝
定義類時可以採取定義刪除的函數來阻止拷貝或賦值,因爲對於某些類,這些操作可能無意義
- 新標準定義可通過將拷貝構造函數和拷貝賦值運算符定義爲 刪除的函數來阻止拷貝,即函數參數列表後加
=delete
- 與
=default
不同,=delete
必須出現在函數第一次聲明的時候 - 與
=default
不同,可以對任何函數指定=delete
- 不能刪除析構函數,否則無法銷燬對象。對於一個刪除了析構函數的類型,編譯器不允許定義該類型的變量或創建該類的臨時對象,但可以動態分配這些類型的對象,但無法釋放
- 新標準之前可將拷貝構造函數和拷貝賦值運算符聲明爲
private
熬阻止拷貝,但不推薦
拷貝控制和資源管理
拷貝操作可使類型的行爲分爲兩種
- 看起來像一個值:即有自己的狀態,拷貝後,副本和元對象完全獨立,改變副本不會影響原對象,反之亦然
- 看起來像一個指針:會共享狀態,當拷貝這種類的對象時,副本和原對象使用相同的底層數據。改變副本會改變原對象,反之亦然
行爲像值的類
- 對於指針成員,應該擁有一份自己的拷貝,否則會與被拷貝對象中的指針指向相同的底層數據
class HasPtr{
public:
// 構造函數都動態分配自己的 string 副本,並將指向該 string 的指針保存到 ps 中
HasPtr(const std::string &s = std::string()) : ps(new std::string(s)), i(0) {}
HasPtr(const HasPtr &p) : ps(new std::string(*p.ps)), i(p.i) {}
HasPtr& operator=(const HasPtr &);
~HasPtr() { delete ps; } // 對 ps 執行 delete, 釋放分配的內存
private:
std::string *ps;
int i;
};
HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
auto newp = new string(*rhs.ps); // 拷貝底層 string
delete ps; // 釋放舊內存
ps = newp; // 從右側運算對象拷貝數據到本對象
i = rhs.i;
return *this; // 返回本對象
}
int main(void)
{
HasPtr hasPtr1("hao"); // 直接初始化,第一個構造函數
HasPtr hasPtr2(hasPtr1); // 直接初始化,調用拷貝構造函數
HasPtr hasPtr4;
hasPtr4 = hasPtr1; // 拷貝賦值運算符
}
- 對賦值運算符的編寫,要注意兩點
- 如果將一個對象賦予它自身,賦值運算符必須能正確工作
- 大多數賦值運算符組合了析構函數和拷貝構造函數的工作
定義行爲像指針的類
令一個類展現類似指針的行爲的最好方法是使用shared_ptr
管理類中的資源。若希望直接管理資源,可以使用 引用計數(reference count), 接下來重新定義HasPtr
,使用引用計數而不是shared_ptr
引用計數的工作方式
- 構造函數(除拷貝構造函數外)初始化對象,創建引用計數,記錄共享狀態對象數目,計數器初始化爲1
- 拷貝構造函數不分配新的計數器,而是拷貝給定對象的數據成員,包括計數器,且計數器遞增
- 析構函數遞減計數器,若計算器變爲
0
, 則析構函數釋放狀態 - 拷貝賦值運算符遞增右側運算對象的計數器,遞減左側運算對象的計數器。若左側運算對象計數器爲
0
,則銷燬
class HasPtr{
public:
// 拷貝構造函數分配新的 string 和新的計數器, 將計數器置爲 1
HasPtr(const std::string &s = std::string()) : ps(new std::string(s)), i(0), use(new std::size_t(1)) {}
// 拷貝構造函數拷貝所有三個數據成員,並遞增計數器
HasPtr(const HasPtr &p) : ps(p.ps), i(p.i), use(p.use) { ++*use; }
HasPtr& operator=(const HasPtr&);
~HasPtr();
private:
std::string *ps;
int i;
std::size_t *use; // 用來記錄有多少個對象共享 *ps 的成員
};
HasPtr::~HasPtr()
{
if (--*use == 0){ // 如果引用計數變爲 0
delete ps; // 釋放 string 內存
delete use; // 釋放計數器內存
}
}
HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
++*rhs.use; // 遞增右側運算對象的引用計數
if(--*use == 0) // 然後遞減本對象的引用計數
{
delete ps; // 如果沒有其他用戶,則釋放本對象分配的成員
delete use;
}
ps = rhs.ps; // 將數據從 rhs 拷貝到本對象
i = rhs.i;
use = rhs.use;
return *this; // 返回本對象
}
交換操作
管理資源的淚還會定義一個名爲swap
的函數,對於那些重拍元素順序的算法,在交換元素時會調用swap
- 交換對象需要進行一次拷貝和兩次賦值
- 若類定義了
swap
,則算法會使用類自定義版本,否則,算法使用標準庫定義的swap
swap
函數不是必須,但是是一種重要的優化手段- 定義了
swap
的類常用swap
定義他們的賦值運算符,使用了名爲 拷貝並交換的技術,將左側運算對象與右側運算對象的一個副本進行交換 - 使用拷貝和交換的賦值運算符自動就是異常安全的,且能正確處理自賦值
class HasPtr{
friend void swap(HasPtr&, HasPtr&); // 定義爲 friend 可訪問私有成員
// ...
};
inline void swap(HasPtr &lhs, HasPtr &rhs)
{
using std::swap; // 若存在類型特定的 swap 版本,匹配程度會優於 std 定義版本
swap(lhs.ps, rhs.ps); // 交換指針,而不是 string 數據
swap(lhs.i, rhs.i); // 交換 int 成員
}
// 參數是按值傳遞,故調用拷貝構造函數創建 rhs
HasPtr& HasPtr::operator=(HasPtr rhs)
{ // 交換左側運算對象和局部變量 rhs 的內容
swap(*this, rhs); // rhs 現在指向本對象曾經使用的內存
return *this; // rhs 被銷燬,從而 delete 了 rhs 中的指針
}
對象移動
新標準定義了移動對象的特性,比拷貝對象大幅度提升性能
- 標準庫容器、
string
和shared_ptr
類型既支持移動也支持拷貝。IO
類和unique_ptr
類可以移動但不能拷貝
右值引用
右值引用就是必須綁定到右值的引用
右值引用只能綁定到一個而將要銷燬的對象, 故可從綁定到右值引用的對象竊取狀態
左值表達式表示的是對象的身份,而右值表達式表示的是對象的值
常規引用爲左值引用,不能將其綁定到要求轉換的表達式、字面常量或是返回右值的表達式
- 右值引用有着完全相反的綁定特性,不能將右值引用綁定到左值上
int i = 42;
int &r = i; // 正確:r 引用 i
int &&rr = i; // 錯誤:不能將一個右值引用綁定到一個左值上
int &r2 = i * 42; // 錯誤:i * 42 是右值
const int &r3 = i *42; // 正確:可以將 const 引用綁定到右值上
int &&rr2 = i * 42; // 正確:將 rr2 綁定到乘法結果上
變量是左值,故不能講一個右值引用直接綁定到變量上,即使該變量爲右值引用類型也不行。
標準庫
move
函數可獲得綁定到左值上的右值引用int &&rr1 = 42; // 字面值常量是右值 int &&rr3 = std:;move(rr1); // ok
移動構造函數和移動賦值運算符
爲了讓自定義類型支持移動操作,需要爲其定義移動構造函數和移動賦值運算符
移動構造函數第一個參數是右值引用,任何額外參數都要有默認實參
資源完成移動後,源對象必須不再指向被移動的資源,這些資源所有權已經歸屬新創建的對象
移動操作通常不分配任何資源,故通常不會拋出異常。新標準定義在函數參數列表後指定
noexcept
表示通知標準庫此函數不會拋出異常。必須在聲明和定義處都制定noexcept
標記了
noexcept
就會使用移動構造函數,否則會使用拷貝構造函數。比如
vector
的push_back
操作可能會要求vector
重新分配內存空間。若採用移動構造函數,且在移動了部分元素後拋出異常,就會產生問題,因爲移動過的源元素已經被改變。而若採用拷貝構造函數則滿足要求不同於拷貝操作,編譯器不會爲某些類合成移動操作
- 如果類定義了拷貝構造函數、拷貝賦值運算符或析構函數,就不會合成移動構造函數和移動賦值運算符
- 只有當類沒有定義任何自己版本的拷貝控制成員,且類的每個非
static
數據成員都可移動時,編譯器纔會合成移動構造函數或移動賦值運算符
與拷貝操作不同,移動操作永遠不會隱式i定義爲刪除的函數
若我們顯示要求編譯器生成
=default
的移動操作,且編譯器不能移動所有成員,則編譯器會將移動操作定義爲刪除的函數定義了一個移動構造函數或移動賦值運算符的類必須也定義自己的拷貝操作,否則,該類的合成拷貝構造函數和拷貝賦值運算符被定義爲刪除的
若一個類有一個可用的拷貝構造函數而沒有移動構造函數,則其對象是通過拷貝構造函數來移動的。拷貝賦值運算符和移動賦值運算符的情況類似
class HasPtr{
public:
HasPtr(HasPtr &&p) noexcept : ps(p.ps), i(p.i) { p.ps = 0; }
HasPtr& operator=(HasPtr rhs) { swap(*this, rhs); return *this; }
// ... 同上
}
int main()
{
HasPtr hp, hp2;
hp = hp2; // hp2 是左值; hp2 通過拷貝構造函數來拷貝
hp = std::move(hp2); // 移動構造函數移動 hp2
}
移動迭代器(move iterator):解引用生成一個右值引用,通過標準庫的
make_move_iterator
函數將普通迭代器轉換爲一個移動迭代器// 使用移動迭代器,原對象可能被銷燬 unitialized_copy(make_move_iterator(begin()), make_move_iterator(end()), first);
右值引用和成員函數
區分移動和拷貝的重載函數通常有一個版本接受一個const T&
, 另一個版本接受一個T &&
通常不需要爲函數定義接受一個
const X&&
或一個普通的X &
參數的版本。因爲移動構造函數需要竊取數據,通常傳遞右值引用,故實參不能爲const
。而拷貝構造函數的操作不應該改變該對象,故不需要普通的X &
參數的版本
// 定義了 push_back 的標準庫容器提供了兩個版本
void push_back(const X&); // 拷貝:綁定到任意類型的 X
void push_back(X&&); // 移動:只能綁定到類型 X 的可修改的左值
string s = "hao"
vector<string> vs;
vs.push_back(s); // 調用 push_back(const string&);
vs.push_back("happy"); // 調用 push_back(string &&); 精確匹配
引用限定符
與定義const
成員函數形同,通過在參數列表後指定引用限定符,指定this
的左右/右值屬性,只能用於(非static
)成員函數,且必須同時出現在函數的聲明和語義中
&
表示this
可以指向一個左值&&
表示this
指向一個右值- 引用限定符和
const
可以同時存在,const
在引用限定符前面 - 引用限定符也可區分重載
// 舊標準中會出現向右值賦值的情況
string s1 = "a value", s2 = "another";
s1 + s2 = "wow!";
// 新標準可通過引用限定符解決上述問題
class Foo{
public:
Foo &operator=(const Foo&) &; // 只能像可修改的左值賦值
// ... Foo 的其他參數
Foo someMem() & const; // 錯誤:const限定符必須在前
Foo anotherMem() const &; // 正確
Foo sorted() &&; // 用於可改變的右值,可以原址排序
Foor sorted() const &; // 對象爲const 或左值,兩種情況都不能進行原址排序
};
Foo &Foo::operator=(const Foo &rhs) &
{
// 其它工作
return *this;
}
結語
每個類都會通過拷貝構造函數、移動構造函數、拷貝賦值運算符、移動賦值運算符和析構函數控制該類型對象拷貝、移動、賦值以及銷燬操作。移動構造函數和移動賦值運算符接受一個(通常是非const
)的右值引用,而拷貝版本則接受一個(通常是const
)的普通左值引用
若類未聲明這些操作,編譯器會自動生成。若這些操作未定義成刪除的,則會逐成員初始化、移動、賦值或銷燬對象:合成的操作依次處理每個非static
數據成員,根據成員來興確定如何移動、拷貝、賦值和銷燬它
分配了內存或其他資源的類幾乎總是需要定義拷貝控制成員來管理分配的資源,如果一個類需要析構函數,則它幾乎也肯定需要定義移動和拷貝構造函數及移動和拷貝賦值運算符