close
四、Designs and Declarations
所謂軟體設計,是令「令軟體作出你希望它做的事情」的步驟和作法,本章將針對良好 C++ 介面的設計和宣告作說明。


18、Make interfaces easy to use correctly and hard to use incorrectly.
C++ 在介面之海漂浮:function 介面、class 介面、template 介面…,每個介面都是程式與使用者互動的手段,理想上,如果使用者企圖使用某個介面,但卻沒有獲得使用者所預期的行為,則,這段程式碼不應該可以通過編譯。
要開發出一個「容易被正確使用,不易被誤用」的介面,首先必須先考慮使用者可能做出什麼樣的錯誤,例如為一個用來表現日期的 class 設計 constructor:
class Date {
  public:
    Date(int month, int day, int year);
    ...
};

這個 class 的使用者很容易搞錯 month, day, year 的順序,而且即使搞錯,也還能通過編譯。

許多使用者端的錯誤可以透過導入新型別而獲得預防,利用 type system ,導入簡單的 wrapper types 來區別 day, month, year:
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 {
  public:
    Date(const month& m, const Day& day, const Year& year);
    ...
};

Date d(30, 3, 1995);  // 錯誤,不正確的 type
Date d(Day(30), Month(3), Year(1995));  // 錯誤,不正確的 type
Date d(Month(3), Day(30), Year(1995));  // 正確的 type

在條款 22 有詳述令 Day, Month, Year 成為 classes 並封裝其內資料,會比簡單使用 struct 好。
一旦正確的型別就定位,要如何限制它的值,如一年只有 12 個有效月,所以 Month 應該反應這個事實,較簡單的方式是使用 enum 來表示月份,但 enum 不具型別安全性,例如 enum 可被拿來當成一個 int 使用,詳見條款 2,而比較安全的作法是預先定義所有有效的 Months:
class Month {
  public:
    static Month Jan() { return Month(1); }  // 傳回有效月份
    ...
    static Month Dec() { return Month(12); }
    ...
  private:
    explicit Month(int m);  // 阻止生成新的月份
    ...
};

利用函式取代物件來表現某個特定月份,是因為 non-local static 物件在初始化時,有可能出問題,詳見條款 4。

預防使用者錯誤的另一個辦法是,限制型別內何事可做,何事不行,常見的限制是加上 const。
另一個一般性準則「讓 type 容易被正確使用」的表現形式是:
「除非有好理由,否則應該儘量令自行定義的 type 行為與內建 type 一致」。


任何介面,如果要求使用者必須記得做某些事,就是有著「不正確使用」的傾向,如條款13 所提:
Investment* createInvestment();

為了避免資源洩漏,createInvestment() 傳回的指標最終必須被 delete,則可能發生的錯誤行為是:
這個指標最後並沒有被 delete,又或是被刪除超過一次。

較佳的介面設計可以令 factory function 傳回一個 smart pointer :
std::tr1::shared_ptr createInvestment();

這個作法,幾乎消除了最後會忘記除底部 Investment 物件的可能性。

又如果,class 的設計,要使用 getRidOfInvestment() 來取代 delete:
std::tr1::shared_ptr createInvestment() {
    std::tr1::shared_ptr retVal(static_cast(0),
            getRidOfInvestment);  // 設定 deleter
    retVal = ...;  // 令 retVal 指向正確物件
    return retVal;
}

std::tr1::shared_ptr 有另一個性質是:會自動使用它的「每個指標專屬的 deleter」。
進而避免了 "corss-DLL problem",這個問題發生於「物件在 DLL(動態連結程式序) 中被 new 出來,卻在另一個 DLL 內被 delete」,將導致執行期的錯誤。
std::tr1::shared_ptr 就沒這個問題,因為它預設的 deleter 是來自於「td::tr1::shared_ptr 誕生所在的那個 DLL」。


要在介面中達成容易被使用,不易被誤用的性質。
「促進正確使用」的辦法包括介面的一致性,以及與內建型別的行為相容。
「阻止誤用」的辦法包話建立新型別、限制型別上的操作、束縛物件值及消除使用者的資源管理責任。
tr1::shared_ptr 支援 custom deleter,可防範 DLL 問題,也可被用來自動解鎖(mutex,詳見條款14)。




