《Effective C++》第三版-2. 構造析構賦值運算(Constructors,Destructors,and Assignment Operators)

Roanapur發表於2024-04-28

目錄
  • 條款05:瞭解C++默默編寫並呼叫哪些函式(Know what functions C++ silently writes and calls)
    • 自動生成的函式
    • 舉例說明
  • 條款06:若不想使用編譯器自動生成的函式,就該明確拒絕(Explicitly disallow the use of compiler-generated functions you do not want)
  • 條款07:為多型基類宣告virtual解構函式(Declare destructors virtual in polymorphic base classes)
    • virtual解構函式的作用
    • Virtual解構函式的缺點
  • Non-virtual解構函式的問題
  • 條款08:別讓異常逃離解構函式(Prevent exceptions from leaving destructors)
    • 解構函式丟擲異常的影響
    • 解構函式丟擲異常的處理
  • 條款09:絕不在構造和解構函式中呼叫virtual函式(Never call virtual functions during construction or deconstruction)
    • 使用後果
    • 解決方案
  • 令operator=返回一個reference to *this(Have assignment operators return a reference to *this)
  • 條款11:在operator=中處理“自我賦值”(Handle assignment to self in operator)
    • 別名導致的現象
    • 安全性問題
  • 條款12:複製物件時勿忘其每一個部分(Copy all parts of an object)
    • Copying函式易出現的問題
    • Copying函式的正確寫法

由於本書的翻譯腔有點佶屈聱牙,從這一章開始lz會增加轉述程度使得文字更流暢

條款05:瞭解C++默默編寫並呼叫哪些函式(Know what functions C++ silently writes and calls)

自動生成的函式

空類(empty class)會由編譯器自動宣告一個copy建構函式、一個copy assignment運算子、一個解構函式,若未手動宣告建構函式則還會自動生命一個default建構函式

class Empty {};  //寫一個空類則編譯器會自動處理為如下形式
class Empty {
public:  //以下編譯器宣告的函式均為public且inline
	Empty() { ... }  //default建構函式
	Empty(const Empty& rhs) { ... }  //copy建構函式
	~Empty() { ... }  //解構函式
	Empty& operator=(const Empty& rhs) { ... }	//copy assignment運算子
};

編譯器宣告的函式的作用:

  • default建構函式和解構函式:用以呼叫幕後的程式碼,如呼叫基類和非靜態成員變數的建構函式和解構函式
    • default解構函式不是虛擬函式,除非該類的基類的解構函式宣告為虛擬函式
  • copy建構函式和copy assignment運算子:單純將來源物件的每個非靜態變數複製到目標物件

舉例說明

template<typename T>
class NameObject {
public:
	NameObject(const char* name, const T& value);
	NameObject(const std::string& name, const T& value);
	...
private:
	std::string nameValue;
	T objectValue;
};

NameObject<int> no1("Smallest Prime Number", 2);
NameObject<int> no2(no1);`

上述程式碼涉及no2的nameValue和objectValue兩個成員變數的初始化:

  • nameValue:string型別,呼叫標準string的建構函式並以no1.nameValue為實參
  • objectValue:int型別(此處T代表int),複製no1.objectValue的每個bits
template<typename T>
class NameObject {
public:
	//nameValue是non-const string的引用,故以下建構函式不再接受const名稱
	NameObject(std::string& name, const T& value);
	...  //假設並未宣告operator=
private:
	std::string& nameValue;  //這裡是引用
	const T objectValue;
};

std::string newDog("Persephone");
std::string oldDog("Satch");

NameObject<int> p(newDog, 2);
NameObject<int> s(oldDog, 2);`
//以下p.nameValue無法改指向s.nameValue所指的string因為C++不允許讓引用改指向不同物件
p = s;  

上述程式碼說明:一般而言只有當生成的程式碼合法且有機會證明它有意義,編譯器才會生成運算子=

  • 若要在包含引用或const成員的類裡面支援複製操作,租需要自己定義copy assignment運算子
  • 若基類將copy assignment運算子宣告為private,編譯器不會為其派生類生成copy assignment運算子
    • 編譯器為派生類生成的copy assignment運算子會預設其可以處理基類的成分,而其又無法呼叫派生類無權呼叫的成員函式

Tips:

  • 編譯器可以暗自為類建立default建構函式、copy建構函式、copy assignment運算子、解構函式

條款06:若不想使用編譯器自動生成的函式,就該明確拒絕(Explicitly disallow the use of compiler-generated functions you do not want)

