《Effective C++》第三版-6. 繼承與物件導向設計(Inheritance and Object-Oriented Design)

Roanapur發表於2024-06-17

目錄
  • 條款32:確定你的public繼承塑模出is-a關係(Make sure public inheritance models “is-a”)
    • public繼承的含義
    • 設計良好的繼承關係
    • is-a的例外
  • 條款33:避免遮掩繼承而來的名稱(Avoid hiding inherited names)
    • 作用域的隱藏
    • 繼承的隱藏
    • 繼承過載的函式
  • 條款34:區分介面繼承和實現繼承(Differentiate between inheritance of interface and inheritance of implementation)
    • 純虛擬函式
    • 虛擬函式
    • 普通函式
  • 條款35:考慮virtual函式以外的其他選擇(Consider alternatives to virtual functions)
    • 藉由Non-Virtual Interface手法實現Template Method模式
    • 藉由Function Pointers實現Strategy模式
    • 藉由Function Pointers實現Strategy模式
    • 古典的Strategy模式
    • 摘要
  • 條款36:絕不重新定義繼承而來的non-virtual函式(Never redefine an inherited non-virtual function)
  • 條款37:絕不重新定義繼承而來的預設引數值(Never redefine a function’s inherited default parameter value)
    • 虛擬函式和預設引數值
    • 避免重複的預設引數值
  • 條款38:通核覆合塑模出has-a或“根據某物實現出”(Model “has-a” or “is-implemented-in-terms-of” through composition)
    • 複合的含義
    • 區分不同的關係
  • 條款39:明智而審慎地使用private繼承(Use private inheritance judiciously)
    • private繼承
    • private繼承和複合
    • 激進的情況
  • 條款40:明智而審慎地使用多重繼承(Use multiple inheritance judiciously)
    • 介面呼叫的歧義性
    • 菱形繼承與虛(virtual)繼承
    • 多重繼承舉例

條款32:確定你的public繼承塑模出is-a關係(Make sure public inheritance models “is-a”)

本條款內容比較簡單,略寫

public繼承的含義

public繼承意味著“is-a”的關係

如果一個類D(derived)public繼承自類B(base):

  • 每個型別D的物件也是一個型別B的物件,反之則不然
  • B比D表示了一個更為一般的概念,而D比B表現了一個更為特殊的概念
  • 任何可以使用型別B的地方,也能使用型別D;但可以使用型別D的地方卻不可以使用型別B
  • D是B,B不是D
class Person { ... };
class Student: public Person { ... };

void eat(const Person& p);  //任何人都可以吃
void study(const Student& s);  //只有學生才到校學習
Person p;  //p是人
Student s;  //s是學生
eat(p);  //沒問題,p是人
eat(s);  //沒問題,s是學生,學生也是人
study(s);        
study(p);  //錯誤!p不是個學生

設計良好的繼承關係

考慮鳥和企鵝的關係:

class Bird {
public:
	virtual void fly();  //鳥可以飛    
	...
};
class Penguin: public Bird {  //企鵝是一種鳥,但不會飛,故直接繼承有問題
	...
};

編譯時會報錯的設計:

class Bird {
	...  //未宣告fly函式
}; 

class FlyingBird: public Bird {
public:
    virtual void fly();  //會飛的鳥類
};

class Penguin :public Bird {
	...  //企鵝不會飛,未宣告fly函式
};

Penguin p;
p.fly();  //錯誤!

執行時會報錯的設計:

class Bird {
public:
    virtual void fly();
};
 
void error(const std::string& msg);
class Penguin :public Bird {
public:
    virtual void fly() {
        error("Attempt to make a penguin fly!");
    }
};

應優先選擇在編譯期間會報錯的設計,而非在執行期間才報錯的設計

is-a的例外

考慮矩形和正方形的關係:

classDiagram graph LR note "繼承關係合理嗎?" Rectangle<|-- Square class Rectangle{ -int Height -int Width }
class Rectangle {
public:
    virtual void setHeight(int newHeight);  //高
    virtual void setWidth(int newWidth);  //寬 
    virtual void height()const;  //返回高
    virtual void width()const;  //返回寬
    ...
};

void makeBigger(Rectangle& r) //這個函式用來增加r的面積
{
    int oldHeight = r.height();  //取得舊高度
    r.setWidth(r.width() + 10);  //設定新寬度
    assert(r.height() == oldHeight);  //永遠為真,因為高度未改變
}

class Square :public Rectangle { ... };
Square s;  //正方形類
...
assert(s.width() == s.height());  //永遠為真,因為正方形的寬和高相同
makeBigger(s);  //由於繼承,可以增加正方形的面積
assert(s.width() == s.height());  //對所有正方形來說,理應還是為真

上述程式碼會遇到問題:

  1. 在呼叫makeBigger之前,s的高度和寬度是相同
  2. 在makeBigger裡面,s的寬度被改變了,但是高度不變
  3. 再次呼叫assert理應還是返回真,因為此處的s為正方形

上例說明:

  • 作用於基類的程式碼,使用派生類也可以執行。
  • 但某些施行於矩形類中的程式碼,在正方形中卻不可以實施
  • is-a並非是唯一存在於classes之間的關係。
    • 另兩個常見的關係是has-a(有一個)和is-implemented-terms-of(根據某物實現出)
    • 把這些相互關係的塑造為is-a會造成錯誤設計

Tips:

  • public繼承意味著is-a,適用於基類每一件事情一定也適用於派生類,因為每一個派生類物件也都是一個基類物件

條款33:避免遮掩繼承而來的名稱(Avoid hiding inherited names)

作用域的隱藏

編譯器會先在區域性作用域內查詢名稱,沒查到再找其他作用域

int x;  //全域性變數
 
void someFunc()
{
    double x;  //區域性變數
    std::cin >> x;  //區域性變數賦值
}

