我根據自己的理解,對原文的精華部分進行了提煉,併在一些難以理解的地方加上了自己的“可能比較準確”的「翻譯」。 Chapter4 設計與聲明 Designs and Declarations 條款18: 讓介面容易被正確使用,不易被誤用 欲開發一個“容易被使用,不容易被誤用”的介面,首先必須考慮客戶可 ...
我根據自己的理解,對原文的精華部分進行了提煉,併在一些難以理解的地方加上了自己的“可能比較準確”的「翻譯」。
Chapter4 設計與聲明 Designs and Declarations
條款18: 讓介面容易被正確使用,不易被誤用
欲開發一個“容易被使用,不容易被誤用”的介面,首先必須考慮客戶可能做出什麼樣的錯誤。
假設我們要設計一個表示日期的class:
class Data{
public:
Date(int month,int day,int year);
...
};
事實上使用它的客戶很容易犯錯誤:
以錯誤的次序傳參:
Date d(30,3,1995); //喔喲! 應該是“3,30”而不是"30,3"
傳遞無效的參數:
Date d(2,30,1995); //2月有30號????
我們可以引入類型系統(type system)和外覆類型(wrapper types)
現以外覆類型來區別天數,月份,年份,然後再Date中使用:
struct Day{ struct Month{
explicit Day(int d) explicit Month(int m)
:val(d) {} :val(m) {}
int val; 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); //not ok
Date d(Day(30),Month(3),Year(1995)); //not ok
Date d(Month(3),Day(30),Year(1995)); //ok
其實你也可以用更成熟的class來封裝外覆類型,但這裡的struct已經很好了。
類型確定後,通常要對值進行限制,比如一年只有12個月。你可以用enum來表現月份。但是enum不具備類型安全性,比如enums可以被拿來當一個ints使用。
比較安全的解法是預先定義所有有效的Months:
class Month{
public:
static Month Jan() { return Month(1); }
static Month Feb() { return Month(2); }
...
static Month Dec() { return Month(12); }
...
private:
explicit Month(int m);
...
};
Date d(Month::Mar(),Day(30),Year(1995));
常見的預防客戶出錯的辦法是限制類型內的許可權。例如加上const。
另外,::應儘量讓你的包裝types與內置types行為一致::。客戶已知道int這樣的type會有什麼行為,所以你應讓你包裝的types也有相同表現。<避免無端與內置類型不相容>
記住,任何介面如果試圖讓客戶「必須記得做某些事情」,就是有著「不正確使用」的傾向。
還記得條款13嗎,tr1::shared_ptr接受了func()返回的指針,這將發揮智能指針的威力。
但是如果客戶自己就忘記了要用到智能指針呢?較佳介面的設計原則是先發制人,也就是這樣寫func():
std::tr1::shared_ptr<xx> func();
這實質上強迫客戶將返回值儲存於一個tr1::shared_ptr內,讓介面設計者得以阻止一大群客戶犯下資源泄漏的錯誤。
還有一種特殊情況。假設作為class設計者的你想讓那些“從func()取得xx*指針”的客戶將該指針傳遞給一個名為getRidOfxx()的函數,而不是粗暴地直接對此指針使用delete。可能你這樣設計有出於你的考慮,但是客戶還是可能忘記並仍使用delete。所以func()的設計者可先發制人,不僅返回一個tr1::shared_ptr,併在它身上綁定刪除器(deleter) getRidOfxx()。
事實上tr1::shared_ptr有一個重載的構造函數接受兩實參:一個是被管理的指針,另一個是當引用次數變為0時被調用的“刪除器”:
std::tr1::shared_ptr<xx> pInv(0,getRidOfxx);
//企圖創建一個null智能指針,但是無法通過編譯。
這個構造函數堅持第一個參數必須是指針,而不是int型的值0———雖然它能被轉換為指針。所以轉型可解決:
std::tr1::shared_ptr<xx> pInv(static_cast<xx*>(0),
getRidOfxx);
//static_cast以後提到.
而作為func()的內部實現:
std::tr1::shared_ptr<xx> func()
{
std::tr1::shared_ptr<xx> retVal(static_cast<xx*>(0),
getRidOfxx);
retVal = ...; //令retVal指向正確對象
return retVal;
}
當然,若將被pInv接管的原始指針已經在建立pInv之前確定了,那麼直接傳此指針給pInv構造函數是更佳選擇。
條款19: 設計class猶如設計type
實際上,你定義一個新class時,可理解為你定一個了新type。
這意味著你不僅是class設計者,還是type設計者,重載(overloading)函數和操作符、控制記憶體的分配和歸還、定義對象的初始化和終結……都在你手上
考慮以下問題,你的回答往往影響你的設計規範:
- 新class,或者說type該如何被創建和銷毀?
這將影響你的構造、析構、記憶體分配與釋放函數:
(operator new, operator new[], operator delete, operator delete[])
前提是你打算撰寫它們
- 對象初始化和賦值該有怎樣的差別?
決定了你的構造函數和賦值操作符的行為。別混淆“初始化”和“賦值”,它們對應不同的函數調用(條款4)。
- 新type對象若被passed by value(以值傳遞),意味著什麼?
考慮copy構造函數用來定義一個type的pass-by-value該如何實現
- 什麼是新type的“合法值”?
對於你設計的class成員變數,你必須考慮它們取值的範圍以及規範(約束條件),這決定了你的成員函數必須進行的錯誤檢查工作。它也影響函數拋出的異常。
- 你的type需要配合某個繼承圖系嗎?
如果你的type繼承自現有的classes,就會受到設計約束。特別是受到“它們的函數是virtual或non-virtual”的影響。若你允許其它classes繼承你的class,這要考慮你的函數是否為virtual。
- 你的type需要什麼樣的轉換?
如果你希望你的type T1能隱式轉換為T2,就必須在class T1內寫一個類型轉換函數( operator T2 )或在class T2內寫一個non-explicit-one-argument(可被單一實參調用)的構造函數。若你只允許顯式轉換,就得寫出專門負責執行轉換的函數。
什麼樣的標準函數應駁回? 那些是你應聲明為private的成員(條款6)
誰改取用新type的成員?
這將幫助你決定哪個成員為public、private、proteced。也幫你決定哪個class,functions應該是友元,以及它們的嵌套是否合理。
條款20: 寧以pass-by-reference-to-const替換pass-by-value
C++預設是以by value方式(繼承自C)傳遞對象至函數(或來自函數)。這樣一來,函數參數都是以實參的副本為初值,而調用端獲得的亦是函數返回值的副本。這些副本是由對象的copy構造函數產出,會成為費時的操作。
考慮以下代碼:
class Person{
public:
Person();
virtual ~Person();
...
private:
std::string name;
std::string address;
};
class Student: public Person{
public:
Student();
~Student();
...
private:
std::string schoolName;
std::string schoolAddress;
};
假設我們有這樣的代碼:
bool validateStudent(Student s); //by value
Student plato;
bool platoIsOk = validateStudent(plato);
無疑,會以plato為藍本初始化s,返回後s被銷毀。
你會發現,在這裡,以by value傳遞一個Student對象會導致調用一次Student copy構造函數、一次Person copy構造函數、四次string copy構造函數。
但如果以pass by reference-to-const的方式,效率會高得多:
bool validateStudent(const Student& s);
這時,沒有任何構造函數或析構函數被調用,因為沒有任何新對象被創建。這個const修飾符很重要,它可以保證函數不會修改源頭的Student。
另外,by reference方式可避免slicing(對象切割)。假設base class為A,derived class為B,有這種代碼:
void func(A obj)..
而你傳參的操作:
B tmp = new B();
func(tmp);
此時A的copy構造函數被調用,但是屬於B的特質化成員會被無視掉,只剩A對象的框架。此時解決方案即為以by reference-to-const方式傳遞參數。
當應用於C++內置類型,如int之類,pass-by-value可能會更高效,這同樣適用於STL迭代器和函數對象。
條款21: 必須返回對象時,別妄想返回其reference
在嘗到傳引用的甜頭後,你可能從此一發不可收拾。但是你總有一次會犯下致命錯誤:開始傳遞一些referencce指向司機不存在的對象。
現在假設有一個表達有理數(Rational Number)的Class:
class Rational{
public:
Rational(int numerator = 0,
int denominator = 1); //分別表示分子和分母
...
private:
int n,d; //分子和分母的內部儲存
friend const Rational operator*(const Rational& lhs
const Rational& rhs);
//將*操作符的重載函數定義為友元
};
然後我們在主調函數中有下麵的操作:
Rational a(1,2); //a = 1/2
Rational b(3,5); //b = 3/5
Rational c = a * b; //c算得3/10
第三條語句相當於 Rational c = operator*(a,b);
,這時函數會返回適當的「值」賦給c。
現在看第一個版本的*運算重載函數:
const Rational operator*(const Rational& lhs
const Rational& rhs)
{
Rational result(las.n*rhs.n , lhs.d*rhs.d);
return result; //返回一份copy
}
經過之前學習,我們知道這樣開銷較大。
現在考慮考慮返回引用的版本,即將細節改成 return &result;
,並將返回類型改成const Rational&
這有嚴重問題,不用new來構造對象的話,對象只是一個local本地對象,它將在函數退出後被銷毀。這會導致你得到的引用指針將會指向一個不明的「殘骸」
看看另一種版本,由new構造的對象儲存在heap堆上:
const Rational& operator*(const Rational& lhs
const Rational& rhs)
{
Rational* result = new result(las.n*rhs.n , lhs.d*rhs.d);
return* result; //返回指針,被&加工為產量指針
}
沒有啥卵用,因為new的過程還是要構造對象。其實這個版本更糟,因為你需要考慮delete。
還有一種坑爹的版本,就是將函數內部的Rational對象聲明為靜態的,並返回它的引用。這裡雖然解決了被銷毀的問題,但是對於C++多線程它是不安全的。
假設我們已經寫好了==重載函數,且完全正確:
bool operator==(const Rational& lhs, const Rational& rhs);
假設有下麵的操作:
Rational a,b,c,d;
...
if((a*b)==(c*d)){...}
else ...
估計你也想到了,兩個*運算都返回一個指向同一處static對象地址的引用,所以這個式子的比較結果永遠為true。
抱歉,說了這麼多,我們還是回到了起點————對於*運算的重載,我們幾乎只能採用返回一個新對象的方法:
//第一個版本的精簡
const Rational operator*(const Rational& lhs
const Rational& rhs)
{
return Rational(las.n*rhs.n , lhs.d*rhs.d);
//返回一份copy
}
總結:
- 絕不要返回指向local stack對象的pointer或reference / 返回指向heap-allocated對象的reference / 返回指向local static對象的pointer或reference,而且可能同時需要多個這樣的對象
條款22: 將成員變數聲明為private
這個建議適用於protected成員
首先,獲取私有成員的渠道大部分是函數,所以客戶訪問成員不需要考慮究竟是否要加小括弧,因為全是函數,他們照做就是。
其次,你可以通過函數精確控制各種訪問許可權:
class AccessLevels{
private:
int noAccess; //無任何訪問動作
int readOnly; //read-only access
int readWrite; //read-write access
int writeOnly; //write-only
public:
...
int getReadOnly() const { return readOnly; }
void setReadWrite(int v) { readWrite = v; }
int getReadWrite() const { return readWrite; }
void setWriteOnly(int v) { writeOnly = value; }
};
一般來說,每個成員變數都需要getter和setter的情況實屬罕見,所以這樣的控制很有必要。
將成員變數隱藏在函數介面的背後,可以為“所有可能的實現”提供彈性。
現在我問你,protected成員的封裝性是否高於public?答案是不盡如人意。
我們知道,public的訪問一般要求客戶自己寫代碼來實現,一旦public的成員函數被取消,所有使用它的客戶代碼都會被破壞。而protected被取消掉的話,它的所有dervied classes都會被破壞。因此protected和public一樣缺乏封裝性。
所以從封裝的角度來看,其實只有兩種訪問許可權:private(提供封裝)和其它(不提供封裝)。
寧以non-member、non-friend替換member函數
假設有一個Class代表網頁瀏覽器。有幾個成員函數,提供了清除緩存、清除歷史記錄、清除cookies:
class WebBrowser{
public:
...
void clearCache();
void clearHistory();
void removeCookies();
void clearEverything(); //調用上述三個函數。
...
};
這些功能也可由一個non-member函數實現,只需傳入一個WebBrowser對象引用就行:
void clearBrowser(WebBrowser& wb)
{
wb.clearCache();
wb.clearHistory();
wb.removeCookies();
}
那麼,哪一個比較好呢?member函數clearEverything還是non-member clearBrowser?
根據面向對象守則,數據以及操作數據的函數應捆綁在一塊兒,這意味member函數是更好的選擇。然而這是一個誤解。
和你直覺相反的是,non-member函數clearBrowser封裝性實際上比member版本的clearEverything還要高。
通常來說,member函數不僅能訪問Class里的private成員,還能取用enums、typedefs等。我們說高封裝性是指應有儘可能少的代碼能夠直接「看到」私有成員變數,這時non-member函數的優越性就體現出來了,能完成同樣的機能,但又和Class的私有成員保持了絕對的距離。
所以如果只考慮封裝性的話,選擇的關鍵在於member和non-member、non-friend之間。(friend的許可權和member一樣大)
我們甚至可以將函數clearBrowser作為某工具類的一個static member函數,給其它Class用時,再變成non-member。
在C++,你以後可能比較自然的做法是,將clearBrowser成為一個non-member函數並位於WebBrowser所在同一個namespace里:
namespace WebBrowserStuff{
class WebBrowser{...};
void clearBrowser(WebBrowser& wb);
}
條款24: 若所有參數都需類型轉換,請採用non-member函數
令class支持隱式轉換通常會有風險。但常見的例外是建立「數值類型」。假設我們又設計一個有理數Class,允許整數“隱式轉換”為有理數很合理。假設我們這樣構造有理數Class:
class Rational{
public:
Rational(int numerator = 0,
int denominator = 1);
//刻意不為explicit,允許int-to-Rational隱式轉換
int numerator() const; //分子訪問
int denominator() const; //分母訪問
private:
...
};
假設此時你想讓Class支持算術運算,比如讓它能作乘法運算。你不確定要用member、non-member還是non-member friend函數。你的直覺告訴你要用member版本的operator*重載:
class Rational{
public:
...
const Rational operator*(const Rational& rhs) const;
};
這個設計能讓相乘很自然:
Rational oneEight(1,8);
Rational oneHalf(1,2);
Rational result = oneHalf * oneEight;
result = result * oneEight;
你不滿足,你希望Rationals能和ints相乘:
result = oneHalf * 2; //Good
result = 2 * oneHalf; //Bad!
Wait,乘法應該滿足交換律啊!
問題出在哪?我們翻譯一下上述代碼:
result = oneHalf.operator*(2); //Good
result = 2.operator*(oneHalf); //Bad!
語句一中,將int型2傳入操作符函數後,發生了隱式轉換(原參數是一個Rational引用)。有點類似於:
const Rational temp(2); //編譯器建立一個臨時對象
result = oneHalf.operator*(temp); //傳參
這裡成功的原因是我們沒有將構造函數聲明為顯式的,這為上面的操作提供了支持。
然而第二個語句呢?2作為一個int型,並沒有class,更別說operator* 成員函數。編譯器會試著在namespace或global域內尋找是否有一個non-member operator*。然而並沒有。
當然,如果構造函數是explicit,沒有一個語句會通過編譯。
結論是,當參數位於參數列(parameter list)內,才有資格參與隱式轉換。這就是為啥第一個語句能夠通過編譯。
但是我們想支持混合運算啊喂!!也就是能讓Rational和其它類型數據相運算!!
現在考慮non-member operator* :
class Rational{
...
};
const Rational operator*(const Rational& lhs,const Rational& rhs)
{
return Rational(lhs.numerator()*rhs.numerator(),
lhs.denominator()*rhs.denominator());
} //變成non-member函數
執行:
Rational oneFourth(1,4);
Rational result;
result = oneFourth * 2;
result = 2 * oneFourth; //全部通過編譯,恭喜!!!!
這都很好。但要不要考慮將operator* 變為一個friend函數呢?答案是否定的。因為我們可以從上面的操作中看出,完全可以只靠Rational的public介面完成operator* 的任務。這導出一個重要的觀察:
member函數的方面就是non-member,而不是friend。
無論何時,可以避免使用friend就避免。
必須告訴你的是,這些不是「真理」。因為從Object-Oriented C++跨入Template C++後,你會考慮將Rational設計為一個class template而非class,這將引入很多新考慮,以後會提到。
Remember :
- 若你要為某函數的所有參數(包括this隱指針所指參數)進行類型轉換,這個函數必須設計為non-member
條款25: 考慮寫出不拋異常的swap函數
swap原本是STL一部分,實現了兩個數據對象的交換。後來成為異常安全性編程的脊柱(exception-safe programming)。
以下是swap在標準程式庫中的典型實現:
namespace std{
template<typename T>
void swap(T& a,T& b)
{
T temp(a);
a = b;
b = temp;
}
}
//只要T類型支持copying(copy構造函數和=賦值符),以上代碼即可幫你自動置換。
這種default swap實現很平庸,特別對於某些類型,它的效率會顯得較低。
現在我們討論這種類型,也就是“以指針指向一個對象,內含真正數據”的類型,也就是“pimp”手法(pointer to implementation)。
現在我們試著用pimp來設計一個Widget class:
class WidgetImpl{ //Widget類的數據實現
public:
...
private:
int a, b, c;
std::vector<double> v; //可能會有很多數據,複製時間長
...
};
class Widget{ //使用pimp手法
public:
Widget(const Widget& rhs);
Widget& operator=(const Widget& rhs) //複製Widget時,令它複製其WidgetImpl對象
{
...
*pImpl = *(rhs.pImpl);
...
}
...
private:
WidgetImpl* pImpl;//指向對象內含Widget數據
};
如果我們交換兩個Widget時,只希望置換其中的pImpl指針;然而預設的swap演算法不知道。在swap的三條置換語句中,不只複製了三個Widget,還複製三個WidgetImpl對象。這很缺乏效率,一點不令人興奮!
所以我們應告訴swap該怎麼做:將 std::swap
針對Widget特化。下麵進行基本構思,但是暫時通不過編譯:
namespace std{
template<>
void swap<Widget>(Widget& a,Widget& b) //"T為Widget"的特化版本
{
swap(a.pImpl, b.pImpl); //僅置換Widgets內部指針
}
}
First,此函數開頭 template<>
表明它是std::swap的一個全特化(total template specialization)版本。 函數名後的 <Widget>
表明這一特化版本系針對”T是Widget”而設計。 所以當你將swap施行於Widget對象身上便會自動調用此版本。
我們通常不被允許改變std空間里的任何東西,但被允許為標準templates(比如此處的swap)製造特化版本。
之前說通不過編譯的原因是,pImpl指針是私有的。可以考慮將此特化版本聲明為friend;但和以往規矩不同,這次我們在Widget內部聲明一個swap的公共函數進行真正的置換工作,再特化 std::swap
,令它調用該member function:
class Widget{
public:
...
void swap(Widget& other)
{
using std::swap;
swap(pImpl, other.pImpl);
}
...
};
namespace std{
template<>
void swap<Widget>(Widget& a,Widget& b)
{
a.swap(b);
}
}
實際上,這也是類似STL容器的寫法,它們都提供public swap成員函數和std::swap特化版本。
另一種情況:假設Widget和WidgetImpl都是class templates而非classes:
template<typename T>
class WidgetImpl { ... };
template<typename T>
class Widget { ... };
在Widget內寫一個swap函數依舊簡單,但是特化 std::swap
時會遇到麻煩:
namespace std{
template<typename T>
void swap<Widget<T>>(Widget<T>& a, Widget<T>& b) //invalid!
{ a.swap(b); }
}
這麼寫不合法的原因是,我們正企圖偏特化(partially specialize)一個function template(std::swap)。然而C++僅允許對class templates偏特化。
所以慣常做法是手動添加一個重載的版本:
namespace std{
template<typename T>
void swap(Widget<T>& a, Widget<T>& b)//註意"swap"後沒有"<...>"
{ a.swap(b); } //其實這也不合法,稍後提出
}
在C++中,重載function templates沒問題。然而std是特殊的命名空間,C++標準委員會禁止膨脹已經寫好的東西,因為可能會發生不明行為。所以問題出在我們的重載版本正在做這樣的事。
繞了一大圈,我們沒有前功盡棄。要提供一個高效的template swap特定版本,可以聲明一個non-member swap讓它調用member swap,而不再特化 std::swap
或在std里重載它。
為了簡化,將Widget相關機能一併置入命名空間WidgetStuff內:
namespace WidgetStuff{
... //模版化的WidgetImpl等等
template<typename T>
class Widget { ... }; //內含swap成員函數
...
template<typename T>
void swap(Widget<T>& a, Widget<T>& b)
{
a.swap(b);
}
}
從現在開始,任何地點的代碼若打算置換倆Widget對象而調用swap。C++的名稱查找法則(name lookup rules)會找到WidgetStuff空間內的Widget專屬豪華版本。
以上做法適用於classes和class templates。不幸的是,有一種情況使我們不得不為classes特化 std::swap
———只要你想讓你的專屬swap能在儘可能多語境被調用,你需要寫一個該class命名空間內的non-member版本和一個 std::swap
特化版本。(稍後解釋)
< 事實上你可以不採用namespace的方式,但global空間里漫天飛的東西真的好看嗎?>
現在開始解釋: 假設你在寫一個function template,需置換兩個對象值:
template<typename T>
void doSomething(T& obj1, T& obj2)
{
...
swap(obj1,obj2);
...
}
該調用哪種swap呢,也許有一種可能存在的T專屬版本此時棲身於某namespace中?(當然不可以在std內) 所以你希望如果存在專屬版本就調用它;不存在就用預設的 std::swap
吧:
template<typename T>
void doSomething(T& obj1, T& obj2)
{
using std::swap; //令std::swap在此函數內可用
...
swap(obj1,obj2); //為T型調用最佳swap版本
...
}
之後C++會在global域和T所在namespace里搜索可能存在的T專屬版swap,若沒有則調用預設 std::swap
。
這裡有一個小trick,如果你這麼寫: std::swap(obj1, obj2);
,語意會截然不同,這相當於強迫使用std內的swap ————— 你get到了嗎,這就是我們要寫特化std::swap
的動機!這使得類型專屬的swap實現也能被這些蠢代碼所用。
OVER