19、Treat class design as type design.
C++ 跟其它 OOP 語言一樣,當一個新的 class 被定義時,也就定義了一個新的 type。
包含: overloading function, operator, 控制記憶體的配置和歸還、定義物件的初始化和終結…等。

設計優秀的 class 是項艱鉅的工作,好的 type 有自然的語言、直觀的語意以及一個或多個高效實作品。

要如何設計高效的 class 呢?需要面對的問題有:
  • 新 type 的物件應該如何被產生和銷毀?這會影響到 constructor, destructor, operator new, operator new[], operator delete, operator delete[] 的設計,詳第條款 8。
  • 物件的初始化和物件的賦值有什麼樣的差別?這決定了 constructor 和 assignment operator 的行為,及其差異,詳見條款 4。
  • 新 type 的物件如果被 pass by value 意味著什麼?copy constructor 就是用來定義一個 type 的 pass by value 該如何實作。
  • 什麼是新 type 的合法值?對 class 的成員變數而言,通常只有某些數值集是有效的,這些數值集決定了 class 必須維護的約束條件,也就決定了成員函式必須進行的錯誤檢查。
  • 新 type 需要配合某個繼承圖系(inheritance graph)嗎?如果繼承某些既有的 class,就會受到那些 class 的束縛,特別是受到「它們的函式是 virtual 或 non-virtual」的影響(詳見條款 34, 36),又如果允許其它 class 繼承新的 class,那會影響到所宣告的函式,特別是 destructor,是否需要為 virtual(詳見條款7)?
  • 新 type 需要有什麼樣的轉換?如果允許 type T1 被隱式轉換為 type T2 ,就必須在 class T1 內寫一個型別轉換函式 (operator T2),或是在 class T2 內寫一個 non-explicit-one-argument constructor,如果只允許 explicit constructor 存在,就得寫出負責執行轉換的函式,且不得為 type conversion operator,或 non-explicit-one-argument constructor。(條款 15 有隱式和顯式轉換的範例)
  • 什麼樣的 operator 和 function 對此新 type 而言是合理的?答案將決定要宣告哪些 function,某些會是 member function,某些則否(詳見條款 23, 24, 26)。
  • 什麼樣的標準函式應該駁回?那些就必須宣告為 private function(詳見條款 6)。
  • 誰該取用新 type 的成員?將決定哪個成員為 public,哪個為 protected,哪個為 private,及哪個 class 或 function 應該要是 friend。
  • 什麼是新 type 的「末宣告介面」(undeclared interface)?它對效率、exception safety(詳見條款 29) 及資源運作(如:Mutex Lock 和 dynamic allocate memory)提供何種保證?
  • 新的 type 有多一般化?或許並非只是定義一個新 type ,而是一整個 type 家處,如果是如此,將應該定義一個新的 class template。
  • 真得需要一個新的 type 嗎?如果只是定義新的 derived class,以便增加功能,說不定單純定義一個或多個 non-member function 或 template 更能達到目標。


Class 的設計就是 type 的設計,在定義一個新的 type 之前,需考慮本條款的所有主題。



20、Prefer pass-by-reference-to-const to pass-by-value.
預設情況下,C++ 以 by value 方式傳遞物件至函式或來自函式,除非另外指令,否則函式參數都是以實際引數的複件為初值,而呼叫端所獲得的也是函式傳回值的一個複件,這些複件都是由物件的 copy constructor 產生,而這也可能使得 pass-by-value 成員昂貴的操作:
class Person {
  public:
    Person();
    virtual Person();  // 條款 7 有說明為何是 virtual
    ...
  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);  // 以 by value 方式接受學生
Student plato;
bool platoIsOK = validateStudent(plato);

當 validateStudent() 被呼叫時,沒有疑問地, Student 的 copy constructor 會被喚起,並以 plato 為藍本將 s 初始化,而當 validateStudent() 回返時,s 會被銷毀,因此參數的傳遞成本是「Student 的 一次 copy constructor 及一次 destructor」。
如果再把 Student 內兩個 string 物件、還有繼承的 Person 物件、及 Person 物件內的兩個 string 物件算進來,則以 by value 方式傳遞一個 Student 物件的總體成本是「六次 copy constructor 及六次 destructor」。