當全域性和區域性存在同名變數時,在區域性作用域中,優先使用區域性變數,全域性變數會被隱藏

flowchart LR subgraph Global scope x subgraph SomeFunc's scope b[x] end end

C++的名稱遮掩規則(name-hiding rules)只遮掩名稱,無論名稱否是同一型別。

繼承的隱藏

在繼承中唯一關心的是成員變數和成員函式的名稱,其型別沒有影響:

class Base
{
private:
    int x;
public:
    virtual void mf1() = 0;
    virtual void mf2();
    void mf3();
    ...
};
 
class Derived :public Base
{
public:
    virtual void mf1();  //重寫(覆蓋)
    void mf4();
    ...
};

//假設派生類中的mf4定義如下
void Derived::mf4()
void Derived::mf4()
{
	...
	mf2();
	...
}
flowchart LR subgraph 基類的作用域 a[x(成員變數)\nmf1(1個函式)\nmf2(1個函式)\nmf3(1個函式)] subgraph 派生類的作用域 e[mf1(1個函式)\nmf4(1個函式)] end end

在派生類的fm4()函式中呼叫了fm2函式,對於fm2函式的查詢順序如下:

  1. 在fm4函式中查詢,若沒有進行下一步
  2. 在派生類類中查詢,若沒有進行下一步
  3. 在基類Base中查詢,若沒有進行下一步
  4. 在Base所在的namespace中查詢,若沒有進行下一步
  5. 在全域性作用域查詢

在派生類中過載mf3:

class Base
{
private:
    int x;
public:
    virtual void mf1() = 0;
    virtual void mf1(int);
    virtual void mf2();
    void mf3();
    void mf3(double);
    ...
};
 
class Derived :public Base
{
public:
    virtual void mf1();  //基類中的所有mf1()都被隱藏
    void mf3();  //基類中的所有fm3()都被隱藏
    void mf4();
    ...
};

//呼叫如下
Derived d;
int x;
... 
d.mf1();  //正確
d.mf1(x);  //錯誤!被隱藏了
d.mf2();  //正確
d.mf3();  //正確
d.mf3(x);  //錯誤!被隱藏了
flowchart LR subgraph 基類的作用域 a[x(成員變數)\nmf1(2個函式)\nmf2(1個函式)\nmf3(2個函式)] subgraph 派生類的作用域 e[mf1(1個函式)\nmf3(1個函式)\nmf4(1個函式)] end end

繼承過載的函式

  • 透過using宣告式
class Base
{
private:
    int x;
public:
    virtual void mf1() = 0;
    virtual void mf1(int);
    virtual void mf2();
    void mf3();
    void mf3(double);
    ...
};
 
class Derived :public Base
{
public:
    using Base::mf1;  //Base所有版本的mf1函式在派生類作用域都可見
    using Base::mf3;  //Base所有版本的mf3函式在派生類作用域都可見
    virtual void mf1();  //重寫mf1()函式
    void mf3();  //隱藏了mf1(),但是mf3(double)沒有隱藏
    void mf4();
    ...
};

//呼叫如下
Derived d;
int x; 
d.mf1();  //正確,呼叫Derived::mf1
d.mf1(x);  //正確,呼叫Base::mf1
d.mf2();  //正確,呼叫Derived::mf2
d.mf3();  //正確,呼叫Derived::mf3
d.mf3(x);  //正確,呼叫Base::mf3
  • 使用轉交函式(forwarding function)
class Base {
public:
	virtual void mf1() = 0;
	virtual void mf1(int);
	...
};
class Derived: private Base {
public:
	virtual void mf1()  //轉交函式
	{ Base::mf1(); }  //暗自成為inline
	...
}; 
...
//呼叫如下
Derived d;
int x;
d.mf1();  //正確,呼叫Derived::mf1
d.mf1(x);  //錯誤! Base::mf1()被覆蓋了

Tips:

  • 派生類內的名稱會覆蓋基類內的名稱,在public繼承中不應如此
  • 可使用using宣告式或轉交函式來繼承被覆蓋的名稱

條款34:區分介面繼承和實現繼承(Differentiate between inheritance of interface and inheritance of implementation)

public繼承由兩部分組成:函式介面(function interface)繼承和函式實現(function implementation)繼承

對於基類的成員函式可以大致做下面三種方式的處理:

  • 純虛擬函式:只繼承成員函式的介面(也就是宣告),讓派生類去實現
  • 虛擬函式:同時繼承函式的介面和實現,也能覆寫(override)所繼承的實現
  • 普通函式:同時繼承函式的介面和實現,且不允許覆寫

以表示幾何圖形的類為例:

class Shape {
public:
	virtual void draw() const = 0;
	virtual void error(const std::string& msg);
	int objectID() const;
	...
};
class Rectangle: public Shape { ... };
class Ellipse: public Shape { ... };

成員函式的介面總是會被繼承

  • public繼承Shape類意味著對其合法的事一定對派生類合法

純虛擬函式

純虛擬函式具有兩個突出特性:

  • 繼承它們的具象類必須重新宣告
  • 在抽象類中無定義

宣告一個純虛擬函式的目的是為了讓派生類只繼承函式介面

  • Shape類需要能夠畫出,~但不同形狀的畫法不同,故只留出通用介面而不提供具體實現
Shape *ps = new Shape;  //錯誤!Shape是抽象的
Shape *ps1 = new Rectangle;  // 沒問題
ps1->draw();  // 呼叫Rectangle::draw
Shape *ps2 = new Ellipse;  // 沒問題
ps2->draw();  // 呼叫Ellipse::draw
ps1->Shape::draw();  // 呼叫Shape::draw
ps2->Shape::draw();  // 呼叫Shape::draw

虛擬函式

宣告簡樸的impure virtual 函式的目的,是讓派生類繼承該函式的介面和預設實現

  • Shape類繼承體系中必須支援遇到錯誤可呼叫的函式,但處理錯誤的方式可由派生類定義,也可直接使用Shape類提供的預設版本

