[轉(zhuǎn)]http://m.shnenglu.com/tiandejian/archive/2007/05/18/cp_18.html
第四章. 設(shè)計規(guī)劃與聲明方式
軟件設(shè)計方案是用來幫助規(guī)劃軟件功能的,通常,它在一開始是一系列優(yōu)秀的思想,但最終都會被具體化,直至其成為可用于開發(fā)的一系列具體接口。之后,這些接口必須轉(zhuǎn)換為 C++ 的聲明。本章中,我們將解決“如何設(shè)計和聲明優(yōu)秀的 C++ 接口”這一問題。我們以下面的建議開始(它可能是設(shè)計接口時要注意的最為重要的一點):接口應(yīng)該易于正確使用,而不宜被誤用。這引出了一系列更為具體的建議,其中我們將討論十分寬泛的議題,包括正確性、效率、封裝、可維護(hù)性、可擴(kuò)展性,還有你需要遵循的慣例。
本章并不期望對于優(yōu)秀接口設(shè)計的建議做到包羅萬象,取而代之的是精選出一部分最為重要的內(nèi)容。各種錯誤和問題對于類 / 函數(shù) / 模板的設(shè)計者來說是家常便飯,本章就是用來時刻警示你遠(yuǎn)離這些常見的錯誤,并為你提供這些問題的解決方法。
第18條: 要讓接口易于正確使用,而不易被誤用
C++ 中到處充滿了接口。函數(shù)接口、類接口、模板接口,等等。每個接口都是實現(xiàn)客戶端程序員與你的代碼相交互的一種手段。假設(shè)你的客戶通情達(dá)理,他們的項目也十分優(yōu)秀,他們便會十分看重你的接口是否易于正確使用。這是千真萬確的,如果他們誤用了你的接口中的任一個,那么你也難推其咎。在理想狀態(tài)下,如果客戶端程序員嘗試使用一個接口,但是沒有達(dá)到預(yù)期的效果,那么代碼則不應(yīng)通過編譯。反之,如果代碼通過了編譯,那么則必須要符合客戶端程序員的需求。
開發(fā)中我們應(yīng)做到讓接口更易于正確使用而不易被誤用,這需要你考慮到客戶端程序員會犯的各種錯誤。請參見下邊的示例,假設(shè)你正在編寫一個表示日期時間的類的構(gòu)造函數(shù):
class Date {
public:
Date(int month, int day, int year);
...
};
乍一看,這一接口設(shè)計得很合理(至少在美國很合理),但是當(dāng)客戶端程序員面對這樣的接口時,很容易犯下兩種錯誤。第一,他們可能會使用錯誤的傳參順序:
Date d(30, 3, 1995); // 啊哦,應(yīng)該是“ 3, 30 ”而不是“ 30, 3 ”
第二,他們可能會傳進(jìn)一個無效的月份或日期:
Date d(3, 40, 1995); // 啊哦,應(yīng)該是“ 3, 30 ”而不是 “3, 40”
(這一示例看上去有些愚蠢,但是不要忘了,在鍵盤上 4 和 3 是緊挨著的。這種“擦肩而過”的錯誤在現(xiàn)實中并不少見)
客戶端程序員犯下的許多錯誤是可以通過引入新類型來避免的。實際上,對于防止不合要求的代碼通過編譯,類型系統(tǒng)是你最得力的助手。在上述情況下,我們可以引入幾個簡單的“包裝類型”來區(qū)別日期、月份、和年分,然后再在 Date 的 構(gòu)造函數(shù)中使用這些類型:
struct Day {
explicit Day(int d) : val(d) {}
int val;
};
struct Month {
explicit Month(int m) : val(m) {}
int val;
};
struct Year {
explicit Year(int y) : val(y) {}
int val;
};
class Date {
public:
Date(const Month& m, const Day& d, const Year& y);
...
};
Date d(30, 3, 1995); // 報錯!類型錯誤
Date d(Day(30), Month(3), Year(1995));// 報錯!類型錯誤
Date d(Month(3), Day(30), Year(1995));// OK ,類型正確
我們可以改善上邊簡單的應(yīng)用結(jié)構(gòu)體的思路,讓 Day 、 Month 、 Year 變得“羽翼豐滿”,從而可以提供完善的數(shù)據(jù)封裝(參見第 22 條)。但是即使是結(jié)構(gòu)體也足以說服我們:引入新的類型可以十分有效地防止接口誤用的發(fā)生。
只要你在恰當(dāng)?shù)牡胤绞褂昧饲‘?dāng)?shù)念愋停惚憧梢院侠淼叵拗七@些類型的值。比如說,一年有 12 個月,所以 Month 類型應(yīng)該能夠反映出這一點。一個途徑是使用枚舉類型來表示月份,但是枚舉類型并不總能達(dá)到我們對于安全的需求。比如說,枚舉類型可以像 int 一樣使用(參見第 2 條)。一個更安全的解決方法是:預(yù)先定義好所有有效 Month 值 的集合:
class Month {
public:
static Month Jan() { return Month(1); } // 用來返回所有有效月份值
static Month Feb() { return Month(2); } // 的函數(shù);
... // 下面你將看出為什么使用
static Month Dec() { return Month(12); } // 函數(shù),而不是對象
... // 其他成員函數(shù)
private:
explicit Month(int m); // 防止創(chuàng)建新的月份值
... // 與月份相關(guān)的數(shù)據(jù)
};
Date d(Month::Mar(), Day(30), Year(1995));
如果上面代碼中使用函數(shù)來代替具體月份的思路讓你感到奇怪,那么可能是由于你已經(jīng)忘記了聲明非局部靜態(tài)對象可能會帶來可靠性問題。第 4 條可以喚醒你的記憶。
為防止客戶端程序員犯下類似的錯誤,我們還可以采用另一個途徑,那就是嚴(yán)格限制一個類型可以做的事情。加強(qiáng)限制的一個常用的手段就是添加 const 。比如說,第 3 條中曾解釋過,對于用戶自定義類型 operator* 的返回值來說,具備怎樣程度的 const 特性就可以防止客戶端程序員犯下下面的錯誤:
if (a * b = c) ... // 啊哦,本來是想進(jìn)行一次比較!
實際上,這僅僅是“讓接口易于正確使用,而不易被誤用”的另一種一般的做法:除非有更好的理由阻止你這樣做,否則你應(yīng)該保證你創(chuàng)建的類型的行為與內(nèi)建數(shù)據(jù)類型保持一致。客戶端程序員已經(jīng)清楚 int 的行為,所以只要是合情合理,你就應(yīng)該力求使你的類擁有與 int 一致的行為。比如說,如 果 a 和 b 是 int 類型,那么為 a*b 賦 值就是不合法的。所以除非你有好的理由拒絕這一規(guī)定,否則你自己創(chuàng)建的類型也應(yīng)該將這一行為界定為不合法。當(dāng)你舉棋不定時,就讓你的類型做與 int 一樣的事情。
設(shè)計接口時應(yīng)防止與內(nèi)建數(shù)據(jù)類型發(fā)生不必要的不兼容性,這樣做的真正目的是讓各類接口擁有一致的行為。除了一致性,很少有特征可以讓接口更加易于正確使用,同時,除了不一致性,也很少有特征可以讓接口變得更加糟糕。 STL 容器的接口大體上(但并不完美)是一致的,這就使得它們更易于使用。比如說每個 STL 容器都有一個名為 size 成員函數(shù),它可以告訴我們這一容器中容納了多少對象。這一點與 Java 和 .NET 是不同的, Java 中使用 length 屬性 來表示數(shù)組的長度, length 方法 來表示字符串的長度,以及 size 方法來表示 List 的大小。而 .NET 中的 Array 擁有一個叫做 Length 的屬性,而 ArrayList 中功能相類似的屬性則叫做 Count 。一些開發(fā)人員認(rèn)為,集成開發(fā)環(huán)境( IDE )的存在讓這類不一致性問題變得不那么重要,但是實際上他們大錯特錯了。不一致性問題會會給開發(fā)人員帶來無窮盡的煩惱,而 IDE 是絕不能解決這些問題的。
任何接口都需要客戶端程序員記憶一些易發(fā)生錯誤的內(nèi)容,這是因為客戶端程序員可能會把這些東西搞砸。比如說,第 13 條中曾引入一個工廠函數(shù)來返回一個指向 Investment 層中動態(tài)分配對象的指針:
Investment* createInvestment();// 來自第 13 章,為簡化代碼省略參數(shù)表
為防止資源泄漏,由 createInvestment 返回的指針在最后必須被刪除,但是這將會給客戶端程序員留下至少兩個犯錯誤的機(jī)會:忘記刪除指針、多于一次刪除統(tǒng)一指針。
第 13 條中介紹了客戶端程序員如何將 createInvestment 的返回值保存在諸如 auto_ptr 或 tr1::shared_ptr 這樣的智能指針中,然后讓智能指針擔(dān)負(fù)起調(diào)用 delete 的責(zé)任。但是如果客戶端程序員忘記了使用智能指針,這該怎么辦呢?通常情況下,更好的接口的設(shè)計方案是:讓工廠函數(shù)返回一個智能指針,在一開始就不給問題任何發(fā)生的機(jī)會。
std::tr1::shared_ptr<Investment> createInvestment();
這樣便可以從根本上強(qiáng)制客戶端程序員將返回值存儲在一個 tr1::shared_ptr 中,在一個原始 Invesement 對象再有用之后,這樣做可以防止你忘記刪除這一無用的對象。
事實上,返回 tr1::shared_ptr 讓接口設(shè)計人員能夠防止由資源釋放所造成的客戶端錯誤,這是因為 tr1::shared_ptr 允許在智能指針創(chuàng)建時,將資源釋放函數(shù)(一個“刪除器”)綁定在這一智能指針中,而 auto_ptr 沒有這一功能。(參見第 14 條)
假設(shè)一個客戶端程序員從一個 createInvestment 中得到了一個 Investment* 指針,他可能會將這個指針傳給一個名為 getRidOfInvestment 的函數(shù),而不是將其刪除。這樣的接口將會為客戶端程序員帶來新的錯誤,客戶端程序員將會使用錯誤的資源析構(gòu)機(jī)制(也就是使用了 delete 而不是 getRidOfInvestment )。實現(xiàn) createInvestment 的程序員可以通過返回一個綁定了 getRidOfInvestment “刪除器”的 tr1::shared_ptr 來預(yù)防此類錯誤。
tr1::shared_ptr 提供了一個擁有兩個參數(shù)的構(gòu)造函數(shù):需要管理的指針,以及當(dāng)引用計數(shù)值為零時需要調(diào)用的刪除器。關(guān)于創(chuàng)建綁定 getRidOfInvestment “刪除器”的 tr1::shared_ptr ,請看下面的方法:
std::tr1::shared_ptr<Investment> pInv(0, getRidOfInvestment);
// 嘗試創(chuàng)建一個 null 的 shared_ptr ,
// 并且讓其包含一個自定義的刪除器;
// 這樣的代碼無法通過編譯
這并不是合法的 C++ 語法。 tr1::shared_ptr 的構(gòu)造函數(shù)的第一個參數(shù)必須是一個指針,而 0 則不是,它是一個 int 值。的確,它可以轉(zhuǎn)換成一個指針,但是這種情況下此類轉(zhuǎn)換并不值得推薦, tr1::shared_ptr 的第一個參數(shù)必須是一個實際的指針。通過一次轉(zhuǎn)型可以解決這一問題:
std::tr1::shared_ptr<Investment>
pInv(static_cast<Investment*>(0), getRidOfInvestment);
// 創(chuàng)建一個 null 的 shared_ptr ,
// 并且讓其包含一個自定義的刪除器;
// static_cast 的更多信息參見第 27 條
上面的代碼意味著,在實現(xiàn) createInvestment 時,可讓其返回一個“綁定了 getRidOfInvestment 刪除器的 tr1::shared_ptr ”:
std::tr1::shared_ptr<Investment> createInvestment()
{
std::tr1::shared_ptr<Investment>
retVal(static_cast<Investment*>(0), getRidOfInvestment);
retVal = ... ; // 讓 retVal 指向恰當(dāng)?shù)膶ο?/span>
return retVal;
}
當(dāng)然,如果在創(chuàng)建 pInv 之前就確定了其所管理的裸指針,那么將裸指針傳遞給 pInv 的構(gòu)造函數(shù)更理想些,而不應(yīng)該將 pInv 初始化為空值然后對其賦值。為什么這樣?請參見第 26 條。
使用 tr1::shared_ptr 的另一個較為顯著的好處在于:它自動為每個指針預(yù)留一個刪除器,用它們來排除另一類客戶端錯誤,也就是所謂的“跨 DLL 問題”。這一問題在下面的情況中會發(fā)生:一個動態(tài)鏈接庫( DLL )中使用 new 創(chuàng)建了一個對象,而這個對象在另一個 DLL 中由 delete 語句刪除了。在許多平臺上,此類跨 DLL 的“ new/delete 對”將導(dǎo)致運行時錯誤。 tr1::shared_ptr 可以防止此類問題發(fā)生,因為如果創(chuàng)建了一個 tr1::shared_ptr ,它的默認(rèn)刪除器在同一個 DLL 中使用 delete 。舉例說,這將意味著如果 Stock 繼承自 Investment ,同時 createInvestment 是這樣實現(xiàn)的:
std::tr1::shared_ptr<Investment> createInvestment()
{
return std::tr1::shared_ptr<Investment>(new Stock);
}
那么返回的 tr1::shared_ptr 將在各 DLL 文件中自由穿梭,而不用考慮跨 DLL 問題。這一指向 Stock 的 tr1::shared_ptr 會始終追蹤這一事件:當(dāng) Stock 的引用計數(shù)值為零時,需要使用哪一個 DLL 的 delete 語句。
本條目講解的主要內(nèi)容是如何讓接口更加易于正確使用,而不易被誤用,而不是 tr1::shared_ptr ,但是 tr1::shared_ptr 對于避免此類客戶端錯誤卻是一個不可多得的好工具,使用它是值得的。 tr1::shared_ptr 最為通用的實現(xiàn)來自 Boost (參見第 55 條)。 Boost 中的 shared_ptr 有兩個裸指針那么大,它為記錄和刪除專用數(shù)據(jù)使用動態(tài)分配內(nèi)存,當(dāng)調(diào)用函數(shù)的刪除器時使用虛函數(shù),如果它認(rèn)為一個應(yīng)用程序是多線程的,那么當(dāng)修改該程序的一個引用計數(shù)值時,將引入線程同步的開銷。(你也可以通過定義一個預(yù)處理記號來禁用多線程。) Boost 中的 shared_ptr 也是有缺點的:它比裸指針更大,更慢,而且使用輔助的動態(tài)內(nèi)存。在許多應(yīng)用程序中,這些額外的運行時開銷并不那么顯著,但是它可以降低客戶端程序員出錯的可能,這一點對每個人來說都是十分顯著的。
牢記在心
l 優(yōu)秀的接口應(yīng)該易于正確使用,而不易誤用。對所有的接口都應(yīng)該力爭做到這一點。
l 保持與內(nèi)置數(shù)據(jù)類型有一致的行為,是使接口易于正確使用的一種可行的方法
l 防止錯誤發(fā)生的方法有:創(chuàng)建新的數(shù)據(jù)類型,嚴(yán)格限定類型的操作,約束對象的值,不要將管理資源的任務(wù)留給客戶端程序員。