天天看點

Effective C++ 2e Item41

條款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。

講完了堆棧和貓,下面将本條款得到的結論總結如下:

· 當對象的類型不影響類中函數的行為時,就要使用模闆來生成這樣一組類。

· 當對象的類型影響類中函數的行為時,就要使用繼承來得到這樣一組類。

真正消化了以上兩點的含義,你就可以在設計中遊刃于繼承或模闆之間。