若派生類忘記覆寫這類函式,則會直接呼叫基類的實現版本,這可能會導致嚴重問題,解決方案如下:

  • 將預設實現分離成單獨函式
    • 可能因過度雷同的函式名稱而汙染類名稱空間
  • 利用純虛擬函式提供預設實現
//將預設實現分離成單獨函式
class Airplane {
public:
    virtual void fly(const Airport& destination) = 0;
    ...
protected:
    void defaultFly(const Airport& destination);
};
void Airplane::defaultFly(const Airport& destination) {
	//飛機飛往指定的目的地(預設行為)
}

class ModelA :public Airplane {
public:
    virtual void fly(const Airport& destination) {
        defaultFly(destination);
    }
    ...
};
//ModelB同ModelA

class ModelC :public Airplane {
public:
    virtual void fly(const Airport& destination);
};

void ModelC::fly(const Airport& destination) {
	//將C型飛機飛至指定的目的地
}
//利用純虛擬函式提供預設實現
class Airplane {
public:
    virtual void fly(const Airport& destination) = 0;
    ...
};
void Airplane::fly(const Airport& destination) {
	//預設(預設)行為,將飛機飛至指定的目的地
}

class ModelA :public Airplane {
public:
    virtual void fly(const Airport& destination) {
        Airplane::fly(destination);
    }
    ...
};
//ModelB同ModelA
 
class ModelC :public Airplane {
public:
    virtual void fly(const Airport& destination);
};
void ModelC::fly(const Airport& destination) {
	//將C型飛機飛到指定目的地
}

普通函式

宣告non-virtual函式的目的是令派生類繼承函式的介面以及一份強制性實現

  • 意味是它並不打算在派生類中有不同的行為
  • 其不變形(invariant)凌駕特異性(specialization)

Tips:

  • 介面繼承和實現繼承不同。在public繼承下,派生類總是繼承基類的介面
  • 純虛擬函式只具體指定介面繼承
  • 簡樸的虛擬函式具體指定介面繼承以及預設實現繼承
  • non-virtual函式具體指定介面繼承以及強制性實現繼承.

條款35:考慮virtual函式以外的其他選擇(Consider alternatives to virtual functions)

以遊戲角色為例:

class GameCharacter {
public:
    virtual int healthValue() const;  //返回人物的健康指數,派生類可覆寫
    ...
};

藉由Non-Virtual Interface手法實現Template Method模式

考慮所有虛擬函式應幾乎總是private的主張:

class GameCharacter {
public:
    int healthValue() const {   //派生類不應該重新定義它
        ...  //事前工作,詳下              
        int retVal = doHealthValue();  //真正的工作
        ...  //事後工作,詳下                
        return retVal;
    }
private:
    virtual int doHealthValue() const  //派生類可以重新定義它
    {
    	...  //預設,計算健康指數
    }
};

NVI(non-virtual interface)手法

  • 透過public non-virtual成員函式間接呼叫private virtual函式
  • Template Method設計模式(和C++ templates無關)的獨特表現形式
  • non-virtual函式稱為virtual函式的外覆器(wrapper)
    • 外覆器使得在non-virtual函式中能做準備和善後工作:
      • 事前工作:鎖定互斥器、製造運轉日誌記錄項(log entry)、驗證類約束條件、驗證函式先決條件等等
      • 事後工作:互斥器解鎖、驗證函式的事後條件、再次驗證類約束條件等等
  • 會在派生類中重新定義private虛擬函式——重新定義它們不呼叫的函式
    • 派生類重新定義虛擬函式可控制如何實現功能
    • 基類保留函式何時被呼叫的權利
  • 沒有規定虛擬函式必須是private
    • 某些類繼承體系要求派生類在虛擬函式的實現中必須呼叫其基類的對應部分,則虛擬函式必須是protected而非private,否則此類呼叫不合法
    • 有時虛擬函式必須是public的(如多型基類中的解構函式),但是此時沒法使用NVI手法

藉由Function Pointers實現Strategy模式

NVI沒有免去定義虛擬函式

考慮每個人物的建構函式接受一個指向健康計算函式的指標:

class GameCharacter;	// 前置宣告*forward declaration)
int defaultHealthCala(const GameCharacter& gc);  //計算健康指數的預設演算法
class GameCharacter {
public:
  typedef int(*HealthCalcFunc)(const GameCharacter& gc);  //函式指標別名
  explicit GameCharacter(HealthCalaFunc hcf = defaultHealthCalc) 
    : healthFunc(hcf) 
  {}    
  int healthValue() 
  { return healthFunc(*this); }  //透過函式指標呼叫函式
  ...
private:
	HealthCalcFunc healthFunc;  //函式指標
};

這個做法是常見的Strategy設計模式的簡單應用,其相對於繼承體系內的虛擬函式具有一些彈性:

  • 同一個人物型別之間可以有不同的健康計算函式
  • 某已知人物的健康函式可在執行期變更
//同一個人物型別之間可以有不同的健康計算函式
class EvilBadGuy: public GameCharacter {
public: 
	explicit EvilBadGuy(HealthCalaFunc hcf = defaultHealthCalc)
	  : GameCharacter(hcf) 
  { ... }
	...
};
 
int loseHealthQuickly(const GameCharacter&);  //健康指數計算函式1
int loseHealthSlowly(const GameCharacter&);  //健康指數計算函式2

EvilBadGuy ebg1(loseHealthQuickly);  //相同型別的人物搭配
EvilBadGuy ebg2(loseHealthSlowly);  //不同的健康計算方式

上述方法的應用範圍:

  • 當全域性函式可以根據類的public介面來取得資訊並且加以計算,則沒有問題
  • 若計算需要訪問到類的non-public資訊,則全域性函式不可使用
    • 唯一的解決方法:弱化類的封裝。例如:
      • 將這個全域性函式定義為類的friend
      • 為其某一部分提供public訪問函式

