設計與宣告
- Item 18 : 讓介面容易被正確使用,不易被誤用
- Item 19 : 設計class猶如type
- Item 20 : 寧以pass-by-reference-to-const替換pass-by-value
- Item 21 : 必須返回物件時,別妄想返回其reference
- Item 22 : 將成員變數宣告為private
- Item 23 : 寧以non-member、non-friend替換member函式
- Item 24 : 若所有引數皆需型別轉化,請為此採用non-member函式
- Item 25 : 考慮寫出一個不拋異常的swap函式
Item 18 : 讓介面容易被正確使用,不易被誤用
這一規則似乎沒那麼專精,但它可能是C++介面最重要的一個設計準則。
介面時客戶與你的程式碼互動的手段,如果客戶使用某個介面,沒有獲得客戶預期的行為,那麼這個程式碼不應該通過編譯,換句話說,如果這個程式碼通過編譯,它的功能即客戶所需。
作為設計者,必須考慮客戶會犯什麼樣的錯。
Date的設計
以一個日期例子說明:
class Date
{
public:
Date(int month, int day, int year);
};
這個介面似乎通情達理,然而卻容易犯下兩個錯誤:
Date d(3, 30, 1995); //以錯誤的次序傳遞引數 Date d(30, 2, 1995); //可能傳遞一個無效的月份或天數
更加正確的行為:
class Day {};
class Month {};
class Year {};
Date d(Month(3), Day(30), Year(1995));
一年有12個月,更清晰的做法是展現每個月份,使用enum
!很棒的想法,有一個小小的缺陷,enum
不具備非常完全的型別安全,它能禁止賦值、取址,然而對整型提升沒有任何抵抗,比如,能夠比較大小等行為並不合理。
class Month { public: static Month Jan() = { return Month(1); } } //static Month Jan = Month(1); Date d(Month::Jan(), Day(30), Year(1995));
這樣就更加清晰,為什麼不使用註釋的那種方式呢?似乎更統一一些,但別忘了Item 4 中的最佳實踐,以local-static替換non-local-static
讓你的type 與內建types一致
客戶已經知道像int
這樣的type有什麼行為,所以你應該努力讓你的type在合理的情況下有相同的表現。
比如:
int a = 3, b = 5;
a * b = 6; //不合法!
那你的class設計也應當如此,除非有更好的理由不這樣做。就像Item 13中factory函式返回 指向Investment的動態分配物件:
Investment* createInvestment();
這需要客戶自己去及時銷燬,那麼客戶很可能忘了delete或delete多次。當然,你可以說客戶可以自己去使用智慧指標去託管?但客戶忘了使用智慧指標怎麼辦?
很多時候,較佳介面的設計原則是先發制人,就令其返回一個智慧指標,強迫客戶去使用智慧指標操作,這樣消弭了記憶體洩漏的危害。
std::shared_ptr<Investment> createInvestment();
此外,std::shared_ptr
一個很好的特質是:它會自動使用它的每個指標專屬的刪除器,因而消除另一個潛在的客戶錯誤:"cross-DLL problem"
這個問題發生在於“物件在動態連結程式庫(DLL)中被new建立,卻在另一個DLL內被delete銷燬”。在許多平臺,這一類跨DLL之new/delete成對運用會導致執行期間錯誤。
而shared_ptr沒有這個問題,因為它預設的刪除器來自shared_ptr所誕生的那個DLL的delete。
Item 19 : 設計class猶如type
和其他面向物件程式語言一樣,在C++中定義一個新的class,也就定義了一個新type。這意味著你不只是class設計者,還是type設計者,過載函式和運算子、控制記憶體的分配和歸還、定義物件的初始化和終結,全都在你手上。因此,應當帶著“語言設計者當初設計語言內建型別時”一樣的謹慎來討論class的設計。
優秀的程式碼如工藝品般,也可稱之為藝術。規範設計形成藝術。
如何設計高效的classes?考慮這些問題:
- 新type的物件應該如何去建立和銷燬?這會影響到你的class建構函式、解構函式以及記憶體分配函式和釋放函式(
operator new
,operator delete,
operator new[],
operator delete[]`)的設計,當然,前提是你打算自己實現。 - 物件的初始化和物件的賦值該有什麼樣的差別?這決定了你的建構函式和
copy assignment
運算子的行為及差別。 - 新type的物件如果被pass-by-value,意味著什麼?記住,copy建構函式用來定義一個type的pass-by-vlue是如何實現的。
- 什麼是新type的合法值?對class成員變數的數值進行約束,也就決定了你的成員函式(特別是建構函式,copy assignment運算子和setter函式)必須進行錯誤檢查工作。
- 你的新type需要配合某個繼承圖系嗎?如果繼承自已有的classes,就要受到那些classes的設計的束縛,特別是收到他們的函式是
virtual
還是non-virtual
影響(見item 34 和item 36)。如果允許其他class繼承你的class,那會影響你所宣告的函式,尤其是解構函式,是否為virtual
? - 你的新type需要什麼樣的轉換?是否接受隱式轉化(型別轉化函式或單引數建構函式)?如果不接受隱式轉化,就得寫出專門負責執行轉換的函式,且不實現 型別轉換運算子(type conversion operators)或單引數建構函式(non-explicit-one-argument)
- 什麼樣的操作符和函式對此新type而言是合理的?這個問題決定你將為class宣告哪些函式?其中哪些應該是
member
函式,哪些不是? - 什麼樣的標準函式應該駁回?哪些正是你必須拒絕的,item 6
- 誰該用新type的成員?這個問題幫助你決定哪個成員為
public
、private
、protected
,也幫助決定哪一個class、function應該是friend
- 什麼是新type的未宣告介面? //TODO
- 你的新type有多麼一般化?如果你定義的不只是一個新type,而是一整個types家族,那麼你應該定義一個新的class template
- 你真的需要一個新type嗎?如果只是定義新的derived class以便未既有的class新增機能,那麼說不定單純定義一或多個
non-member
函式或template
更能夠達到目標
*
Item 20 : 寧以pass-by-reference-to-const替換pass-by-value
效率
預設情況下C++以pass-by-value方式傳遞物件至函式。這樣的結果是,實參傳遞的其實是實參的附件,返回值返回的同樣是返回值的附件,這些附件由物件的copy建構函式產出,這將導致pass-by-value成為昂貴的操作。
考慮一下class繼承體系:
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 validdateStudent(Student s);
Student plato;
bool platoIsOk = validdateStudent(plato);
當上述函式呼叫時,會發生什麼?
- 實參傳遞pass-by-value呼叫copy建構函式,構造s,
validdateStudent
函式返回s被銷燬,呼叫一次Student
解構函式 Student
物件內有兩個string
物件,因此構造Student物件也會構造兩個string
物件,Student
物件銷燬時析構兩個string
物件Student
繼承自Person
,構造Student
物件會呼叫Person
建構函式構造Person
,Student
物件銷燬呼叫Person
解構函式,同樣Person
有兩個string
物件,又是兩次構造兩次析構
這樣一次pass-by-value,總體成本是:六次建構函式和六次解構函式。
這樣的行為是正確的,但有更高效的辦法可以繞過構造和析構。指標和引用。C++中pass by reference-to-const提供了比pass-by-value更高效的行為,函式引數傳遞不會引起構造和析構,畢竟引用可以視為別名。
需要注意的是,const是非常必要的,因為不這樣做呼叫可能會改變傳入的原有值。
物件切割問題(slicing)
當一個derived class物件以by-value方式傳遞並被視為一個base class物件,base class的copy建構函式將被呼叫,這樣會造成derived class部分的特性被切割。
假設你在一組classes上工作,用來實現一個圖形視窗系統
class Window
{
public:
std::string name() const; //返回視窗名稱
virtual void display() const; //顯示視窗和其內容
};
class WidowWithScrollBars: public Window
{
public:
virtual void display() const;
};
現在假設你希望寫個函式列印視窗名稱,然後顯示該視窗,下面是錯誤示例
void printNameAndDisplay(Window w)
{
std::cout << w.name();
w.display();
}
WindowWithScrollBars wwsb;
printNameAndDisplay(wwsb);
這樣的函式會導致呼叫的總是Window::display
,絕不是WindowWithScrollBars::display
解決切割問題的方法就是以by reference-to-const的方式傳遞
void printNameAndDisplay(const Window& w)
{
std::cout << w.name;
w.display();
}
注意到了嗎?多型正是這樣體現的,傳參不同,使用的display也將不同。
如果窺視C++編譯器底層,會發現reference往往以指標實現,因此pass-by-reference通常意味著真正傳遞的是指標。因此如果有物件屬於內建型別,pass-by-value往往比pass-by-reference更高效。見item 1
總結
- 儘量以pass-by-reference-to-const替代pass-by-value,前者通常比較高效,並可以避免切割問題
- 以上規則並不適用內建型別,以及STL的迭代器和函式物件,對它們而言,pass-by-value往往比較適當。
Item 21 : 必須返回物件時,別妄想返回其reference
這個條款連線上一個,告訴你不要過猶不及,過分追求引用傳遞,也會引起問題。
考慮一個有理數的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);
};
這個版本的operator*
通過by value方式傳遞(返回值),如果可以改為引用,就不用付出那麼大的代價。
在不優化的情況下:呼叫一次構造,一次複製構造,兩次析構
優化情況下:呼叫一次構造,一次析構
算了,程式碼說明吧
const Rational operator*(const Rational& lhs, const Rational& rhs)
{
Rational result(lhs.n * rhs.n , lhs.d, rhs.d);
return result;
}
//未優化情況
//偽碼
void (const Rational &__result, const Rational& lhs, const Rational& rhs)
{
Rational result(lhs.n * rhs,n , lhs.d, rhs.d); //呼叫建構函式
__result.Rational::Rational(result); //呼叫複製建構函式
return;
}
//返回值優化情況
void (const Rational &__result, const Rational& lhs, const Rational& rhs)
{
__result.Rational::Rational(lhs.n * rhs,n , lhs.d, rhs.d); //呼叫建構函式
return;
}
如果我們希望返回值為引用,提高效率,進行修改有兩種方式:
- 在stack空間建立
- 在heap空間建立
const Rational& operator* (const Rational& lhs, const Rational& rhs)
{
Rational result(lhs.n * rhs.n, lhs.d * rhs.d);
return result;
}
const Rational& operator* (const Rational& lhs, const Rational& rhs)
{
Rational *result = new Rational(lhs.n * rhs.n, lhs.d * rhs.d);
return result;
}
這兩種做法看似都可以,但實際卻相當危險,在stack上建立,result是local物件,而local在函式退出前被銷燬了。而在heap上建立,誰去負責記憶體的回收,進行delete
呢?
似乎建立static
是個不錯的選擇?那麼執行緒安全性如何保證?同時又潛在另一個問題,static的值是在靜態區,引發判斷失效情況
const Rational& operator* (const Rational& lhs, const Rational& rhs)
{
static Rational result(lhs.n * rhs.n, lhs.d * rhs.d);
return result;
}
bool operator==(const Rational& lhs, const Rational& rhs);
Rational a, b, c, d;
if ((a * b) == (c * d)) {
} else {
}
表示式((a*b) == (c*d))
總是返回true,不管a,b,c,d的值是多少。
Item 22 : 將成員變數宣告為private
封裝性。成員變數宣告為private
,客戶獲取他們只能通過成員函式獲取,如果進行修改成員變數,客戶一點也不會知道class內部的變化。
此外,避免客戶做一些危險的操作,修改一些關鍵的成員,這些都是必要的。
Item 23 : 寧以non-member、non-friend替換member函式
一個class用來表示網頁瀏覽器,一些用於清除高速緩衝區,一些用於清楚訪問過的URL,用於清除歷史記錄。
class WebBrower
{
public:
void clearCache();
void clearHistory();
void removeCookies();
};
許多使用者會想一次執行所有這些動作,因此WebBrowser
也提供這樣一個函式
class WebBrowser
{
public:
void clearEverything(); //掉用clearCache, clearHistory, removeCookies
};
當然這一操作也可以由一個non-member函式呼叫適當的member函式而提供:
void clearEverything(WebBrowser& wb)
{
wb.clearCache();
wb.clearHistory();
wb.removeCookies();
}
那麼哪一種設計比較好呢?是member函式函式non-member函式?
面向物件的守則要求:資料以及操作資料的那些函式應該被綁在一起,這意味著它建議member函式是較好的選擇,不幸的是這個選擇並不正確。
與直觀相反,member函式帶來的封裝性比non-member函式低。
先從封裝開始說起:
如果某些東西被封裝,它就不再可見,越多的東西被封裝,越少的人可以看到它,從而我們有越大的彈性去變化它。越少的程式碼可以訪問資料,我們就越能自由的改變物件資料,改變成員變數的數量、型別等等。因此,訪問資料的函式越少,封裝性越好,反之,越多函式訪問它,資料封裝性越低。
就像將成員變數設定為private
一樣,如果為public
,就有無限量的函式可以訪問他們,導致毫無封裝性。
而friend
函式和member
函式對封裝性的衝擊是同樣的,因此也要對其進行考慮。
第二件值得注意的事情是:因為在意封裝性而讓函式成為class的non-member
並不意味它 不可以是另一個class的member
。比如可以令clearBrowser
成為某工具類的一個static member
函式,只要它不是WebBrowser
的一部分或friend
,就不會影響其封裝性。
在C++中,比較自然的做法是讓clearBrowser
成為一個non-membe
r函式,幷包含於WebBrowser
所在的同一個namesapace
內:
namespace WebBrowserStuff
{
class WebBrowser
{
};
void clearBrowser(WebBrowser& wb);
}// end of namespace WebBrowserStuff
一個像WebBrowser
這樣的class可能有大量的便利函式,某些於書籤相關,某些與列印相關,還有一些與cookie的管理相關。通常大多數客戶只對其中某些感興趣,沒道理一個只對書籤相關便利函式感興趣的客戶卻與cookie相關便利函式發生編譯相關關係。分離的做法是在不同的標頭檔案宣告。
//webbrowser.h
namespace WebBrowserStuff
{
class WebBrowser {...};
//核心機能,幾乎所有客戶需要的
//non-member函式
}
//webbrowserbookmarks.h
namespace WebBrowserStuff
{
//與書籤相關的便利函式
}
//webbrowsercookies.h
namespace WebBrowserStuff
{
//與cookie相關的便利函式
}
這正是C++ 標準庫的阻止方式,降低編譯依賴也是一個很關鍵的問題,這允許客戶只對他們所有的那一小部分系統形成編譯依賴。
將所有便利函式放在多個頭檔案內但隸屬於同一個名稱空間,意味客戶可以輕鬆擴充套件這一組便利函式。他們只需要在WebBrowserStuff
名稱空間內建立一個頭檔案,內含新的non-member
、non-friend
函式即可。這是class不能提供的,因為class定義式對客戶而言是不能擴充套件的。當然可以使用派生,但是又有新的問題,private
對於derived來說是封裝的,意味著這種擴充套件性被封印了一部分,只有次級身份,此外,參考item 7 ,並非所有classes都被設計用來base classes。
Item 24 : 若所有引數皆需型別轉化,請為此採用non-member函式
令class支援隱式轉換通常是個糟糕的主意,當然這條規則有例外,最常見的例外是建立數值型別時。
假如你設計一個class用來表現有理數,允許整數隱式轉化為有理數似乎頗為合理。
class Rational
{
public:
Rational (int numberator = 0, int denominator = 1);//建構函式可以不宣告為explicit,允許int-to-Rational隱式轉換
int numberator() const; //分子的訪問
int denominator() const; //分母的訪問
private:
};
如果想要支援加法、乘法等,但不確定應該使用member
函式,friend
函式、non-member
函式來實現。
直覺來看似乎應該在Rational
class內實現,條款23 又反直覺的主張:將函式放入class內會與面向物件守則衝突。
先將這些守則放在一旁,研究下這些情況的寫法。
class Rational
{
public:
const Rational operator*(const Rational& rhs) const;
};
注意返回型別和引數傳遞,參考item 3,20,21
這個設計能讓兩個有理數輕鬆相乘
Rational oneEighth(1, 8);
Rational oneHalf(1, 2);
Rational result = oneHalf * oneEighth;
result = result * oneEighth;
但是混合運算呢?用Rationals和int相乘
result = oneHalf * 2; //很好
result = 2 * oneHalf; //錯誤!
如果上面的不夠明顯,那麼以函式形式重寫:
result = oneHalf.operator* (2); //很好
result = 2.operator*(oneHalfp); //錯誤
編譯器會優先呼叫class的operator*
,再呼叫non-member operator*
(名稱空間內或在global作用域)
因此第一個能正常呼叫,而第二個2沒有相應的class,也沒有operator*
成員函式,同樣在名稱空間內沒有這樣的operator*
,因此錯誤
有的小朋友提出,為什麼第一個能通過,明明class的operator*
傳參型別不是int
因為在這裡發生了隱式型別轉化,編譯器知道傳遞的是int,需要的是Rational
,但也知道只要呼叫Rational
建構函式並賦予提供的int,就可以編出一個適當的Rational
來,於是就這樣做了
result = oneHalf.operator*(2);
result = oneHalf.operator*(Rational temp(2));
因為建構函式時non-explict
的,如果建構函式宣告為explict
,那麼就無法通過編譯了。
其實需要兩個條件:
- 建構函式是
non-explict
的 - 建構函式引數傳遞一個引數就能構造(單引數建構函式, 有預設引數的建構函式)
似乎兜了一圈,用non-member
函式實現是可行之道
class Rational
{};
const Rational operator*(const Rational& lhs, const Rational& rhs)
{
return Rational(lhs.numerator() * rhs.numerator(),
lhs.denominator() * rhs.denominator());
}
Rational oneFourth(1, 4);
Rational result;
result = oneFourth * 2; //沒問題
result = 2 * oneFourth; //沒問題
最後一個問題:operator*
是否應該成為Rational
class的一個friend
函式呢?
就本例而言是否定的,因為能使用public
介面完成。這匯出一個結論:
member
函式的反義詞是non-member
函式,而不是firend
函式。
無論何時,如果可以避免friend
函式就該避免,就像現實,朋友的價值低於他帶來的麻煩。friend
一定程度上破壞了封裝性。但並非friend
就如此一無是處,有時候會具有正當性。
Item 25 : 考慮寫出一個不拋異常的swap函式
swap是個有趣的函式,原本只是stl的一部分,後來成為異常安全的脊柱,以及用來處理自我賦值可能性的一個常見機制。
swap就是交換兩物件的值,預設情況下,swap可由標準程式庫提供的swap演算法完成
namespace std {
template<typename T>
void swap(T& a, T& b)
{
T temp(a);
a = b;
b = temp;
}
}//end of namespace std
//只要型別支援copy建構函式和copy assignment運算子,預設的swap會幫你置換型別為T的物件,無需做其他工作。它涉及三個物件的複製,
//在特定的型別中,這些複製操作沒有必要,最主要就是指標指向一個物件,內含真正的資料。
class WidgetImpl
{
private:
int a, b, c; //可能有很多資料
std::vector<double> v; //複製時間很長
};
class Widget
{
public:
Widget(const Widget& rhs);
Widget& operator=(const Widget& rhs)
{
*pImpl = *(rhs.pImpl);
}
private:
WidgetImpl* pImpl;
};
置換兩個Widget物件值,我們唯一需要做的就是置換其pImpl指標,但預設的swap不知道這一點,它不止複製了三個Widgets,還複製了WidgetImpl物件,缺乏效率。
一個思路是模板特化
編譯器會先找特化版本,再找預設版本
namespace std {
template<>
void swap<widget> (Widget& a, Widget& b)
{
swap(a.pImpl, b.pImpl);
}
}
函式的template<>表示它是std::swap的一個全特化版本,函式名稱後的‘
通常我們不被允許改變std名稱空間內的任何東西,但可以被允許為標準templates製造特化版本,使它專屬於我們自己的class。
遺憾的是這個版本不能通過編譯,因為它企圖訪問private 成員pImpl。
可以將這個特化版本宣告為friend,但更常規的方法是令Widget宣告一個swap完成置換工作。
class Widget
{
public:
void swap(Widget& rhs)
{
unsing std::swap;
swap(Impl, other.pImpl);
}
};
namespace std
{
template<>
void swap<Widget>(Widget&a, Widget& b)
{
a.swap(b);
}
}
這種做法不僅能通過編譯,而且與STL有一致性,因為所有STL容器也提供有public swap成員函式和std::swap特化版本
模板類
假設Widget和WidgetImpl是class template而非class:
template<typename T>
class WidgetImpl{};
template<typename T>
class Widget {};
我們可以嘗試偏特化
namespace std
{
template<typename T>
void swap<Widget<T>> (Widget<T>& a, Widget<T>& b)
{
a.swap(b);
}
}
然而這是不合法的,C++允許對類模板偏特化,卻不允許在函式模板上偏特化
當你打算偏特化一個function template時,慣用做法是簡單地為它新增一個過載版本
namespace std
{
template<typename T>
void swap(Widget<T>&a, Widget<T>&b)
{
a.swap(b);
}
}
過載模板函式是沒有問題的,但是std是個特殊的名稱空間,客戶可以全特化std內的template,但不可以新增新的templates到std裡
std的內容有C++標準委員會決定,那會禁止我們膨脹已經宣告之外好的東西。
簡單的方法是,不再對std::swap進行特化,而是新增一個名稱空間,同樣宣告一個non-member swap讓它呼叫member swap
namespace WidgetStuff
{
template<typename T>
class Widget {...};
template<typename T>
void swap(Widget<T>& a, Widget<T>& b)
{
a.swap(b);
}
}
另外,對於swap的呼叫,應該讓編譯器自動呼叫最佳swap版本,除非你的本意是強迫呼叫某個版本。
template<typename T>
void doSomething(T& obj1, T& obj2)
{
using std::swap; //令std::swap再此函式內可用
swap(obj1, obj2); //為T型物件呼叫最佳swap版本
}
template<typename T>
void doSomething(T& obj1, T& obj2)
{
std::swap(obj1, obj2); //錯誤呼叫方式
}
第二種將強行呼叫std::swap或std::swap的特化版本,從而無法呼叫宣告在其他位置的版本
比如像上面的WidgetStuff名稱空間,如果沒有定義non-member swap
,兩種都將呼叫std::swap預設版本,而如果定義了,第一種將優先呼叫WidgetStuff的版本,第二種只能呼叫預設std::swap
這將影響C++挑選適當的函式
總結:
如果預設的swap對你的class或class template提供可接受的效率,你不需要做額外的事。
如果swap的預設實現效率不足(class或class template 使用了pImpl手法),嘗試:
- 提供一個public swap成員函式,讓它高效的置換你的型別的兩個物件值,這個函式絕不該丟擲異常
- 在class或template所在的名稱空間內提供一個non-member swap,令其呼叫swap成員函式,對於class而非class template,也請特化std::swap
- 呼叫swap時應使用using宣告式,然後呼叫swap不帶任何名稱空間資格修飾
- 為使用者定義型別進行std::template全特化是好的,但千萬不要嘗試在std內加入某些對std全新的東西
- 成員版的swap絕不能丟擲異常,因為swap的一個最好應用是幫助class提供強烈異常安全性。這一約束只施行於成員版,而不是非成員版,預設swap依賴於複製建構函式和賦值運算子,這兩個操作都允許丟擲異常。而當實現一個自定義的swap,不僅是為了提高效率,同時也要保證不丟擲異常。因為高效率的swap近乎總是基於對內建型別的操作(pImpl手法的底層指標),而內建型別的操作絕不會丟擲異常。