1. 程式人生 > 實用技巧 >設計與宣告

設計與宣告

目錄

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的成員?這個問題幫助你決定哪個成員為publicprivateprotected,也幫助決定哪一個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建構函式構造PersonStudent物件銷燬呼叫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-member函式,幷包含於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-membernon-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,那麼就無法通過編譯了。
其實需要兩個條件:

  1. 建構函式是non-explict
  2. 建構函式引數傳遞一個引數就能構造(單引數建構函式, 有預設引數的建構函式)

似乎兜了一圈,用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的一個全特化版本,函式名稱後的‘’表示這一特化版本針對Widget而設計的。也就是當swap施行與Widgets身上就會啟用這個版本,
通常我們不被允許改變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手法的底層指標),而內建型別的操作絕不會丟擲異常。