文章目錄
引言
當定義一個類時,我們顯式地或隱式地在此類型的對象拷貝、移動、複製和銷燬時做什麼。一個類通過定義五種特殊的成員函數來控制這些操作,包括:
- 拷貝構造函數
- 拷貝賦值運算符
- 移動構造函數
- 移動複製運算符
- 析構函數
拷貝與賦值
拷貝構造函數
class Foo{
public:
Foo();
Foo(const Foo&);
};
拷貝構造函數的第一個參數必須是一個引用類型,雖然我們可以定義一個接受非const
引用的拷貝構造函數,但此參數幾乎總是一個const的引用。拷貝構造函數在幾種情況下都會被隱式地使用。因此,拷貝構造函數通常不應該是explicit的
拷貝複製運算符
class Foo{
Foo& operator=(const Foo&);
}
賦值運算符通常應該返回一個指向左側運算對象的引用
三五法則
需要析構函數的類也需要拷貝和賦值操作
當我們決定一個類是否要定義它自己版本的拷貝控制成員時,一個基本原則是首先確定這個類是否需要一個析構函數。通常,對析構函數的需求要比拷貝構造函數或賦值運算符的需求更加明顯。如果這個類需要一個析構函數,我們幾乎肯定它也需要一個拷貝構造函數和一個拷貝複製運算符
(一個類有析構函數肯定是有分配在堆上的內存需要釋放,那麼如果不定義拷貝構造函數和拷貝複製運算符編譯器就會調用合成版本導致淺拷貝,即多個對象指向同一個內存)
需要拷貝操作的類也需要複製操作,反之亦然
使用=default
我們可以通過將拷貝控制成員定義爲=default來顯示地要求編譯器生成合成的版本
class Sales_ data{
public:
//拷貝控制成員;使用default
Sales_ data() = default;
Sales_ data (const Sales_ data&) = default;
Sales_ data& operator= (const Sales_ data &);
~Sales_ data() = default;
//其他成員的定義,如前
};
Sales_ data& Sales_ data: :operator= (const Sales_ data&) = default;
我們只能對具有合成版本的成員函數使用=default(即,默認構造函數或拷貝控制成員)
阻止拷貝
雖然大多數類應該定義拷貝構造函數和拷貝賦值運算符,但對某些類來說,這些操作沒有合理的意義。在此情況下,定義類時必須採用某種機制阻止拷貝或賦值。例如,iostream類阻止了拷貝,以避免多個對象寫入或讀取相同的IO緩衝。爲了阻止拷貝,看起來可能應該不定義拷貝控制成員。但是,這種策略是無效的;如果我們的類未定義這些操作,編譯器爲它生成合成的版本。
定義刪除的函數
在新標準下,我們可以通過將拷貝構造函數和拷貝賦值運算符定義爲刪除的函數來組織拷貝。刪除的函數是這樣一種函數:我們雖然聲明瞭它們,但不能以任何方式使用它們。在函數的參數列表後面加上=delete來指出我們希望將它定義爲刪除的。
struct NoCopy{
NoCopy() = default;
NoCopy(const NoCopy&) = delete;
NoCopy &operator=(const NoCopy&) = delete;
~NoCopy() = default;
}
析構函數不能是刪除的成員
合成的拷貝控制成員可能是刪除的
- 如果類的某個成員的析構函數是刪除的或不可訪問的(例如,是private的),
則類的合成析構函數被定義爲刪除的。 - 如果類的某個成員的拷貝構造函數是刪除的或不可訪問的,則類的合成拷貝構造函
數被定義爲刪除的。如果類的某個成員的析構函數是刪除的或不可訪問的,則類合
成的拷貝構造函數也被定義爲刪除的。 - 如果類的某個成員的拷貝賦值運算符是刪除的或不可訪問的,或是類有一個const
的或引用成員,則類的合成拷貝賦值運算符被定義爲刪除的。 - 如果類的某個成員的析構函數是刪除的或不可訪問的,或是類有一個引用成員,它
沒有類內初始化器(參見2.6.1節,第65頁),或是類有一個const成員,它沒有類內初始化器且其類型未顯式定義默認構造函數,則該類的默認構造函數被定義爲
刪除的。
定義行爲像值的類
行爲像值的類即每個對象之間沒有關聯,就跟值一樣,如int類,string類等
class HasPtr{
public:
HasPtr (const std: :string &s = std: :string()):ps(new std: :string(s)),i(0) {}
//對ps指向的string,每個HasPtr對象都有自已的拷貝
HasPtr (const HasPtr &p) :
ps (new std: :string(*p.ps)), i(p.i) { }
HasPtr& operator= (const HasPtr &) ;
~HasPtr() { delete ps; }
private:
std: :string *ps;
int i;
};
HasPtr& HasPtr: :operator= (const HasPtr &rhs){
auto newp = new string(*rhs.ps); // 拷貝底層string
delete ps;
// 釋放舊內存
ps = newp;
/1從右側運算對象拷貝數據到本對象
i = rhs.i;
return *this; //返回本對象
}
定義行爲像指針的類
行爲像指針的類是多個對象共享底層數據,只有最後一個對象析構後底層數據纔會釋放。
對於行爲類似指針的類,我們需要爲定義拷貝構造函數和拷貝賦值運算符,來拷貝指針成員本身而不是它指向的數據。
令一個類展現類似指針的行爲的最好方法是使用shared_ptr來管理類中的資源。拷貝(或賦值)一個shared_ptr會拷貝(賦值)shared_ptr所指向的指針。shared_ptr類自己記錄有多少用戶共享它所指向的對象。當沒有用戶使用對象時,shared_ptr類負責釋放資源。
這裏我們直接使用引用計數的方法定義一個類
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的函數。
class HasPtr {
friend void swap (HasPtr&,HasPtr&) ;
};
inline void swap (HasPtr &lhs, HasPtr &rhs){
using std: : swap;
swap (1hs.ps,rhs.ps) ; // 交換指針, 而不是string數據
swap(1hs.i, rhs.i) ; //交換int成員
}
對象移動
新標準的一個最主要的特性是可以移動而非拷貝對象的能力。很多情況下都會發生對象拷貝。在其中某些情況下,對象拷貝後就立即被銷燬了。在這些情況下,移動而非拷貝對象會大幅度提升性能。使用移動而不是拷貝的另一個原因源於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函數
我們可以通過調用一個名爲move的新標準庫函數來獲得綁定到左值上的右值引用
int&& rr3 = std::move(rr1);
move調用高速編譯器:我們有一個左值,但我們希望像一個右值一樣處理它。我們必須認識到,調用move就意味着承諾:除了對rr1賦值或銷燬它外,我們將不再使用它。在調用move之後,我們不能對移後源對象的值做任何假設。
移動構造函數和移動複製運算符
爲了讓我們自己的類型支持移動操作,需要爲其定義移動構造函數和移動複製運算符,這兩個成員類似對應的拷貝操作,但它們從給定對象“竊取”資源而不是拷貝資源。
StrVec: :StrVec (StrVec &&s) noexcept //移動操作不應拋出任何異常
//成員初始化器接管s中的資源
: elements (s.elements), first_ free(s.first_ free), cap(s. cap)
{
//令s進入這樣的狀態一 對其運行析構函數是安全的
s.elements = s.first_ free = s.cap = nullptr;
}
StrVec &StrVec: :operator= (StrVec &&rhs) noexcept
{
//直接檢測自賦值
if (this != &rhs) {
free() ;
//釋放已有元素:
elements = rhs.elements; // 從rhs接管資源
first_ free = rhs.first_ free;
cap = rhs.cap; //將rhs置於可析構狀態
rhs.elements = rhs.first_ free = rhs.cap = nullptr;
}
return *this;
}
由於移動操作“竊取"資源,它通常不分配任何資源。因此,移動操作通常不會拋出任何異常。當編寫一個不拋出異常的移動操作時,我們應該將此事通知標準庫。我們將看到,除非標準庫知道我們的移動構造函數不會拋出異常,否則它會認爲移動我們的類對象時可能會拋出異常,並且爲了處理這種可能性而做一些額外的工作。
只有當一個類沒有定義任何自己版本的拷貝控制成員,且它的所有數據成員都能移動構造或移動賦值時,編譯器纔會爲它合成移動構造函數或移動複製運算符。