條款19:設計class 猶如設計type
Treat class design as type design
在 C++ 中,就像其它面向對象編程語言,可以通過定義一個新的類來定義一個新的類型。作為一個C++開發者,你的大量時間就這樣花費在擴張你的類型系統。這意味著你不僅僅是一個類的設計者,而且是一個類型的設計者。重載函數和運算符,控制內存分配和回收,定義對象的初始化和終結過程——這些全在你的掌控之中。因此你應該在類設計中傾注大量心血,就如語言設計者在語言內置類型設計中所傾注的大量心血。
設計良好的類是有挑戰性的,因為設計良好的類型是有挑戰性的。良好的類型擁有簡單自然的語法,符合直覺的語義,以及一個或更多高效的實現。那麼,如何才能設計高效的類呢?首先,你必須理解你所面對的問題。實際上每一個類都需要你面對下面這些問題,其答案通常就導向你的設計規范:
· 新類型的對象應該如何創建和銷毀?如何做這些將影響到你的類的構造函數和析構函數,以及內存分配和回收函數(operator new,operator new[],operator delete,和 operator delete[])的設計,除非你不寫它們。
· 對象的初始化和對象的賦值應該有什麼不同?這個問題的答案決定了你的構造函數和賦值運算符的行為以及它們之間的不同。
· 值傳遞(passed by value)對於新類型的對象意味著什麼?拷貝構造函數定義了一個新類型的傳值如何實現。
· 新類型的合法值是什麼?通常,對於一個類的數據成員來說,僅有某些值的組合是合法的。那些數值集決定了你的類必須維護的約束條件。也決定了必須在成員函數內部進行的錯誤檢查,特別是構造函數,賦值運算符,以及"setter"函數。它可能也會影響函數拋出的異常,以及(極少被使用的)函數異常明細(exceptionspecification)。
· 你的新類型需要配合某個繼承圖系中?如果你從已經存在的類繼承,你就受到那些類的設計約束,特別受到它們的函數是virtual還是non-virtual的影響。如果你希望允許其他類繼承你的類,將影響到你是否將函數聲明為virtual,特別是你的析構函數。
· 你的新類型允許哪種類型轉換?你的類型身處其它類型的海洋中,所以是否要在你的類型和其它類型之間有一些轉換?如果你希望允許 T1 類型的對象隱式轉型為 T2 類型的對象,你就要麼在T1類中寫一個類型轉換函數(如operator T2),要麼在 T2 類中寫一個non-explicit-one argument構造函數。如果你只允許顯示構造函數存在,就得寫出專門負責執行轉換的函數,且不得為類型轉換操作符或non-explicit-oneargument構造函數。
· 對於新類型哪些運算符和函數是合理的?這個問題的答案決定你為你的類聲明哪些函數。其中一些是成員函數,另一些不是。
· 哪些標准函數應該駁回?你需要將那些都聲明為 private。
· 你的新類型中哪些成員可以被訪問?這個問題的可以幫助你決定哪些成員是 public,哪些是 protected,以及哪些是 private。它也可以幫助你決定哪些類 和/或 函數應該是友元,以及一個類嵌套在另一個類內部是否有意義。
· 什麼是新類型的未聲明接口 "undeclaredinterface"?它對於效率,異常安全,以及資源使用(例如,多任務鎖定和動態內存)提供哪種保證?你在這些領域提供的保證將為你的類的實現代碼加上相應的約束條件。
· 你的新類型有多大程度的通用性?也許你並非真的要定義一個新的類型,也許你要定義一整個類型家族。如果是這樣,你就不該定義一個新的類,而應該定義一個新的類模板。
· 一個新的類型真的是你所需要的嗎?是否你可以僅僅定義一個新的繼承類,以便讓你可以為一個已有的類增加一些功能,也許通過簡單地定義一個或更多非成員函數或模板能更好地達成你的目標。
· 類設計就是類型設計。定義高效的類是有挑戰性的。在C++中用戶自定義類生成的類型最好可以和內建類型一樣好。
條款20:寧以pass-by-reference-to-const替換pass-by-value
Prefer pass-by-reference-to-const to pass-by-value
缺省情況下,C++以傳值方式將對象傳入或傳出函數(這是一個從C繼承來的特性)。除非你另外指定,否則函數的參數就會以實際參數的副本進行初始化,而函數的調用者會收到函數返回值的一個復件。這個復件由對象的拷貝構造函數生成,這就使得傳值成為一個代價不菲的操作。例如,考慮下面這個類繼承體系:
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;
};
現在,考慮以下代碼,在此我們調用函數validateStudent,它得到一個Student實參(以傳值方式),並返回它是否有效:
bool validateStudent(Student s); // 函數以by value方式接受Student
Student plato;
bool platoIsOK = validateStudent(plato); //call the function
很明顯,Student的拷貝構造函數被調用,用plato來初始化參數s。同樣明顯的是,當 validateStudent返回時,s就會被銷毀。所以這個函數的參數傳遞代價是一次Student的拷貝構造函數的調用和一次Student的析構函數的調用。
但這還不是全部。Student對象內部包含兩個string對象,Student對象還要從一個 Person對象繼承,Person對象內部又包含兩個額外的string對象。最終,以傳值方式傳遞一個Student對象的後果就是引起一次Student的拷貝構造函數的調用,一次Person的拷貝構造函數的調用,以及四次string的拷貝構造函數調用。當Student對象的拷貝被銷毀時,每一個構造函數的調用都對應一個析構函數的調用,所以以傳值方式傳遞一個Student的全部代價是六個構造函數和六個析構函數!
這是正確和值得的行為。畢竟,你希望全部對象都得到可靠的初始化和銷毀。盡管如此,pass by reference-to-const方式會更好:
bool validateStudent(const Student& s);
這樣做非常有效:沒有任何構造函數和析構函數被調用,因為沒有新的對象被構造。修改後參數聲明中的const是非常重要的,原先validateStudent以by-value方式接受一個Student參數,所以調用者知道函數絕不會對它們傳入的Student做任何改變,validateStudent只能改變它的復件。現在Student以引用方式傳遞,同時將它聲明為const是必要的,否則調用者必然擔心validateStudent改變了它們傳入的Student。
以傳引用方式傳遞參數還可以避免切斷問題(slicing problem)。當一個派生類對象作為一個基類對象被傳遞(傳值方式),基類的拷貝構造函數被調用,而那些使得對象行為像一個派生類對象的特化性質被“切斷”了,只剩下一個純粹的基類對象例如,假設你在一組實現一個圖形窗口系統的類上工作:
class Window {
public:
...
std::string name() const; // 返回窗口名稱
virtual void display() const; // 顯示窗口及其內容
};
class WindowWithScrollBars: public Window {
public:
...
virtual void display() const;
};
所有Window對象都有一個名字(name函數),而且所有的窗口都可以顯示(display函數)。display為 virtual的事實清楚地告訴你:基類的Window對象的顯示方法有可能不同於專門的WindowWithScrollBars對象的顯示方法。現在,假設你想寫一個函數打印出一個窗口的名字,並隨後顯示這個窗口。以下是錯誤示范:
void printNameAndDisplay(Window w) //incorrect! 參數可能被切割
{
std::cout << w.name();
w.display();
}
考慮當你用一個 WindowWithScrollBars 對象調用這個函數時會發生什麼:
WindowWithScrollBars wwsb;
printNameAndDisplay(wwsb);
參數w將被作為一個Window對象構造——它是被傳值的,而且使wwsb表現得像一個 WindowWithScrollBars對象的特殊信息都被切斷了。在printNameAndDisplay中,全然不顧傳遞給函數的那個對象的類型,w將始終表現得像一個Window 類的對象(因為其類型是Window)。因此在printNameAndDisplay中調用display將總是調用 Window::display,絕不會是WindowWithScrollBars::display。繞過切斷問題的方法就是以passby reference-to-const方式傳遞w:
void printNameAndDisplay(const Window& w)
{ // 參數不會被切割
std::cout << w.name();
w.display();
}
現在傳進來的窗口是什麼類型,w就表現出那種類型。用指針實現引用是非常典型的做法,所以pass by reference實際上通常意味著傳遞一個指針。由此可以得出結論,如果你有一個內置類型對象(一個int),以傳值方式傳遞它常常比傳引用方式更高效;同樣的建議也適用於 STL 中的迭代器和函數對象。
一個對象小,並不意味著調用它的拷貝構造函數就是廉價的。很多對象(包括大多數STL容器)內含的東西只比一個指針多一些,但是拷貝這樣的對象必須同時拷貝它們指向的每一樣東西,那將非常昂貴。即使當小對象有一個廉價的拷貝構造函數,也會存在性能問題。一些編譯器對內置類型和用戶自定義類型並不一視同仁,即使他們有同樣的底層表示。例如,一些編譯器拒絕將僅由一個double組成的對象放入一個寄存器中,即使通常它們非常願意將一個純粹的double 放入那裡。當這種事發生,你以傳引用方式傳遞這樣的對象更好一些,因為編譯器理所當然會將一個指針(引用的實現)放入寄存器。
小的用戶定義類型不一定是傳值的上等候選者的另一個原因是:作為用戶定義類型,它的大小常常變化。通常情況下,你能合理地假設傳值廉價的類型僅有內置類型及STL中的迭代器和函數對象。對其他任何類型,請盡量以pass-by-reference-to-const替換pass-by-value。
· 盡量以pass-by-reference-to-const替換pass-by-value。前者更高效且可以避免切斷問題。
· 這條規則並不適用於內建類型及STL中的迭代器和函數對象類型。對於它們,pass-by-value通常更合適。
摘自 pandawuwyj的專欄