條款41: 區分繼承和模闆
考慮下面兩個設計問題:
· 作為一位立志獻身計算機科學的學生,你想設計一個類來表示對象的堆棧。這将需要多個不同的類,因為每個堆棧中的元素必須是同類的,即,它裡面包含的必須隻是同種類型的對象。例如,會有一個類來表示int的堆棧,第二個類來表示string的堆棧,第三個類來表示string的堆棧的堆棧,等等。你也許對設計一個最小的類接口(參見條款18)很感興趣,是以會将對堆棧的操作限制在:建立堆棧,銷毀堆棧,将對象壓入堆棧,将對象彈出堆棧,以及檢查堆棧是否為空。設計中,你不會借助标準庫中的類(包括stack ---- 參見條款49),因為你渴望親手寫這些代碼。重用(Reuse)是一件美事,但當你的目标是探究事情的工作原理時,那就隻有挖地三尺了。
· 作為一位愛貓的寵物迷,你想設計一個類來表示貓。這也将需要多個不同的類,因為每個品種的貓都會有點不同。和所有對象一樣,貓可以被建立和銷毀,但,正如所有貓迷所知道的,貓所做的其它事不外乎吃和睡。然而,每一種貓吃和睡都有各自惹人喜愛的方式。
這兩個問題的說明聽起來很相似,但卻導緻完全不同的兩種設計。為什麼?
答案涉及到"類的行為" 和 "類所操作的對象的類型"之間的關系。對于堆棧和貓來說,要處理的都是各種不同的類型(堆棧包含類型為T的對象,貓則為品種T),但你必須問自己這樣一個問題:類型T影響類的行為嗎?如果T不影響行為,你可以使用模闆。如果T影響行為,你就需要虛函數,進而要使用繼承。
下面的代碼通過定義一個連結清單來實作Stack類,假設堆棧的對象類型為T:
class Stack {
public:
Stack();
~Stack();
void push(const T& object);
T pop();
bool empty() const; // 堆棧為空?
private:
struct StackNode { // 連結清單節點
T data; // 此節點資料
StackNode *next; // 連結清單中下一節點
// StackNode構造函數,初始化兩個域
StackNode(const T& newData, StackNode *nextNode)
: data(newData), next(nextNode) {}
};
StackNode *top; // 堆棧頂部
Stack(const Stack& rhs); // 防止拷貝和
Stack& operator=(const Stack& rhs); // 指派(見條款27)
};
于是,Stack對象将構造如下所示的資料結構:
Stack對象 top--> data+next--> data+next--> data+next--> data+next
------------------------------------------------------------------------------------
StackNode對象
連結清單本身是由StackNode對象構成的,但那隻是Stack類的一個實作細節,是以StackNode被聲明為Stack的私有類型。注意StackNode有一個構造函數,用來確定它所有的域都被正确初始化。即使你閉着眼睛都可以寫出一個連結清單,但也不要忽視了C++的一些新特性,如struct中的構造函數。
下面看看你對Stack成員函數的實作。和許多原型(prototype)的實作(離制作成軟體産品相差太遠)一樣,這裡沒有錯誤檢查,因為在原型世界裡,沒有東西會出錯。
Stack::Stack(): top(0) {} // 頂部初始化為null
void Stack::push(const T& object)
{
top = new StackNode(object, top); // 新節點放在
} // 連結清單頭部
T Stack::pop()
{
StackNode *topOfStack = top; // 記住頭節點
top = top->next;
T data = topOfStack->data; // 記住節點資料
delete topOfStack;
return data;
}
Stack::~Stack() // 删除堆棧中所有對象
{
while (top) {
StackNode *toDie = top; // 得到頭節點指針
top = top->next; // 移向下一節點
delete toDie; // 删除前面的頭節點
}
}
bool Stack::empty() const
{ return top == 0; }
這些代碼毫無吸引人之處。實際上,唯一有趣的一點在于:即使對T一無所知,你還是能夠寫出每個成員函數。(上面的代碼中實際上有個假設,即,假設可以調用T的拷貝構造函數;但正如條款45所說明的,這是一個絕對合理的假設)不管T是什麼,對構造,銷毀,壓棧,出棧,确定棧是否為空等操作所寫的代碼不會變。除了 "可以調用T的拷貝構造函數" 這一假設外,stack的行為在任何地方都不依賴于T。這就是模闆類的特點:行為不依賴于類型。
将stack類轉化成一個模闆就很簡單了,即使是Dilbert的老闆都會寫:
template<class T> class Stack {
... // 完全和上面相同
};
但是,貓呢?為什麼貓不适合模闆?
重讀上面的說明,注意這一條:"每一種貓吃和睡都有各自惹人喜愛的方式"。這意味着必須為每種不同的貓實作不同的行為。不可能寫一個函數來處理所有的貓,所能做的隻能是制定一個函數接口,所有種類的貓都必須實作它。啊哈!衍生一個函數接口的方法隻能是去聲明一個純虛函數(參見條款36):
class Cat {
public:
virtual ~Cat(); // 參見條款14
virtual void eat() = 0; // 所有的貓吃食
virtual void sleep() = 0; // 所有的貓睡覺
};
Cat的子類 ---- 比如,Siamese和BritishShortHairedTabby ---- 當然得重新定義繼承而來的eat和sleep函數接口:
class Siamese: public Cat {
public:
void eat();
void sleep();
...
};
class BritishShortHairedTabby: public Cat {
public:
void eat();
void sleep();
...
};
好了,現在知道了為什麼模闆适合Stack類而不适合Cat類,也知道了為什麼繼承适合Cat類。唯一剩下的問題是,為什麼繼承不适合Stack類。想知道為什麼,不妨試着去聲明一個Stack層次結構的根類 ---- 所有其它的堆棧類都從這個唯一的類繼承:
class Stack { // a stack of anything
public:
virtual void push(const ??? object) = 0;
virtual ??? pop() = 0;
...
};
現在問題很明顯了。該為純虛函數push和pop聲明什麼類型呢?記住,每一個子類必須重新聲明繼承而來的虛函數,而且參數類型和傳回類型都要和基類的聲明完全相同。不幸的是,一個int堆棧隻能壓入和彈出int對象,而一個Cat堆棧隻能壓入和彈出Cat對象。Stack類要怎樣聲明它的純虛函數才能使使用者既可以建立出int堆棧又可以建立出Cat堆棧呢?冷酷而嚴峻的事實是,做不到。這就是為什麼說繼承不适合建立堆棧。
但也許你做事喜歡偷偷摸摸。或許你認為自己可以通過使用通用(void*)指針來騙過編譯器。但事實證明,現在這種情況下,通用指針也幫不上忙。因為你無法避開這一條件:派生類虛函數的聲明永遠不能和它在基類中的聲明相抵觸。但是,通用指針可以幫助解決另外一個不同的問題,它和模闆所生成的類的效率有關。詳細介紹參見條款42。
講完了堆棧和貓,下面将本條款得到的結論總結如下:
· 當對象的類型不影響類中函數的行為時,就要使用模闆來生成這樣一組類。
· 當對象的類型影響類中函數的行為時,就要使用繼承來得到這樣一組類。
真正消化了以上兩點的含義,你就可以在設計中遊刃于繼承或模闆之間。