C++進階_Effective_C++第三版(五) 實現 Implementations

實現 Implementations

大多數情況下,適當提出你的classes(和class templates)定義以及functions(和function templates)聲明,是最複雜的兩件事。一旦正確完成它們,相應的實現大多直截了當。儘管如此,還是有些東西需要注意。太快定義變量可能造成效率上的拖延;過度使用轉型可能導致代碼變慢且難以維護,還有一些難解的錯誤;未考慮異常帶來的衝擊則可能導致資源泄露和數據敗壞;過度熱心地inlining可能引起代碼膨脹;過度耦合則可能導致讓人不滿意的冗長建置時間(build times)。

26、儘可能延後變量定義式的出現時間

Postpone variable definitions as long as possible.
只要定義了一個變量而其類型帶有一個構造函數或析構函數,那麼當程序的控制流到達這個變量定義式時,便要承受構造成本,當這個變量離開其作用域時,便要承受析構成本。即使這個變量最終並未被使用,仍需要耗費這些成本,應該儘可能 避免這種情形。
如下代碼,計算通行密碼的加密版本後返回,前提是密碼夠長,如果密碼太短,函數會拋出一個異常,類型爲logic_error:

std::string encryptPassword(const std::string& password)
{
	using namespace std;
	string encrypted;
	if(password.length() < MinimumPasswordLength)
	{
		throw logic_error(“Password is too short);
	}//必要動作,加密後的密碼放入到encrypted內
	return encrypted;
}

對象encrypted在此函數中並非完全被使用,但如果有個異常被丟出,它就真的沒有被使用。這樣如果拋出異常,則多付出了encrypted的構造成本和析構成本。所以最好延後encrypted的定義式,直到確實需要它:

std::string encryptPassword(const std::string& password)
{
	using namespace std;
	if(password.length() < MinimumPasswordLength)
	{
		throw logic_error(“Password is too short);
	}
	string encrypted;//必要動作,加密後的密碼放入到encrypted內
	return encrypted;
}

但是這段代碼仍然不夠完美,因爲encrypted雖然定義卻無任何實參作爲初值。意味着調用的是default構造函數。許多時候應該對對象做的第一次事就是給它個值,通常通過一個賦值動作達成。假設如下函數爲對數據加密:

void encrypt(std::string& s);
//最高效的實現如下:
std::string encryptPassword(const std::string& password)
{
	…
	std::string encrypted(password);
	encrypt(encrypted);
	return encrypted;
}

對於循環,比如有如下實現:

//方法A:定義於循環外
Widget w;
for(int I = 0; I < n; ++i)
{
	w =;}
//方法B:定義於循環內
for(int I = 0; I < n; ++i)
{
	Widget w();}

做法A成本:1個構造函數+1個析構函數 + n個賦值操作
做法B成本:n個構造函數+n個析構函數
如果classes的一個賦值成本低於一組構造+析構成本,做法A大體比較高效。尤其當n值很大的時候。否則做法B或許較好。此外做法A造成名稱w的作用域(覆蓋整個循環)比做法B更大,有時候對程序的可理解性和易維護性造成衝突。因此除非直到賦值成本比構造+析構成本低,正在處理代碼中效率高度敏感的部分,否則應該使用做法B。

  • 儘可能延後變量定義式的出現。這樣做可增加程序的清晰度並改善程序效率。

27、儘量少做轉型動作

Minimize casting.
C++規則的設計目標之一是保證“類型錯誤”絕不可能發生。理論上如果程序很乾淨地通過編譯,就表示它並不企圖在任何對象身上執行任何不安全、無意義、愚蠢荒謬的操作。但是轉型破壞了類型系統。那可能導致任何種類的麻煩。

//C風格轉型,舊式轉型
(T)expression; 		//將expression轉型爲T
T(expression);		//將expression轉型爲T
//C++提供的新式轉型
const_cast<T>( expression);
dynamic_cast<T>( expression);
reinterpret_cast<T>( expression);
static_cast<T>( expression);

