C++中的Primer拷貝、賦值與銷毀詳解
拷貝控制和資源管理
在本章中,我們將學到,類可以定義構造函數,用來控制在創(chuàng)建此類型對象時做什么。在本章中,我們還將學習類如何控制該類型對象拷貝、賦值、移動或銷毀時做什么。類通過一些特殊的成員函數控制這些操作,包括:拷貝構造函數、移動構造函數、拷貝賦值運算符、移動賦值運算符以及析構函數。
當定義一個類時,我們顯式地或隱式地指定在此類型的對象拷貝、移動、賦值和銷毀時做什么。一個類通過定義五種特殊的成員函數來控制這些操作,包括:拷貝構造函數(copy constructor)、拷貝賦值運算符(copy-assignment operator)、移動構造函數(move constructor)、移動賦值運算符(move-assignment operator)和析構函數(destructor)??截惡鸵苿訕嬙旌瘮刀x了當用同類型的另一個對象初始化本對象時做什么。拷貝和移動賦值運算符定義了將一個對象賦予同類型的另一個對象時做什么。析構函數定義了當此類型對象銷毀時做什么。我們稱這些操作為拷貝控制操作(copy control)。
如果一個類沒有定義所有這些拷貝控制成員,編譯器會自動為它定義缺失的操作。因此,很多類會忽略這些拷貝控制操作。但是,對一些類來說,依賴這些操作的默認定義會導致災難。通常,實現拷貝控制操作最困難的地方是首先認識到什么時候需要定義這些操作。
在定義任何C++類時,拷貝控制操作都是必要部分.對初學C++的程序員來說,必須定義對象拷貝、移動、賦值炎銷毀時做什么,這常常令他們感到困擾。這種困擾很復雜,因為如果我們不顯式定義這些操作,編譯器也會為我們定義,但編譯器定義的版本的行為可能并非我們所想。
拷貝、賦值與銷毀
我們將以最基本的操作一一拷貝構造函數、拷貝賦值運算符和析構函數作為開始。
拷貝構造函數
如果一個構造函數的第一個參數是自身類類型的引用,且任何額外參數都有默認值,則此構造函數是拷貝構造函數。
class Foo{ public: Foo(); //默認構造函數 Foo(const Foo&); // 拷貝構造函數 }
拷貝構造函數的第一個參數必須是一個引用類型,原因我們稍后解釋。雖然我們可以定義一個接受非const引用的拷貝構造函數,但此參數幾乎總是一個const的引用??截悩嬙旌瘮翟趲追N情況下都會被隱式地使用。因此,拷貝構造函數通常不應該是explicit的。
合成拷貝構造函數
如果我們沒有為一個類定義拷貝構造函數,編譯器會為我們定義一個。與合成默認構造函數不同,即使我們定義了其他構造函數,編譯器也會為我們合成一個拷貝構造函數。
對某些類來說,合成拷貝構造函數(synthesized copy constructor)用來阻止我們拷貝該類類型的對象。而一般情況,合成的拷貝構造函數會將其參數的成員逐個拷貝到正在創(chuàng)建的對象中。編譯器從給定對象中依次將每個非static成員拷貝到正在創(chuàng)建的對象中。
每個成員的類型決定了它如何拷貝:對類類型的成員,會使用其拷貝構造函數來拷貝;內置類型的成員則直接拷貝。雖然我們不能直接拷貝一個數組,但合成拷貝構造函數會逐元素地拷貝一個數組類型的成員。如果數組元素是類類型,則使用元素的拷貝構造函數來進行拷貝。
作為一個例子,我們的sales_data類的合成拷貝構造函數等價于:
class Sales_data public: //其他成員和構造函數的定義,如前 //與合成的拷貝構造函數等價的指貝構造函數的聲明 Sales_data(const Sales_data&); private: std::string bookKNo; int unit_sold = 0; double revenue=0.0; //與Sales_data的合成的指貝構造函數等價 Sales_data::Sales_data(const Sales_data&orig): bookNo(orig.bookNo),//使用stritng的指貝構造函數 units_sold(orig.units_sold),//指貝orig.units_sold revenue(orig.revenue)//拷貝orig.revenue {}//空函數體
拷貝初始化
現在,我們可以完全理解直接初始化和拷貝初始化之間的差異了:
string dots(10,'.');//直接初始化 string s(dots)}//直接初始化 string s2=dots;//拷貝初始化 string null_book="9-~999-99999-9";//拷貝初始化 string nines=string(100,'9'); //拷貝初始化
當使用直接初始化時,我們實際上是要求編譯器使用普通的函數匹配來選擇與我們提供的參數最匹配的構造函數。當我們使用拷貝初始化(copy initialization)時,我們要求編譯器將右側運算對象拷貝到正在創(chuàng)建的對象中,如果需要的話還要進行類型轉換。
拷貝初始化通常使用拷貝構造函數來完成。但是,如果一個類有一個移動構造函數,則拷貝初始化有時會使用移動構造函數而非拷貝構造函數來完成。但現在,我們只需了解拷貝初始化何時發(fā)生,以及拷貝初始化是依靠拷貝構造函數或移動構造函數來完成的就可以了。
拷貝初始化不僅在我們用=定義變量時會發(fā)生,在下列情況下也會發(fā)生
- 將一個對象作為實參傳遞給一個非引用類型的形參
- 從一個返回類型為非引用類型的函數返回一個對象
- 用花括號列表初始化一個數組中的元素或一個聚合類中的成員
某些類類型還會對他們所分配的對象使用拷貝初始化。例如,當我們初始化標準庫容器或是調用其insert或push成員時,容器會對其元素進行拷貝初始化。與之相對,用emplace成員創(chuàng)建的元素都進行直接初始化。
拷貝初始化的限制
如前所述,如果我們使用初始化值要求通過一個explicit的構造函數來進行類型轉換,那么使用拷貝初始化是直接初始化就不是無關緊要的了:
vector<int>v1(10);//正確,直接初始化 vector<int>v2=10;//錯誤:接受大小參數的構造函數是explicit的 void(vector<int>);//的參數進行指貝初始化 f(10);//錯誤:不能用一個explicit的構造函數拷貝一個實參 f(vector<int>(10));//正確:從一個int直接構造一個臨時vector
直接初始化v1是合法的,但看起來與之等價的拷貝初始化v2則是錯誤的,因為vector的接受單一大小參數的構造函數是explicit的。出于同樣的原因,當傳遞一個實參或從函數返回一個值時,我們不能隱式使用一個explicit構造函數。如果我們希望使用一個explicit構造函數,就必須顯式地使用,像此代碼中最后一行那樣。編譯器可以繞過拷貝構造函數
在拷貝初始化過程中,編譯器可以(但不是必須)跳過拷貝/移動構造函數,直接創(chuàng)建對象。即,編譯器被允許將下面的代碼
string null_book="9-999-99999-9";//拷貝初始化
改寫為
string null_book("9-999-99999-9");//編譯器略過了拷貝構造函數
但是,即使編譯器略過了拷貝/移動構造函數,但在這個程序點上,拷貝/移動構造函數必須是存在且可訪問的(例如,不能是private的)。
拷貝賦值運算符
與類控制其對象如何初始化一樣,類也可以控制其對象如何賦值:
Sales_data trans,accum; trans=accum;//使用Sales_data的拷貝賦值運算符
與拷貝構造函數一樣,如果類未定義自己的拷貝賦值運算符,編譯器會為它合成一個。
重載賦值運算符
在介紹合成賦值運算符之前,我們需要了解一點兒有關重載運算符(overloaded operator)的知識。
重載運算符本質上是函數,其名字由operator關鍵字后接表示要定義的運算符的符號組成。因此,賦值運算符就是一個名為operator=的函數。類似于任何其他函數,
運算符函數也有一個返回類型和一個參數列表。
重載運算符的參數表示運算符的運算對象。某些運算符,包括賦值運算符,必須定義為成員函數。如果一個運算符是一個成員函數,其左側運算對象就綁定到隱式的this參數。對于一個二元運算符,例如賦值運算符,其右側運算對象作為顯式參數傳遞。
拷貝賦值運算符接受一個與其所在類相同類型的參數:
class Foo{ public: Foo& operator=(const Foo&);//賦值運算符 };
為了與內置類型的賦值保持一致,賦值運算符通常返回一個指向其左側運算對象的引用。另外值得注意的是,標準庫通常要求保存在容器中的類型要具有賦值運算符,且其返回值是左側運算對象的引用。
賦值運算符通常應該返回一個指向其左側運算對豫的引用。
合成拷貝賦值運算符
與處理拷貝構造函數一樣,如果一個類未定義自己的拷貝賦值運算符,編譯器會為它生成一個合成拷貝賦值運算符(synthesized copy-assignment operator)。類似拷貝構造函數,對于某些類,合成拷貝賦值運算符用來禁止該類型對象的賦值。如果拷貝賦值運算符并非出于此目的,它會將右側運算對象的每個非static成員賦予左側運算對象的對應成員,這一工作是通過成員類型的拷貝賦值運算符來完成的。對于數組類型的成員,逐個賦值數組元素。合成拷貝賦值運算符返名一個指向其左側運算對象的引用。
作為一個例子,下面的代碼等價于Sales_data的合成拷貝賦值運算符:
//等價于合成指貝賦值運算符 Sales_data& Sales_data::operator=(const Sales_data&rhs) { bookkNo=rhs.bookNo//調用string::operator= units_sold=rhs.units_sold;//使用內置的int賦值 revenue=rhs.revenue;//使用內置的double賦值 return*this;//返回一個此對象的引用 }
析構函數
析構函數執(zhí)行與構造函數相反的操作:構造函數初始化對象的非static數據成員,還可能做一些其他工作;析構函數釋放對象使用的資源,并銷毀對象的非static數據成員。
析構函數是類的一個成員函數,名字由波浪號接類名構成。它沒有返回值,也不接受參數
class Foo{ public: ~Foo();//析構函數 }
由于析構函數不接受參數,因此它不能被重載。對一個給定類,只會有唯一一個析構函數。
析構函數完成什么工作
如同構造函數有一個初始化部分和一個函數體,析構函數也有一個函數體和一個析構部分。在一個構造函數中,成員的初始化是在函數體執(zhí)行之前完成的,且按照它們在類中出現的順序進行初始化。在一個析構函數中,首先執(zhí)行函數體,然后銷毀成員。成員按初始化順序的逆序銷毀。在對象最后一次使用之后,析構函數的函數體可執(zhí)行類設計者希望執(zhí)行的任何收尾工作。通常,析構函數釋放對象在生存期分配的所有資源。在一個析構函數中,不存在類似構造函數中初始化列表的東西來控制成員如何銷毀,析構部分是隱式的。成員銷毀時發(fā)生什么完全依賴于成員的類型。銷毀類類型的成員需要執(zhí)行成員自己的析構函數。內置類型沒有析構函數,因此銷毀內置類型成員什么也不需要做。
隱式銷毀一個內置指針類型的成員不會delete它所指向的對象。
與普通指針不同,智能指針是類類型,所以具有析構函數。因此,與普通指針不同,智能指針成員在析構階段會被自動銷毀。什么時候會調用析構函數
無論何時一個對象被銷毀,就會自動調用其析構函數:
- 變量在離開其作用域時被銷毀。
- 當一個對象被銷毀時,其成員被銷毀。
- 容器(無論是標準庫容器還是數組)被銷毀時,其元素被銷毀。
- 對于動態(tài)分配的對象,當對指向它的指針應用delete運算符時被銷毀。
- 對于臨時對象,當創(chuàng)建它的完整表達式結束時被銷毀。
由于析構函數自動運行,我們的程序可以按需要分配資源,而(通常)無須擔心何時釋放這些資源。
例如,下面代碼片段定義了四個sales_data對象:
{//新作用域 //p和p2指向動態(tài)分配的對象 Sales_data *p=new Sales_data;//p是一個內置指針 auto p2=make_shared<Sales_data>();//p2是一個shared_ptr Sales_data item(*p);//指貝構造函數將*p拷貝到item中 vector<Sales_data>vec;//局部對象 vec.push_back(*p2);//拷貝p2指向的對象 delete p;//對p指向的對象執(zhí)行析構函數 }//退出局部作用域;對item、p2和vec調用析構函數 //銷毀p2會途減其引用計數;如果引用計數變?yōu)?,對象被釋放 //銷毀vec會銷鱷它的元素
每個Sales_data對象都包含一個string成員,它分配動態(tài)內存婁保存bookNo成員中的字符。但是,我們的代碼唯一需要直接管理的內存就是我們直接分配的Sales_data對象。我們的代碼只需直接釋放綁定到p的動態(tài)分配對象。
其他Sales_data對象會在離開作用域時被自動銷毀。當程序塊結束時,vec、p2和item都離開了作用域,意味著在這些對象上分別會執(zhí)行vector、shared_ptr和Sales_data的析構函數。vector的析構函數會銷毀我們添加到vec的元素。shared_ptr的析構函數會遞減p2指向的對象的引用計數。在本例中,引用計數會變?yōu)?,因此shared_ptz的析構函數會delete p2分配的Sales_data對象。
在所有情況下,Sales_data的析構函數都會隱式地銷毀bookNo成員.銷毀bookNo會調用string的析構函數,它會釋放用來保存ISBN的內存。
當指向一個對象的引用或指針離開作用域時,析構函數不會執(zhí)行。
合成析構函數
當一個類未定義自己的析構函數時,編詳器會為它定義一個合成析構函數(synthesized destructor)。類似拷貝構造函數和拷貝賦值運算符,對于樹些類,合成析構函數被用來阻止該類型的對象被銷毀。如果不是這種情況,合成析構函數的函數體就為空。
例如,下面的代碼片段等價于Sales_data的合成析構函數:
class Sales_data{ public: //成員會被自動銷毀,除此之外不需要做其他事情 ~Sales_data()《 //其他成員的定義,如前 }
在(空)析構函數體執(zhí)行完畢后,成員會被自動銷毀。特別的,string的析構函數會被調用,它將釋放bookNo成員所用的內存。
認識到析構函數體自身并不直接銷毀成員是非常重要的。成員是在析構函數體之后隱含的析構階段中被銷毀的。在整個對象銷毀過程中,析構函數體是作為成員銷毀步驟之外的另一部分而進行的。
三/五法則
如前所述,右三個基本操作可以控制類的拷貝操作:拷貝構造函數,拷貝賦值運算符和析構函數。而且,在新標準下,一個類還可以定義一個移動構造函數和一個移動賦值運算符。
C++語言并不要求我們定義所有這些操作:可以只定義其中一個或兩個,而不必定義所有。但是,這些操作通常應該被看做一個整體。通常,只需要其中一個操作,而不需要定義所有操作的情況是很少見的。
需要析構函數的類也需要拷貝和賦值操作
當我們決定一個類是否要定義它自己版本的拷貝控制成員時,一個基本原則是首先確定這個惡類是否需要一個析構函數。通常,對析構函數的需求要比對拷貝構造函數或賦值運算符的需求更為明顯。如果這個類需要一個析構函數,我們幾乎可以肯定它也需要一個拷貝構造函數和一個拷貝賦值運算符。
我們在練習中用過的HasPtr類是一個好例子。這個類在構造函數中分配動態(tài)內存。合成析構函數不會delete一個指針數據成員。因此,此類需要定義一個析構函數來釋放構造函數分配的內存。
應該怎么做可能還有點兒不清晰,但基本原則告訴我們,HasPtr也需要一個拷貝構造函數和一個拷貝賦值運算符。
如果我們?yōu)镠asPtr定義一個析構函數,但使用合成版本的拷貝構造函數和拷貝賦值運算符,考慮會發(fā)生什么:
class HasPtr{ public: HasPtr(const std:;string&s=std::string()): ps(new std::string(s)),i(0){} ~HasPtr(){deleteps; //錯誤:HasPtr需要一個拷貝構造函數和一個拷貝賦值運算符 //其他成員的定義,如前 }; 在這個版本的類定義中,構造函數中分配的內存將在HasPtr對象銷毀時被釋放。但不幸的是,我們引入了一個嚴重的錯誤!這個版本的類使用了合成的拷貝構造函數和拷貝賦值運算符。這些函數簡單拷貝指針成員,這意味著多個HasPtr對象可能指向相同的內存: HasPtr f(HasPtr hp)// HasPtr是傳值參數,所以將被指貝 { HasPtr ret=hp;//拷貝給定的HasPt //處理ret return ret;//ret和hp被銷毀 } 當f返回時,hp和ret都被銷毀,在兩個對象上都會調用HasPtr的析構函數。此析構函數會delete ret和hp中的指針成員。但這兩個對象包含相同的指針值。此代碼會導致此指針被delete兩次,這顯然是一個錯誤。將要發(fā)生什么是未定義的。 此外,f的調用者還會使用傳遞給f的對象: ```cpp HasPtr("some values"); f(p);//當王結束時,p指向的肉存被釋放 HasPtr(p);//現在p和q都指向無效內存!
p以及q指向的內存不再有效,在hp(或ret!)銷毀時它就被歸還給系統(tǒng)了。
需要拷貝操作的類也需要賦值操作,反之亦然
雖然很多類需要定義所有(或是不需要定義任何)拷貝控制成員,但某些類所要完成的工作,只需要拷貝或賦值操作,不需要析構函數。作為一個例子,考慮一個類為每個對象分配一個獨有的、唯一的序號。這個類需要一個拷貝構造函數為每個新創(chuàng)建的對象生成一個新的、獨一無二的序號。除此之外,這個拷貝構造函數從給定對象拷貝所有其他數據成員。這個類還需要自定義拷貝賦值運算符來邀
免將序號賦予目的對象。但是,這個類不需要自定義析構函數。
這個例子引出了第二個基本原則:如果一個類需要一個拷貝構造函數,幾乎可以肯定它也需要一個拷貝賦值運算符。反之亦然一一如果一個類需要一個拷貝賦值運算符,幾乎可以肯定它也需要一個拷貝構造函數。然而,無論是需要拷貝構造函數還是需要拷貝賦值運算符都不必然意味著也需要析構函數。
使用=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修飾成員的聲明時,合成的函數將隱式地聲明為內聯的(就像任何其他類內聲明的成員函數一樣)。如果我們不希望合成的成員是內聯函數,應該只對成員的類外定義使用=default,就像對拷貝賦值運算符所做的那樣。
阻止拷貝
雖然大多數類應該定義(而且通常也的確定義了)拷貝構造函數和拷貝賦值運算符,但對某些類來說,這些操作沒有合理的意義。在此情況下,定義類時必須采用某種機制阻止拷貝或賦值。例如,iostream類阻止了拷貝,以避免多個對象寫入或讀取相同的IO緩沖。為了阻止拷貝,看起來可能應該不定義拷貝控制成員。但是,這種策略是無效的:如果我們的類未定義這些操作,編譯器為它生成合成的版本。
定義刪除的函數
在新標準下,我們可以通過將拷貝構造函數和拷貝賦值運算符定義為刪除的函數(deleted function)來阻止拷貝。刪除的函數是這樣一種函數:我們雖然聲明了它們,但不能以任何方式使用它們。在函數的參數列表后面加上=delete來指出我們希望將它定義為刪除的:
struct NoCopy{ NoCopy()=default;//使用合成的默認構造出數 NoCopy(const NoCopy&)=delete;//阻止拷貝 NoCopy&operator=(const NoCopy&)=delete;//阻止賦值 ~NoCopy() = default; // 使用合成的析構函數 // 其他成員
一個刪除了析構函數的類型,編譯器將不允許定義該類型的變量或創(chuàng)建該類的臨時對象。而且,如果一個類有某個成員的類型刪除了析構函數,我們也不能定義該類的變量或臨時對象。因為如果一個成員的析構函數是刪除的,則該成員無法被銷毀。而如果一個成員無法被銷毀,則對象整體也就無法被銷毀了。
對于刪除了析構函數的類型,雖然我們不能定義這種類型的變量或成員,但可以動態(tài)分配這種類型的對象。但是,不能釋放這些對象:
struct NoDtor{ NoDtor()=default;//使用合成默認構造函數 ~NoDtor()=delete;//我們不能銷毀NoDtor類型的對象 NoDtor nd;//錯誤:NoDtor的析構函數是剛除的 NoDtor*p=new NoDptor();//正確:但我們不能delete p delete p;//錯誤:NoDtor的析構函數是剔除的 }
合成的拷貝控制成員可能是刪除的
如前所述,如果我們未定義拷貝控制成員,編譯器會為我們定義合成的版本。類似的,如果一個類未定義構造函數,編譯器會為其合成一個默認構造函數。對某些類來說,編譯器將這些合成的成員定義為刪除的函數:
- 如果類的某個成員的析構函數是刪除的或不可訪問的(例如,是private的),則類的合成析構函數被定義為刪除的。
- 如果類的某個成員的拷貝構造函數是刪除的或不可訪問的,則類的合成拷貝構造函數被定義為刪除的。
- 如果類的某個成員的析構函數是刪除的或不可訪問的,則類合成的拷貝構造函數也被定義為刪除的。
- 如果類的樹個成員的拷貝賦值運算符是刪除的或不可訪問的,或是類有一個const的或引用成員,則類的合成拷貝賦值運算符被定義為刪除的。
- 如果類的某個成員的析構函數是刪除的或不可訪問的,或是類有一個引用成員,它沒有類內初始化器,或是類有一個const成員,它沒有類內初始化器且其類型未顯式定義默認構造函數,則該類的默認構造函數被定義為刪除的。
本質上,這些規(guī)則的含義是:如果一個類有數據成員不能默認構造、拷貝、復制或銷毀,則對應的成員函數將被定義為刪除的。
一個成員有刪除的或不可訪問的析構函數會導致合成的默認和拷貝構造函數被定義為刪除的,這看起來可能有些奇怪。其原因是,如果沒有這條規(guī)則,我們可能會創(chuàng)建出無法銷毀的對象。
對于具有引用成員或無法默認構造的const成員的類,編詳器不會為其合成默認構造函數,這應該不奇怪。同樣不出人意料的規(guī)則是:如果一個類有const成員,則它不能使用合成的拷貝賦值運算符。畢竟,此運算符試圖賦值所有成員,而將一個新值賦予一個const對象是不可能的。
雖然我們可以將一個新值賦予一個引用成員,但這樣做改變的是引用指向的對象的值,而不是引用本身。如果為這樣的類合成拷貝賦值運算符,則賦值后,左側運算對象仍然指向與賦值前一樣的對象,而不會與右側運算對象指向相同的對象。由于這種行為看起來并不是我們所期望的,因此對于有引用成員的類,合成拷貝賦值運算符被定義為刪除的。
本質上,當不可能拷貝、賦值或銷毀類的成員時,類的合成拷貝控制成員就被定義為刪除的。
private拷貝控制
在新標準發(fā)布之前,類是通過將其拷貝構造函數和拷貝賦值運算符聲明為private的來阻止拷貝:
class PrivateCopy{ //無訪問說明符;接下來的成員默認為private的; //拷貝控制成員是private的,因此普通用戶代碼無法訪問 PrivateCopy(const PrivateCopy&); PrivateCopy&operator=(const PrivateCopy&); //其他成員 public: PrivateCopy()=default;//使用合成的默認構造出數 ~PrivateCopy();//用戶可以定義此類型的對象,但無法拷貝它們 }
由于析構函數是public的,用戶可以定義PrivateCopy類型的對象。但是,由于拷貝構造函數和拷貝賦值運算符是private的,用戶代碼將不能拷貝這個類型的對象。但是,友元和成員函數仍舊可以拷貝對象。為了阻止友元和成員函數進行拷貝,我們將這些拷貝控制成員聲明為private的,但并不定義它們。
聲明但不定義一個成員函數是合法的,對此只有一個例外。試圖訪問一個未定義的成員將導致一個鏈接時錯誤。通過聲明(但不定義)private的拷貝構造函數,我們可以預先阻止任何拷貝該類型對象的企圖:試圖拷貝對象的用戶代碼將在編譯階段被標記為錯誤;成員函數或友元函數中的拷貝操作將會導致鏈接時錯誤。
總結
以上為個人經驗,希望能給大家一個參考,也希望大家多多支持腳本之家。
相關文章
基于c++11的event-driven library的理解
這篇文章主要介紹了基于c++11的event-driven library的理解,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧2020-02-02