藉由Function Pointers實現Strategy模式

若使用tr1::funciton物件替換函式指標用,則約束會大大減少:

這些物件可以持有任何可呼叫實體(callable entity,即函式指標、函式物件、成員函式指標),只要其簽名式和需求相容

class GameCharacter;  //如前
int defaultHealthCala(const GameCharacter& gc);  //如前
class GameCharacter {
public:
    //只是將函式指標改為了function模板,其接受一個const GameCharacter&引數,並返回int
    typedef std::tr1::function<int(const GameCharacter&)> HealthCalcFunc;	
    explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc) 
	    : healthFunc(hcf) 
    {}
    int healthValue() const
    { return healthFunc(*this); }
    ...
private:
    HealthCalcFunc healthFunc;
};

上述程式碼中HealthCalFunc具有如下性質:

  • 是對一個例項化的tr1::function的typedef
    • 其行為像一個泛化函式指標型別
  • tr1::function例項的目標籤名(target signature)表明:
    • 函式接受一個指向const GameCharacter的引用
    • 返回一個int型別
  • 該tr1::function型別的物件可以持有任何同這個目標籤名相相容的可呼叫物,相容的含義為:
    • 可呼叫物的引數要麼是const GameCharacter&,要麼可隱式轉換成這個型別
    • 其返回值要麼是int,要麼可以隱式轉換成int

上述設計和上一個GameCharacter持有一個函式指標的設計基本相同,唯一的不同是GameCharacter現在持有一個tr1::function物件——一個指向函式的泛化指標。從而使客戶現在在指定健康計算函式上有了更大的彈性:

short calcHealth(const GameCharacter&);  //計算健康指數的函式

struct HealthCalculator {  //計算健康指數的函式物件
    int operator()(const GameCharacter&)const {}
};
class GameLevel {
public:
    float health(const GameCharacter&) const;  //成員函式,用以計算健康
    ...
};

//人物1,使用函式來計算健康指數
EvilBadGuy ebg1(calcHealth);

//人物2,使用函式物件來計算健康指數
EyeCandyCharacter ecc1(HealthCalculator());

//人物2,使用GameLevel類的成員函式來計算健康指數
GameLevel currentLevel;
...
EvilBadGuy ebg2(std::tr1::bind(&GameLevel::health, currentLevel, _1));

古典的Strategy模式

古典的Strategy做法會將計算健康的函式設計為一個分離的繼承體系中的virtual成員函式:

--- title: 分離繼承體系中virtual成員函式 --- classDiagram GameCharacter <|-- EyeCandyCharacter GameCharacter <|-- EvilBadGuy HealthCalcFunc <|-- SlowHealthLoser HealthCalcFunc <|-- FastHealthLoser GameCharacter *-- HealthCalcFunc

程式碼如下:

該方案可讓熟悉Strategy模式的人容易辨認,且可納入既有的健康演算法

class GameCharacter;  //前置宣告
class HealthCalcFunc {  //計算健康指數的類
public:
    virtual int calc(const GameCharacter& gc)const {}
};
 
HealthCalcFunc defaultHealthCalc;
 
class GameCharacter {
public:
		//具有彈性,它為現存的健康計算演算法的調整提供了可能性
    explicit GameCharacter(HealthCalcFunc* hcf = &defaultHealthCalc)  
	    : pHealthCalc(hcf) 
	  {}
    int healthValue() const 
    { return pHealthCalc->calc(*this); }
private:
    HealthCalcFunc* pHealthCalc;
};

摘要

虛擬函式的替換方案包括:

  • 使用non-virtual interface(NVI)手法
    • 是Template Method設計模式的一種特殊形式
    • 以public non-virtual成員函式包裹較低訪問性(private或protected)的virtual函式
  • 將virtual函式替換為函式指標成員變數
    • 是Strategy設計模式的一種分解表現形式。
  • 以tr1::function成員變數替換virtual函式
    • 允許使用任何可呼叫物搭配一個相容於需求的簽名式
    • 是Strategy設計模式的某種形式
  • 將繼承體系內的virtual函式替換為另一個繼承體系內的virtual函式
    • 是Strategy設計模式的傳統實現手法

Tips:

  • 虛擬函式的替代方案包括NVI手法及Strategy設計模式的多種形式,NVI手法自身是一個特殊形式的Template Method設計模式
  • 將功能從成員函式移到類外部函式會使得非成員函式無法訪問類的non-public成員
  • tr1::function物件的行為就像一般函式指標,其可接納與給定的目標籤名式(target signature)相容的所有可呼叫物(callable entities)

條款36:絕不重新定義繼承而來的non-virtual函式(Never redefine an inherited non-virtual function)

考慮D類由B類以public形式派生,且B類定義有一個public成員函式:

class B {
public:
	void mf();
	...
};
class D: public B { ... }

以下兩種行為存在不同:

D x;  //x是一個型別為D的物件
B *pB = &x;                               
pB->mf();  //透過B*呼叫mf                      
D *pD = &x;                              
pD->mf();  //透過D*呼叫mf    

更具體地說,若mf是non-virtual並且D定義了自己的mf版本,上面的行為會明顯不一樣:

class D: public B {
public:
	void mf();         
	...                     
};
pB->mf();  //呼叫B::mf
pD->mf();  //呼叫D::mf

造成差異的原因:

  • non-virtual函式都是靜態繫結的(statically bound)
    • pB被宣告為指向B的指標,透過pB觸發的non-virtual函式都會是定義在類B上的函式,即使pB指向的是B的派生類物件
  • virtual函式是動態繫結的(dynamically bound)
    • 若mf是一個虛擬函式,無論透過pB或者pD對mf進行呼叫都會觸發D::mf,因為pB或者pD真正指向的是型別D的物件。
  • 引用也會有相同的問題