各有不同的目的:
const_cast通常被用來將對象的常量性轉除(cast away the constness)。它也是唯一有此能力的C+±style轉型操作符。
dynamic_cast主要用來執行“安全向下轉型”(safe downcasting),也就是用來決定某對象是否歸屬繼承體系中的某個類型。它是唯一無法由舊式語句執行的動作,也是唯一可能耗費重大運行成本的轉型動作。
reinterpret_cast意圖執行低級轉型,實際動作及結果可能取決於編譯器,這也就表示它不可移植。例如將pointer to int轉型爲一個int。這類轉型在低級代碼以外很少見。
static_cast用來強迫隱式轉換(implict conversions),例如將non-const對象轉爲const對象,將int轉爲double等等。它也可以執行上述多種類型的反向轉換,例如將void*指針轉爲typed指針,將pointer-to-base轉爲pointer-to-derived。但它無法將const轉爲non-const整個只有const_cast纔可以。
舊式轉型合法,但是新式轉型較受歡迎。原因:它們很容易在代碼中被辨識出來;各轉型動作的目標愈窄化,編譯器可能診斷出錯誤的運用。唯一使用舊式轉型的時機是當要調用一個explicit構造函數將一個對象傳遞給一個函數時:

class Widget{
public:
	explicit Widget(int size);};
void doSomeWork(const Widget& w);
doSomeWork(Widget(15));		//以一個int加上“函數風格”的轉型動作創建一個Widget。
doSomeWork(static_cast<Widget>(15)); //以一個int加上C++風格的轉型動作創建一個Widget。

關於轉型很容易寫出某些似是而非的代碼。例如應用框架都要求derived classes內的virtual函數代碼的第一個動作就先調用base class的對應函數。如有個Window基類和一個SpecialWindow派生類,兩者都定義了virtual函數onResize。SpecialWindow的onResize函數被要求首先調用Window的onResize。下面實現看起來對實際是錯誤的:

class Window{
public:
	virtual void onResize() {}	//基類onResize實現代碼};
class SpecialWindow:public Window{
public:
	virtual void onResize() 					//派生類onResize實現代碼
	{
		static_cast<Window>(*this).onResize();		//轉型調用,錯誤的}
};

這段代碼中看起來將*this轉型爲Window,對函數onResize也調用了,但是調用的並不是當前對象上的函數。而是轉型動作所建立的一個“*this對象的基類成分”的暫時副本身上的onResize!解決辦法就是去掉轉型動作如下:

class SpecialWindow:public Window{
public:
	virtual void onResize() 					//派生類onResize實現代碼
	{
		Window::onResize();		//調用Window::onResize作用與*this身上}
};

dynamic_cast的許多實現版本執行速度相當慢。例如一個基於class名稱的字符串比較,如果在四層深得單繼承體系內的某個對象身上執行dynamic_cast,可能會造成多達四次的strcmp調用用以比較class名稱。深度繼承或多重繼承的成本更高!在注重效率的代碼中對dynamic_cast的使用要高度謹慎。之所以需要dynamic_cast,通常是因爲想在一個認定爲派生類對象身上執行派生類操作函數,但是隻有一個指向基類的指針或引用,只能利用這個來處理對象。有兩個一般性做法可以避免這個問題。
第一,使用容器並在其中存儲直接指向派生類對象的指針(通常是智能指針)如此便消除了“通過基類接口處理對象”的需要。假設Window/SpecialWindow繼承體系中只有SpecialWindow才支持閃爍效果,不要這樣實現:

class Window {};
class SpecialWindow:public Window {
public:
	void blink();};
typedef std::vector<std::tr1::shared_ptr<Window> > VPW;
VPW winPtrs;for(VPW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter)
{	//不要用dynamic_cast,影響效率
	if(SpecialWindow* psw = dynamic_cast< SpecialWindow *>(iter->get())) 
		psw->blink();
}

應該改爲:

typedef std::vector<std::tr1::shared_ptr<SpecialWindow> > VPW;
VPW winPtrs;for(VPW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter)
{
	(*iter)->blink();
}

但是如此做的話無法在同一個容器內存儲指向所有可能的各種Window派生類指針。這樣就需要多個容器,且都必須具備類型安全性。
第二種做法可通過基類接口處理“所有可能的各種Window派生類”,那就是在基類內提供virtual函數作想對各個Window派生類做的事。例如只有SpecialWindow可以閃爍,將閃爍函數聲明在基類內並提供一份什麼也沒做的缺省實現碼:

class Window{
public:
	virtual void blink() {	}	//缺省實現代碼啥也不做};
class SpecialWindow:public Window {
public:
	void blink(){}	//具體實現};

typedef std::vector<std::tr1::shared_ptr<Window> > VPW;
VPW winPtrs;for(VPW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter)
{
	(*iter)->blink();
}

特別注意絕對必須避免就是所謂的連串的dynamic_cast,這樣的代碼又大又慢,而且基礎不穩。優良的C++代碼很少使用轉型。

