章節回顧:
《Effective C++》第1章 讓自己習慣C++-讀書筆記
《Effective C++》第2章 構造/析構/賦值運算(1)-讀書筆記
《Effective C++》第2章 構造/析構/賦值運算(2)-讀書筆記
《Effective C++》第3章 資源管理(1)-讀書筆記
《Effective C++》第3章 資源管理(2)-讀書筆記
《Effective C++》第4章 設計與宣告(1)-讀書筆記
《Effective C++》第4章 設計與宣告(2)-讀書筆記
《Effective C++》第8章 定製new和delete-讀書筆記
所謂軟體設計,是“令軟體做出你希望它做的事情”的步驟和做法,通常以頗為一般性的構想開始,最終演變成十足的細節,以允許特殊介面的開發。
條款18:讓介面容易被正確使用,不易被誤用
很顯然的道理:如果客戶使用某個介面卻沒有得到他預期的行為,這個程式碼就不該通過編譯;如果程式碼通過了編譯,就應該是客戶預期的行為。想要開發一個“容易被正確使用,不容易被誤用”的介面,首先要考慮客戶會出現什麼樣的錯誤。
舉例說明,有一個與日期有關的class:我們只看它的建構函式。
class Date { public: Date(int month, int day, int year); ... };
它的客戶可能這樣使用:
Date d(30, 3, 1995); //月和日位置放反了 Date d(3, 40, 1995); //非法的日
許多客戶端錯誤可以因為匯入新型別而解決,我們看下面的修改方案:
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 { Date(const Month& m, const Day& d, const Year& y); };
如果客戶像下面這樣使用:(看程式碼中註釋)
Date d(30, 3, 1995); //錯誤,explicit不提供隱式轉換。 Date d(Day(30), Month(3), Year(1995)); //錯誤,型別不對應。 Date d(Month(3), Day(30), Year(1995)); //很好,不錯的。
好的,當然這不能解決非法的數字輸入。下面接著看:
一年只有12月份,所以可以用enum表現月份,但enum因為可以被用來當作int,所以不具備型別安全性。比較好的做法是預先定義所有有效的月份。
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)); //好的,真棒 Date d(Month(13), Day(30), Year(1995)); //錯誤,建構函式是private的
好了,下面介紹另一個準則:“讓types容易被正確使用,不容易被誤用”,它的原則是:儘量讓你的types的行為與內建types一致。因為客戶一般知道像int這樣的內建型別有什麼行為。
避免與內建型別不相容的真正理由是為了提供行為一致的介面。可以想象:沒有其他性質比“一致性”更能導致介面被容易使用。
任何介面如果要求客戶必須記得做某些事情,就是有著不正確使用的傾向,因為客戶很可能會忘記做那件事。舉工廠函式那個例子:
Investment* createInvestment(); //返回一個動態分配的物件指標
客戶可能會做錯兩件事:忘記刪除指標,或者刪除兩次以上指標。
如果你還記得使用智慧指標管理資源的話,實際上最佳的介面是這樣的:
std::tr1::shared_ptr<Investment> createInvestment(); //返回一個智慧指標
這個函式強迫客戶必須將函式的返回值儲存在智慧指標內。
請記住:促進正確使用:包括介面一致性以及與內建型別的行為相容。阻止誤用:建立新型別、限制型別上的操作、束縛物件值,以及消除客戶的資源管理責任。
條款19:設計class猶如設計type
當你定義了一個新的class,也就定義了一個type,所以你應該像語言設計者設計內建型別時一樣的嚴謹來考慮class的設計。不妨試著回答下面幾個問題:
(1)新type的物件該如何被建立和銷燬?
(2)物件的初始化和賦值該有什麼區別?
(3)新type物件如果被“值傳遞”會發生什麼?
拷貝建構函式定義了一個type的“值傳遞”發生了什麼。
(4)什麼是新type的合法值?
(5)你的新type需要配合某個繼承體系嗎?
如果你繼承自某些class,肯定受到那些class的設計束縛,比如virtual函式。如果你允許其他class繼承你,那會影響你函式的宣告,例如,virtual解構函式。
(6)你的新type需要什麼樣的轉換?
你的類物件轉換為其他物件或者其他型別物件隱式或顯式轉換為你的物件。
(7)什麼樣的操作符和函式對此新type是合理的?
(8)什麼樣的標準函式應該駁回?
(9)誰該取用新type成員?
(10)什麼是新type的“未宣告介面”?
(11)你的新type有多麼一般化?
你是定義一個class還是一個新的class template。
(12)你真的需要一個新的type嗎?
這些問題都是不好回答的,我也只是對某些內容知道而已,但對於為何如此考慮也是一知半解。
條款20:寧以pass-by-reference-to-const替換pass-by-value
相信很多人都知道這個條款。當你以傳值方式傳遞一個物件至函式時,函式獲得的是實參的一個副本,函式的返回值也是一個副本。這兩個副本由物件的copy建構函式產生。
下面舉個例子:
class Person { public: Person(); virtual ~Person(); ... private: std::string name; std::string address; }; class Student : public Person { public: Student(); virtual ~Student(); ... private: std::string schoolName; std::string schoolAddress; };
有下面函式宣告和呼叫:
bool validateStudent(Student s); //宣告一個傳值呼叫函式 Student plato; bool platoIsOK = validateStudent(plato); //請忽略函式名和變數名的含義
這個函式呼叫的消耗是這樣的:一次Student拷貝建構函式,一次Person拷貝建構函式,四次string拷貝建構函式以及對應的六次解構函式。
但當你這樣呼叫時:(當然不能直接呼叫,函式宣告也得修改一下)
bool validateStudent(const Student& s);
效率更高:沒有任何建構函式和解構函式呼叫,因為沒有任何新物件被建立。(當然,函式內的情況我們是不知道的)
另外by reference傳遞引數還可以避免slicing問題。當一個派生類物件作為一個基類物件(傳值)被傳入時,基類的copy建構函式被呼叫,僅僅留下了基類部分。舉例說明:
class Window { public: std::string name() const; virtual void display() const; }; class WindowWithScrollBars: public Window { public: virtual void display() const; };
當你想列印這兩個物件時,編寫了如下函式:
void printNameAndDisplay(Window w) //傳值方式 { std::cout << w.name(); w.display(); }
你是這樣呼叫的:
WindowWithScrollBars wwsb;
printNameAndDisplay(wwsb);
不用懷疑,呼叫的一定是Window::name和Window::display。儘管display()是個virtual函式。
解決slicing問題的方法是,以by reference-to-const方式傳遞物件。
void printNameAndDisplay(const Window& w) //好的,引用方式 { std::cout << w.name(); w.display(); }
傳入物件是什麼,就表現什麼行為。
通過C++編譯器底層,會發現引用往往以指標實現,因此pass-by-value往往意味著真正傳遞的是指標。如果你有個物件屬於內建型別,pass-by-value往往比by reference效率高。此外,STL的迭代器和函式物件,都被設計為pass-by-value。
還有一種觀點認為:所有小型types都應該pass-by-value,包括使用者自定義型別。這種觀點是錯誤的:物件小並不意味copy構造不昂貴。例如,許多STL容器只包含比指標多一些,但複製這些物件卻需要承擔複製那些指標所指的每一樣東西。
請記住:
(1)儘量以pass-by-reference-to-const取代pass-by-value,一般情況下它更高效而且可以避免slicing問題。
(2)內建型別,以及STL迭代器和函式物件,pass-by-value往往比較適當。
條款21:必須返回物件時,別妄想返回其reference
條款20告訴我們傳物件和返回物件存在效率問題,這可能使我們盲目追求pass-by-reference。但可能有一個致命錯誤:傳遞reference指向其實並不存在的物件。舉例說明:
class Rational { public: Rational(int numerator = 0, int denominator = 1); private: int n, d; friend const Rational operator*(const Rational& lhs, const Rational& rhs); //返回const物件 };
首先要說明operator*返回物件的版本(上面的)是可取的。
假設你出於效率考慮返回一個reference,你一定要考慮這個reference只是個別名,它另外的一個名字是什麼。(真正所指向的東西)
當你寫下這樣的函式時:
const Rational& operator*(const Rational& lhs, const Rational& rhs) { Rational result(lhs.n * rhs.n, lhs.d * rhs.d); return result; //注意,這裡返回的是引用 }
reference所指向的區域性物件已經被析構了,你對result做的任何操作都會出錯。任何函式如果返回一個reference指向某個local物件,結果都會很可悲。(指標是同樣的道理)
於是你修改了版本,為了讓reference不指向local而指向heap。
const Rational& operator*(const Rational& lhs, const Rational& rhs) { Rational *result = new Rational(lhs.n * rhs.n, lhs.d * rhs.d); return result; //注意,這裡返回的是引用 }
這個函式還是要付出建構函式呼叫的代價。核心是:由誰來呼叫delete。像下面這種操作,恐怕無法呼叫delete了。
Rational w, x, y, z;
w = x * y * z;
必須兩次delete,但是呼叫者無法取得operator*返回的reference背後的指標。絕對會導致記憶體洩露。
你可能還會修改出下面的版本:
const Rational& operator*(const Rational& lhs, const Rational& rhs) { static Rational result; result = ... ; return result; }
我知道你想避免析構帶來的問題,姑且不說static造成的多執行緒安全問題。僅僅考慮下面的程式碼:
bool operator==(const Rational& lhs, const Rational& rhs); Rational a, b, c, d; if ((a * b) == (c * d)) { } else { }
if條件語句肯定是true。原因如下:operator==被呼叫前先呼叫兩個operator*,這兩個operator*確實都改變了static物件值,但兩者最終都返回了reference,operator==運算肯定作用在同一個static物件上了。
請記住:
絕不要返回pointer或reference指向一個local stack物件,或返回一個reference指向heap-allocated物件,或返回一個pointer或reference指向一個local static物件而有可能需要多個這樣的物件。