所謂軟件設計,是“令軟件做出你希望它做的事情”的步驟和方法,通常以頗為一般性的構想開始,最終十足的細節,以允許特殊接口(interface)的開發。這些接口而後必須轉換為C++聲明式。本文討論對良好C++接口的設計和聲明。
C++擁有許多的接口,function接口,class接口,template接口….每一種接口實施客戶與你的代碼互動的手段。理想情況下,客戶總是會准確的使用你的接口並獲得理想的結果,而如果客戶錯誤的使用了接口,代碼就不應該通過編譯。
假設我們現在需要做一個表示時間的class
class Date {
public:
Date(int month, int day, int year);
...
};
乍看起來,這個類的構造函數並沒有什麼問題,但其實存在著很多的隱患。我們當然希望用戶可以准確的使用我們的類,但用戶卻有可能因為某些特定的原因無法正確使用我們的類,例如沒有按照月,天,年的順序來完成構造。而此時,為了避免用戶犯錯,我們需要強制用戶按照我們的設計來用這個類:
// special design
// 缺省情況下,struct內部都是public訪問限制。
struct Day {
explicit Day(int d) : val(d) { }
int val;
};
struct Month {
explicit Month(int m) : val(d) { }
int val;
};
struct Year {
explicit Year(int y) : val(d) { }
int val;
};
class Date {
public:
Date(const Month &m, const Day &d, const Year &y);
...
};
Date d1(30, 3, 1996); // error!
Date d2(Month(3), Day(30), Year(1996)); // right!
用struct來封裝數據,可以明智而審慎地導入新類型並預防“接口被誤用”。
一旦類型限定了,限定其值也是合情合理的了。例如一年只有12個月,所以Month應該反映這一點。辦法之一就是用enum表現月份,但enum不具備我們希望的類型安全性,例如enum可以被當做一個int使用。比較安全的做法是:預先定義所有有效的Month。
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));
以函數替換對象,表現某個特定的月份是一種相當不錯的方法。
除非有更好的理由,否則盡量讓你的type的行為與內置type一致!
用戶很清楚像int這樣的type有什麼行為,所以你應該努力讓你的type在合情合理的前提下也有相同的操作。例如,如果a和b都是int,那麼對a*b賦值就是不合法的。
避免無端與內置類型不兼容,真正的理由是為了==提供行為一致的接口==。很少有其他性質比”一致性“更能導致”接口被正確使用“,也很少有性質比得上”不一致性“更加劇接口的惡化。
新type的對象應該如何被創建和銷毀?這回應該到你如何設計class的構造函數和析構函數以及內存分配函數和釋放函數。 對象的初始化和對象的賦值有什麼樣的差別?這決定了你如何設計構造函數和賦值操作符。最重要的是別混淆“初始化”和“賦值”,因為他們對應不同的函數調用。 新type對象如果被passed by value,意味著什麼?記住,copy構造函數用來定義一個type的pass by value如何實現。 什麼是新type的“合法值”?這意味你的成員函數必須進行錯誤檢查工作,也影響了函數拋出的異常、以及函數異常明細列。 你的type需要配合某個繼承體系嗎?如果你繼承自某些既有的class,你就會受到這些class設計的束縛,特別是受到他們的函數是virtual或non-virtual的影響。如果你允許你的class被其他class繼承,那會影戲到你的析構函數是否會virtual。 你的新type需要什麼樣的轉換?因為你的type存在於其他大量的type之間,這決定了你是否需要讓自己type有途徑轉換為其他的type(隱式還是顯式的?) 什麼樣的操作符和函數對此新type而言是合理的?這取決於你的成員函數的設計。 什麼樣的標准函數應該駁回?那些就是你聲明為private的對象。 誰該取用新type的成員?這決定了如何安排函數是public,protected還是private,以及那些函數/類是friend。 什麼是新type的“未聲明接口”?他對效率、異常安全性以及資源運用提供何種保證? 你的新type有多麼一般化?如果你並不是為了定義一個新type而是要定義一整個type家族,那麼應該定義一個新的class template。 你是否真的需要一個新的type?如果你只是為了給base class添加某些功能,那麼定義一個或多個non-member 函數或template,更好。C++就像其他OOP語言一樣,當你定義一個新class,也就定義了一個新的type。包括,重載函數和操作符、控制內存的分配和歸還、定義對象的初始化和析構……全都在你控制,因而你應該帶著和“語言設計者當初設計語言內置類型時”一樣的謹慎來設計class。以下給出了部分class設計規范。
設計class是一件非常具有挑戰的事情,所以如果你希望設計一個class,最好像設計一個type一樣,把各種問題都思考一遍。
在缺省情況下C++總是以pass-by-value的方式傳遞對象至函數,實際上,就是傳遞復件,而這些復件都是由copy構造函數產生的,這可能使得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的函數,也就意味著一定會出現copy構造函數,對於此函數而言,參數的傳遞成本是“一次student copy構造函數調用,加上一次student析構函數調用”。不僅如此,student還繼承於person,所以還有一次person構造函數和person析構函數,以及student裡面的兩個string對象,和person裡面的兩個string對象,總而言之,總體成本就是“六次構造函數和六次析構函數!”多麼可怕的開銷!
解決這個問題非常的簡單。只要使用pass by reference to const就可以了。因為by reference不會導致構造函數和析構函數的使用,節省了大量開銷,同時因為是const,也保證了參數不會再函數內被更改。
bool checkStudent(const Student &s);
pass-by-value還會導致對象切割問題(slicing)。當一個dereived class對象以by value方式傳遞並被視為一個base class對象時,bass class的copy構造函數就會被調用,而“造成此對象的行為像個derived class對象”的那些特化性質全部被切割掉,只剩下base class對象。這並不奇怪。
class Window {
public:
...
std::string name() const;
virtual void display() const;
};
class SpecialWindow {
public:
..
virtual void display() const;
};
....
// in main:
void print(Window w) {
cout << w.name();
w.display();
}
當你把一個SpecialWindow對象傳遞給void print(Window w)函數時,就像前文所說的,會使得SpecialWindow的特化性質全部被切割掉,於是乎,你本想著輸出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。迭代器和函數對象的實踐者都有責任看看他們是否高效且不受切割問題。
有人認為,所有小型type對象都應該適用pass-by-value,甚至對於用戶定義的class。實際上是不准確的。第一,對象小,並不意味著他的copy構造函數開銷小;2)即使是小型對象並不擁有昂貴的copy構造函數,也可能存在效率上的問題,例如某些編譯器不願意把只由一個double組成的對象放進緩存器,但如果你使用reference,編譯器一定會把指針(就是reference的實現體)放進緩存器。3)作為用戶自定義類型,其大小是很容易被改變的。隨著不斷的使用,對象可能會越來越大。
一般而言,合理假設“pass-by-value更合適”的唯一對象就是內置類型和STL的迭代器和函數對象,其他的最好還是使用by reference。
前面我們討論了pass-by-reference可以提高效率,於是乎,有的人就開始堅定地使用reference,甚至開始傳遞一些refereence指向其實並不存在的對象。
此問題產生的理由非常的簡單,就是作者希望可以節省開銷提高效率。並因此而產生大量的錯誤。
class Rational {
public:
Rational(int num1 = 0, int num2 = 1);
...
private:
int n1, n2;
friend Rational& operator*(const Rational& lhs, const Rational& rhs);
operator*試圖返回一個引用,並為此尋找合乎邏輯的實現代碼。
Rational& operator*(const Rational& lhs, const Rational& rhs) {
Rational result(lhs.n1 * rhs.n1, lhs.n2 * rhs.n2);
return result;
}
問題顯然。因為result是一個on the stack對象,在作用域結束後,對象就被銷毀,於是返回了一個沒有指向的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 == 被調用前,已有兩個操作符被調用,每一個都返回操作函數內部的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, rhs.n2*rhs.n2);
}
理由一:語法一致性。在我們最初學習C++ OOP時就有一天准則,成員變量總是要聲明為private。本節我們來討論為何成員變量要被聲明為private。
class AccessLevel {
private:
int noAccess;
int ReadOnly;
int WriteOnly;
int readWrite;
public:
// ...
int getReadOnly() {
return ReadOnly;
}
void setWriteOnly(int i) {
WriteOnly = i;
}
void setreadWrite(int i) {
readWrite = i;
}
int readreadWrite() {
return readWrite;
}
};
如此精細地對各個數據成員進行訪問限制是有必要的。
理由三:封裝!我們繼續來討論protected的封裝性。
一般人會認為protected比public更具有封裝性。其實不然。更准確的判斷方法是:某些東西的封裝性與“當其內容改變時可能造成的代碼破壞量”成反比。所謂改變,也許是從class中移除他。於是乎,我們可以進行以下分析。對於public的成員變量,如果我們移除他,意味著我們要破壞所有使用它的客戶代碼。(破壞量很大吧?)而對於protected的成員變量呢,如果我們移除它,意味著要破壞所有derived class(破壞量也很大吧?)因此protected和public的封裝性其實是一樣的。這也就意味著,一旦我們決定把某個成員變量聲明為public或protected,就很難改變某個成員變量所涉及的一切。
結論就是,其實只有兩種訪問權限: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相關機能有更大的包裹彈性,從而最終導致較低的編譯相依度,增加WebBrowser的可衍生性。以下我們給出理由。
封裝性。愈多的東西被封裝,越少人可以按到它,那麼我們就有越大的彈性去改變它,而我們的改變只會影響看到改變的那些人和事物。這就是我們推崇封裝性的原因:它使我們能夠改變事物而只影響有限客戶。 考慮對象內數據。越少代碼可以看到數據,越多的數據可被封裝,而我們也就越能自動地改變對象數據。越多的函數可以訪問數據成員,數據的封裝性就越差!因此,因為non-member non-friend函數不能直接改變數據成員,因此他就可以最大限度的實現封裝。
在C++中,最自然的做法,是讓clearEverything稱為一個non-member函數並且位於WebBrowser所在的同一個namespace內:
namespace WebBrowserStuff {
class WebBrowser {...};
void clearEverything(WebBroswer &web);
...
}
namespace和class是不用的!前者可以跨越多個源碼文件而後者不能,這很重要!
像clearEverything這樣的函數就是便利函數,雖然沒有對WebBrowser有特殊的訪問權限,但可以極大的便利客戶。而實際上,我們會補充大量的類似的便利函數,並且他們可能分屬於不同的模塊,於是我們便采用把不同模塊便利函數寫於不同的頭文件中,但他們都隸屬於同一個命名空間:
#include "webbrowser.h" 提供class聲明本身,以及其中核心機能
namespace WebBrowserStuff {
class WebBroser { ... };
... // 核心機能,幾乎所有用戶都需要的non-member便利函數
}
// 頭文件 “webbrowserbookmarks.h"
// 與標簽相關
namespace WebBrowserStuff {
... // 與標簽相關的便利函數
}
// 頭文件 ”webbrowsercookies.h"
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); of course wrong!
第一個式子能夠成立,是因為實現了隱式類型轉換。編譯器知道你在傳遞一個int,而函數需要的是rational,但它也知道只要調用Rational構造函數並賦予你所提供的int,就可以變出一個適當的rational出來,於是就這麼做了。相當於:
const Rational temp(2);
result = oneHalf * temp;
當然這只涉及non-explicit構造函數,才能這麼做。如果是explicit構造函數,這個語句無法通過編譯。
result = oneHalf * 2; // ok!
result = 2 * oneHalf; // error!
只有當參數被列於參數列內,這個參數才是隱式類型轉換的合格參與者。地位相當於“被調用之成員函數所隸屬的那個對象”-即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有copying相關操作即可。然而這個算法對於有些情況卻顯得不那麼高效。例如,在處理“以指針指向一個對象,內含真正數據”的那種類型。(這種設計的常見形式是所謂“pimpl手法:pointer to implemention)
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<> // 表示他是std::swap的一個全特化
void swap(Widget &a, Widget &b) {
swap(a.pImpl, b.pImpl);
}
}
通常來說,我們是不能夠改變std命名空間內的任何東西,但可以(被允許)為標准template制造特化版本的。
但實際上這個是無法通過編譯的。因為他企圖調用class的私有成員。
所以更合理的做法,是令他調用成員函數。
class Widget {
public:
...
void swap(Widget& other) {
using std::swap;
swap(pImpl, other.pInmpl);
}
...
};
private:
WidgetImpl* pImpl;
};
namespace std {
template<>
void swap(Widget &a,
Widget &b) {
a.swap(b);
}
}
這個做法不僅能夠通過編譯,而且與STL容器有一致性。
假設Widget和WidgetImpl都是class template而非class,也許我們可以試試把WidgetImpl內的數據類型加以參數化:
template
class WidgetImpl {...};
template
class Widget {...};
// 在Widget裡面放入swap成員函數就像以往一樣簡單
// 但在寫特化std::swap時出現了問題
namespace std {
template
void swap< Widget > (Widget& a, Widget& b) {
a.swap(b);
}
}
以上特化swap其實有問題的。我們企圖偏特化這個function template,但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,但不在將那個non-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特化版本。
另外,如果沒有像上面那樣額外使用某個命名空間,上述每件事情仍然使用。但你又何必再global命名空間裡面塞這麼多東西呢?
目前提到得都是和swap編寫有關的。現在我們換位思考,從客戶觀點看看問題。假設我們需要寫一個function template:
template
void doSomething(T& obj1, T& obj2) {
...
swap(obj1, obj2);
...
}
此時swap是調用哪個版本呢?我們當然希望是調用T專屬版本,並且在該版本不存在的情況下,調用std內的一般化版本。
template
void doSomething(T& obj1, T& obj2) {
using std::swap;
...
swap(obj1, obj2); // 為T類型對象調用最佳swap版本。
...
}
C++名稱查找法則確保將找到global作用域或T所在命名空間內的任何T專屬的swap。如果T是Widget並位於命名空間WidgetStuff內,編譯器會使用”實參取決之查找規則“找出WidgetStuff內的swap。如果沒有T專屬之swap存在,編譯器就使用std內的swap。
以下是我設計的一個不大合乎邏輯的代碼,但證明了上述說法是合理的。
#include
using namespace std;
namespace test {
class trys {
public:
void swap(trys &one, trys &two) {
cout << "yes!" << endl;
}
};
void swap(trys &one, trys &two) {
cout << "yes!" << endl;
}
}
int main(int argc, const char * argv[]) {
// insert code here...
test::trys a;
int b = 12;
{
using std::swap;
swap(b, b);
swap(a, a);
}
return 0;
}
/*
yes!
Program ended with exit code: 0
*/
如果swap缺省實現版的效率不足,(那幾乎意味著你的class或template使用了某種pimpl手法),可以試著做以下事情:
提供一個public swap成員函數,讓他高效地置換你的類型的兩個對象值。 在你的class或template所在的命名空間內提供一個non-member swap,並命它調用上述swap成員函數。 如果你在編寫一個class,並為你的class特化std::swap,並令他調用你的swap成員函數。最後,如果你調用swap,請確保包含一個using聲明式。
模板為什麼要特化,因為編譯器認為,對於特定的類型,如果你能對某一功能更好的實現,那麼就該聽你的。
模板分為類模板與函數模板,特化分為全特化與偏特化。全特化就是限定死模板實現的具體類型,偏特化就是如果這個模板有多個類型,那麼只限定其中的一部分。
先看類模板:
template
class Test
{
public:
Test(T1 i,T2 j):a(i),b(j){cout<<"模板類"<
class Test
{
public:
Test(int i, char j):a(i),b(j){cout<<"全特化"<
class Test
{
public:
Test(char i, T2 j):a(i),b(j){cout<<"偏特化"<
那麼下面3句依次調用類模板、全特化與偏特化:
Test t1(0.1,0.2);
Test t2(1,'A');
Test t3('A',true);
而對於函數模板,卻只有全特化,不能偏特化:
//模板函數
template
void fun(T1 a , T2 b)
{
cout<<"模板函數"<
void fun(int a, char b)
{
cout<<"全特化"<
void fun(char a, T2 b)
{
cout<<"偏特化"<
至於為什麼函數不能偏特化,似乎不是因為語言實現不了,而是因為偏特化的功能可以通過函數的重載完成。