  • 如果可以,儘量避免轉型,特別是在注重效率的代碼中避免dynamic_cast。如果有個設計需要轉型動作,試着發展無需轉型的替代設計
  • 如果轉型是必要的,試着將它隱藏於某個函數背後。客戶隨後可以調用該函數,而不需將轉型放進他們自己的代碼內。
  • 寧可使用C+±style(新式)轉型,不要使用舊式轉型。前者很容易辨識出來,而且也比較有着分門別類的職掌。

28、避免返回handles指向對象內部成分

Avoid returning “handles” to object internals.
如有一個程序涉及矩形,每個矩形有左上角和右下角表示。將定義矩形的這些點存放在一個輔助的struct內,再讓Rectangle去指向它:

class Point{
public:
	Point(int x, int y);void setX(int newVal);
	void setY(int newVal);
};
Struct RectData{
	Point ulhc;	//左上
	Point lrhc;	//右下
};
class Rectangle{private:
	std::tr1::shared_ptr<RectData> pData;
};

Rectangle的使用者必須能夠計算Rectangle的範圍,所以這個class提供upperLeft函數和lowerRight函數,這兩個函數返回references。代表底層的Point對象:

class Rectangle{
public:
	Point& upperLeft() const {	return pData->ulhc;		}
	Point& lowerRight() const {	return pData-> lrhc;	}};

但是這樣的設計雖然可通過編譯,但卻是錯誤的。因爲雖然upperLeft函數和lowerRight函數被聲明爲const成員函數,目的是爲了提供一個得知Rectangle相關座標點的方法,而不是讓調用者修改Rectangle。兩個函數卻都返回references指向private內部數據,調用者於是可通過這些references更改內部數據。如果它們返回的是指針或迭代器,相同的情況還是會發生。References、指針和迭代器都是所謂的handles。爲了讓調用者不能更改內部數據,對它們的返回值類型加上const:

class Rectangle{
public:
	const Point& upperLeft() const {	return pData->ulhc;		}
	const Point& lowerRight() const {	return pData-> lrhc;	}};

這樣實現,不再允許調用者更改對象狀態。但是兩個函數還是返回了“代表對象內部”的handles,有可能在其他場合帶來問題。可能導致dangling handles(空懸的句柄):這種handles所指東西(的所屬對象)不復存在。例如某個函數返回GUI對象的外框(bounding box):

class GUIObject{};
const Rectangle boundingBox(const GUIObject& obj);
//使用
GUIObject* pgo;	//對象pgo指向某個GUIObject
…
Const Point* pUpperLeft = &( boundingBox(*pgo).upperLeft());//取得一個指針指向外框左上點

對boundingBox的調用獲得一個新的/暫時的Rectangle對象。這個對象沒有名稱。先稱爲temp。隨後upperLeft作用與temp身上,返回一個reference指向temp的一個內部成分。但是在語句結束之後,boundingBox的返回值被銷燬,這將導致temp內的Points析構。最終導致pUpperLeft指向一個不存在的對象。也就變成了空懸、虛吊(dangling)。
這並不意味絕對不可以讓成員函數返回handle。有時候必須這樣做,例如operator[]就允許“摘採”strings和vectors的個別元素,而這些operator[]s就是返回references指向“容器內的數據”,那些數據會隨着容器被銷燬而銷燬。