理論層面的理由:

  • 條款32解釋了pulibc繼承意味著is-a(是一種)的關係
    • 適用於B物件的每一件事都適用於D物件,因為每一個D物件都是一個B物件
      • 若D覆寫了mf,則is-a關係不成了,那麼D不應該以public形式繼承B
  • 條款34描述了為什麼在一個類中宣告一個non-virtual函式,則其不變性(invariant)凌駕於特異性(specialization)
    • B的派生類一定會繼承mf的介面和實現,因為mf是B的non-virtual函式
      • 若D需要以public繼承B且覆寫了mf,則不變性就沒有凌駕於特異性,那麼mf應該宣告為虛擬函式(如解構函式)
  • 綜上所述,禁止重新定義一個繼承而來的non-virtual函式

Tips:

  • 絕對不要重新定義繼承而來的non-virtual函式

條款37:絕不重新定義繼承而來的預設引數值(Never redefine a function’s inherited default parameter value)

虛擬函式和預設引數值

虛擬函式是動態繫結,而預設引數值是靜態繫結的

  • 靜態繫結,又名前期繫結(early binding)
  • 動態繫結,又名後期繫結(late binding)
//一個用以描述幾何形狀的類
class Shape {
public:
	enum ShapeColor { Red, Green, Blue };
	//所有形狀都必須提供一個函式,用來繪出自己
	virtual void draw(ShapeColor color = Red) const = 0;
	...
};

class Rectangle: public Shape {
public:
	//賦予不同的預設引數值,不好!
	virtual void draw(ShapeColor color = Green) const;
	...
};
class Circle: public Shape {
public:
	virtual void draw(ShapeColor color) const;
	//注意,以上這麼寫則當客戶以物件呼叫此函式,一定要指定引數值
	//因為靜態繫結下這個函式並不從其base繼承預設引數值
	//但若以指標(或引用)呼叫此函式,可以不指定引數值
	//因為動態繫結下這個函式會從其基類繼承預設引數值
	...
};
--- title: 類繼承圖 --- classDiagram Shape <|-- Rectangle Shape <|-- Circle

考慮以下指標:

以下指標均宣告為指向Shape的指標,故無論它們指向什麼,靜態型別都為Shape*;動態型別則是當前所指的物件的型別

Shape *ps;  //靜態型別為Shape*,無動態型別,未指向任何物件                         
Shape *pc = new Circle;  //靜態型別為Shape*,動態型別為Circle*             
Shape *pr = new Rectangle;  //靜態型別為Shape*,動態型別為Rectangle* 
ps = pc;  //ps的動態型別變為Circle*
ps = pr;  //ps的動態型別變為Rectangle*  
pc->draw(Shape::Red);  //呼叫Circle::draw(Shape::Red)   
pr->draw(Shape::Red);  //呼叫Rectangle::draw(Shape::Red)        
pr->draw();  //呼叫Rectangle::draw(Shape::Red) !
  • 虛擬函式是動態繫結的,故哪個函式被呼叫是由發出呼叫的物件的動態型別來決定的
  • 慮函式帶預設引數值的虛擬函式時,預設引數是靜態繫結的,則可能函式定義在派生類中卻使用了基類中的預設引數

採用上述執行方式的原因和效率相關:

  • 如果一個預設引數是動態繫結的,編譯器必須在執行時為虛擬函式決定適當的引數預設值
    • 這比起在編譯期決定這些引數的機制更慢且更復雜

避免重複的預設引數值

同時提供預設引數值給基類和派生類會導致程式碼重複且具有相依性(with dependencies):

class Shape {
public:
	enum ShapeColor { Red, Green, Blue };
 	virtual void draw(ShapeColor color = Red) const = 0;
	...
};

class Rectangle: public Shape {
public:
	virtual void draw(ShapeColor color = Red) const;
	...
};

可使用NVI手法解決上述問題:

  • 用基類中的public非虛擬函式呼叫一個private虛擬函式,private虛擬函式可以在派生類中重新被定義
  • 用非虛擬函式指定預設引數,而用虛擬函式來做實際的工作
class Shape {
public:
	enum ShapeColor { Red, Green, Blue };
	void draw(ShapeColor color = Red) const   
	{                                                                 
		doDraw(color);                           
	}                                                  
	...                                                 
private:                                       
	virtual void doDraw(ShapeColor color) const = 0; 
}; 
class Rectangle: public Shape {
public:
	...
private:
	virtual void doDraw(ShapeColor color) const; 
	...                                                               
};          

Tips:

  • 絕對不要重新定義一個繼承而來的預設引數值,因為預設引數值都是靜態繫結,而唯一應該覆寫的虛擬函式卻是動態繫結。

條款38:通核覆合塑模出has-a或“根據某物實現出”(Model “has-a” or “is-implemented-in-terms-of” through composition)

複合的含義

複合(composition)是型別之間的一種關係:

  • 這種關係見於一種型別的物件包含另外一種型別的物件時
  • 又稱分層(layering)、包含( containment)、聚合 (aggregation)、和植入(embedding)
class Address { ... };  // 住址
class PhoneNumber { ... };
class Person {
	public:
	...
private:
	std::string name;  //合成成分物(composed object)
	Address address;  //同上
	PhoneNumber voiceNumber; 	//同上
	PhoneNumber faxNumber;  //同上
};   

複合根據軟體中兩種不同的領域(domain)有有兩種含義:

  • has-a:物件是應用域(application domain)的一部分
    • 對應世界上的真實存在的東西,如人、汽車、影片畫面等
  • is-implemented-in-terms-of(根據某物實現出):物件是實現域(implementation domain)的一部分
    • 對應純實現層面的人工製品,如像快取區(buffers)、互斥器(mutexs)、查詢樹(search trees)等

區分不同的關係