通常不希望類支援某一功能則不宣告對應的函式即可,但是對於copy建構函式和copy assignment運算子(編譯器會自動生成它們)則需要將成員函式宣告為private且不實現它們

class HomeForSale {
public:
	...
private:
	...
	HomeForSale(const HomeForSale&);  //只宣告不實現
	HomeForSale& operator=(const HomeForSale);
};

上述程式碼在複製HomeForSale物件時編譯器會報錯,即使在成員函式友元函式之內複製則聯結器也會報錯。

進一步,可將連線期錯誤轉移至編譯器(好事,越早檢測出錯誤越好),只要將copy建構函式和copy assignment運算子在一個專門為阻值copying動作而設計的基類中宣告為private即可。

成員函式或友元函式複製HomeForSale物件時,編譯器生成的copy建構函式和copyassignment運算子會呼叫基類的private複製函式,則編譯器會報錯

class Uncopyable {
protected:  //允許派生物件構造和析構
	Uncopyable() {}
	~Uncopyable() {}
private:
	Uncopyable(const Uncopyable&);  //但阻止copying
	Uncopyable& operator=(const Uncopyable&);
};

class HomeFOrSale: private Uncopyable {  //繼承Uncopyable
	...  //不再宣告copy建構函式或copy assign.運算子
};

Uncopyable類使用的注意事項:

  • 不一定得以public繼承它
  • 其解構函式不一定得是虛擬函式
  • 其不含資料,符合空基類最佳化(empty base class optimization)條件;但其常作為基類,可能需要多重繼承(還需要繼承其他類),而多重繼承有時會阻止空基類最佳化
  • 也可使用Boost提供的noncopyable類

Tips:

  • 為了避免編譯器自動生成的功能,可將相應的成員函式宣告為private並且不予實現
  • 使用像Uncopyable這樣的基類也可以

條款07:為多型基類宣告virtual解構函式(Declare destructors virtual in polymorphic base classes)

virtual解構函式的作用

以計時器為例,使用者只想在程式中使用時間而非瞭解計算細節,故可實際工廠函式,其返回一個基類指標,指向新生成的派生類物件

class TimeKeeper {
public:
	TimeKeeper();
	~TimeKeeper();
	...
};
class AtomicClock: public TimeKeeper {...};  //原子鐘
class WaterClock: public TimeKeeper {...};  //水鍾
class WristClock: public TimeKeeper {...};  //腕錶

TimeKeeper* getTimeKeeper();  //工廠函式,返回指標指向一個TimeKeeper派生類的動態分配物件
TimeKeeper* ptk = getTimeKeeper();  //從TimeKeeper繼承體系獲得一個動態分配物件
...
delete ptk;  //釋放,避免資源洩漏

上述程式碼存在問題:getTimeKeeper返回的指標指向一個派生類物件,但是那個物件卻經由一個基類指標(如TimeKeeper*指標)被刪除,而當前的基類有non-virtual解構函式,則實際執行時物件派生的成分未被銷燬

解決方案:給基類一個virtual解構函式,則刪除派生類時會銷燬整個物件

class TimeKeeper {
public:
	TimeKeeper();
	virtual ~TimeKeeper();  //virtual解構函式
	...
};
TimeKeeper* ptk = getTimeKeeper();
...
delete ptk;  //行為正確

Virtual解構函式的缺點

當類不作為基類時,令其解構函式為虛擬函式往往不好

class Point {
public:
	Point(int xCoord, int yCoord);
	~Point();
private:
	int x, y;
};
  • 沒有虛擬函式時,若int佔用32 bits,則Point物件可放入64-bit快取器重,甚至可當做64-bit量傳給其他如C或FORTRAN等語言寫的函式
  • 要實現虛擬函式則物件必須攜帶額外的資訊,用以在執行期間決定哪個虛擬函式該被呼叫,故其物件體積會增加
    • 額外的資訊:
      • 資訊載體為vptr(virtual table pointer)指標
      • vptr指標指向一個由函式指標構成的陣列,即vtbl(virtual table)
      • 每個帶有虛擬函式的類都有一個相應的vtbl
      • 呼叫某一虛擬函式時實際被呼叫的函式取決於該物件的vptr所指的vpbl,即編譯器在其中尋找對應的函式指標
    • 增加的體積:
      • 在32-bit計算機體系結構中將佔用64 bits(兩個ints)至96 bits(兩個ints和vptr)
      • 在64-bit計算機體系結構中將佔用64~128 bits(此時vptr佔64 bits)
      • 物件大小將增加50%~100%
    • 由於其他語言沒有vptr,故不能直接將其傳遞至其他語言所寫的函式,不再具有移植性

