C++Primer第十三章 拷貝控制
拷貝控制
13.1 拷貝、賦值與銷燬
13.1.1 拷貝建構函式
class Foo{
public:
Foo(); //預設建構函式
Foo(const Foo&); //拷貝建構函式
}
拷貝建構函式的第一個引數自身型別的引用,任何額外引數都有預設值。
合成拷貝建構函式
合成拷貝建構函式,無論是否有自定義的拷貝建構函式,編譯器都會合成拷貝建構函式。一般情況,合成的拷貝建構函式會將其引數的成員逐個拷貝到正在建立的物件中,成員的型別決定了拷貝的方式。
class Sales_data { public: //與合成的拷貝建構函式等價的拷貝建構函式 Sales_data(const Sales_data&); private: std::string bookNo; int units_sold = 0; double revenue = 0.0; }; Sales_data::Sales_data(const Sales_data &orig) : bookNo(orig.bookNo); units_sold(orig.units_sold); revenue(orig.revenus); {}
拷貝建構函式
直接初始化和拷貝初始化之間的差異:
直接初始化是編譯器使用函式匹配來選擇與我們提供的引數匹配的建構函式。
拷貝初始化將右側運算物件拷貝到正在建立的物件中。
拷貝初始化發生的情況有:
- 用“=”定義變數
- 將一個物件作為實參傳遞給一個非引用型別的形參
- 從一個返回型別為非引用型別的函式返回一個物件
- 用花括號列表初始化一個數組中的元素或聚合類中的成員。
- 呼叫
insert
或push/push_back
成員,容器會對元素進行拷貝初始化,呼叫emplace
成員會進行直接初始化。
拷貝建構函式自己的引數必須是引用型別?
函式呼叫的過程中,對非引用型別的引數進行拷貝初始化。如果拷貝建構函式自己的引數不是引用型別,為了呼叫拷貝建構函式,就要拷貝其實參,然後有需要呼叫拷貝建構函式,無限迴圈。
13.1.2拷貝賦值運算子
過載運算子的引數表示運算子的運算物件。賦值運算子必須定義為成員函式。如果一個運算子為成員函式,左側運算物件繫結到隱式的this引數,二元運算子右側運算物件作為顯示引數傳遞。
class Foo{
public:
Foo& operator=(const Foo&); //賦值運算子
};
賦值運算子通常返回一個指向其左側運算物件的引用。
合成拷貝賦值運算子
Salse_data& Sales_data::operaotr=(const Sales_data &rhs) { bookNo = rhs.bookNo; //呼叫string::operator = units_sold = rhs.units_sold;//使用內建的Int賦值 revenue = rhs.revenue; //使用內建的double賦值 return *this; }
13.1.3 解構函式
解構函式釋放物件使用的資源,並銷燬物件的非static成員。
class Foo{
~Foo(); //解構函式
};
解構函式不接受任何引數,無法過載。
解構函式先執行函式體,然後按照初始化順序的逆序銷燬成員。隱式銷燬一個內建指標型別的成員不會delete它所指向的物件。
什麼時候呼叫解構函式
- 變數在離開作用域時被銷燬
- 當一個物件被銷燬時,其成員被銷燬
- 容器被銷燬時,其元素被銷燬
- 對於動態分配的物件,當對指向它的指標使用delete時被銷燬
- 對於臨時物件,當建立它的完整表示式結束時被銷燬
當指向一個物件的引用或指標離開作用域時,解構函式不會執行。
合成解構函式
當一個類未定義自己的解構函式時,編譯器會為它定義一個解構函式。
class Sales_data {
public:
~Sales_data() {}
};
解構函式體執行完畢後成員會被自動銷燬。解構函式體不直接銷燬成員,成員在解構函式體之後隱含的階段中被銷燬。
13.1.4 三/五法則
需要解構函式的類也需要拷貝和賦值操作
給HasPtr定義一個解構函式,但是使用合成的拷貝建構函式和賦值函式。
class HasPtr {
public:
HasPtr(const std::string &s = std::string()) : ps(new std::string(s)), i(0) {}
~HasPtr() {
delete ps;
}; //解構函式
private:
std::string *ps;
int i;
};
HasPtr f(HasPtr hp) {
HasPtr ret = hp; //拷貝給定的HasPtr;
return ret; //ret和hp銷燬
}
HasPtr p("some values");
f(p); //f結束的時候1,p.ps指向的記憶體被釋放
HasPtr q(p); //p和q指向無效記憶體
合成建構函式和賦值函式只進行簡單拷貝指標成員,多個HasPtr物件指向相同的記憶體。f返回的時候hp和ret銷燬,呼叫~HasPtr()會Delete ret和Hp的指標成員,但是兩個指標成員指向同一個地址,指標會delete兩次。
一個類需要自定義解構函式,幾乎可以肯定需要自定義拷貝賦值運算子和拷貝建構函式
需要拷貝操作的類也需要賦值操作,反之亦然,但是需要拷貝和賦值的類不一定需要解構函式
13.1.5 使用=default
將拷貝控制成員定義為=default
來顯式的要求編譯器生成合成的版本。
13.1.6 阻止拷貝
對於某些類來說無法執行拷貝和賦值操作,比如iostream
類阻止了拷貝,避免多個物件寫入或讀取相同的IO緩衝,需要一種機制來阻止拷貝。
定義刪除函式
引數列表後面加上=delete
表示定義刪除函式。
struct Nocopy{
Nocopy() = default; //預設的建構函式
Nocopy(const Nocopy&) = delete; //阻止拷貝
Nocopy& operator=(const Nocopy&) = delete; //阻止賦值
~Nocopy() = default; //使用合成的解構函式
};
解構函式不能是刪除的成員
如果刪除了解構函式,我們就無法銷燬這個物件。因此對於解構函式已刪除的型別,不能定義該型別的變數或釋放指向該型別的動態指標。
合成的拷貝控制成員可能是刪除的
如果一個類有資料成員不能預設構造、拷貝、複製或銷燬,則對應的成員函式將被定義為刪除的。這一類資料成員有:
- 一個成員有刪除的或不可訪問的解構函式(比如是private)會導致合成的預設和拷貝建構函式定義為刪除的。
- 對於具有引用成員或無法預設構造的const成員的類。不可能給一個const 成員賦新值,對於引用的成員,如果我們修改了引用的值相當於是修改了引用物件的值。
希望阻止拷貝的類應該使用=delete
來定義它們自己的拷貝建構函式和拷貝賦值函式,而不應該將它們宣告為private的
13.2 拷貝控制和資源管理
13.2.1 行為像值的類
類的行為像值,說明類有自己的狀態,當拷貝一個像值的物件時,副本和原物件是完全獨立的。改變副本不會改變原物件的值。為了提供類值的行為,每個物件都應該有一個自己的拷貝。
類值拷貝賦值運算子
賦值運算子組合了拷貝建構函式和解構函式的工作。向左側物件賦值時需要釋放左側運算物件的資源,還需要從右側運算物件中拷貝資料。操作要按照正確的順序執行,保證處理好異常的情況。
class HasPtr {
public:
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 &hp) {
auto new_p = new std::string(*hp.ps); //先拷貝底層的string
delete ps; //釋放舊記憶體
//執行拷貝建構函式相似的操作。
ps = new_p;
i = hp.i;
return *this;
}
~HasPtr() { delete ps; }
private:
std::string *ps;
int i;
};
編寫賦值運算子時,先將右側運算物件拷貝到一個臨時物件中,然後釋放左側運算物件現有成員,最後將臨時物件的拷貝到左側運算物件的成員中。
13.2.2 定義行為像指標的類
定義行為指標的類可以採用shared_ptr
,如果想直接管理資源,最好採用引用計數。
引用計數的工作方式:
- 每個建構函式建立一個物件時還要建立一個引用計數,並初始化為1
- 拷貝建構函式不分配新的計數器,拷貝給定物件的資料成員包括計數器。
- 解構函式遞減計數器
- 拷貝賦值運算子遞增右側物件的計數器,遞減左側物件的計數器。
我們可以採用動態記憶體的方式存放引用計數器
13.6 物件移動
使用移動而不是拷貝可以大幅度提高效能,而且有些類比如iostream
,unique_ptr
不包含共享的資源無法拷貝,只能採用移動的方式。
13.6.1 右值引用
右值引用:繫結到右值的引用,使用"&&"來獲取右值引用,右值引用只能繫結到一個將要銷燬的物件,與左值引用完全相反,右值引用可以繫結到要求轉換的表示式、字面常量、返回右值的表示式。
int i = 42;
int &r = i; //right
int &&rr = i; //error,不能用右值引用繫結到一個左值上
int &r2 = i * 42; //錯誤,i * 42是一個右值
const int &r3 = i * 42; //正確,const的引用可以繫結右值
int &&rr2 = i * 42; //right,右值引用可以繫結表示式
右值引用只能繫結到臨時物件:
- 所引用的物件將要被銷燬
- 該物件沒有其他使用者
說明右值引用可以自由接管所引用物件的資源。
變量表達式都是左值,不能將右值引用繫結到一個右值引用型別的變數上。
int &&rr1 = 42 //right,字面常量是一個右值
int &&rr2 = rr1; //error,rr1是一個左值
可以顯示的將一個左值轉換為對應的右值引用型別。
int &&r3 = std::move(rr1);
move告訴編譯器,有一個左值希望像一個右值一樣處理它,但是對於移後源物件我們可以銷燬或者賦新值,但是不能再使用移後源物件的值。
13.6.2 移動建構函式和移動賦值運算子
移動建構函式的第一個引數為右值引用,使用移動建構函式必須確保移後源物件處於銷燬它是無害的狀態,而且一旦完成資源的移動移動源物件就不能再指向被移動的資源。移動操作是竊取資源而非拷貝資源。
StrVec::StrVec(StrVec &&s) noexcept : elements(s.elements), first_free(s.first_free), cap(s.cap) {
s.elements = s.first_free = s.cap = nullptr;
}
由上面程式碼可知,在我們移動資源過後,我們可以確保移動過後移動源物件的銷燬都是無害的。
移動操作、標準庫容器和異常
通過在移動建構函式的引數列表後面加上noexcept來告訴標準庫,移動建構函式沒有任何異常。
class StrVec{
public:
StrVec(StrVec &&) noexcept; //移動建構函式
};
StrVec::StrVec(StrVec &&s) noexcept;
不丟擲異常的移動建構函式和移動賦值運算子必須標記為noexcept
移動賦值運算子
StrVec &StrVec::operator = (StrVec &&rhs) noexcept {
//檢測自賦值
if (this != &rhs) {
free();
elements = rhs.elements;
first_free = rhs.first_free;
cap = rhs.cap;
rhs.elements = rhs.first_free = rhs.cap = nullptr;
}
return *this;
}
移後源物件必須可析構
移後源物件必須保持有效的、可析構的狀態,使用者不能對其值做任何假設。
合成的移動操作
只有當一個類沒有定義任何自己版本的拷貝控制成員,且他的所有資料成員都能移動建構函式或移動賦值時,編譯器才會合成移動建構函式或移動賦值運算子。
struct X{
int i;
std::string s;
};
struct hasX {
X mem; //X有合成的移動操作
};
X x, x2 = std::move(x);
hasX hx, hx2 = std::move(hx); //使用合成的移動建構函式
移動函式永遠不會被定義為隱式的函式,如果顯式地要求編譯器生成=default
移動操作,但是編譯器無法移動所有成員,移動操作就會被定義為刪除的移動函式。
移動函式被定義為刪除的移動函式的條件:
- 類成員定義了自己的拷貝建構函式但是未定義移動建構函式,或類成員未定義自己的拷貝建構函式且編譯器不能為其合成移動建構函式。
- 類成員的移動建構函式或移動賦值運算子被定義為刪除的或是不可訪問的。
- 類的解構函式被定義為刪除的或不可訪問的,移動建構函式被定義為刪除的
- 有類成員是const或是引用,類的移動賦值運算子被定義為刪除的。
//假定y為定義了拷貝建構函式但是未定義自己的拷貝建構函式
struct hasY {
hasY() = default;
hasY(hasY &&) = default;
Y mem;
};
hasY hy, hy2 = std::move(hy); //error,移動建構函式為刪除的
如果一個類定義了移動建構函式/移動賦值運算子,則合成的拷貝建構函式/合成的賦值運算子被定義為刪除的
移動右值,拷貝左值
一個類既有拷貝建構函式又有移動建構函式,按照函式匹配規則來選擇。
StrVec v1, v2;
v1 = v2 ; //v2為左值,呼叫拷貝賦值
StrVec getVec(istream &); //getVec返回一個右值
v2 = getVec(cin); //使用移動賦值
```c++
如果沒有移動建構函式,右值也會被拷貝.
```c++
class FOO{
public:
Foo() = default;
Foo(const Foo&);
};
Foo x;
Foo y(x);
Foo z(std::move(x)); //拷貝建構函式,因為未定義移動建構函式
13.6.3 右值引用和成員函式
區分移動和拷貝的過載函式通常有一個版本接受一個const T&
, 而另一個版本接受一個T&&
class StrVec {
public:
void push_back(const std::string &);
void push_back(std::string &&);
};
void StrVec::push_back(const std::string &s) {
chk_n_alloc();
alloc.construct(first_free++, s);
}
void StrVec::push_back(std::string &&s) {
chk_n_alloc();
alloc.construct(first_free++, std::move(s));
}
StrVec vec;
string s = "string";
vec.push_back(s); //呼叫push_back(const std::string &);
vec.push_back("c++") //呼叫push_back(std::string &&);
右值和左值引用成員函式
string s1 = "a value", s2 = "another";
auto n = (s1 + s2).find('a'); //一個右值string呼叫find成員函式
s1 + s2 = "wow"; //對右值進行賦值
新標準庫中允許對右值進行賦值,但是有時候想要阻止自己的類使用這種方法,可以在引數列表後面加上引用限定符來強制左側運算物件是一個左值/右值。
class Foo {
public:
Foo &operator=(const Foo&) &; //只能向可修改的左值賦值
};
Foo &Foo::operator=(const Foo &rhs) & {
return *this;
}
引用限定符(&和&&),只能用於成員函式,且必須同時出現在函式的宣告和定義中。
&
限定的函式只能用於左值,對於&&
限定的函式只能用於右值。
Foo &retFoo(); //返回一個引用,retFoo呼叫是一個左值
Foo retVal(); //返回一個值,retVal呼叫是一個右值
Foo i, j; //i 和 j是一個左值
i = j; //right
retFoo() = j; //retFoo返回一個左值
retVal() = j; //error, retVal返回一個右值
i = retVal(); //right
一個函式可以同時使用const限定符和引用限定符,但是引用限定符必須在const限定符之後。
過載和引用函式
class Foo {
public:
Foo sorted() &&;
Foo sorted() const &;
private:
vector<int> data;
};
//物件為一個右值,可以對原址排序
Foo Foo::sorted() && {
sort(data.begin(), data.end());
return *this;
}
//物件為const或是一個左值,不能對原址進行排序需要拷貝
Foo Foo::sorted() const & {
Foo ret(*this);
sort(ret.data.begin(), ret.data.end());
return ret;
}
retVal().sorted(); //retVal()為一個右值,呼叫Foo::sorted() &&
retFoo().sorted(); //retFoo()為一個左值,呼叫Foo::sorted() const &
定義兩個或兩個以上具有相同名字和相同引數列表的成員函式,必須對所有函式都加上引用限定符,或者所有都不加。
class Foo {
public:
Foo sorted() &&;
Foo sorted() const ; //error,必須加上引用限定符
using Comp = bool(const int &, const int &);
Foo sorted(Comp*);
Foo sorted(Comp*) const ; //right,兩個版本都沒有引用限定符
};
如果一個成員函式具有引用限定符,則具有相同引數列表的所有版本都必須具有引用限定符。