【C++】C++的拷贝控制
目錄結構:
contents structure [-]
定義一個類,會顯式或隱式指定此類型的對象拷貝、移動、賦值和銷毀時做什么。類通過定義五種特殊的成員函數來控制這些操作,包括:拷貝構造函數(copy constructor)、拷貝賦值運算符(copy-assignment operator)、移動構造函數(move constructor)、移動賦值運算符(move-assignment operator)和析構函數(destructor)。
其中拷貝構造和移動構造器定義了當用同類型的另一個對象初始化本對象時的行為。而拷貝賦值和移動賦值定義了將一個對象賦予同類型的另一個對象時的行為。析構函數定義了此類型對象銷毀時做什么。這些統稱為拷貝控制操作。
對于沒有顯式定義這些成員的,編譯器會自動定義默認的版本。一些類必須要自己定義拷貝控制成員,另外一些則不需要。所以,何時需要自己去定義就考察程序員的功底了。
?
1 拷貝、賦值與銷毀
移動語義是C++11新引入的,過后再談。
1.1 拷貝構造函數
僅有一個參數為自身類類型引用的構造函數就是拷貝構造函數,形如:
class Foo{ public:Foo(); //默認構造函數Foo(const Foo&); //拷貝構造函數 }該參數必須是引用類型,一般是const引用。由于拷貝構造函數會在幾種情況下隱式地調用,所以一般不是explicit。
如果自己不定義,編譯器就會合成一個默認的(合成拷貝構造函數)。合成的拷貝構造函數會把參數成員逐個拷貝到正在創建的對象中(非static成員)。
成員的類型決定了拷貝的方式:類類型的成員會用它自己的拷貝構造函數來拷貝;內置類型則直接值拷貝。數組會逐個復制,如果數組成員是類類型,會逐個調用成員本身的拷貝構造函數。
?
1.1.1 拷貝初始化
拷貝初始化和直接初始化的差異:
拷貝初始化一般由拷貝構造函數完成,之所以說一般是因為移動語義的引入,導致如果類由移動構造函數時,拷貝初始化有時會使用移動構造函數而非拷貝構造函數。
拷貝初始化不僅在用=定義變量時發生,在下列情形也會發生:
將一個對象作為實參傳遞給一個非引用類型的形參
從一個返回類型為非引用類型的函數返回一個對象
用花括號列表初始化一個數組中的元素或一個聚合類中的成員
某些類類型還會對它們所分配的對象使用拷貝初始化。如初始化標準庫容器或調用其insert或push成員時,容器會對其元素進行拷貝初始化。而emplace創建的元素都是直接初始化。
1.1.2 參數和返回值
拷貝構造函數被用來初始化非引用類類型參數,所以拷貝構造函數自身的參數必須是引用類型。不然的話,就二者矛盾而無限循環了。
為了調用拷貝構造函數,我們必須拷貝它的實參,但為了拷貝實參,我們又需要調用拷貝構造函數。
?
1.2 拷貝賦值運算符
Sales_data trans, accum; trans = accum; //使用Sales_data的拷貝賦值運算符如果類未定義,編譯器會合成一個(合成拷貝賦值運算符)。
這個函數的定義涉及了重載運算符的概念,這里重載的是賦值運算符。
重載運算符本質上是函數,名字由operator關鍵字接要定義的運算符符號組成。所以,賦值運算符就對應operator=的函數。
重載運算符的參數表示運算符的運算對象,某些運算符包括賦值必須定義為成員函數。如果一個運算符是一個成員函數,其左側運算對象就綁定到隱式的this參數。對一個二元運算符,例如賦值運算符,右側運算對象作為顯式參數傳遞。
拷貝賦值運算符接受一個與其所在類相同類型的參數:
為了與內置類型的賦值保持一直,賦值運算符通常返回一個指向其左側運算對象的引用。另外,標準庫通常要求保存在容器中的類型具有賦值運算符,且返回值是左側運算符對象的引用。
編譯器合成的拷貝賦值運算符類似拷貝構造,也是逐一進行成員拷貝(非static),類類型通過它自身的拷貝賦值運算符來完成,數組成員為類類型的,也會逐一調用自身的拷貝賦值運算符。最后,返回一個指向左側運算對象的引用。
?
1.3 析構函數
與構造執行的操作相反。
析構函數名字比構造函數多了一個~。沒有返回值,也沒有參數。
析構函數不能被重載,是惟一的。
調用析構的時機:
變量在離開作用域時被銷毀
當一個對象被銷毀時,其成員被銷毀
容器被銷毀時(標準庫容器或數組),其元素被銷毀
動態分配的對象,當對指向它的指針應用delete時被銷毀
臨時對象,當創建它的完整表達式結束時被銷毀
如果類未定義析構,則編譯器會自動合成(合成析構函數)。
class Sales_data{ public://成員會被自動銷毀,除此之外不需要做其他事情~Sales_data(){}//其他成員的定義 ... };析構函數體(空)執行完畢后,成員會被自動銷毀。本例中string的析構函數會被調用,釋放bookNo的內存。析構函數體本身不直接銷毀成員,它們是在函數體之后隱含的析構階段中被銷毀的。析構函數體只是析構過程的一部分。
?
2 三五法則
這里解釋一下三五法則(分別是Three Rule,Five Rule)。Three Rule指的是定義的類有拷貝構造函數,拷貝賦值運算符,和析構函數。而Five Rule就是除了前面的三種,還有移動賦值運算符,移動構造函數。
這里是一個Five Rule的案例:
class rule_of_five {char* cstring; // raw pointer used as a handle to a dynamically-allocated memory blockpublic:rule_of_five(const char* s = ""): cstring(nullptr){ if (s) {std::size_t n = std::strlen(s) + 1;cstring = new char[n]; // allocatestd::memcpy(cstring, s, n); // populate } }~rule_of_five(){delete[] cstring; // deallocate }rule_of_five(const rule_of_five& other) // copy constructor : rule_of_five(other.cstring){}rule_of_five(rule_of_five&& other) noexcept // move constructor : cstring(std::exchange(other.cstring, nullptr)){}rule_of_five& operator=(const rule_of_five& other) // copy assignment {return *this = rule_of_five(other);}rule_of_five& operator=(rule_of_five&& other) noexcept // move assignment {std::swap(cstring, other.cstring);return *this;}// alternatively, replace both assignment operators with // rule_of_five& operator=(rule_of_five other) noexcept // { // std::swap(cstring, other.cstring); // return *this; // } };更詳細的可以查看:https://en.cppreference.com/w/cpp/language/rule_of_three
?
需要析構函數的類也需要拷貝和賦值操作。
因為析構函數需要去手工delete成員指針。這種情況下,編譯器合成的拷貝構造和賦值運算符就會有問題,因為僅僅只是完成了淺拷貝,拷貝了成員指針的地址值,這可能引起問題。所以這種情況我們要自己寫深拷貝代碼。
需要拷貝操作的類也需要賦值操作,反之亦然
因為語義上拷貝構造和賦值操作是一致的,只是調用時機不同。提供了一個就說明需要特化某些操作,那么對應的另一個也要一致。但需要二者卻不一定需要一個析構。
=default
=default可以顯式地要求編譯器生成合成的版本。
類內使用=default聲明,合成的函數會隱式地聲明為inline。
=delete
有些情況我們希望阻止類的拷貝或賦值。比如iostream就阻止了拷貝,避免多個對象寫入或讀取相同的IO緩沖。
=delete通知編譯器,不希望定義這些成員。
注意,析構函數不能刪除,其他任何函數都可以指定=delete。雖然語法上允許析構函數指定=delete,但這樣一來涉及到該類的對象都不能用,因為它無法銷毀。
所以,記著析構函數不能加=delete這條軟規則即可。
如果一個類有數據成員不能默認構造、拷貝、復制或銷毀,那么對應的成員函數將被定義為刪除的。這就意味著,composite模式的數據成員自身殘疾將影響整個團隊殘疾。
具有引用成員或無法默認構造的const成員的類,編譯器不會合成默認構造函數。如果類有const成員,則它不能使用合成的拷貝賦值運算符(新值是不能給const對象的)。
在沒有=delete之前,C++是通過private權限限制拷貝構造函數和拷貝賦值運算符來阻止拷貝的。這種方法有一個疏漏,就是友元函數和成員函數是可以進行拷貝的。
?
3 拷貝控制和資源管理
類一旦管理了類外資源,往往就需要自定義析構,根據三五法則也就意味著要自定義拷貝構造和拷貝賦值運算符。
而定義拷貝控制成員時,首先要確定類的拷貝語義,我們是讓類的行為看起來像值還是像指針。
如果是像值,比如string、標準庫容器類等,它們的拷貝會使得副本對象和原對象完全獨立,改變副本不會影響原對象。
如果是像指針,比如shared_ptr,那么拷貝的就是指針,指向的是同一個對象。
當然,也可以設置為不允許拷貝或賦值,此時既不像值也不像指針。
行為像值的類
賦值運算符要謹記一個好習慣,在銷毀左側運算對象資源之前先拷貝右側運算對象資源。
行為像指針的類
賦值運算符要考慮自賦值的情況,所以在左側遞減引用計數之前先遞增右側引用計數。
?
4 交換操作
除了拷貝控制成員外,管理資源的類一般還定義一個swap函數。對與重排元素順序的算法一起使用的類來說,swap非常重要,因為這些算法交換兩個元素時會調用swap。
如果類自己定義了swap,算法就使用自定義版本,否則使用標準庫定義的swap。
swap不是必要的,但對分配了資源的類來說,定義swap是一種很重要的優化手段。
swap定義的一個坑:
這種未加限定的寫法之所以可行,本質上是因為類型特定的swap版本匹配程度優于聲明的std::swap版本。而對std::swap的聲明可以使得在找不到類型特定版本時可以正確的找到std中的版本。
swap常用于賦值運算符,它可以一步到位完成拷貝并交換的技術。
這里的參數不是引用,右側運算對象是值傳遞,所以rhs是右側運算對象的副本。因此直接swap就一步到位了,自動銷毀rhs時就自動銷毀了原對象(執行析構)。
使用拷貝和交換的賦值運算符天生異常安全,且能正確處理自賦值。
?
5 對象移動
C++11引入了一個特性:可以移動而非拷貝對象。移動而非拷貝對象會大幅度提升性能。
舊版本即使在不必拷貝對象的情況下,也不得不拷貝,對象如果巨大,那么拷貝的代價是昂貴的。在舊版本的標準庫中,容器所能保存的類型必須是可拷貝的。但在新標準中,可以用容器保存不可拷貝,但可移動的類型。
標準庫容器、string和shared_ptr類既支持移動也支持拷貝。IO類和unique_ptr類可以移動但不能拷貝。
?
5.1 右值引用
為了支持移動操作,C++11引入了一個新的引用類型——右值引用(rvalue reference)。所謂右值引用就是必須綁定到右值的引用。通過&&來獲得右值引用(左值引用是通過&)。右值引用只能綁定到一個將要銷毀的對象。因此,才得以自由地將一個右值引用的資源轉移給另一個對象。
最特別的就是const左值引用是可以綁定到右值的。
變量表達式都是左值,所以不能將一個右值引用直接綁定到一個變量上,即使這個變量的類型是右值引用也不行。
左值是持久的,右值是短暫的。
5.2 標準庫move函數
雖然右值引用不能綁定到左值,但可以顯式地將左值轉換為對應的右值引用類型。調用move函數可以獲得綁定在左值上的右值引用,此函數定義在頭文件utility中。
move告訴編譯器:我們有一個左值,但我們希望像一個右值一樣處理它。但使用move就意味著承諾:除了對rr1賦值或銷毀它外,我們將不再使用它。
可以銷毀一個移后源對象,也可以賦予它新值,但不能使用移后源對象的值。
調用move函數的代碼應該使用std::move而非move,這樣做可以避免潛在的名字沖突。
?
5.3 移動構造函數
移動構造函數類似拷貝構造,第一個參數是該類類型的引用。不同于拷貝構造函數,這個引用參數在移動構造函數中是一個右值引用。其他任何額外參數都必須有默認值(與拷貝構造一致)。
除了完成資源移動,移動構造函數還要保證移后源對象處于一個狀態:銷毀它是無害的。移動之后,源對象必須不再指向被移動的資源,這些資源歸新對象所有。
移動構造函數不會分配任何新內存,它接管給定的StrVec的內存。接管之后,源對象的指針置nullptr。
移動操作通常不分配任何資源,因此移動操作通常不拋出任何異常。而通過noexcept可以通知標準庫構造函數不會拋出異常,如果不通知,那么標準庫會認為移動構造函數可能會拋出異常,為此會做一些額外的工作。
為什么要指出移動操作不拋出異常呢?因為標準庫能對異常發生時其自身的行為提供保證,比如vector保證push_back時發生異常不會改變vector本身。
之所不異常時不改變vector,是因為拷貝構造函數中發生異常時,舊元素的內存空間是沒有變化的,至于新內存空間盡管發生了異常,vector可以直接釋放新分配的內存(尚未成功構造)并返回,這不會影響vector原有的元素。但移動語義就不同,如果移動了部分元素時發生了異常,那么這時源元素就已經被改變了,這就無法滿足自身保持不變的要求了。
所以除非vector知道元素類型的移動構造函數不會拋異常,否則在重新分配內存時,它必須使用拷貝構造而不是移動構造。基于此,如果希望vector重新分配內存時可以使用自定義類型對象的移動操作而不是拷貝操作,那就要顯式的聲明我們的移動構造函數是noexcept的。
?
5.4 移動賦值運算符
類似移動構造,如果不拋出任何異常,也要標記為noexcept。
?
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; }?
5.5 合成的移動操作
如果自己不定義,編譯器也會自動合成移動操作,但這和拷貝操作不同,它需要一些條件。
如果一個類定義了自己的拷貝構造函數、拷貝賦值運算符或者析構函數,編譯器就不會合成移動操作。
只有當一個類沒有定義任何自己版本的拷貝控制成員,且類的每個非static數據成員都可以移動時,編譯器才會為它合成移動構造和移動賦值運算符。
與拷貝操作不同,移動操作永遠不會被隱式定義為刪除的函數。但如果顯式地要求編譯器生成=default的移動操作,且編譯器不能移動全部成員,則移動操作會被定義為刪除的函數。
定義了移動構造或移動賦值的類也必須定義自己的拷貝操作,否則拷貝操作默認被定義為刪除的。
如果類既有移動構造,也有拷貝構造,那么編譯器使用普通的函數匹配規則來確定使用哪個構造函數。賦值也類似。
如果類有拷貝構造,但沒有移動構造,函數匹配規則會保證該類型的對象會被拷貝:
class Foo{ public:Foo() = default;Foo(const Foo&);... }; Foo x; Foo y(x); //拷貝構造函數,x是左值 Foo z(std::move(x)); //拷貝構造函數,因為未定義移動構造函數在未定義移動構造的情境下,Foo z(std::move(x)之所以可行,是因為我們可以把Foo&&轉換為一個const Foo&。
五個拷貝控制成員應該當成一個整體來對待。如果一個類需要任何一個拷貝操作,它就應該定義所有五個操作。
C++11標準庫定義了移動迭代器(move iterator)適配器。一個移動迭代器通過改變給定迭代器的解引用運算符的行為來適配此迭代器。移動迭代器的解引用運算符返回一個右值引用。調用make_move_iterator函數能將一個普通迭代器轉換成移動迭代器。原迭代器的所有其他操作在移動迭代器中都照常工作。
最好不要在移動構造函數和移動賦值運算符這些類實現代碼之外的地方隨意使用move操作。std::move是危險的。
?
5.6 右值引用和成員函數
在非static成員函數的形參列表后面添加引用限定符(reference qualifier)可以指定this的左值/右值屬性。引用限定符可以是&或者&&,分別表示this可以指向一個左值或右值對象。引用限定符必須同時出現在函數的聲明和定義中。
一個非static成員函數可以同時使用const和引用限定符,此時引用限定符跟在const限定符之后。
class Foo { public:Foo someMem() & const; // errorFoo anotherMem() const &; // ok };引用限定符也可以區分成員函數的重載版本。
如果定了兩個或兩個以上具有相同名字和相同參數列表的成員函數,要么都加引用限定符,要么都不加,這一點不受const this的影響。
?
原文鏈接:
https://r00tk1ts.github.io/2018/11/29/C++%20Primer%20-%20%E6%8B%B7%E8%B4%9D%E6%8E%A7%E5%88%B6/
轉載于:https://www.cnblogs.com/HDK2016/p/11147437.html
《新程序員》:云原生和全面數字化實踐50位技術專家共同創作,文字、視頻、音頻交互閱讀總結
以上是生活随笔為你收集整理的【C++】C++的拷贝控制的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: VC实现将对话框最小化到系统托盘
- 下一篇: [EffectiveC++]item32