所謂軟體設計,是「令軟體做出你希望它做的事情」的步驟與方法,通常以概略構想開始,最終落實至細節,以允許特定介面的開發。這些介面隨後必須轉換為 C++ 宣告式。本文深入探討良好 C++ 介面的設計與宣告技巧。
C++ 擁有多種介面:函式介面、類別介面、樣板介面等。每種介面都是客戶端與你的程式碼互動的手段。理想情況下,客戶端總能準確使用介面並獲得預期結果;若使用錯誤,程式碼應無法通過編譯。
假設我們需要一個表示日期的類別:
class Date {
public:
Date(int month, int day, int 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& d, const Year& y);
...
};
// Date d1(30, 3, 1996); // 編譯錯誤!
Date d2(Month(3), Day(30), Year(1996)); // 正確
用結構體封裝資料,能明智地引入新型別並預防「介面被誤用」。
進一步限制型別的合法值也很合理。例如月份只有 1~12 月,可預先定義所有有效月份:
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(1996));
以函式取代物件來表示特定月份,是相當不錯的做法。
除非有更好理由,否則盡量讓你的型別行為與內建型別一致!
使用者清楚 int 等型別的行為,所以你應努力讓自訂型別在合理前提下具備相同操作。例如,若 a 和 b 都是 int,對 a*b 賦值就是不合法的。避免無端與內建型別不兼容,真正理由是為了提供行為一致的介面。鮮有其他特性比「一致性」更能促成「介面被正確使用」,也鮮有特性比「不一致性」更加劇介面惡化。
C++ 像其他 OOP 語言一樣,定義新 class 即定義新型別。包括運算子多載、記憶體分配與釋放控制、物件初始化與解構等,全在你掌控。因此你應帶著「語言設計者設計內建型別時」同樣的謹慎來設計 class。以下給出部分 class 設計規範:
設計 class 具有挑戰性,若想設計一個 class,最好像設計型別一樣,把上述問題都思考一遍。
預設情況下 C++ 以 pass-by-value 傳遞物件至函式,實際上是傳遞副本,由複製建構函式產生,可能使 pass-by-value 成為昂貴且耗時的操作。
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;
};
// in main:
bool checkStudent(Student s);
Student one;
bool whoh = checkStudent(one);
呼叫 checkStudent 時發生什麼?這是 pass-by-value 函式,意味著一定會出現複製建構函式。參數傳遞成本是「一次 Student 複製建構函式 + 一次 Student 解構函式」。Student 繼承 Person,還有一次 Person 建構/解構,加上各自的 string 物件,總成本是「六次建構函式和六次解構函式!」多麼可怕的開銷!
解決方法簡單:使用 pass by reference to const。因為 by reference 不會導致建構/解構函式被呼叫,節省大量開銷,同時 const 保證參數不會在函式內被修改。
bool checkStudent(const Student& s);
pass-by-value 還會導致物件切割。當 derived class 物件以 by value 傳遞並被視為 base class 物件時,base class 的複製建構函式被呼叫,「導致此物件行為像 derived class 物件」的特化性質全被切掉,只剩 base class 物件。
class Window {
public:
...
std::string name() const;
virtual void display() const;
};
class SpecialWindow : public Window {
public:
...
virtual void display() const;
};
void print(Window w) {
cout << w.name();
w.display();
}
將 SpecialWindow 物件傳給 print(Window w) 時,特化性質被切掉,本想輸出 SpecialWindow 特別內容卻只輸出 Window 內容。
同樣使用 reference,引發動態繫結,從而使用 SpecialWindow 的 display。
void print(const Window& w) {
cout << w.name();
w.display();
}
窺視 C++ 編譯器底層會發現,reference 實際上以指標實現,pass by reference 通常意味著真正傳遞的是指標。因此,若物件屬於內建型別(如 int),pass-by-value 通常效率更好。這對 STL 迭代器和函式物件同樣適用,因為慣例上它們設計為 pass-by-value。迭代器和函式物件的實踐者有責任確保它們高效且不受切割問題影響。
有人認為所有小型型別物件都適用 pass-by-value,甚至對使用者定義的 class。實際上不準確:1)物件小不代表複製建構函式開銷小;2)即使小型物件無昂貴複製建構函式,也可能有效率問題(某些編譯器不願把只含一個 double 的物件放進暫存器,但用 reference 編譯器一定會把指標放進暫存器);3)使用者定義型別大小易改變,隨使用可能越來越大。一般而言,合理假設「pass-by-value 更合適」的唯一物件就是內建型別、STL 迭代器和函式物件,其他最好使用 by reference。
前面討論 pass-by-reference 可提高效率,有人便堅定使用 reference,甚至回傳指向並不存在物件的 reference。
作者想節省開銷提高效率,卻產生大量錯誤。
class Rational {
public:
Rational(int num1 = 0, int num2 = 1);
...
private:
int n1, n2;
friend Rational& operator*(const Rational& lhs, const Rational& rhs);
};
operator* 試圖回傳 reference,並尋找合乎邏輯的實作。
Rational& operator*(const Rational& lhs, const Rational& rhs) {
Rational result(lhs.n1 * rhs.n1, lhs.n2 * rhs.n2);
return result;
}
問題顯而易見:result 是堆疊物件,作用域結束後被銷毀,回傳了懸空 reference。失敗!
Rational& operator*(const Rational& lhs, const Rational& rhs) {
Rational* result = new Rational(lhs.n1 * rhs.n1, lhs.n2 * rhs.n2);
return *result;
}
乍看似無問題,實則隱含殺機:動態配置記憶體意味著必須管理資源,但在 main 函式裡不可能一直有變數守著並及時 delete。大量使用 * 運算子時管理大量資源根本不可能。
Rational& operator*(const Rational& lhs, const Rational& rhs) {
static Rational result(lhs.n1 * rhs.n1, lhs.n2 * rhs.n2);
return result;
}
看似成功,實則隱蔽:
bool operator==(const Rational& lhs, const Rational& rhs);
if ((a*b) == (c*d)) { ... } else { ... }
問題出在等號運算:等號永遠成立!因為 operator== 被呼叫前,已有兩個 operator* 被呼叫,每個都回傳函式內部的 static 物件,這兩個物件實際上是同一個!根本沒完成 * 運算子應有功能。
別掙扎了!使用 pass-by-value。不就是一點建構/解構函式開銷嗎?比起大量錯誤和記憶體管理,這點開銷很划算。
class Rational {
public:
Rational(int num1 = 0, int num2 = 1);
...
private:
int n1, n2;
friend Rational operator*(const Rational& lhs, const Rational& rhs) {
return Rational(lhs.n1 * rhs.n1, lhs.n2 * rhs.n2);
}
};
理由一:語法一致性。我們初學 C++ OOP 時就有一條準則:成員變數總是要宣告為 private。本節討論為何。
繼續討論 protected 的封裝性。一般人認為 protected 比 public 更具封裝性,其實不然。更準確的判斷方法:某東西的封裝性與「當其內容改變時可能造成的程式碼破壞量」成反比。對於 public 成員變數,移除它意味著破壞所有使用它的客戶端程式碼(破壞量大);對 protected 成員變數,移除它意味著破壞所有 derived class(破壞量也大)。因此 protected 和 public 的封裝性其實一樣。結論:其實只有兩種存取權限:private(實現封裝)和其他(不實現封裝)。
面向對象守則要求,資料以及操作資料的函式應被捆綁,意味著建議所有操作資料成員的函式都應是 member 函式。然而事實上真的是這樣嗎?
假設我們要寫一個類別描述網頁瀏覽器:
class WebBrowser {
public:
...
void clearCache();
void clearHistory();
void removeCookies();
...
// 使用者希望有個函式能清除所有資訊
// 問題:該函式是否應宣告為 member?
void clearEverything();
};
// 也可宣告為 non-member
void clearEverything(WebBrowser& web) { ... }
哪種選擇更好?
根據面向對象守則,宣告為 member 函式似乎更好。然而這是對面向對象真實意義的誤解。面向對象要求資料應盡可能被封裝,但與直覺相反,member 函式 clearEverything 帶來的封裝性比 non-member 函式低。此外,提供 non-member 函式允許對 WebBrowser 相關功能有更大的包裹彈性,從而導致較低的編譯相依度,增加可衍生性。理由如下:
封裝性。 越多東西被封裝,越少人能碰到它,我們就有越大彈性去改變它,改變只會影響看到改變的人和事物。這就是我們推崇封裝的原因:它使我們能改變事物而只影響有限客戶。 考慮物件內資料。 越少程式碼能看到資料,越多資料可被封裝,我們也就越能自如地改變物件資料。越多函式能存取資料成員,資料封裝性就越差!因此,因為 non-member non-friend 函式不能直接改變資料成員,所以能最大程度實現封裝。在 C++ 中,最自然的做法是讓 clearEverything 成為 non-member 函式並位於 WebBrowser 所在的同一個 namespace 內:
namespace WebBrowserStuff {
class WebBrowser { ... };
void clearEverything(WebBrowser& web);
...
}
namespace 和 class 不同!前者可跨越多個原始碼檔案而後者不能,這很重要!
像 clearEverything 這樣的函式是便利函式,雖無特殊存取權限,但能極大便利客戶端。實際上我們會補充大量類似便利函式,它們可能分屬不同模組,於是我們採用把不同模組便利函式寫在不同標頭檔,但都隸屬同一命名空間:
#include "webbrowser.h" // 提供 class 宣告本身及核心機能
namespace WebBrowserStuff {
class WebBrowser { ... };
... // 核心機能,幾乎所有使用者都需要的 non-member 便利函式
}
// 標頭檔 "webbrowserbookmarks.h" - 與書籤相關
namespace WebBrowserStuff {
... // 與書籤相關的便利函式
}
// 標頭檔 "webbrowsercookies.h" - 與 Cookie 相關
namespace WebBrowserStuff {
... // 與 Cookie 相關的便利函式
}
...
注意這是 C++ 標準程式庫的組織方式。標準程式庫並非擁有單一、整體、龐大的類別。
令 class 支援隱式型別轉換通常是個糟糕的主意。當然也有例外,例如建立數值型別時。
假設設計有理數類別:
class Rational {
public:
Rational(int numerator = 0, int denominator = 1);
int numerator() const;
int denominator() const;
private:
...
};
class Rational {
public:
...
const Rational operator*(const Rational& rhs) const;
};
// 可輕鬆實現乘法
Rational oneEighth(1, 8);
Rational oneHalf(1, 2);
Rational result = oneHalf * oneEighth; // 沒問題
result = result * oneEighth; // 沒問題
到目前為止沒致命問題,然而:
result = oneHalf * 2; // ok!
result = 2 * oneHalf; // error!
// result = 2.operator*(oneHalf); 當然錯!
第一個式子能成立,是因為實現了隱式型別轉換。編譯器知道傳遞 int 而函式需要 Rational,但它也知道只要呼叫 Rational 建構函式並賦予提供的 int,就能變出適當的 Rational。相當於:
const Rational temp(2);
result = oneHalf * temp;
這只涉及非 explicit 建構函式。若是 explicit 建構函式,該語句無法通過編譯。
只有當參數列於參數列內,該參數才是隱式型別轉換的合格參與者。地位相當於「被呼叫之成員函式所隸屬的那個物件」——即 this 物件——那個隱喻參數,絕不是隱式轉換的合格參與者。這就是為什麼語句 1 能通過編譯而語句 2 不能。
方法是:讓 operator* 成為 non-member 函式,允許編譯器在每一實參上執行隱式型別轉換。
const Rational operator*(const Rational& lhs, const Rational& rhs) { ... }
Rational oneFourth(1, 4);
Rational result = oneFourth * 2; // right!
result = 2 * oneFourth; // right!
補充思考:是否應把 operator* 宣告為 friend?
答案是否定的!請注意,member 的反面不是 friend,而是 non-member!此處 operator* 完全可借由 Rational 的 public 介面完成任務,故不必宣告為 friend。無論何時,若能避免 friend 函式就應避免。
總結:如果你需要為某函式的所有參數(包含 this)進行型別轉換,那麼這個函式必須是 non-member。
swap 作為 STL 一部分,成為異常安全性程式設計的脊柱,以及處理自我賦值可能性的常見機制。因如此有用,意味著它具有非凡的複雜度。本節談論這些複雜度及相應處理。
namespace std {
template
void swap(T& a, T& b) {
T temp(a);
a = b;
b = temp;
}
}
這是標準程式庫提供的 swap 演算法。非常簡單,只要 T 有複製相關操作即可。但對某些情況效率不彰。例如處理「以指標指向物件,內含真正資料」的型別(所謂 pimpl 慣用法:pointer to implementation)。
class WidgetImpl {
public:
... // 針對 Widget 設計的 class
private:
int a, b, c;
std::vector v;
...
};
class Widget {
public:
Widget(const Widget& rhs);
Widget& operator=(const Widget& rhs) { ... *pImpl = *(rhs.pImpl); ... }
private:
WidgetImpl* pImpl;
};
對此類呼叫演算庫的 swap 會非常低效:總共要複製三個 Widget 和三個 WidgetImpl 物件!事實上只需改變指標指向即可。
嘗試讓 swap 針對 Widget 特化:
namespace std {
template<>
void swap(Widget& a, Widget& b) {
swap(a.pImpl, b.pImpl);
}
}
通常不能改變 std 命名空間內容,但允許為標準樣板製造特化版本。但此代碼無法編譯,因為企圖存取 class 私有成員。更合理做法是令其呼叫成員函式:
class Widget {
public:
...
void swap(Widget& other) {
using std::swap;
swap(pImpl, other.pImpl);
}
...
private:
WidgetImpl* pImpl;
};
namespace std {
template<>
void swap(Widget& a, Widget& b) {
a.swap(b);
}
}
這不僅能編譯,而且與 STL 容器有一致性。
假設 Widget 和 WidgetImpl 都是 class template,嘗試偏特化 std::swap 會有問題:C++ 只允許對 class template 偏特化,不允許對 function template 偏特化。常見做法是添加重載函式:
namespace std {
template
void swap(Widget& a, Widget& b) {
a.swap(b);
}
}
但這也不行!std 是特殊命名空間,客戶可全特化 std 內的 template,但不可添加新 template 到 std 裡。
宣告一個 non-member swap 讓它呼叫 member swap,但不將其宣告為 std::swap 特化或重載版本:
namespace WidgetStuff {
template
class WidgetImpl { ... };
template
class Widget { ... };
...
template
void swap(Widget& a, Widget& b) {
a.swap(b);
}
}
現在任何時候想置換兩個 Widget 物件而呼叫 swap,C++ 名稱查找法則都會找到 WidgetStuff 內的 Widget 專屬版本。此做法對 class 和 class template 都行得通。若想讓 class 專屬版 swap 在盡可能多語境下被呼叫,需同時在該 class 所在命名空間內寫 non-member 版本以及 std::swap 特化版本。
假設我們寫一個 function template:
template
void doSomething(T& obj1, T& obj2) {
using std::swap;
swap(obj1, obj2); // 為 T 型別物件呼叫最佳 swap 版本
}
C++ 名稱查找法則確保會找到全域作用域或 T 所在命名空間內的任何 T 專屬 swap。若 T 是 Widget 且位於 WidgetStuff 內,編譯器會用「實參依賴查找規則」找出 WidgetStuff 內的 swap。若無 T 專屬 swap,則使用 std 內的 swap。
若預設 swap 實作效率不足(幾乎意味著你的 class 或 template 使用了 pimpl 慣用法),可嘗試:
最後,若你呼叫 swap,請確保包含 using 宣告式。
模板為何要特化?因為編譯器認為,對特定型別若你能對某功能更好實現,就該聽你的。模板分類模板與函數模板,特化分全特化與偏特化。全特化限定死模板實現的具體型別,偏特化只限定其中一部分(當模板有多個型別參數時)。
類模板範例:
template
class Test {
public:
Test(T1 i, T2 j) : a(i), b(j) { cout << "模板類" << endl; }
private:
T1 a; T2 b;
};
// 全特化
template<>
class Test {
public:
Test(int i, char j) : a(i), b(j) { cout << "全特化" << endl; }
private:
int a; char b;
};
// 偏特化
template
class Test {
public:
Test(char i, T2 j) : a(i), b(j) { cout << "偏特化" << endl; }
private:
char a; T2 b;
};
依次呼叫類模板、全特化、偏特化:
Test t1(0.1, 0.2);
Test t2(1, 'A');
Test t3('A', true);
函數模板只有全特化,不能偏特化:
template
void fun(T1 a, T2 b) { cout << "模板函數" << endl; }
template<>
void fun(int a, char b) { cout << "全特化" << endl; }
// 以下偏特化編譯錯誤
// template
// void fun(char a, T2 b) { cout << "偏特化" << endl; }
函數不能偏特化,似乎不是語言實現不了,而是偏特化功能可透過函數重載完成。