Non-virtual解構函式的問題

不要繼承帶有non-virtual解構函式的類(如STL容器)

class SpecialString: public std::string {...};  //不好!std::string有non-virtual解構函式
SpecialString* pss = new SpecialString("Impeding Doom");
std::string* ps;
...
ps = pss;  //SpecialString* => std::string
...
delete ps;  //未有定義!*ps的SpecialString資源會洩漏,因為SpecialString解構函式未被呼叫

可以考慮宣告一個pure virtual解構函式從而建立一個抽象(abstract)類,其常用作基類且不能被例項化(instantiated)

class AWOV {  //AWOV="Abstract w/o Virtuals"
public:
	virtual ~AWOV() = 0;  //宣告pure virtual解構函式
};
AWOV::~AWOV() { }  //**必須定義pure virtual解構函式**

解構函式的運作方式:

  • 最先呼叫最深層派生(most derived)的類的解構函式
  • 再呼叫每個基類的解構函式
  • 編譯器會在AWOV的派生類的解構函式中建立一個對~AWOV的呼叫動作,因此需要定義該函式,否則聯結器會報錯

給基類一個virtual解構函式的規則只適用於帶多型性質(polymorphic)的基類,其設計目的是透過基類介面處理派生類物件;並非所有基類的設計目的都是為了多型用途,如標準string和STL容器

Tips:

  • 帶多型性質的基類應宣告一個virtual解構函式;帶有virtual函式的類也應該有virtual解構函式
  • 若不作為基類或不需要多型性,這個類就不該宣告virtual解構函式

條款08:別讓異常逃離解構函式(Prevent exceptions from leaving destructors)

解構函式丟擲異常的影響

最好不要讓解構函式丟擲異常,以如下程式碼為例:

  • vector v被銷燬時,需要銷燬其含有的所有Widgets(呼叫其解構函式),不妨假設有十個Widgets
  • 假設銷燬時前兩個Widgets的解構函式都丟擲了異常,兩個異常會使程式終止或導致不明確行為
  • 使用STL容器或TR1的任何容器或array,也會有相同情況
class Widget {
public:
	...
	~Widget() {...}  //假設這裡可能丟擲異常
};
void doSomething()
{
	std::vector<Widget> v;
	...
}  //v在這裡被自動銷燬

解構函式丟擲異常的處理

若解構函式必須執行可能丟擲異常的動作(如下面的close):

class DBConnection {
public:
	...
	static DBConnection create();
	void close();  //關閉聯機,失敗則丟擲異常
};

class DBConn {  //用這個類管理DBConnection物件,確保不會忘記關閉聯機
public:
	...
	~DBConn()  //確保資料庫連線總會被關閉
	{
		db.close();
	}
private:
	DBConnection db;
};
//使用者可使用如下
{  //開啟區塊(block)
DBConn dbc(DBConnection::create());  //建立DBConnection物件,由DBConn物件管理
...  //透過DBConn介面使用DBConnection物件
}  //在區塊結束點DBConn物件被銷燬而自動呼叫DBConnection物件的close

有兩種方法避免close丟擲異常而導致麻煩:

//丟擲異常就結束程式
DBConn::~DBConn()
{
	try { db.close(); }
	catch (...) {
		...  //記錄呼叫失敗的日誌
		std::abort();  //強制終止程式,防止異常傳播
	}
}
//吞下異常
DBConn::~DBConn()
{
	try { db.close(); }
	catch (...) {
		...  //記錄呼叫失敗的日誌
	}
}

更好的方式是重新設計DBConn介面使客戶能對潛在的問題做出反應:

如果某個操作可能丟擲異常,而又必需處理該異常,則該異常必須來自解構函式以外的函式

class DBConn {
public:
	...
	void close()  //供客戶使用的新函式
	{
		db.close();
		closed = true;
	}
	~DBConn()
	{
		if (!closed) {
			try {
				db.close();
			}
			catch (...) {
				...  //記錄呼叫失敗的日誌
			}
		}
	private:
		DBConnection db;
		bool closed;
	}

Tips:

  • 解構函式決不能丟擲異常。若解構函式呼叫的函式可能丟擲異常,則解構函式應捕捉任何異常,併吞下它們(不傳播)或結束程式
  • 若需要處理某個操作函式執行期間丟擲的異常,則類應該提供一個普通函式(而非在解構函式中)處理

條款09:絕不在構造和解構函式中呼叫virtual函式(Never call virtual functions during construction or deconstruction)

使用後果

這一段程式碼太多懶得敲了

不要再建構函式和解構函式期間呼叫virtual函式,否則很可能出現預期外的結果