  • 避免返回handles(包括references、指針、迭代器)指向對象內部。遵守這個條款可增加封裝性,幫助const成員函數的行爲像個const,並將發生“虛吊句柄”(dangling
    handles)的可能性降至最低。

29、爲“異常安全”而努力是值得的

Strive for exception-safe code.
假設有個class用來表現夾帶背景圖案的GUI菜單。這個class希望用於多線程環境,所以它有個互斥鎖(mutex)作爲併發控制(concurrency control)之用:

class PrettyMenu{
public:void changeBackground(std::istream& imgSrc);	//改變背景圖像private:
	Mutex mutex;		//互斥鎖
	Image* bgImage;	//目前的背景圖像
	int imageChanges;	//背景圖像被改變的次數
};
// changeBackground可能實現:
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
	lock(&mutex);			//加鎖
	delete bgImage;		//去掉舊的背景圖像
	++imageChanges;		//修改變更次數
	bgImage = new Image(imgSrc);	//加載新的背景圖像
	unlock(&mutex);		//解鎖
}

這個函數從異常安全性的觀點來看很糟糕,異常安全性有兩個條件:當異常拋出時,不泄露任何資源(上述代碼一旦“new Image(imgSrc)”導致異常,對unlock的調用絕不會執行,互斥鎖就被永遠鎖住);不允許數據敗壞(如果“new Image(imgSrc)”拋出異常,bgImage就是指向一個已被刪除的對象,imageChanges也已被累加,而實際並沒有新的圖像被成功安裝)。
解決資源泄露問題可以使用對象管理資源,導入一個Lock class作爲一種“確保互斥鎖被及時釋放”的方法:

void PrettyMenu::changeBackground(std::istream& imgSrc)
{
	Lock ml(&mutex);			//加鎖
	delete bgImage;		//去掉舊的背景圖像
	++imageChanges;		//修改變更次數
	bgImage = new Image(imgSrc);	//加載新的背景圖像
}

異常安全函數提供三個保證之一:基本承諾(如果異常被拋出,程序內的任何事物仍然保持在有效狀態下)、強烈保證(如果異常被拋出,程序狀態不改變)、不拋擲保證(承諾絕不拋出異常,因爲它們總是能夠完成它們原先承諾的功能)。
對changeBackground而言,可以通過提供強烈保證保證異常安全。首先改變PrettyMenu的bgImage成員變量的類型,從一個類型爲Image*的內置指針改爲一個“用於資源管理”的智能指針。重新排列changeBackground內的語句次序,使得在更換圖像之後才累加imageChanges,這個實現不再需要手動delete舊圖像,因爲智能指針內部處理掉了:

class PrettyMenu{
	…
	std::tr1::shared_ptr<Image> bgImage;	//目前的背景圖像};
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
	Lock ml(&mutex);			//加鎖
	bgImage.reset(new Image(imgSrc)); //以“new Image”的執行結果設定bgImage內部指針
	++imageChanges;
}

上述實現幾乎足夠讓changeBackground提供強烈的異常安全保證。美中不足的是參數imgSrc。如果imgSrc構造函數拋出異常,有可能輸入流得讀取記號已被移走,而這樣的搬移對程序其餘部分是一種可見的狀態改變。所以changeBackground在解決這個問題之前只提供基本的異常安全保證。一般化的設計策略很典型地會導致強烈保證,很值得熟悉它。這個策略被稱爲copy and swap。具體細節爲打算修改的對象原件做出一份副本,然後再那副本身上做一切必要修改。若有任何修改動作拋出異常,原對象仍保持爲本改變狀態。所以改變都成功後,再將修改過的那個副本和原對象在一個不拋出異常的操作中置換(swap)。

struct PMImpl{
	std::tr1::shared_ptr<Image> bgImage;
	int imageChanges;
};
class PrettyMenu{private:
	Mutex mutex
	std::tr1::shared_ptr< PMImpl > pImpl;	//目前的背景圖像
};
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
	Using std::swap;
	Lock ml(&mutex);			//加鎖
	std::tr1::shared_ptr< PMImpl > pNew(new PMImpl(*pImpl));
	pNew->bgImage.reset(new Image(imgSrc));	//修改副本
	++pNew->imageChanges;
	swap(pImpl, pNew);	//置換數據
}
  • 異常安全函數即使發生異常也不會泄露資源或允許任何數據結構敗壞。這樣的函數區分爲三種可能的保證:基本型、強烈型、不拋異常型。
  • “強烈保證”往往能夠以copy-and-swap實現出來,但“強烈保證”並非對所有函數都可實現或具備現實意義。
  • 函數提供的“異常安全保證”通常最高只等於其所調用的各個函數的“異常安全保證”中的最弱者。

30、透徹瞭解inlining的裏裏外外

Understand the ins and outs of inlining.
Inline函數可以調用它們不需要蒙受函數調用所招致的額外開銷,編譯器最優化機制通常被設計用來濃縮那些“不含函數調用”的代碼,所以對它執行語境相關最優化。但是inline函數會將對此函數的每一個調用都以函數本體替換,這回增加目標碼(object code)大小。造成的代碼膨脹會導致額外的換頁行爲,降低指令高速緩存裝置的擊中率以及伴隨這些而來的效率損失。inline只是對編譯器的一個申請,不是強制命令。這項申請可以隱喻提出,也可以明確提出。隱喻方式是將函數定義於class定義式內:

class Person{
public:
	int age() const {	return theAge;	}	//一個隱喻的inline申請
private:
	int theAge;
};

明確的inline函數的做法是在其定義式前加上關鍵字inline。例如標準的max template這樣實現出來:

template<typename T>
inline const T& std::max(const T& a, const T& b){	return a < b ? b : a;	}

inline函數通常一定被置於頭文件內,因爲大多數構建環境(build environments)在編譯過程中進行inlining,而爲了將一個“函數調用”替換爲“被調用函數的本體”,編譯器必須知道那個函數長什麼樣子。某些構建環境可以在連接期完成inlining,少量構建環境可在運行期完成inlining。
大部分編譯器拒絕將太多複雜(例如帶有循環或遞歸)的函數inlining,而所有對virtual函數的調用也都會使inlining落空。意味着一個表面看似inline的函數是否真是inline,取決於構建環境。

