【六】繼承與面向對象設計
條款32 : 確保public繼承是"is a"的關系
Item 32: Make sure public inheritance models “is-a”.
C++
面向對象程序設計中,最重要的規則便是:public
繼承應當是"is-a
"的關系。當Derived public
繼承自Base
時, 相當于你告訴編譯器和所有看到你代碼的人:Base
是Derived
的抽象,Derived
就是一個Base
,任何時候Derived
都可以代替Base
使用。
當然這只適合public繼承,如果是private繼承那是另外一回事了,見 Item 39
。
比如一個Student繼承自Person,那么Person有什么屬性Student也應該有,接受Person類型參數的函數也應當接受一個Student:
void eat(const Person& p);
void study(const Person& p);Person p; Student s;
eat(p); eat(s);
study(p); study(s);
語言的二義性
上述例子也好理解,也很符合直覺。但有時情況卻會不同,比如Penguin繼承自Bird,但企鵝不會飛:
class Bird{
public:
vitural void fly();
};
class Penguin: public Bird{
// fly??
};
這時你可能會困惑Penguin到底是否應該有fly()方法。但其實這個問題來源于自然語言的二義性: 嚴格地考慮,鳥會飛并不是所有鳥都會飛。我們對會飛的鳥單獨建模便是:
class Bird{...};
class FlyingBird: public Bird{
public:virtual void fly();
};
class Penguin: public Bird{...};
這樣當你調用penguin.fly()時便會編譯錯。當然另一種辦法是Penguin繼承自擁有fly()方法的Bird, 但Penguin::fly()中拋出異常。這兩種方式在概念是有區別的:前者是說企鵝不能飛;后者是說企鵝可以飛,但飛了會出錯。
哪種實現方式好呢?Item 18 中提到,接口應當設計得不容易被誤用,最好將錯誤從運行時提前到編譯時。所以前者更好!
錯誤的繼承
生活的經驗給了我們關于對象繼承的直覺,然而并不一定正確。比如我們來實現一個正方形繼承自矩形:
class Rect{...};
void makeBigger(Rect& r){int oldHeight = r.height();r.setWidth(r.width()+10);assert(r.height() == oldHeight);
}
class Square: public Rect{...};Square s;
assert(s.width() == s.height());
makeBigger(s);
assert(s.width() == s.height());
根據正方形的定義,寬高相等是任何時候都需要成立的。然而makeBigger卻破壞了正方形的屬性, 所以正方形并不是一個矩形(因為矩形需要有這樣一個性質:增加寬度時高度不會變)。即Square繼承自Rect是錯誤的做法。 C++類的繼承比現實世界中的繼承關系更加嚴格:任何適用于父類的性質都要適用于子類!
本節我們談到的是"is-a"關系,類與類之間還有著其他類型的關系比如"has-a", "is-implemented-in-terms-of"等。這些在Item-38和Item-39中分別介紹。
條款33: 避免隱藏繼承而來的名稱
條款33:Avoid hiding inherited names
簡單變量的作用域
這里我們先引入作用域的情況,在以下代碼簡單變量中,作用域是這樣的:
繼承類的作用域
那么繼承的作用域是如何的呢,看以下代碼:
我們假定derived class內的mf4實現如下:
void Derived::mf4(){...mf2();...
}
編譯器看到名稱mf2查找順序如下:
先查看local作用域(也就是mf4覆蓋的作用域)——>外圍作用域Derived覆蓋作用域——>再外圍查找這里是base class的mf2——>base class所在的namespace作用域——> global作用域
注:上述箭頭是在當前沒有找到的情況下,進行下一步箭頭操作
我們再假定:重載mf1``mf3,并添加一個新版mf3到Derived去。如下圖:
這里以作用域為基礎的“名稱遮掩規則”并沒有改變,因此base class所有名為mf1 mf3都被derived class的mf1 mf3遮掩掉了。
處理“繼承而來”的遮掩行為
那如果使用才能搞定C++的“繼承而來”的缺省遮掩行為:
如果Derived以private形式繼承Base,而Derived唯一想繼承的mf1是那個無參版本。using聲明式這里就不起作用了,因為using聲明式會令繼承而來的某給定名稱之所有同名函數在derived class都可見,這里可以使用一個簡單的轉交函數搞定forwarding function:
注意:
- derived class 內名稱會遮掩
base class內的
名稱。在public繼承下沒有人希望如此。 - 為了讓被遮掩的名稱重見天日,可使用
using
聲明式和轉交函數forwarding function
條款34:區分接口繼承和實現繼承
Item 34: Dirrerentiate between inheritance of interface and inheritance of implementation.
不同于Objective C或者Java,C++中的繼承接口和實現繼承是同一個語法過程。 當你public繼承一個類時,接口是一定會被繼承的(見Item32),你可以選擇子類是否應當繼承實現:
- 不繼承實現,只繼承方法接口:純虛函數。
- 繼承方法接口,以及默認的實現:虛函數。
- 繼承方法接口,以及強制的實現:普通函數。
一個例子
為了更加直觀地討論接口繼承和實現繼承的關系,我們還是來看一個例子:Rect和Ellipse都繼承自Shape。
class Shape{
public:
// 純虛函數
virtual void draw() const = 0;
// 不純的虛函數,impure...
virtual void error(const string& msg);
// 普通函數
int id() const;
};
class Rect: public Shape{...};
class Ellipse: public Shape{...};
純虛函數draw()使得Shape成為一個抽象類,只能被繼承而不能創建實例。一旦被public繼承,它的成員函數接口總是會傳遞到子類。
- draw()是一個純虛函數,子類必須重新聲明draw方法,同時父類不給任何實現。
- id()是一個普通函數,子類繼承了這個接口,以及強制的實現方式(子類為什么不要重寫父類方法?參見 Item 33)。
- error()是一個普通的虛函數,子類可以提供一個error方法,也可以使用默認的實現。
因為像ID這種屬性子類沒必要去更改它,直接在父類中要求強制實現!
危險的默認實現
默認實現通常是子類中共同邏輯的抽象,顯式地規約了子類的共同特性,避免了代碼重復,方便了以后的增強,也便于長期的代碼維護。 然而有時候提供默認實現是危險的,因為你不可預知會有怎樣的子類添加進來。例如一個Airplane類以及它的幾個Model子類:
class Airplane{
public:
virtual void fly(){// default fly code
}
};
class ModelA: public Airplane{...};
class ModelB: public Airplane{...};
不難想象,我們寫父類Airplane時,其中的fly是針對ModelA和ModelB實現了通用的邏輯。如果有一天我們加入了ModelC卻忘記了重寫fly方法:
class ModelC: public Airplane{...};
Airplane* p = new ModelC;
p->fly();
雖然ModelC忘記了重寫fly方法,但代碼仍然成功編譯了!這可能會引發災難。。這個設計問題的本質是普通虛函數提供了默認實現,而不管子類是否顯式地聲明它需要默認實現。
安全的默認實現
我們可以用另一個方法來給出默認實現,而把fly聲明為純虛函數,這樣既能要求子類顯式地重新聲明一個fly,當子類要求時又能提供默認的實現。
class Airplane{
public:
virtual void fly() = 0;
protected:
void defaultFly(){...}
}
class ModelA: public Airplane{
public:
virtual void fly(){defaultFly();}
}
class ModelB: public Airplane{
public:
virtual void fly(){defaultFly();}
}
這樣當我們再寫一個ModelC時,如果自己忘記了聲明fly()會編譯錯,因為父類中的fly()是純虛函數。 如果希望使用默認實現時可以直接調用defaultFly()。
注意defaultFly是一個普通函數!如果你把它定義成了虛函數,那么它要不要給默認實現?子類是否允許重寫?這是一個循環的問題。。
優雅的默認實現
上面我們給出了一種方法來提供安全的默認實現。代價便是為這種接口都提供一對函數:fly, defaultFly, land, defaultLand, … 有人認為這些名字難以區分的函數污染了命名空間。他們有更好的辦法:為純虛函數提供函數定義。
確實是可以為純虛函數提供實現的,編譯會通過。但只能通過Shape::draw
的方式調用它。
class Airplane{
public:
virtual void fly() = 0;
};
void Airplane::fly(){// default fly code
}class ModelA: public Airplane{
public:
virtual void fly(){Airplane::fly();
}
};
上述的實現和普通成員函數defaultFly并無太大區別,只是把defaultFly和fly合并了。 合并之后其實是有一定的副作用的:原來的默認實現是protected,現在變成public了。在外部可以訪問它:
Airplane* p = new ModelA;
p->Airplane::fly();
在一定程度上破壞了封裝,但Item 22
我們提到,protected并不比public更加封裝。 所以也無大礙,畢竟不管defaultFly還是fly都是暴露給類外的對象使用的,本來就不能夠封裝。
注意:
- 接口繼承和實現繼承不同。在public繼承下,derived class總是繼承base class的接口
- pure virtual函數只具體指定接口繼承
- impure virtual函數具體指定接口繼承及缺省實現繼承
- non-virtual函數具體指定接口繼承以及強制性實現繼承
條款 35 考慮virtural函數以外的其他替代設計
補 倆個 設計模式 然后改
Item 35: Consider alternatives to virtual functions.
比如你在開發一個游戲,每個角色都有一個healthValue()方法。很顯然你應該把它聲明為虛函數,可以提供默認的實現,讓子類去自定義它。 這個設計方式太顯然了你都不會考慮其他的設計方法。但有時確實存在更好的,本節便來舉幾個替代的所涉及方法。
- 非虛接口范式(NVI idiom)可以實現模板方法設計模式(Template Method),用非虛函數來調用更加封裝的虛函數。
- 用函數指針代替虛函數,可以實現策略模式。
- 用tr1::function代替函數指針,可以支持所有兼容目標函數簽名的可調用對象。
- 用另一個類層級中的虛函數來提供策略,是策略模式的慣例實現。
NVI實現模板方法模式
模板方法設計模式:我們知道實現某個業務的步驟,但具體算法需要子類分別實現。
使用非虛接口(Non-Virtual Interface Idiom)可以實現模板方法模式。比如上面的healthValue聲明為普通函數,它調用一個私有虛函數doHealthValue來實現。 實現起來是這樣的:
class GameCharacter{
public:
// 子類不應重新定義該方法,見Item 36
int healthValue() const{// do sth. beforeint ret = doHealthValue();// do sth. afterreturn ret;
}
private:
// 子類可以重新定義該方法
virtual int doHealthValue() const{// 默認實現
}
}
NVI Idiom的好處在于,在調用doHealthValue前可以做一些設置上下文的工作,調用后可以清除上下文。 比如在調用前給互斥量(mutex)加鎖、驗證前置條件、類的不變式;調用后給互斥量解鎖、驗證后置條件、類的不變式等。
上述C++代碼也有奇怪的地方,你可能已經發現了。doHealthValue在子類中是不可調用的,然而子類卻重寫了它。 但C++允許這樣做是有充分理由的:父類擁有何時(when)調用該接口的權利;子類擁有如何(how)實現該接口的權利。
有時為了繼承實現方式,子類虛函數會調用父類虛函數,這時doHealthValue就需要是protected了。 有時(比如析構函數)虛函數還必須是public,那么就不能使用NVI了。
函數指針實現策略模式
上述的NVI隨是實現了模板方法,但事實上還是在用虛函數。我們甚至可以讓healthValue()完全獨立于角色的類,只在構造函數時把該函數作為參數傳入。
class GameCharacter;int defaultHealthCalc(const GameCharacter& gc);class GameCharacter{
public:
typedef int (*HealthCalcFunc)(const GameCharacter&);
explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc): healthFunc(hcf){}
int healthValue() const{return healthFunc(*this);
}
private:
HealthCalcFunc healthFunc;
}
這便實現了策略模式。可以在運行時指定每個對象的生命值計算策略,比虛函數的實現方式有更大的靈活性:
- 同一角色類的不同對象可以有不同的healthCalcFunc。只需要在構造時傳入不同策略即可。
- 角色的healthCalcFunc可以動態改變。只需要提供一個setHealthCalculator成員方法即可。
我們使用外部函數實現了策略模式,但因為defaultHealthCalc是外部函數,所以無法訪問類的私有成員。 如果它通過public成員便可以實現的話就沒有任何問題了,如果需要內部細節:
我們只能弱化GameCharacter的封裝。或者提供更多public成員,或者將defaultHealthCalc設為friend。 弱化的封裝和更靈活的策略是一個需要權衡的設計問題,取決于實際問題中動態策略的需求有多大。
tr1::function實現策略模式
C++ std::tr1::function使用-CSDsN博客
如果你已經習慣了模板編程,可能會發現函數指針實現的策略模式太過死板。 為什么不能接受一個像函數一樣的東西呢(比如函數對象)?為什么不能是一個成員函數呢?為什么一定要返回int而不能是其他兼容類型呢?
tr1中給出了解決方案,使用tr1::function代替函數指針!tr1::function是一個對象, 他可以保存任何一種類型兼容的可調用的實體(callable entity)例如函數對象、成員函數指針等。 看代碼:
現在tr1在C++11標準中已經被合并入std命名空間啦(叫做多態函數對象包裝器),不需要std::tr1::function了,可以直接寫std::function。
class GameCharacter;
int defaultHealthCalc(const GameCharacter& gc);class GameCharacter{
public:
typedef std::function<int (const GameCharacter&)> HealthCalcFunc;
explicit GameCaracter(HealthCalcFunc hcf = defaultHealthCalc): healthCalcFunc(hcf){}
int healthValue() const{return healthFunc(*this);
}
private:
HealthCalcFunc healthFunc;
};
注意std::function的模板參數是int (const GameCharacter&),參數是GameCharacter的引用返回值是int, 但healthCalcFunc可以接受任何與該簽名兼容的可調用實體。即只要參數可以隱式轉換為GameCharacter返回值可以隱式轉換為int就可以。 用function代替函數指針后客戶代碼可以更加靈活:
// 類型兼容的函數
short calcHealth(const GameCharacter&);
// 函數對象
struct HealthCalculator{
int operator()(const GameCharacter&) const{...}
};
// 成員函數
class GameLevel{
public:
float health(const GameCharacter&) const;
};
無論是類型兼容的函數、函數對象還是成員函數,現在都可以用來初始化一個GameCharacter對象:
GameCharacter evil, good, bad;
// 函數
evil(calcHealth);
// 函數對象
good(HealthCalculator());
// 成員函數
GameLevel currentLevel;
bad(std::bind(&GameLevel::health, currentLevel, _1));
最后一個需要解釋一下,GameLevel::health接受一個參數const GameCharacter&, 但事實上在運行時它是需要兩個參數的,const GameCharacter&以及this。只是編譯器把后者隱藏掉了。 那么std::bind的語義就清楚了:首先它指定了要調用的方法是GameLevel::health,第一個參數是currentLevel, this是_1,即¤tLevel(細節略過啦!,這里的重點在于成員函數也可以傳入!)。
如果你寫過JavaScript你會發現這就是Function.prototype.bind嘛!
經典的策略模式
可能你更關心策略模式本身而不是上述的這些實現,現在我們來討論策略模式的一般實現。 在UML表示中,生命值計算函數HealthCalcFunc應當定義為一個類,擁有自己的類層級。 它的成員方法calc應當為虛函數,并在子類可以有不同的實現。
實現代碼可能是這樣的:
class HealthCalcFunc{
public:
virtual int calc(const CameCharacter& gc) const;
};
HealthCalcFunc defaultHealthCalc;
class GameCharacter{
public:
explicit GameCharacter(HealthCalcFunc *phcf = &defaultHealthCalc): pHealthCalc(phcf){}
int healthValue() const{return pHealthCalc->calc(*this);
}
private:
HealthCalcFunc *pHealthCalc;
};
熟悉策略模式的人一眼就能看出來上述代碼是策略模式的經典實現。可以通過繼承HealthCalcFunc
很方便地生成新的策略。
總結 :
條款 36 不要重寫(重新定義)繼承來的(noo-vitrual)非虛函數
Item 36: Never redefine an inherited non-virtual function.
我們還是在討論public繼承,比如Derived
繼承自Base。如果Base有一個非虛函數func,那么客戶會傾向認為下面兩種調用結果是一樣的:
Derived d;
Base* pb = &d;
Derived* pd = &d;
// 以下兩種調用應當等效
pb->func();
pd->func();
為什么要一樣呢?因為public繼承表示著"is-a
"的關系,每個Derived
對象都是一個Base
對象(Item 32 確保public繼承是"is a"的關系)。
然而重寫(override
)非虛函數func將會造成上述調用結果不一致:
class Base{
public:
void func(){}
};
class Derived: public Base{
public:
void func(){} // 隱藏了父類的名稱func,見Item 33
};
因為pb
類型是Base*
,pd類型是Derived*
,對于普通函數func
的調用是靜態綁定的(在編譯期便決定了調用地址偏移量)。 總是會調用指針類型定義中的那個方法。即pb->func()調用的是Base::func,pd->func()調用的是Derived::func。
當然虛函數不存在這個問題,它是一種動態綁定的機制。
在子類中重寫父類的非虛函數在設計上是矛盾的:
- 一方面,父類定義了普通函數
func
,意味著它反映了父類的不變式。子類重寫后父類的不變式不再成立,因而子類和父類不再是"is a
"的關系。 - 另一方面,如果
func
應當在子類中提供不同的實現,那么它就不再反映父類的不變式。它就應該聲明為virtual
函數。
條款 37 絕不要重新定義繼承父類函數的(缺省參數值)默認參數
Item 37: Never redefine a function’s inherited default parameter value.
不要重寫父類函數的默認參數。 因為雖然虛函數的是動態綁定的,但默認參數是靜態綁定的。只有動態綁定的東西才應該被重寫。
靜態綁定與動態綁定
靜態綁定是在編譯期決定的,又稱早綁定(early binding
);
動態綁定是在運行時決定的,又稱晚綁定(late binding
)。
舉例來講,Rect
和Circle
都繼承自Shape
,Shape
中有虛方法draw
。那么:
Shape* s1 = new Shape;
Shape* s2 = new Rect;
Shape* s3 = new Circle;
s1->draw(); // s1的靜態類型是Shape*,動態類型是Shape*
s2->draw(); // s2的靜態類型是Shape*,動態類型是Rect*
s3->draw(); // s3的靜態類型是Shape*,動態類型是Circle*
在編譯期是不知道應該調用哪個draw
的,因為編譯期看到的類型都是一樣的:Shape*
。 在運行時可以通過虛函數表的機制來決定調用哪個draw方法,這便是動態綁定。
靜態綁定的默認參數
虛函數是動態綁定的,但為什么參數是靜態綁定的呢?這是出于運行時效率的考慮,如果要動態綁定默認參數,則需要一種類似虛函數表的動態機制。 所以你需要記住默認參數的靜態綁定的,否則會引起困惑。來看例子吧:
Class Shape{public:virtual void draw(int top = 1){cout<<top<<endl;}
};
class Rect: public Shape{
public:
virtual void draw(int top = 2){ // 賦予不同的缺省參數值 cout<<top<<endl;
}
};class Circle: public Shape{
public:
virtual void draw(int top){ // 賦予不同的缺省參數值 cout<<top<<endl;
}
};Rect* rp = new Rect;
Shape* sp = rp;
Circle* cp = new Circle;sp->draw(); // 調用 Shape::draw()
rp->draw(); // 調用 Rect::draw()
cp->draw(); // 調用 Shape::draw() 一樣缺省 但是調用基類的func 各出一半的力氣 !!
在Rect中重定義了默認參數為2,上述代碼的執行結果是這樣的: 輸出 1 2 1
默認參數的值只和靜態類型有關,是靜態綁定的。
最佳實踐
為了避免默認參數的困惑,請不要重定義默認參數。但當你遵循這條規則時卻發現及其蛋疼:
class Shape{
public:
virtual void draw(Color c = Red) const = 0;
};
class Rect: public Shape{
public:
virtual void draw(Color c = Red) const;
};
代碼重復(相依性)!如果父類中的默認參數改了,我們需要修改所有的子類。所以最終的辦法是:避免在虛函數中使用默認參數。可以通過 Item 35
的NVI范式來做這件事情:
class Shape{
public:void draw(Color c = Red) const{doDraw(color);}
private:virtual void doDraw(Color c) const = 0;
};class Rect: public Shapxe{
...
private:virtual void doDraw(Color c) const; // 虛函數沒有默認參數啦!
};
我們用普通函數定義了默認參數,避免了在動態綁定的虛函數上定義靜態綁定的默認參數。
如標題所見, 你唯一應該覆寫的東西 —— 動態綁定
條款 38 通過復合模型數模出 has-a 或 根據某物實出現
Item 38: Model “has-a” or “is-implemented-in-terms-of” through composition.
- 一個類型包含另一個類型的對象時,我們這兩個類型之間是組合關系。組合是比繼承更加靈活的軟件復用方法。
Item 32 確保public繼承是"is a"的關系
提到 : public繼承
的語義是"is-a
"的關系。對象組合也同樣擁有它的語義:- 就對象關系來講,組合意味著一個對象擁有另一個對象,是"
has-a
"的關系 (復合模型); - 就實現方式來講,組合意味著一個對象是通過另一個對象來實現的,是"
is-implemented-in-terms-of
"的關系。 (eg set 利用 list實現)
擁有 has-a
擁有的關系非常直觀,比如一個Person擁有一個name:
class Person{
public:string name;
};
以…實現 is-implemented-in-terms-of
假設你實現了一個List
鏈表,接著希望實現一個Set
集合。因為你知道代碼復用總是好的,于是你希望Set
能夠繼承List
的實現。 這時用public
繼承是不合適的,List
是可以有重復的,這一性質不適用于Set
,所以它們不是"is-a
"的關系。 這時用組合更加合適,Set
以List
來實現的。
template<class T> // the right way to use list for 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; // representation for Set data
};
Set的實現可以很大程度上重用List的實現,比如member方法:
template<typename T> bool Set<T>::member(const T& item) const {return std::find(rep.begin(), rep.end(), item) != rep.end();
}
復用List的實現使得Set的方法都足夠簡單,它們很適合聲明成inline函數(見Item 30)。
條款 39 明智而謹慎地使用 private 繼承
Item 39: Use private inheritance judiciously.
Item 32
提出public
繼承表示"is-a
"的關系,這是因為編譯器會在需要的時候將子類對象隱式轉換為父類對象。 然而private繼承則不然:
class Person { ... };
class Student: private Person { ... }; // inheritance is now private
void eat(const Person& p); // anyone can eatPerson p; // p is a Person
Student s; // s is a Student
eat(p); // fine, p is a Person
eat(s); // error! a Student isn't a Person
Person
可以eat
,但Student
卻不能eat
。這是private
繼承和public
繼承的不同之處:
- 編譯器不會把子類對象轉換為父類對象
- 父類成員(即使是public、protected)都變成了private
子類繼承了父類的實現,而沒有繼承任何接口(因為public成員都變成private了)。 因此private繼承是軟件實現中的概念,與軟件設計無關。 private繼承和對象組合類似,都可以表示"is-implemented-in-terms-with"的關系。那么它們有什么區別呢? 在面向對象設計中,對象組合往往比繼承提供更大的靈活性,只要可以使用對象組合就不要用private繼承。
private繼承
我們的Widget
類需要執行周期性任務,于是希望繼承Timer
的實現。 因為Widget
不是一個Timer
,所以我們選擇了private
繼承:
class Timer {
public:explicit Timer(int tickFrequency);virtual void onTick() const; // automatically called for each tick
};
class Widget: private Timer {
private:virtual void onTick() const; // look at Widget usage data, etc.
};
在Widget中重寫虛函數onTick,使得Widget可以周期性地執行某個任務。為什么Widget要把onTick聲明為private呢? 因為onTick只是Widget的內部實現而非公共接口,我們不希望客戶調用它(Item 18
指出接口應設計得不易被誤用)。
private繼承的實現非常簡單,而且有時只能使用private繼承:
- 當Widget需要訪問
Timer
的protected
成員時。因為對象組合后只能訪問public
成員,而private
繼承后可以訪問protected
成員。 - 當Widget需要重寫
Timer
的虛函數時。比如上面的例子中,由于需要重寫onTick
單純的對象組合是做不到的。
對象組合
我們知道對象組合也可以表達"is-implemented-in-terms-of
"的關系, 上面的需求當然也可以使用對象組合的方式實現。但由于需要重寫(override
)Timer
的虛函數,所以還是需要一個繼承關系的:
class Widget {
private:class WidgetTimer: public Timer {public:virtual void onTick() const;};WidgetTimer timer;
};
內部類WidgetTimerpublic繼承自Timer,然后在Widget中保存一個WidgetTimer對象。 這是public繼承+對象組合的方式,比private繼承略為復雜。但對象組合仍然擁有它的好處:
- 你可能希望禁止Widget的子類重定義onTick。在Java中可以使用finel關鍵字,在C#中可以使用sealed。 在C++中雖然沒有這些關鍵字,但你可以使用public繼承+對象組合的方式來做到這一點。上述例子便是。
- 減小Widget和Timer的編譯依賴。如果是private繼承,在定義Widget的文件中勢必需要引入#include"timer.h"。 但如果采用對象組合的方式,你可以把WidgetTimer放到另一個文件中,在Widget中保存WidgetTimer的指針并聲明WidgetTimer即可, 見
Item 31
。
EBO特性
我們講雖然對象組合優于private繼承,但有些特殊情況下仍然可以選擇private繼承。 需要EBO(empty base optimization
)的場景便是另一個特例。 由于技術原因,C++中的獨立空對象也必須擁有非零的大小,請看:
class Empty {};
class HoldsAnInt {
private:int x;Empty e;
};
Empty e是一個空對象,但你會發現sizeof(HoldsAnInt) > sizeof(int)。 因為C++中獨立空對象必須有非零大小,所以編譯器會在Empty里面插入一個char,這樣Empty大小就是1。 由于字節對齊的原因,在多數編譯器中HoldsAnInt的大小通常為2*sizeof(int)。更多字節對齊和空對象大小的討論見Item 7。 但如果你繼承了Empty,情況便會不同:
class HoldsAnInt: private Empty {
private:int x;
};
這時sizeof(HoldsAnInt) == sizeof(int),這就是空基類優化(empty base optimization,EBO)。 當你需要EBO來減小對象大小時,可以使用private繼承的方式。
繼承一個空對象有什么用呢?雖然空對象不可以有非靜態成員,但它可以包含typedef, enum, 靜態成員,非虛函數 (因為虛函數的存在會導致一個徐函數指針,它將不再是空對象)。 STL就定義了很多有用的空對象,比如unary_function, binary_function等。
總結
- private繼承的語義是"is-implemented-in-terms-of",通常不如對象組合。但有時卻是有用的:比如方法protected成員、重寫虛函數。
- 不同于對象組合,private繼承可以應用EBO,庫的開發者可以用它來減小對象大小 (對象尺寸最小化)。
條款 40 明智而審慎地使用多重繼承
Item 40: Use multiple inheritance judiciously.
多繼承(Multiple Inheritance,MI
)是C++
特有的概念,在是否應使用多繼承的問題上始終爭論不斷。一派認為單繼承(Single Inheritance,SI
)是好的,所以多繼承更好; 另一派認為多繼承帶來的麻煩更多,應該避免多繼承。本文的目的便是了解這兩派的視角。具體從如下三個方面來介紹:
- 多繼承比單繼承復雜,引入了歧義的問題,以及虛繼承的必要性;
- 虛繼承在大小、速度、初始化/賦值的復雜性上有不小的代價,當虛基類中沒有數據時還是比較合適的;
- 多繼承有時也是有用的。典型的場景便是:
public
繼承自一些接口類,private
繼承自那些實現相關的類。
歧義的名稱
多繼承遇到的首要問題便是父類名稱沖突時調用的歧義。如:
class A{
public:void func();
};
class B{
private:bool func() const;
};
class C: public A, public B{ ... };C c;
c.func(); // 歧義!c.B::func(); // 沒有歧義 需要明確指出 但是B::func 是 private的
多繼承菱形
當多繼承的父類擁有更高的繼承層級時,可能產生更復雜的問題比如多繼承菱形(deadly MI diamond)。如圖:
class File{};
class InputFile: public File{};
class OutputFile: public File{};
class IOFile: public InputFile, public OutputFile{};
這樣的層級在C++標準庫中也存在,例如basic_ios, basic_istream, basic_ostream, basic_iostream。
IOFile的兩個父類都繼承自File,那么File的屬性(比如filename)應該在IOFile中保存一份還是兩份呢? 這是取決于應用場景的,就File::filename來講顯然我們希望它只保存一份,但在其他情形下可能需要保存兩份數據。 C++還是一貫的采取了自己的風格:都支持!默認是保存兩份數據的方式。如果你希望只存儲一份,可以用virtual繼承:
class File{};
class InputFile: virtual public File{};
class OutputFile: virtual public File{};
class IOFile: public InputFile, public OutputFile{};
可能多數情況下我們都是希望virtual的方式來繼承。但總是用virtual也是不合適的,它有代價:
- 虛繼承類的對象會更大一些;
- 虛繼承類的成員訪問會更慢一些;
- 虛繼承類的初始化更反直覺一些。繼承層級的最底層(most derived class)負責虛基類的初始化,而且負責整個繼承鏈上所有虛基類的初始化。
基于這些復雜性,Scott Meyers對于多繼承的建議是:
- 如果能不使用多繼承,就不用他;
- 如果一定要多繼承,盡量不在里面放數據,也就避免了虛基類初始化的問題。
接口類
這樣的一個不包含數據的虛基類和Java或者C#提供的Interface有很多共同之處,這樣的類在C++中稱為接口類, 我們在Item 31中介紹過。一個Person的接口類是這樣的:
class IPerson {
public:virtual ~IPerson();virtual std::string name() const = 0;virtual std::string birthDate() const = 0;
};
由于客戶無法創建抽象類的對象,所以必須以指針或引用的方式使用IPerson。 需要創建實例時客戶會調用一些工廠方法,比如:
shared_ptr<IPerson> makePerson(DatabaseID personIdentifier);
同時繼承接口類與實現類
在Java中一個典型的類會擁有這樣的繼承關系:
public class A extends B implements IC, ID{}
繼承B通常意味著實現繼承,繼承IC和ID通常意味著接口繼承。在C++中沒有接口的概念,但我們有接口類! 于是這時就可以多繼承:
class CPerson: public IPerson, private PersonInfo{};
PersonInfo是私有繼承,因為Person是借助PersonInfo實現的。 Item 39提到對象組合是比private繼承更好的實現繼承方式。 但如果我們希望在CPerson中重寫PersonInfo的虛函數,那么就只能使用上述的private繼承了(這時就是一個合理的多繼承場景)。
現在來設想一個需要重寫虛函數的場景: 比如PersonInfo里面有一個print函數來輸出name, address, phone。但它們之間的分隔符被設計為可被子類定制的:
class PersonInfo{
public:
void print(){char d = delimiter();cout<<name<<d<<address<<d<<phone;
}
virtual char delimiter() const{ return ','; }
};
CPerson通過private繼承復用PersonInfo的實現后便可以重寫delimiter函數了:
class CPerson: public IPerson, private PersonInfo{
public:
virtual char delimiter() const{ return ':'; }
...
};
至此完成了一個合理的有用的多繼承(MI)的例子。
總結
我們應當將多繼承視為面向對象設計工具箱中一個有用的工具。相比于單繼承它會更加難以理解, 如果有一個等價的單繼承設計我們還是應該采用單繼承。但有時多繼承確實提供了清晰的、可維護的、合理的方式來解決問題。 此時我們便應該理智地使用它。
- 多繼承比單繼承復雜,引入了歧義的問題,以及虛繼承的必要性;
- 虛繼承在大小、速度、初始化/賦值的復雜性上有不小的代價,當虛基類中沒有數據時還是比較合適的;
- 多繼承有時也是有用的。典型的場景便是:public繼承自一些接口類,private繼承自那些實現相關的類。
參考 :
https://zhuanlan.zhihu.com/p/536534500
https://zhuanlan.zhihu.com/p/63609476
http://gapex.web.fc2.com/c_plusplus/book/EffectiveC3rdEdition.pdf
https://harttle.land/effective-cpp.html