區分is-a和has-a很簡單;區分is-a和is-implemented-in-terms-of比較困難,過程見下:

  1. 假設需要一個類别範本表示由不重複物件組成的set
  2. 首先考慮使用標準庫的set模板以實現複用(reuse)
  3. 不幸的是,set的實現對於其中的每個元素都會引入三個指標的開銷
    1. set通常作為一個平衡查詢樹(balanced search trees)來實現,以保證查詢、插入、刪除元素時具有對數時間(logarithmic-time)效率
    2. 當速度比空間重要時,此設計合理
    3. 當空間比速度重要時,此設計不合理,需要自己實現模板
  4. 考慮利用C++標準庫中的list template以在底層使用linked lists,從而實現sets
    1. 此方法會讓Set template繼承std::list,即Set繼承list
    2. 但是,list物件可以包含重複元素而Set不可以,這違反了is-a關係,不應使用public繼承
template<typename T>   //把list用於Set。錯誤做法!               
class Set: public std::list<T> { ... };     
  1. 正確的方式是Set物件可以被implemented in terms of為一個list物件
    1. Set成員函式的實現可以依賴list已經提供的功能和標準庫的其他部分,以簡化實現
template<class T>  //把list用於Set。正確做法                         
class Set {                                        
public:                                            
	bool member(const T& item) const; 
	void insert(const T& item);            
	void remove(const T& item);         
	std::size_t size() const;                   
private:                                          
	std::list<T> rep;  //用以表述Set的資料            
};   
//依賴list已經提供的功能和標準庫的其他部分實現Set的成員函式
template<typename T>
bool Set<T>::member(const T& item) const
{
	return std::find(rep.begin(), rep.end(), item) != rep.end();
}
template<typename T>
void Set<T>::insert(const T& item)
{
	if (!member(item)) rep.push_back(item);
}
template<typename T>
void Set<T>::remove(const T& item)
{
	typename std::list<T>::iterator it = //見條款42對
	std::find(rep.begin(), rep.end(), item); //typename的討論
	if (it != rep.end()) rep.erase(it);
}
template<typename T>
std::size_t Set<T>::size() const
{
	return rep.size();
}
  1. 以上過程展示了is-implemented-in-terms-of而非is-a的關係

Tips:

  • 複合的意義和public繼承完全不同
  • 在應用域,複合意為has-a(有一個);在實現域,複合意味著is-implemented-in-terms-of(根據某物實現出)

條款39:明智而審慎地使用private繼承(Use private inheritance judiciously)

private繼承

考慮private繼承的例子:

class Person { ... };
class Student: private Person { ... };  //改用private繼承
void eat(const Person& p);  //任何人都可以吃
void study(const Student& s);  //只有學生才在校學習
Person p;  //p是人
Student s;  //s是學生
eat(p);  //正確,p是人
eat(s);  //錯誤! 

private繼承的規則:

  • 類之間為private繼承時,編譯器不會將派生類物件(Student)轉換成為基類物件(Person)
    • 因此物件s呼叫eat會失敗
  • 由基類private繼承而來的所有成員(包括protected和public成員),在派生類中都會變成private屬性

private繼承的意義:

  • private繼承意味著is-implemented-in-terms-of(根據某物實現出)
    • 如果你讓類D private繼承自類B,你的用意是因為你想利用類B中的一些讓你感興趣的性質,而不是因為在型別B和型別D之前有任何概念上的關係
  • private繼承純粹只是一種實現技術
    • 因此從private基類中繼承而來的任何東西在派生類中都變為了private
      • 所有東西只被繼承了的實現部分
  • private繼承意味著只有實現部分被繼承,而介面應略去
    • 如果類D是private繼承自類B,就意味著D物件的實現依賴於類B物件,沒有其他含義
  • private繼承在軟體實現層面才有意義,在軟體設計層面沒有意義

private繼承和複合

儘量使用複合(composition),必要時才使用private繼承

  • 要訪問基類的protected成員或要重定義基類的虛擬函式時
  • 需要追求極致的空間時

考慮Widget類,需要了解:

  • 成員函式的使用頻率
  • 呼叫比例如何變化
    • 帶有多個執行階段(execution phases)的程式,可能在不同階段擁有不同的行為輪廓(behavioral profiles)
      • 例如編譯器在解析(parsing)階段所用的函式不同於在最最佳化(optimization)和程式碼生成(code generation)階段所用的函式

因此需要設定定時器以提醒統計呼叫次數的時機:

class Timer {
public:
	explicit Timer(int tickFrequency);
	virtual void onTick() const; //定時器每滴答一次
	...  //此函式就自動呼叫一次
};      

Widget必須繼承自Timer以重定義Timer內的虛擬函式:

  • Widget不是一個Timer,故public繼承不合適
  • 可使用private繼承,但非必要
  • 可使用複合,且應選擇複合
    • 防止Widget的派生類重寫onTick函式
      • Widget的派生類無法訪問作為private成員的WidgetTimer類,則無法重寫onTick
    • 可以將Widget的編譯依存性降至最低
      • 使用private繼承必須#include "Timer.h”
      • 使用複合則可以無需#include任何與Timer有關的東西
        • 若將WidgetTimer定義在Widget之外,然後在Widget內定義一個WidgetTimer的指標,此時Widget可以只帶著WidgetTimer的宣告式
//private繼承
class Widget: private Timer {
private:
	virtual void onTick() const; 
	...                                           
}   

//複合
class Widget{
private:
    class WidgetTimer :public Timer {
    public:
        virtual void onTick()const;
        ...
    };
    WidgetTimer timer;
    ...
};
classDiagram Widget *-- WidgetTimer Timer <|-- WidgetTimer Timer : +onTick() WidgetTimer : +onTick() Widget : -WidgetTimer timer

激進的情況