有個方法可以迴避上述的 constructor 及 destructor,進而提高傳遞效率:pass by reference-to-const:
bool validateStudent(const Student& s);

這種傳遞方式將沒有任何 constructor 及 destructor 會被喚起,因為沒有任何新物件被產生出來。
而宣告此參數為 const 是必要的,否則將無法確定 validateStudent() 是否會改變所傳入的 Student 物件,因為在 pass-by-value 的方式中,validateStudent() 頂多只能對所傳入物件的複本作修改。

以 by reference 方式傳遞參教,也可避免 slicing 的問題,當一個 derived class 物件被以 by value 方式傳遞,並被視為一個 base class 物件時,只有 base class 的 copy constructor 會被喚起,而造成「專屬 derived class 物件的行為」全被切割。
class Window {
  public:
    ...
    std::string name() const;  // 傳回視窗名稱
    virtual void display() const;  // 顯示視窗及其內容
};

class WindowWithScrollBars : public Window {
  public:
    ...
    virtual void display() const;
};

void printNameAndDisplay(Window w) {  // 不正確,參數可能被 slice
    std::cout 

當 printNameAndDisplay() 被呼叫時,參數 w 會 construct 成一個 Window 物件,而造成 wwsb 「之所以是個 WindowWithScrollBars 物件」的所有特化資訊都會被切除,要解決 slicing 問題的辦法就是以 by reference-to-const 的方式傳遞 w:
void printNameAndDisplay(const Window& w) {  // 正確,參數不會被 slice
    std::cout 


如果窺視 C++ 編譯器的底層,會發現 reference 往往是以指標實作出來,因此 pass by reference 通常意味真正傳遞的是指標,也因此如果有個物件屬於內建型別(如:int),pass by value 往往比 pass by reference 的效率高些,這個忠告也適用於 STL 的迭代器和函式物件,因為習慣上它們都被設計為 pass by value。

一般而言,可以合理假設「pass by value 並不昂貴」的唯一對象是內建型別及 STL 的迭代器及函式物件,至於其它東西,都儘量以 pass by reference-to-const 取代 pass by value。


儘量以 pass-by-reference-to-const 取代 pass-by-value,前者通常較有效率,並可避免 slicing problem。
但對於內建型別及 STL 的迭代器及函式物件,pass-by-value 往往比較適當。




21、Don't try to return a reference when you must return an object.
在某些情況下, pass-by-value 是必需的,如果硬要 pass-by-reference ,則會產生錯誤:
class Rational {
  public:
    Rational(int number = 0, int denominator = 1);
    // 條款 24 將說明為何不需為 explicit
    ...

  friend const Rational operator* (const Rational& lhs,
            const Rational& rhs);
    // 條款 3 有說明為何需為 const

  private:
    int n, d;  // numerator and denominator
};

Rational a(1, 2);  // a = 1 / 2
Rational b(3, 5);  // b = 3 / 5
Rational c = a * b;  // c 應該是 3 / 10

這個 operator* 以 by value 方式傳回計算結果(一個 Rational 物件),但若要改為 by reference 傳回結果,則在 operator* 內,必須要自己產生那個 Rational 物件。

函式產生新物件的途徑有二:stack space 或 heap space。

試著在 stack 空間產生物件,並以 by-reference 傳回結果:
const Rational& operator* (const Rational& lhs,
        const Rational& rhs) {
    Rational result(lhs.n * rhs.h, lhs,d * rhs.d);  // 錯誤寫法
    return result;
}

這種作法除了喚起了一個 Rational constructor,更嚴重的是,這個函式傳回一個 reference 指向 local 物件,此 local 物件會在函式退出前就被銷毀。

在考慮從 heap 內建構一個物件,並傳回 reference 指向它,Heap-based 物件是以 new 產生的:
const Rational& operator* (const Rational& lhs,
        const Rational& rhs) {
    Rational* result = new Rational(lhs.n * rhs.h,
            lhs,d * rhs.d);  // 錯誤寫法
    return *result;
}

同樣的得付出一個 Rational constructor 的代價,還產生了另一個問題:將要負責 delete 這個 new 出來的物件?
就算使用者確定會 delete,但也無法在下列情況下,完成 delete 的任務:
Rational w, x, y, z;
w = x * y * z;  // 等同於 operator*(operator*(x, y), z)

呼叫了兩次的 operator* ,也就是用了兩次 new,那就需要兩次的 delete,但沒有合理的方式,可以讓使者用取得 operator* 傳回的 reference 背後所隱藏的指檔,所以絕對會導致 memory leak。

試試 static 物件,如果「讓 operator* 傳回的 reference 指向一個被定義於函式內部的 static Rational 物件」:
const Rational& operator* (const Rational& lhs,
        const Rational& rhs) {
    static Rational result;
    result = lhs.n * rhs.h, lhs,d * rhs.d);  // 錯誤寫法
    return result;
}

就像所有用 static 物件的設計一樣,這個例子會有是否能在 multi-thread 下順利執行的疑慮。
不過就算是 single-thread ,也有錯誤的情況:
Rational a, b, c, d;
if ((a*b) == (c*d)) {  // 很遺憾的,永遠是 true
    ...
}

因為 (a*b) 跟 (c*d) 所傳回的 reference 是同一個,所以結果只會是 true,程式碼的等價函式型式為: if (operator== (operator*(a, b), operator*(c, d))
所以,一個「必須傳回新物件」的函式的正確寫法是:就讓那個函式傳回一個新物件吧:
inline const Rational operator* (const Rational& lhs,
        const Rational& rhs) {
    return Rational(lhs.n * rhs.n, lhs.d * rhs.d));
}

絕對不要傳回 pointer 或 reference 指向一個 local stack 物件。
也不要傳回 reference 指向一個 heap-allocated 物件。
也不要傳回 pointer 或 reference 指向一個可能同時被操作的 local static 物件。




22、Declare data members private.
以語法一致性的方面來說,如果成員變數不是 public,則唯一能夠存取物件的辦法就是透過成員函式,而只要 public 介面內的每樣東西都是函式,使用者只要在打算存取 class 成員時,使用小括號就對了。

除了一致性,使用函式也可以讓成員變數的處理有更精確的控制:
class AccessLevels {
  public:
    ...
    int getReadOnly() const { return readOnly; };
    void setReadWrite(int value) const { readWrite = value; };
    int getReadWrite() const { return readWrite; };
    void setWriteOnly(int value) const { writeOnly = value; };
  private:
    int noAccess;  // 無任何存取
    int readOnly;  // 唯取
    int readWrite;  // 存取皆可
    int writeOnly;  // 唯存
};

除了上述兩個理由,更重要的理由是封裝。
如果透過函式存取資源變數,日後若以某個計算取代這個成員變數,則 class 的使用者一點也不需要知道,也不需要修改 code。
class SpeedDataCollection {
    ...
  public:
    void addValue(int speed);  // 添加一筆新資料
    double averageSoFar() const;  // 傳回平均速度
    ...
};

以成員函式 averageSoFar() 的實作為考量:
  • 第一種作法是在 class 內設計一個成員變數,用來記錄至今以來的所有速度平均值,當 averageSoFar() 被呼叫時,只需回傳那個變數即可。
  • 第二種作法是令 averageSoFar() 被呼叫時,就重新計算平均值。

以第一種作法來說,進度極快,但會使 SpeedDataCollection 物件變大,因為必需為用來存放目前平均值、累積總量、數據點數的每個成員變數配置空間。
相反的,如果被詢問才計算平均值,雖然執行速度慢,但 SpeedDataCollection 物件比較小。

說不上哪一個一定好,而是在不同的情況下,可能會需要不同的 solution,這時,使用者還是不需要修改 code,頂多只是需要重新編譯,但如果遵循條款 31 所描述的技術,甚至可以消除重新編譯的不便性。

封裝是很重要的,如果封裝成員變數,因為只有成員函式可以影響它們,所以保留了日後變更實作的權利,如果不隱藏這些成員變數,則會發現,就算擁有 class source code,但要改變任何 public 事物的能力還是受到極端的束縛,因為那會破壞太多客戶端的程式碼。

至於 protected 成員變數,事實上它和 public 成員變數的論點相同,只要修改 protected 成員變數,則所有使用它的 derived classes 也都會被破壞。

一旦將一個成員變數宣告為 public 或 protected,而使用者開始使用它,就很難改那個成員變數所涉及的一切,因為有太多的程式碼需要重寫、重新測試、重新編寫文件、重新編譯。

以封裝的角度來說,其實只有兩種存取權限:
private(提供封裝)和其它(不提供封裝)。


切記將成員變數宣告為 private,這將賦予存取資料的一致性、可細微劃分存取控制、並提供 class 的充分實作彈性。
protected 並不比 public 更具封裝性。




23、Prefer non-member non-friend functions to member functions.
想像有個 class 用來表示網頁瀏覽器:
class WebBrowser {
  public:
    ...
    void clearCache();  // 清除 cache
    void clearHistory();  // 清除 URL history
    void removeCookies();  // 清除 cookies
    ...
};

為了讓使用者可以一次執行這三個函式,因為 WebBrowser 也可提供這樣一個函式:
class WebBrowser {
  public:
    ...
    void clearEverything();
    // 呼叫 clearCache(), clearHistory(), removeCookies()
    ...
};

當然這一個功能也可以由 non-member function 呼叫適當的 member function 而提供出來:
void clearBrowser(WebBrowser& wb) {
    wb.clearCache();
    wb.clearHistory();
    wb.removeCookies();
}

物件導向守則要求資料應該儘可能被封裝,而 member function clearEverything() 帶來的封裝性比 clearBrowser() 低。
此外,提供 non-member function function 可允許對 WebBrowser 相關機能有較大的 packaging flexibility,那將導致較低的編譯相依度,增加 WebBrowser 的可延伸性。
因此在許多方面, non-member function 的作法會比 member 作法好。

條款 22 說過,成員變數應該是 private,如果不是,就有無限量的函式可直接存取它們,它們也就毫無封裝性。
能夠存取 private 成員變數的函式,只有 class 的 member function 及 friend function,如果要在一個 member function 和一個 non-member non-friend function 之間作抉選,且兩者提供相同的機能,那麼,導致較大封裝性的是 non-member non-friend function,因為它不增加「能夠存取 class 內之 private 成分」的函式數量。

上述的論點,有兩件事需要注意:
  1. friend function 對 class private 成員的存取權力和 member function 相同,因此從封裝的角度來看,這裡的選擇關鍵是在 member 和 non-member non-friend function 之間;但封裝並非唯一考量,條款 24 解釋當在考慮隱式型別轉換時,應該在 member 和 non-member function 之間作抉擇。
  2. 只因在意封裝性而讓函式「成為 class 的 non-member」,並不意味它「不可以是另一個 class 的 member」。

在 C++ 中,比較自然的作法是讓 clearBrowser 成為一個 non-member function 並且位於 WebBrowser 所在的同一個 namespace 內:
namespace WebBrowserStuff {
    class WebBrowser { ... };
    void clearBrowser(WebBrowser& wb);
    ...
}

然而這不只是看起來自然,要知道 namespace 和 class 不同,namespace 可跨越多個源碼檔,而 class 不行。
一個像 WebBrowser 這樣的 class 可能擁有大量便利函式,某些與 bookmark 有關,某些與列印有關,某些與 cookie 有關…等等,分離它們的最直接作法就是將 bookmark 相關便利函式宣告於一個 header file,將 cookie 相關便利函式宣告於另一個 header file,再將列印相關便利函式宣告於第三個 header file:
namespace WebBrowserStuff {
    class WebBrowser { ... };
    ...  // 幾乎所有使用者都會需要的 non-member function
}

namespace WebBrowserStuff{
    ...  // bookmark 相關便利函式
}

namespace WebBrowserStuff{
    ...  // cookie 相關便利函式
}

而這種作法,也正是 C++ 標準函式庫的組織方式,標準函式庫有數十個 header file (, , 等等),每個 header file 內宣告 std 的某些機能,這允許使用者只對他們所用的那一小部分系統形成編譯相依(條款 31 有討論降低編譯依存性的其它作法)。
但此種方式切割機能並不適用於 class 成員函式,因為一個 class 必須整體定義。

將所有便利函式放在多個 header file,但隸屬於同一個命名空間,意味著使用者可以輕鬆擴展這組便利函式,只要添加更多的 non-member non-friend function 到此命名空間內即可。


寧可拿 non-member non-friend function 取代 member function,這樣做可以增加封裝性、package flexibility和機能擴充性。



24、Declare non-member functions when type conversions should apply to all parameters.
之前有提到過,令 classes 支援隱式型別轉換通常是個糟糕的主意,不過這條規則有例外的時候。

如果設計一個 class 用來表現有理數,則允許整數「隱式轉換」為有理數,是頗為合理:
class Rational {
  public:
    Rational(int numerator = 0, int denominator = 1);
    // implicition constructor
    int numerator() const;
    int denominator() const;
  private:
    ...
};

如果想支援算術運算,如:加、減、乘、除等,但不確定是否該由 member function 或 non-member function 或 non-member friend function 來實作,在此,把條款 23 擺旁邊,先研究將 operator* 寫成 member function 的寫法:
class Rational {
  public:
    ...
    const Rational operator* (const Rational& rhs) const;
};

如此的設計,可以輕鬆自在的方式將兩個有理數相乘,但如果是混合式運算,也就是拿 Rational 和 int 相乘,則會發生問題:
Rational oneEight(1, 8);
Rational oneHalf(1, 2);
Rational result = oneHalf * oneEight;  // 正確
result = result * oneEight;  // 正確

result = result * 2;  // 正確
result = 2 * result;  // 錯誤

以對應的方式重寫上述兩個式子:
result = oneHalf.operator*(2);  // 正確
result = 2.operator*(oneHalf);  // 錯誤

錯誤的原因在於:
整數 2 並沒有相應的 class,也就沒有 operator* 成員函式,雖然編譯器會嘗試尋找可被以下這般呼叫的 non-member operator*(也就是在命名空間內,或 global 作用域內):
result = operator * (2, oneHalf);  // 錯誤

但本例中,不存在一個接受 int 和 Rational 做為參數的 non-member operator*,因此搜尋失敗。

回頭看第一個可以成功呼叫的式子,其第二個參數是整數 2,為什麼 2 可以被接受?
因為這裡發生了 implicit type conversion,編譯器知道目前傳遞的是一個 int,也知道 function 需要的是 Rational,但編譯器也知道只要呼叫 Rational constructor 並賦予所提供的 int,就可以產生一個適當的 Rational,於是編譯器就這麼做了,有點類似:
const Rational temp(2);
result = oneHalf * temp;

編譯器可以這麼做的理由是因為 Rational constructor 為 non-explicit,如果是 explicit,則第一個式子也無法通過編譯。


想要支援混合式算術運算,可行之道是:
讓 operator* 成為一個 non-member function,將允許編譯器在每個引數身上執行 implicit type converstion:
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;  // 正確

本條款雖然有道理,但如果從 Object-Oriented C++ 跨進到 Template C++,並讓 Rational 成為一個 class template,而非 class,則又有一些需要考慮的新爭議、新解法、還有設計牽聯,這些形成了條款 46。


如果需要為某個 function 的所有參數進行型別轉換,那麼這個 function 必須是個 non-member function。



25、Consider support for a non-throwing swap.
swap 原植只是 STL 的一部分,後來成為 exception-safe programming 的脊柱,詳見條款 29,以及後來處理自我賦值可能性的一個常見機制,詳見條款 11,也由於 swap 如此有用,因此,適當的實作是很重要的。

標準程式庫所提供的 swap 典型實作為:
namespace std {
    template&lttypename T&gt  // std::swap 典型實作
    void swap(T&a, T&b) {  // 調換 a 和 b 的值
        T temp(a);
        a = b;
        b = temp;
    }
}

只要型別 T 支援 copying function,預設的 swap 實作碼就會幫忙置換型別為 T 的物件。

但對某些型別而言,這些 swap 的複製動作無一必要,最主要型別就是「以指標指向一個物件,內含真正資料」的型別。
這種設計的常見形式就是所謂的「pimpl 手法」,pimpl 也就是 pointer to implimentation 詳見條款 31:
class WidgetImpl {  // 針對 Widget 資料而設計的 class
  public:
    ...
  private:
    int a, b, c;
    std::vector v;
    ...
};

class Widget {  // 這個 class 使用 pimpl 手法
  public:
    Widget(const Widget& rhs);
    Widget& operator=(const Widget& rhs) {
    // 複製 Widget 時,令複製其 WidgetImpl 物件
        ...
        *pImpl = *(rhs.pImpl);
        ...
    }
  private:
    WidgetImpl* pImpl;  // 指標,所指物件內含 Widget 資料
};

一旦要置換兩個 Widget 物件,唯一需要做的就是置換其 pImpl 指標,但預設的 swap 演算法將不只複製三個 Widget,還複製三個 WidgetImpl 物件,非常缺乏效率。
適當的做法是告訴 std::swap,在 Widget 被置換時,真正該做的是置換其內部的 pImpl 指標,實踐這一思路的作法是:
將 std::swap 針對 Widget 進行特化:
namespace std {
    template&lt&gt
    void swap&ltWidget&gt(Widget& a, Widget& b) {
        swap(a.pImpl, b.pImpl);  // 錯誤,pImpl 為 private
    }
}

函式一開始的 "template" 表示它是 std::swap 的一個 total template specialization 版本,函式名稱之後的 "&ltWidget&gt" 表示這個 specialization 版本是針對「T是 Widget」而設計的。
當一般性的 swap template 施行於 Widget 時,便會啟用這個版本。
通常改變 std 命名空間內的任何東西是不被允許的,但為標準 template 製造特化版本是被允許的。

為了解決存取 a 和 b 內的 pImpl 指標的錯誤,令 Widget 宣告一個名為 swap 的 public 成員函式做真正的置換工作,然後將 std::swap 進行 specialization,令它呼叫該成員函式:
class Widget {
  public:
    ...
    void swap(Widget& other) {
        using std::swap;  // 這個宣告是必要的
        swap(pImpl, other, pImpl);  // 置換 pImpl 指標
    }
    ...
};

namespace std {  // 修正後的 std::swap specialization 版本
    template&lt&gt
    void swap&ltWidget&gt(Widget& a, Widget& b) {
        a.swap(b);  // 呼叫其 swap 成員函式置換 Widget
    }
}

這個作法還與 STL 容器有一致性,因為所有 STL 容器也都提供有 public swap() 成員函式及 std::swap specialization 版本。


接著假設 Widget 及 WidgetImpl 都是 class template 而非 class,則在 Widget 或 WidgetImpl 放個 swap 成員函式就像以往一樣簡單,但會在 specialize std::swap 時出錯:
template&lttypename T&gt
class WidgetImpl { ... };
template&lttypename T&gt
class Widget { ... };

namespace std {
    template&lttypename T&gt
    void swap&lt Widget&ltT&gt &gt(Widget&ltT&gt& a, Widget&ltT&gt& b) {
    // 錯誤,不合法
        a.swap(b);
    }
}

上述例子企圖 partially specialize 一個 function template,但 C++ 只允許對 class template 進行 partially specialize,在 function template 實行 partially specialize 是行不通的。

一般來說,如果要 partially specialize 一個 function template,慣常作法是簡單地為它添加一個重載版本:
namespace std {
    template&lttypename T&gt  // std::swap 的重載版本
    void swap(Widget&ltT&gt& a, Widget&ltT&gt& b) {
    // swap 之後沒有 &lt...&gt,不過還是不合法
        a.swap(b);
    }
}

一般而言,重載 fucntion template 沒有問題,但 std 是個特殊的命名空間,具有特殊的管理規則,std 內的 template 可以被 total specialize,但不允許添加新的 template 或 class 或 function 或其它任何東西到 std 裡頭。

正確的作法是:
宣告一個 non-member swap() 讓它呼叫 member swap,但不將那個 non-member swap 宣告為 std::swap() 的 specialization 版本或 overloading 版本,為求簡化,假設 Widget 的所有相關機能被被置於 namespace WidgetStuff 內:
namespace WidgetStuff {
    ...
    template&lttypename T&gt
    class Widget { ... };
    ...
    template&lttypename T&gt  // non-member swap() function
    void swap(Widget&ltT&gt& a, Widget&ltT&gt& b) {
    // 這裡不屬於 std namespace
        a.swap(b);
    }
}

如此,任何地點的程式碼,如果打算置換兩個 Widget 物件,因而呼叫 swap,根據據 C++ 的 name lookup rules,就會找到 WidgetStuff 內的 Widget 專屬版本。

這個作法對 class 或 class template 都行得通,所以似乎在任何時候都應該使用這個作法,但有個不幸的理由,使你應該也為 class 特化 std::swap。

順帶一提,如果沒有額外使用某個 namespace,上述的每件事情也仍然適用,也就是說還是需要一個 non-member swap 用來呼叫 member swap,不過何必在 global namespace 內塞滿了各式各樣的 class, template, function, enum, enumerant 及 typedef 名稱呢?

接著,換位思考,以使用者的角度來看事情,在一個 function template 中,其它需要置換兩個物件值,則應該喚起哪個 swap?是 std 既有的那個一般化版本?還是某個可能存在的特化版本?抑或是一個可能存在的 T 專屬版本且可能棲身於某個 namespace 內?
template&lttypename T&gt
void doSomething(&ltT&gt& obj1, &ltT&gt& obj2) {
    using std::swap;  // 令 std::swap 在此函式內可用
    ...
    swap(obj1, obj2);  // 為 T 型物件呼叫最佳的 swap 版本
    ...
}

一旦編譯器看到對 swap 的呼叫,依 C++ 的 name lookup rules,將確保找到 global 作用域或 T 所在之 namespace 內的任何 T 專屬的 swap,如果 T 是 Widget 並位於 namespace WidgetStuff 內,編譯器會使用「argument-dependent lookup」找出 WidgetStuff 內的 swap,如果沒有 T 專屬的 swap() 存在,編譯器就使用 std 內的 swap,這歸功於 using 宣告式讓 std::swap 在函式內曝光,接著再依序找找 std::swap 的 T 專屬 specialization 版本,最後才是一般化的 std::swap()。

令適當的 swap() 被喚起是不困難的,但別為這一呼叫添加額外飾詞,那會影響 C++ 挑選適當函式,如:
std::swap(obj1, obj2);  // 錯誤的 swap 呼叫方式

這將強迫編譯器只認 std 內的 swap,而無法喚起一個定義於它處的較適當 T 專屬版本,但有些 programmer 的確以此方式修飾 swap,而這也是「需要對 std::swap 進行 total template specialization」的原因。

總結如下:
在 class 或 class template 中,如果預設的 swap() 可以提供可接受的效率,就不需要作任何事,但若效率不足,則試著:
  1. 提供 public swap 成員函式,讓它高效地置換此型別的兩個物件值
  2. 在 class 或 tempalte 所在的 namespace 內提供 non-member swap,並令它呼叫上述的 swap() 成員函式。
  3. 如果是 class,為此 class 特化 std::swap(),並令它呼叫 swap() 函員函式。
  4. 最後,以 swap 為例,請確認包含一個 using 宣告式,以便讓 std::swap 在函式內曝光可見,然後不加任何 namespace 修飾詞,赤裸裸地呼叫 swap。

至於成員版 swap 絕不可拋出 exception 的原因,這是因為 swap 的一個最好的應用是幫助 class 和 class template 提供強烈的 exception safety 保障,詳見條款 29,但此技術基於一個假設:
成員版的 swap 絕對不拋出 exception。

不可施行於非成員版的原因在於:
因為 swap 預設版本是以 copy constructor 及 copy operator= 為基礎,一般情況下,兩者都允許拋出 exception。

因此,當寫下一個自定版本的 swap 時,除了提供高效置換物件值的辦法,而且也要不拋出 exception,因為高效率的 swap 幾乎都是奠基於內建型別的操作,如 pImpl 手法的底層指標,而內建型別上的操作絕不會拋出 exception。


當 std::swap() 對自訂型別效率不高時,提供一個 swap() 成員函式,並確保這個函式不拋出 exception。
如果提供一個 member swap(),也該提供一個 non-member swap() 用來呼叫前者,對於 class ,也需要 specialize std::swap。
呼叫 swap() 時,應針對 std::swap 使用 using 宣告式,然後不帶任何 namespace 呼叫 swap()。
為了「使用者定義型別」而對 std templates 進行 totally specialize 是好的,但不要嘗試在 std 內加入對 std 而言是全新的東西。
arrow
arrow
    全站熱搜
    創作者介紹

    silverfoxkkk 發表在 痞客邦 留言(0) 人氣()