  • 將大多數inlining限制在小型、被頻繁調用的函數身上。這可使日後的調試過程和二進制升級更容易,也可以潛在的代碼膨脹問題最小化,使程序的速度提升機會最大化。
  • 不要只因爲function templates出現在頭文件,就將它們聲明爲inline。

31、將文件間的編譯依存關係降至最低

Minimize compilation dependencies between files.
假設對C++程序的某個class實現文件做了些輕微修改。然後重新構建這個程序,然後預計花數秒就好了,因爲只有一個class被修改。但是真實情況是整個項目被重新編譯和連接了。這個問題出在C++並沒有把“將接口從實現中分離”這件事做得很好。Class的定義不只詳細敘述了class接口,還包括十足的實現細目。例如:

class Person{
public:
	Person(const std::string& name, const Date& birthday, const Address& addr);
	std::string name() const;
	std::string birtjDate() const;
	std::string address() const;
private:
	std::string theName;
	Date theBirthdate;
	Address theAddress;
};

這裏的class Person無法通過編譯,因爲編譯器無法取得其實現代碼所用到的classes string,Date和Address的定義式。定義式通常有#include指示符提供,所以在此文件最上方應該存在:

#include <string>
#include “date.h”
#include “address.h”

但是如此做的話Person定義文件和其含入文件之間形成了一種編譯依存關係。如果這些頭文件中有任何一個被改變,或這些頭文件所依賴的其他頭文件有任何改變,那麼每一個含入Person class的文件就得重新編譯,任何使用Person class的文件也必須重新編譯。這樣的連串編譯依存關係會對許多項目造成難以形容的災難。
如將實現細目置於class定義式中,這樣只需要在Person接口被修改時才重新編譯:

#include <string>	//標準程序庫不該被前置聲明
#include <memory>	//爲了使用智能指針
class PersonImpl;	// Person實現類的前置聲明
class Date;		// Person接口用到的classes的前置聲明
class Address;
class Person{
public:
	Person(const std::string& name, const Date& birthday, const Address& addr);
	std::string name() const;
	std::string birtjDate() const;
	std::string address() const;
private:
	std::tr1::shared_ptr< PersonImpl > pImpl; //指針,指向實現物
};

上述實現中class(Person)只內含一個指針成員,指向其實現類(PersonImpl)這種設計常被稱爲pointer to implementation。這種設計下Person的調用者就完全與Dates,Addresses以及Persons的實現細目分離了。那些classes的任何實現修改都不需要Person客戶端重新編譯。此外由於調用者無法看到Person的實現細目,也就不可能寫出什麼“取決於那些細目”的代碼,真正的接口與實現分離。
這個分離的關鍵在於以“聲明的依存性”替換“定義的依存性”,正是編譯依存性最小化的本質:現實中讓頭文件儘可能自我滿足,萬一做不到,則讓它與其他文件內的聲明式(而非定義式)相依。其他每一件事都源自於這個簡單的設計策略:如果使用object references或object pointers可以完成任務,就不要使用objects。如果能夠,儘量以class聲明式替換class定義式。爲聲明式和定義式提供不同的頭文件。
像Person這樣使用pimpl idiom的classes往往被稱爲Handle classes。例如Person兩個成員函數的實現:

#include “Person.h”
#include “PersonImpl.h”
Person::Person(const std::string& name, const Date& birthday, const Address& addr)
: pImpl(new PersonImpl(name, birthday, addr ))
{}
std::string Person::name() const
{
	return pImpl->name();
}

Person構造函數以new調用PersonImpl構造函數以及Person::name函數內調用PersonImpl::name。讓Person變成一個Handle class並不會改變它做的事,只會改變它做事的方法。
另一個製作Handle class的辦法是,令Person成爲一種特殊的abstract base class(抽象基類),稱爲Interface class。這種class的目的是詳細—描述派生類的接口,因此它通常不帶成員變量,也沒有構造函數,只有一個virtual析構函數以及一組pure virtual函數,用來敘述整個接口。

class Person{
public:
	virtual ~Person();
	virtual std::string name() const = 0;
	virtual std::string birtjDate() const = 0;
	virtual std::string address() const = 0;
};

這個class的使用者必須以Person的pointers和references來撰寫應用程序,因爲它不可能針對“內含pure virtual函數”的Person classes創建出實體。然而卻有可能對派生自Person的classes創建出實體。除非Handle classes的接口被修改否則其使用者不需要重新編譯。
假設Interface class Person有個可以創建實體的派生類RealPerson,後者提供繼承而來的virtual函數的實現:

class RealPerson: public Person{
public:
	RealPerson(const std::string& name, const Date& birthday, const Address& addr)
	:theName(name),theBirthDate(birthday), theAddress(addr){}
	virtual ~RealPerson();
	std::string name() const;
	std::string birtjDate() const;
	std::string address() const;
private:
	std::string theName;
	Date theBirthdate;
	Address theAddress;
};
//有了RealPerson之後,Person::create創建對象可以如下實現:
std::tr1::shared_ptr<Person> Person::create(std::string& name, const Date& birthday, const Address& addr)
{
	return std::tr1::shared_ptr<Person>(new RealPerson(name, birthday, addr));
}

Person::create實現代碼可以根據額外的參數值等創建不同類型的派生類對象。

  • 支持“編譯依存性最小化”的一般構想是:相依於聲明式,不要相依於定義式。基於此構想的兩個手段是Handle
    classes和Interface classes。
  • 程序庫頭文件應該以“完全且僅有聲明式”的形式存在。這種做法不論是否涉及template都適用。

上一篇: C++進階_Effective_C++第三版(四) 設計與聲明 Designs and Declarations
下一篇: C++進階_Effective_C++第三版(六) 繼承與面向對象設計 Inheritance and Object-Oriented Design

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章