只適用在沒有資料的類中,這種類:

  • 沒有非靜態資料成員
  • 沒有虛擬函式(因為虛擬函式的存在會為每個物件新增一個vptr指標,見條款7)
  • 沒有虛基類(因為這樣的基類同樣會引入額外開銷,見條款40)
  • 從概念上來說,這樣沒有資料需要儲存的空類物件應該不使用空間,然而由於技術的原因,C++使得獨立物件必須佔用空間
class Empty {};  //沒有資料,應該不使用任何記憶體
 
class HoldsAnint {  //應該只需要一個int空間
private:
    int x;
    Empty e;  //應該不需要任何記憶體
};

上述程式碼中:

  • sizeof(HoldsAnInt)>sizeof(int),一個Empty資料成員也會佔用空間
  • 對於大多數編譯器來說,sizeof(Empty)為1
    • 因為C++法則處理大小為0的獨立物件時會預設向空物件中插入一個char
      • 不能被應用在派生類物件的基類部分中,因為它們不是獨立的
  • 齊位需求(alignment,見條款50)可能導致編譯器向HoldsASnInt這樣的類中新增襯墊(padding)
    • HoldsAnInt物件可能不只獲得一個char的大小,實際上會被增大到可容納第二個int

若繼承Empty則其非獨立,其大小可以為0:

由於EBO(empty base optimization,空白基類最最佳化),幾乎可以確定sizeof(HoldsAnInt)==sizeof(int)。EBO一般在單一繼承而非多重繼承下才可行

class HoldsAnInt: private Empty {
private:
	int x;
};

事實上,空類不是真的空:

  • 雖然它們永遠不會擁有非靜態資料成員,它們通常會包含typedefs、enums、靜態資料成員或non-virtual非虛擬函式
    • STL就包含很多技術用途的空類,其中包含有用的成員(通常為typedefs)的空類
      • 包括基類unary_function和binary_function,使用者定義的函式物件會繼承這些類
      • EBO使得這些繼承很少會增加派生類的大小

Tips:

  • private繼承意為is-implemented-in-terms-of(根據某物實現出),通常優先使用而非private繼承,但是當派生類需要訪問protected基類成員或需要重新定義虛擬函式時可以使用
  • 和複合不同,private繼承可以使空基類最最佳化,有利於物件尺寸最小化

條款40:明智而審慎地使用多重繼承(Use multiple inheritance judiciously)

本條款主要討論兩種觀點:

  • 單一繼承(single inheritance,SI)是好的,多重繼承(multiple inheritance,MI)會更好
  • 單一繼承是好的,但多重繼承不值得擁有

介面呼叫的歧義性

以下例子具有歧義(ambiguity):

class BorrowableItem {  //圖書館允你借某些東西
public:                    
	void checkOut();  //離開進行檢查
  ...                                       
};                                       
class ElectronicGadget {    
private:                             
	bool checkOut() const;  //注意,此處的為private 
	... 
};
class MP3Player:  //多重繼承
	public BorrowableItem,
	public ElectronicGadget 
{ ... };              
MP3Player mp;
mp.checkOut();  //歧義!呼叫的是哪個checkOut?

對checkout的呼叫是歧義的:

  • 即使只有兩個函式中的一個是可取用的(checkout在BorrowableItem中是public的而在ElectronicGadget中是private的),歧義仍然存在
  • 這與C++用來解析(resolving)過載函式呼叫的規則相符:
    • 在看到是否有個函式可取用之前,C++首先識別出對此呼叫而言的最佳匹配函式
    • 找到最佳匹配函式之後才會檢查函式的可取用性
    • 本例的兩個checkOut具有相同的匹配程度(所以造成歧義),沒有最佳匹配,因此ElectronicGadget::checkOut的可取用性未被編譯器審查
  • 為了解決這個歧義,必須指定呼叫哪個基類的函式
mp.BorrowableItem::checkOut();   

菱形繼承與虛(virtual)繼承

多重繼承意味著繼承多個基類,但是這些基類在繼承體系往往沒有更高層次的基類,否則會導致致命的”鑽石型多重繼承”:

class File { ... };
class InputFile: public File { ... };
class OutputFile: public File { ... };
class IOFile: public InputFile, public OutputFile
{ ... };

--- title: 菱形繼承 --- classDiagram File <|-- InputFile File <|-- OutputFile InputFile <|-- IOFile OutputFile <|-- IOFile

如果基類和派生類之間有一條以上的相通路線,則必須面對基類中的資料成員是否在每條路徑上都要被複制的問題:

  • 假設File類有一個資料成員,fileName
  • IOFile應該有它的幾份複製?
    • 方案一:它從每個基類中都繼承了一份複製,所以IOFile應該會有兩個fileName資料成員
    • 方案二:一個IOFIle只有一個檔名,所以從兩個基類中繼承的fileName部分不應該重複
    • C++對兩個方案都支援,但預設做法是執行復制(方案一)

如果想執行方案二,則必須使包含資料(即File)的類成為虛基類,即虛繼承它

C++標準庫程式內含一個類模版的多重繼承體系,包含basic_ios、basic_istream、basic_ostream、basic_iostream

class File { ... };
class InputFile: virtual public File { ... };
class OutputFile: virtual public File { ... };
class IOFile: public InputFile, public OutputFile
{ ... };
--- title: 虛繼承 --- classDiagram File <|-- InputFile File <|-- OutputFile InputFile <|-- IOFile OutputFile <|-- IOFile basic_ios <|-- basic_istream basic_ios <|-- basic_ostream basic_istream <|-- basic_iostream basic_ostream <|-- basic_iostream class InputFile{ <<virtual>> } class OutputFile{ <<virtual>> } class basic_istream{ <<virtual>> } class basic_ostream{ <<virtual>> }