  • 在基類構造期間,虛擬函式不是虛擬函式
    • 派生類物件內的基類成分會在派生類自身成分構造之前先構造妥當
    • 在基類構造期間虛擬函式絕不會下降到派生類階層
  • C++不允許使用物件內部尚未初始化的部分
    • 在基類構造期間,若虛擬函式下降至派生類階層,很可能會取用尚未初始化的local變數
  • 在派生類物件的基類構造期間,物件的型別是基類而非派生類
    • 虛擬函式會被編譯器解析至(resolve to)基類
    • 執行型別資訊(runtime type information,如dynamic_cast和typeid)也會把物件視為基類
  • 在派生類物件析構完派生類成員變數之後,這些成員變數便呈現未定義值,進入基類解構函式後物件便成為一個基類物件

解決方案

以下程式碼通常不會讓編譯器和聯結器報錯,logTransaction是Transaction內的純虛擬函式,其被呼叫時大多執行系統會終止程式;若logTransaction是虛擬函式且在Transaction內有一份實現程式碼,該版本就會被呼叫,即在建立派生類時呼叫錯誤版本的logTransaction

class Transaction {
public:
	Transaction()
	{ init(); }  //有多個建構函式且執行某些相同的工作,可將相同部分放入init避免程式碼重複
	virtual void logTransaction() const = 0;    //呼叫non-virtual
	...
private:
	void init()
	{
		...
		logTransaction();  //這裡呼叫virtual
	}
};

為了保證每次有Transaction繼承體系上的物件被建立,就會有適當版本的logTransaction被呼叫,可在Transaction類內將logTransaction改為non-virtual,並要求派生類建構函式傳遞必要的資訊給Transaction建構函式,之後便可安全地呼叫non-virtual logTransaction

換言之,由於無法使用虛擬函式從基類向下呼叫,在構造期間可以讓派生類將必要的構造資訊向上傳遞至積累建構函式

class Transaction {
public:
	explicit Transaction(const std::string& logInfo);
	void logTransaction(const std::string& logInfo) const;  //non-virtual函式
	...
};
Transaction::Transaction (const std::string& logInfo)
{
	...
	logTransaction(logInfo);  //non-virtual呼叫
}
class BuyTransaction: public Transaction {
public:
	BuyTransaction( params **)
		: Transaction(createLogString( *params* ))  //將log資訊傳給基類建構函式
	{ ... }
	...
private:
	static std::string createLogString( *params* );
}.

Tips:

  • 在構造和析構期間不要呼叫虛擬函式,因為這類呼叫從不下降至派生類(就當前執行建構函式和解構函式的那層而言)

令operator=返回一個reference to *this(Have assignment operators return a reference to *this)

int x, y, z;
x = y = z = 15;  //賦值連鎖形式
x = (y = (z = 15));  //賦值採用右結合律,上述賦值被解析為左式

為實現連鎖賦值,賦值運算子需要樊麾指向運算子左側實參的引用,其他所有與賦值相關的運算也適用該規則(但此規則並無強制性,即使不遵循編譯器也不報錯)

class Widget {
public:
	...
	Widget& operator=(const Widget& rhs)
	{
		...
		return* this;  //返回左側物件的引用
	}
	...
};

class Widget {
public:
	...
	Widget& operator+=(const Widget& rhs)  //適用於+=,-=,*=等
	{
		...
		return* this;
	}
	Widget& operator=(int rhs)  //適用,即使此運算子的引數型別不符協定
	{
		...
		return* this;
	}
	...
};

Tips:

  • 令賦值運算子返回指向*this的引用

條款11:在operator=中處理“自我賦值”(Handle assignment to self in operator)

別名導致的現象

考慮以下現象:

  • 自我賦值合法
  • 來自同一繼承體系的兩個物件可能實際指稱同一物件
class Widget { ... };
Widget w;
...
w = w;  //賦值給自己
//潛在的自我賦值
a[i] = a[j];  //若i=j
*px = *py;  //若px和py指向同一個東西

class Base { ... };
class Derived: public Base { ... };
void doSomething(const Base& rb, Derived* pd);  //rb和*pd可能指向同一物件

以上現象是別名(aliasing)導致的,別名即有多個方法指稱某物件

安全性問題

自我賦值的安全性:

