我根據自己的理解,對原文的精華部分進行了提煉,併在一些難以理解的地方加上了自己的“可能比較準確”的「翻譯」。 Chapter 5 實現 Implementations 適當提出屬於你的class定義以及各種functions聲明相當花費心思。一旦正確完成它們,相應的實現大多直截了當。儘管如此,還是要 ...
我根據自己的理解,對原文的精華部分進行了提煉,併在一些難以理解的地方加上了自己的“可能比較準確”的「翻譯」。
Chapter 5 實現 Implementations
適當提出屬於你的class定義以及各種functions聲明相當花費心思。一旦正確完成它們,相應的實現大多直截了當。儘管如此,還是要小心很多細節。
條款26 : 儘可能延後變數定義式的出現時間
當你定義了一個變數,其類型帶有構造函數和析構函數,當程式控制流(control flow)到達此變數定義式時,你需要承擔構造成本;此變數離開作用域時,便需要承擔析構成本———即使你自始至終都沒有用過它。所以你應避免這種情況
你會問:怎麼可能定義「不被使用的變數」?下麵考慮一個函數,作用是計算通行密碼的加密版本後返回,但前提是密碼足夠長。若太短,函數會拋出異常,類型為logic_error
//此函數過早定義變數“encrypted”
std::string encryptPassword(const std::string& password)
{
using namespace std;
string encrypted;
if(password.length()<MinimumPasswordLength){
throw logic_error("Password is too short");
}
... //諸如將加密後的密碼放進encrypted內的動作
}
這裡存在的問題是,如果拋出了異常,那encrypted就真的沒被使用———然而你還得付出構造和析構的成本。 看起來較好的解決方案是這樣的:
...
if(password.length()<MinimumPasswordLength){
throw logic_error("Password is too short");
}
string encrypted; //延後定義式,直到真正需要它
...
其實效率不夠高,因為encrypted雖獲定義卻無實參作初值。更好的做法是“直接在構造時指定初值”,這樣的效率高於default構造函數(構造對象再對它賦值)。<我們在條款4討論過效率問題>
現在我們一步一步進行分析。假設將函數encryptPassword的加密部分用 void encrypt(std::string& s);
實現,於是encryptPassword實現如下:
//此版本雖延後了定義,但仍效率低下:
std::string encryptPassword(const std::string& password)
{
... //檢查length,同前
std::string encrypted; //default constructor,無意義
encrypted = password;
encrypt(encrypted);
return encrypted;
}
更受歡迎的做法是直接將password作為encrypted初值,跳過無意義預設構造:
std::string encrypted(password);
現在我們大概能理解「儘可能延後」的深層含義:你應嘗試延後這份變數定義直到能夠給它初值實參為止。
但遇到迴圈怎麼辦?若我們只在迴圈內用到變數,是該將它定義與迴圈外併在每次迴圈迭代賦值給它,還是將其定義於迴圈內? :
//A方案,定義於迴圈外: //方法B,定義於迴圈內 :
Widget w; for(int i=0;i<n;++i){
for(int i=0;i<n;++i){ Widget w(表達式取決於i值);
w = 表達式;(取決於i值) ...
} }
首先看A和B做法的成本:
- A: 1個構造函數 + 1個析構函數 + n個賦值操作
- B: n個構造函數 + n個析構函數
我們可以理清:
A的適用情況:
class的一個賦值成本低於一組構造+析構成本 ;否則做法B較好另外,A造成名稱w作用域大於B,有潛在對程式可理解性和易維護性的衝突。
結論
除非你知道賦值成本小於“析構+構造” ;
你正在處理代碼中對性能高度敏感(performance-sensitive)部分。
否則你該使用做法B。
條款27: 儘量少做轉型動作 Minimize casting
很不幸,轉型(casts)可能導致各種麻煩,有的顯而易見,有的非常隱晦。
讓我們複習一下轉型的語法:
- C風格:
(T)expression 將expression轉為T - 函數風格:
T(expression) 將expression轉為T
它們並無差別,只是小括弧位置不同而已。我們可以稱這兩種為「舊式轉型」(old-style casts)。
C++還提供了四種新式轉型:
- const_cast
- dynamic_cast
- reinterpret_cast
- static_cast
各有不同用途:
const_cast通常用來將對象的常量性質除去(cast away the constness)(不是真正除去)。它是唯一有此能力的C++-style轉型操作符。
dynamic_cast主要用作“ 安全向下轉型 safe downcasting ”,能決定對象是否屬於繼承體系中某個類型。它是唯一無法用舊式語法執行的動作,並可能耗費大量運行成本。(後面會討論)
reinterpret_cast執行低級轉型,實際動作及結果取決於編譯器,這意味它不可移植。
static_cast用來強迫隱式轉換(implicit conversions)。例如將non-const對象轉為const對象,將int轉為double等。但將const轉為non-const只有const_cast做得到
新式轉型較受歡迎:
- 它們易被辨識(不論人工還是工具)
- 可以縮小轉型動作的選擇範圍。比如想去掉常量性(constness),只有const_cast能辦到
使用舊式轉型一般是調用explicit構造函數將對象傳給一個函數:
class Widget{
public:
explicit Widget(int size);
...
};
void doSomeWork(const Widget& w);
doSomeWork(Widget(15)); //函數式
doSomeWork(static_cast<Widget>(15)); //新式
使用第一種的原因可能是你覺得比較這樣自然,不像第二種蓄意的“生成對象”。但是為了以後代碼的可靠性,還是老老實實用新式轉型吧。
另外,C++的指針會產生偏移(offset)現象:
class Base { ... }
class Derived: public Base { ... };
Dervied d;
Base* pb = &d; //隱式轉換
註意上方代碼,有時候引用d和指針pb兩個指針(d其實相當於常量指針)的值並不相同。這種情況下,在運行期間會有一個偏移量(offset)被施於Dervied* 指針身上,以獲取正確的Base*指針值。
上面的論述表明,單一對象(例如這裡的Derived對象)可能擁有一個以上的地址(例如對象“以Base* 指向它”時的地址和“以Derived*指向它”時的地址)。其它語言幾乎不可能出現這種情況,然而神奇的C++可以!!實際上C++碰到多重繼承,這事兒其實一直在發生———即使單一繼承也可能發生。
請註意了,對於偏移量(offset),對象佈局和它們的地址計算方式隨編譯器不同而不同————則意味著可能你設計的轉型在某平臺可用,在另一平臺不一定可用!
另一件關於轉型的有趣的問題:許多應用框架(application frameworks)要求dervied class內的virtual函數代碼第一語句即調用其base class的對應函數
我們很容易寫一些看起來很對的轉型代碼:
class Window{
public:
virtual void onResize() {...} //基類onResize實現
...
};
class SpecialWindow: public Window{ //derived class
public:
virtual void onResize(){
static_cast<Window>(*this).onResize();
/* 試圖將*this,即當前對象指針轉為父類,然後調用其
onResize,not ok! */
... //SpecialWindow專屬動作
}
};
我們在代碼中用了新式轉型(實際上用舊式也是有問題的)。 一如你預期,程式確實將* this轉型為Window基類,對onResize的調用也因此調用了Window::onResize。但你一定沒想到,它調用的並不是當前對象的函數,而是轉型動作「早期」,程式建立的一個 ”this類型但只包含其base class Window成分的對象”*的暫時副本身上的onResize!
我們再理解一遍 :上述代碼並非「在當前對象身上調用Window::onResize後又在該對象身上執行SpecialWindow專屬動作」。不不不,它是「在”當前對象之base class成分”的副本上調用Window ::onResize」,然後才在當前對象上執行SpecialWindow專屬動作。會出現啥問題呢??假設onResize函數作用是改變對象的某內容,調用它時,首先轉型*this指針為Window然後調用Window的onResize,並對Window成分進行專屬操作。 但實際上此時調用的是「含有Window成分」對象副本的onResize,動作根本沒有落實到真正的base class成分上;但SpecialWindow的onResize會真的改動原對象!想象一下,這會使當前對象進入“傷殘”狀態
解決之道是拿掉轉型動作,別去哄騙編譯器將*this視為一個base class對象:
class SpecialWindow: public Window{
public:
virtual void onResize(){
Window::onResize(); //調用Window域的onResize作用於*this
...
}
...
};
現在談談dynamic_cast。它的許多實現版本執行速度很慢。一個普遍的實現版本基於“class之字元串比較”,如果你在四層深的單繼承體系內某對象身上執行dynamic_cast,這個實現版本每層的一次dynamic_cast可能會耗用四次strcmp調用來比較class名稱!深度繼承和多重繼承成本更高。(有些版本為了必須實現的動態鏈接必須這麼做)但你應在註重效率的代碼中思量是否要使用dynamic_cast。
通常用到dynamic_cast的場景為:
你想在一個dervied class對象身上執行專屬於此之類自己的函數,但你只有一個base class類型的pointer或reference。
有兩個一般性做法可以避免窘境:
通過STL直接儲存指向dervied class對象的指針(通常為智能指針,見條款13)。
假設之前的Window/SpecialWindow繼承體系中,SpecialWindow有專屬函數void blink()
,不要這麼寫代碼:
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){
if(SpecialWindow* psw=dynamic_cast<SpecialWindow*>(iter->get())) //dynamic_cast效率低下
psw->blink();
}
應這麼寫:
typedef
std::vector<std::tr1::shared_ptr<SpecialWindow> > VPSW;
VPSW winPtrs;
...
for(VPSW::iterator iter = winPtrs.begin();
iter!=winPtrs.end();++iter)
(*iter)->blink();
//不用dynamic_cast的實現
當然,這種寫法會使你無法在一個容器內儲存「可指向所有base派生類」的指針。若確實需要處理多種類型,可能需要多個容器,他們必須具備類型安全性。
另一種做法可讓你通過base class介面處理所有base派生類,那就是在base里提供一個virtual函數。這類似Java里的抽象類:
class Window{
public:
virtual void blink(){}
//預設實現代碼「什麼也沒做」,交給子類實現。以後會告訴你這可能是餿主意
...
};
class SpecialWindow: public Window{
public:
virtual void blink() {...};
//子類的blink里做一些事。
...
};
typedef std::vector<std::tr1::shared_ptr<Window> > VPW;
VPW winPtrs; //容器內含base類型指針
...
for(VPW::iterator iter = winPtrs.begin();
iter!=winPtrs.end();++iter)
(*iter)->blink();
上述兩種方法並非具有強大的普遍性,但是很多時候你應該以此替代dynamic_cast。
有一個你絕對,必須避免的東西:連串(cascading)dynamic_casts。也就是看起來像這樣的東西:
class Window {...};
...
typedef std::vector<std::tr1::shared_ptr<Window> > VPW;
VPW winPtrs;
...
for(VPW::iterator iter = winPtrs.begin();
iter!=winPtrs.end();++iter)
{
if(SpecialWindow1* pswl =
dynamic_cast<SpecialWindow1*>(iter->get())) {...}
else if(SpecialWindow2* psw2 =
dynamic_cast<SpecialWindow2*>(iter->get())) {...}
else if(SpecialWindow3* psw3 =
dynamic_cast<SpecialWindow3*>(iter->get())) {...}
...
}
這樣產生的代碼又腫又慢,基礎不穩。例如一旦加入新的dervied class,上述連串判斷可能就要加入新的分支。這樣的代碼應以“基於virtual函數調用”的東西取代。
優良的C++代碼很少使用轉型,但完全擺脫轉型操作不切實際。
最後,請記住:
- 儘量避免轉型,特別是在註重效率的代碼中避免dynamic_cast。
- 如果轉型必要,試著將它隱藏在某個函數後,客戶只需調用函數,而不用將具體實現加進他們的代碼里。
- 寧用C++-style(新式)轉型。
條款28: 避免返回handles指向對象內部成分
Avoid returning “handles” to object internals
現在假設你的程式涉及矩形,每個矩形由左上角和右下角的點坐標確定。為了讓一個Rectangle對象儘可能小,你可能決定把定義的點放在輔助點struct內
class Point{ //此類表示“點”
public:
Point(int x,int y);
...
void setX(int newVal);
void setY(int newVal);
...
};
struct RectData{ //這些“點”數據用來表現矩形
Point ulhc; //"upper left-hand comer"(左上角)
Point lrhc; //"lower right-hand comer"(右下角)
};
class Rectangle{
...
private:
std::tr1::shared_ptr<RectData> pData;
};
使用Rectangle的客戶需要計算Rectangle範圍,所以此類提供upperLeft和lowerRight函數來返回左上角和右下角的坐標。根據條款20的討論,我們讓函數返回引用,代表底層的Point對象:
class Rectangle{
public:
...
Point& upperLeft() const { return pData->ulhc; }
Point& lowerRight() const { return pData->lrhc; }
...
}
這種設計有一個重大缺陷:雖然兩個函數被設計為const從而不能修改類成員函數,但是它所返回的reference卻可以直接指向private內部數據,例如:
Point coord1(0,0);
Point coord2(100,100);
const Rectangle rec(coord1,coord2); //一個const矩形
rec.upperLeft().setX(50); //??一個const矩形的值竟然被改變了?
伙計,rec應該是不可變的啊!這給我們一個教訓:
成員變數的封裝性最多等於「返回其reference」的函數的訪問級別
如果類似的函數返回指針或迭代器的,相同的事情還是會發生。原因很簡單,references、pointers和迭代器統統是所謂的 handles(號碼牌,用來取得某對象)。所以返回一個“代表對象內部數據”的handle會帶來降低對象封裝性的風險。
之前的問題可以通過一個開頭的修飾符輕鬆解決:
const Point& upperLeft() const { return pData->ulhc; }
const Point& lowerRight() const { return pData->lrhc; }
這樣一來,返回的引用的許可權僅為“只讀”。
可即使如此,在其它場合可能還是會有問題。它可能導致_dangling handles(空懸的號碼牌);_也就是說handles所指物(的所屬對象)不復存在。這種問題常見來源是函數返回值。例如某函數返回GUI對象的外框,這個框採用矩形形式:
class GUIObject {...};
const Rectangle
boundingBox(const GUIObject& obj);
//以by value形式返回矩形
現在客戶可能這麼使用此函數:
GUIObject* pgo;
... //讓pgo指向某個GUIObject
const Point* pUpperLeft = &(boundingBox(*pgo).upperLeft());
//取得一個指針指向外框左上角坐標
上述的操作中,boundingBox返回一個臨時的Rectangle副本對象,它沒有名稱,暫且稱它temp。隨後upperLeft作用於temp並返回一個reference指向temp內部的Point成分,然後指針pUpperLeft指向那個Point對象。然鵝。。。在語句結束後,temp將會被銷毀,間接導致temp內的Points析構,最終導致pUpperLeft指向不存在的對象。
這就是為啥函數返回一個handle總是危險的原因。但這不是說你絕不能讓成員函數返回handles,有時你必須這麼做。比如operator[]返回的引用允許你取得string對象或vector對象的個別元素。
條款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); //取得互斥器(見條款14)
delete bgImage; //擺脫舊背景圖案
++imageChanges; //增加次數
bgImage = new Image(imgSrc); //安裝新背景
unlock(&mutex); //釋放互斥器
}
從“異常安全性”的兩個條件來看,這個函數很糟:
不泄漏任何資源 一旦
new Image(imgSrc)
導致異常,對unlock的調用就不會執行,於是互斥器就永遠被把持住了。不允許數據敗壞 若
new Image(imgSrc)
拋出異常,bgImage即指向一個已被刪除的對象,imageChanges也被累加,然而其實沒有新圖像被成功安裝。
- - - -
解決資源泄漏很容易,以前的條款14曾討論過,導入Lock class作為一種「確保互斥器幾被及時釋放」的方法:
void PrettyMenu::changeBackground(std::istream& imgSrc){
Lock ml(&mutex); //來自條款14
delete bgImage;
++imageChanges;
bgImage = new Image(imgSrc);
}
如Lock流的“資源管理類(resource management classes)”通常能使代碼更短。在上例的體現在於省去了unlock函數。
- - - -
接下來解決數據的敗壞。
首先,異常安全函數(exception-safe functions)提供以下三個保證之一:
基本承諾: 一旦異常拋出,程式內任何事物仍保持在有效狀態下。沒有任何對象或數據結構因此敗壞,處於前後一致狀態。但現實狀態(exact state)不可預料,比如我們可以設計changeBackground為,一旦拋出異常,PrettyMenu可繼續持有原背景或賦給預設背景圖案。
強烈保證: 一旦異常拋出,程式狀態不變。這樣的函數,只要成功,就完全成功;如果失敗,程式會恢復到“調用函數之前”狀態。
不拋擲保證(nothrow): 承諾絕不拋出異常,因為它們總是能完成它們原先承諾的功能。 C++內置類型都能提供nothrow保證。
事實上最先考慮的應該是第三個保證,因為只有不能完全做到它我們才會考慮前兩個保證。
但是在C part of C++領域中很難完全不調用可能拋出異常的函數。任何使用動態記憶體的東西(比如STL),若記憶體不足以滿足需求,通常拋出bad_alloc異常(請見以後的條款49)。
所以只能說儘可能提供nothrow保證,但很多情況只能做到「基本保證」或「強烈保證」。
那我們如何為之前討論的changeBackground函數提供「強烈保證」呢?首先改變PrettyMenu的成員bgImage的類型,從Image*改為智能指針:
class PrettyMenu{
...
std::tr1::shared_ptr<Image> bgImage;
...
};
void PrettyMenu::changeBackground(std::istream& imgSrc){
Lock ml(&mutex);
bgImage.reset(new Image(imgSrc));
++imageChanges;
//不再需要手動delete,智能指針已經完成
}
這個操作幾乎足夠讓此函數提供「強烈保證」,美中不足的是參數imgSrc。比如若Image構造函數拋出異常,可能輸入流(input stream)的讀取記號(read marker)已被移走,而這種搬移對程式其餘部分是一種可見的狀態改變。
有一個設計策略叫做copy and swap,可以改善問題,原則是這樣的:
為打算修改的對象原件做一份副本,再在副本上作需要的修改。一旦有任何異常拋出,原件保持不變。若所有改動皆成功則將修改的副本和原件在一個不拋出異常的操作中置換(swap)
對於PrettyMenu寫法如下:
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; //條款25
Lock ml(&mutex); //獲得mutex副本數據
std::tr1::shared_ptr<PMImpl>
pNew(new PMImpl(*pImpl));
pNew->bgImage.reset(new Image(imgSrc)); //修改副本
++pNew->imageChanges;
swap(pImpl,pNew); //置換同時釋放mutex
}
持續更新中................................