虛繼承的代價:

  • 虛繼承耗費資源
    • 使用虛繼承的類建立出來的物件會比不使用虛繼承的類建立出來的物件要大
    • 訪問虛基類中的資料成員比訪問非虛基類中的資料成員要慢
  • 支配虛基類初始化列表的規則比非虛基類更加複雜,且不直觀
    • 初始化虛基類部分的責任由繼承體系中最底層的派生類(most derived class)承擔,則:
      • 繼承自虛基類的類如果需要初始化,它們必須意識到虛基類的存在,無論這個虛基類離派生類有多遠
      • 當一個派生類被新增到繼承體系中的時候,它必須承擔初始化虛基類的責任(無論是直接的還是間接的虛基類)。

使用虛繼承(即虛基類)的建議:

  • 不要使用虛基類,除非你需要它。預設情況下使用非虛基類。
  • 若必須使用虛基類,儘可能避免在這些類中放置資料,從而避免古怪的初始化和賦值
    • Java和.NET中的介面在許多方面相當於C++的虛基類,且不允許包含任何資料的

多重繼承舉例

以表示人的C++介面類為例:

class IPerson {
public:
	virtual ~IPerson();
	virtual std::string name() const = 0;  //返回人的名稱
	virtual std::string birthDate() const = 0;  //返回生日
};

IPerson是抽象類:

  • 無法例項化,則必須依賴IPerson指標和引用來編寫程式
  • 為了建立可以視為IPerson的物件,可使用工廠函式來例項化派生自IPerson的具象類
//工廠函式,根據一個獨一無二的資料庫ID建立一個Person物件
std::tr1::shared_ptr<IPerson> makePerson(DatabaseID personIdentifier);
//該函式從使用者手上取得一個資料庫ID
DatabaseID askUserForDatabaseID();
DatabaseID id(askUserForDatabaseID());
//建立一個支援IPerson介面的物件,由IPerson成員函式處理*pp
std::tr1::shared_ptr<IPerson> pp(makePerson(id)); 
...                                                                            

上述程式碼中,makePerson能建立物件並返回指向它的指標:

  • 必有些派生自IPerson的具象類,使得makePerson能夠對這些具現類進行例項化
  • 假設該類為CPerson,它必須為繼承自IPerson的純虛擬函式提供一份實現程式碼
    • 可從頭編寫實現程式碼
    • 可方便地利用現成的元件,其實現了大部分甚至全部的必要功能
      • 假設一箇舊資料庫指定的類PersonInfo為CPerson提供了它需要的最基本的東西:
class PersonInfo {
public:
	explicit PersonInfo(DatabaseID pid);
	virtual ~PersonInfo();
	virtual const char* theName() const;
	virtual const char* theBirthDate() const;
	...
private:
	virtual const char* valueDelimOpen() const; 
	virtual const char* valueDelimClose() const; 
	...
};

PersonInfo用以協助以各種格式列印資料庫欄位:

  • 每個欄位值的起始點和結束點以特殊字串為界
  • 預設的頭尾界限符號是方括號
    • 例如Ring-tailed Lemur將被格式化為[Ring-tailed Lemur]
  • 兩個virtual函式valueDelimOpen和valueDelimClose允許派生類自己定義的頭尾界限符號
    • 以PersonInfo::theName為例:
const char* valueDelimOpen()const
{
  return "[";  //預設的起始符號
}
 
const char* valueDelimClose()const
{
  return "]";  //預設的結尾符號
}
const char* theName()const {
	//保留緩衝區給返回值使用;由於緩衝區是static,因此會自動初始化為全0
  static char value[Max_Formatted_Field_Value_Length];
  std::strcpy(value, valueDelimOpen());  //寫入起始符號
  //將value內的字串新增到該物件的name成員變數中(須避免緩衝超限)
  std::strcat(value, valueDelimClose());  //寫入結尾符號
  return value;
}

CPerson和PersonInfo之間的關係:

  • PersonInfo恰好有一些函式使得CPerson的實現更加容易
    • 因此它們的關係為is-implemented-in-terms-of
  • CPerson需要重新定義valueDelimOpen和valueDelimClose
    • 最簡單的解決方案是讓CPerson直接private繼承PersonInfo(此處採用)
    • 也可讓CPerson可以使用組合和繼承的結合體來有效重定義PersonInfo的虛擬函式

CPerson同樣必須實現IPerson介面,故需要使用public繼承,則此時多重繼承應用合理:public繼承現有介面結合private繼承以重定義實現

class IPerson {  //這個類支出需要實現的介面
public:
  virtual ~IPerson();
  virtual std::string name() const = 0;
  virtual std::string birthDate() const = 0;
};
 
class DatabaseID { ... };
 
class PersonInfo {  //這個類由若干有用的函式可實現IPerson介面
public:
  explicit PersonInfo(DatabaseID pid);
  virtual ~PersonInfo();
  virtual const char* theName()const;
  virtual const char* theBirthDate()const;
  virtual const char* valueDelimOpen()const;
  virtual const char* valueDelimClose()const;
};
 
class CPerson :public IPerson, private PersonInfo {  //注意,多重繼承
public:
  explicit CPerson(DatabaseID pid) :PersonInfo(pid) {}
  virtual std::string name() const  //實現必要的IPerson成員函式
  { return PersonInfo::theName(); }
  virtual std::string birthDate() const  //實現必要的IPerson成員函式
  { return PersonInfo::theBirthDate(); }
private:  //重定義繼承來的virtual“界限函式”
  virtual const char* valueDelimOpen() const { return "" };
  virtual const char* valueDelimClose() const { return "" ;
};                                                               

--- title: CPerson多重繼承關係圖 --- classDiagram IPerson <|-- CPerson PersonInfo <|-- CPerson: Private

Tips:

  • 多重繼承比單一繼承複雜,它可能導致歧義以及對virtual繼承的需求
  • virtual繼承會增加大小、速度、初始化、賦值的複雜度等等成本,其在virtual基類不帶任何資料時最具使用價值
  • 多重繼承存在合適的應用,其中之一是兩種情況的融合:public繼承某個介面類和private繼承某個協助實現的類

相關文章