  • 使用物件來管理資源,且該物件在copy發生時有正確的舉措,則是自我賦值安全的(self-assignment safe)
  • 若要自行管理資源(自己寫管理資源的類),則要注意不能在通知使用之前釋放它
class Bitmap { ... };
class Widget {  //不安全的運算子=實現版本,不具備自我賦值安全性和異常安全性
	...
private:
	Bitmap* pb;  //指標,指向一個從heap分配而得的物件
};
Widget& Widget::operator=(const Widget& rhs)  //不安全的運算子=實現版本
{
	delete pb;  //停止使用當前的bitmap
	pb = new Bitmap(*rhs.pb);  //使用rhs的bitmap的副本
	return *this;  //*this和rhs可能是同一個物件,則可能rhs的bitmap已被銷燬
}

可以增加證同測試(identity test)實現自我賦值安全性,但有以下缺點:

  • 使程式碼變大(包括原始碼和目標碼)
  • 匯入新的控制流(control flow)分支
  • 二者會導致執行速度減低,prefetching、caching、pipelining等指令的效率都會降低
  • 不保證異常安全性
Widget& Widget::operator=(const Widget& rhs)  //不安全的運算子=實現版本
{
	if (this == &rhs) return *this;  //證同測試
	delete pb;  //停止使用當前的bitmap
	pb = new Bitmap(*rhs.pb);  //new Bitmap異常可能使指標指向一塊被刪除的Bitmap
	return *this; 
}

實現異常安全性往往能兼顧自我賦值安全性,其方法有:

  • 賦值pb所指的東西之前別刪除pb
  • copy and swap技術
  • 直接pass by value
    • 犧牲了可讀性,但是把copying動作從函式本體移到函式引數構造階段可能使編譯器生成更高效的程式碼
//賦值pb所指的東西之前別刪除pb
Widget& Widget::operator=(const Widget& rhs)  //不安全的運算子=實現版本
{
	Bitmap* pOrig = pb;  //儲存原pb
	pb = new Bitmap(*rhs.pb);  //令pb指向*pb的副本
	delete pOrig;  //刪除原pb
	return *this;
}

//也可用copy and swap技術
class Widget {  
	...
	void swap(WIdget& rhs);  //交換*this和rhs的資料
	...
};
Widget& Widget::operator=(const Widget& rhs) 
{
	Widget temp(rhs);  //製作rhs的副本
	swap(temp);  //交換*this和上述副本
	return *this;
}

//pass by value,直接傳遞副本
Widget& Widget::operator=(Widget rhs) 
{
	swap(rhs);  //交換*this和上述副本
	return *this;
}

Tips:

  • 確保物件自我賦值時運算子=有良好行為。可以考慮證同測試、語句順序調整、copy and swap等相關技術
  • 確保任何函式如果操作多個物件,而其中部分是同一物件時,其行為仍然正確

條款12:複製物件時勿忘其每一個部分(Copy all parts of an object)

Copying函式易出現的問題

copying函式的版本:

  • 編譯器自動生成版:將複製物件的所有成員變數都複製一份
  • 自定義版:當類的成員變數變化時, 需要修改copying函式
class PriorityCustomer: public Customer {
public:
	...
	PriorityCustomer(const PriorityCustomer& rhs);
	PriorityCustomer& operator=(const PriorityCustomer& rhs);
	...
private:
	int priority;
};
PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)
	: priority(rhs.priority)
{
	logCall("PriorityCustomer copy constructor");
}
PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer&rhs)
{
	logCall("PriorityCustomer copy assignment operator");
	priority = rhs.priority;
	return *this;
}

PriorityCustomer的copying函式未複製其繼承的Customer成員變數副本

  • PriorityCustomer的copy建構函式並沒有制定實參傳給其基類建構函式(成員初值列未提到Customer)
  • PriorityCustomer物件的Customer成分會被Customer不帶實參的default建構函式初始化
  • 對於copy assignment運算子情況類似,只是不改變其基類的成員變數

Copying函式的正確寫法

正確寫法:複製所有local成員變數+呼叫所有基類內適當的copying函式

不應使copy assignment運算子呼叫copy建構函式;也不應使copy建構函式呼叫copy assignment運算子

PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)
	: Customer(rhs)  //呼叫基類的copy建構函式
		priority(rhs.priority)
{
	logCall("PriorityCustomer copy constructor");
}
PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer&rhs)
{
	logCall("PriorityCustomer copy assignment operator");
	Customer::operator=(rhs);  //對基類成分賦值
	priority = rhs.priority;
	return *this;
}

Tips:

  • Copying函式應確保賦值物件內的所有成員變數和所有基類成分
  • 不要嘗試以某個copying函式實現另一個copying函式。用把共同的功能放進第三個函式中,並由兩個copying函式共同呼叫

相關文章