【Effective C++】 (六) 繼承與面向對象設計

在這里插入圖片描述

【六】繼承與面向對象設計

條款32 : 確保public繼承是"is a"的關系

Item 32: Make sure public inheritance models “is-a”.

C++面向對象程序設計中,最重要的規則便是:public繼承應當是"is-a"的關系。當Derived public繼承自Base時, 相當于你告訴編譯器和所有看到你代碼的人:BaseDerived的抽象,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,即&currentLevel(細節略過啦!,這里的重點在于成員函數也可以傳入!)。
如果你寫過JavaScript你會發現這就是Function.prototype.bind嘛!

經典的策略模式

可能你更關心策略模式本身而不是上述的這些實現,現在我們來討論策略模式的一般實現。 在UML表示中,生命值計算函數HealthCalcFunc應當定義為一個類,擁有自己的類層級。 它的成員方法calc應當為虛函數,并在子類可以有不同的實現。
image.png
實現代碼可能是這樣的:

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很方便地生成新的策略。
總結 :
image.png
image.png

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

image.png
不要重寫父類函數的默認參數。 因為雖然虛函數的是動態綁定的,但默認參數是靜態綁定的。只有動態綁定的東西才應該被重寫。

靜態綁定與動態綁定

靜態綁定是在編譯期決定的,又稱早綁定(early binding);
動態綁定是在運行時決定的,又稱晚綁定(late binding)。
舉例來講,RectCircle都繼承自ShapeShape中有虛方法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"的關系。 這時用組合更加合適,SetList來實現的。

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繼承:

  1. 當Widget需要訪問Timerprotected成員時。因為對象組合后只能訪問public成員,而private繼承后可以訪問protected成員。
  2. 當Widget需要重寫Timer的虛函數時。比如上面的例子中,由于需要重寫onTick單純的對象組合是做不到的。

對象組合

我們知道對象組合也可以表達"is-implemented-in-terms-of"的關系, 上面的需求當然也可以使用對象組合的方式實現。但由于需要重寫(overrideTimer的虛函數,所以還是需要一個繼承關系的:

class Widget {
private:class WidgetTimer: public Timer {public:virtual void onTick() const;};WidgetTimer timer;
};

內部類WidgetTimerpublic繼承自Timer,然后在Widget中保存一個WidgetTimer對象。 這是public繼承+對象組合的方式,比private繼承略為復雜。但對象組合仍然擁有它的好處:

  1. 你可能希望禁止Widget的子類重定義onTick。在Java中可以使用finel關鍵字,在C#中可以使用sealed。 在C++中雖然沒有這些關鍵字,但你可以使用public繼承+對象組合的方式來做到這一點。上述例子便是。
  2. 減小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,庫的開發者可以用它來減小對象大小 (對象尺寸最小化)。

image.png

條款 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)。如圖:
image.png

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。

image.png
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也是不合適的,它有代價:
image.png

  • 虛繼承類的對象會更大一些;
  • 虛繼承類的成員訪問會更慢一些;
  • 虛繼承類的初始化更反直覺一些。繼承層級的最底層(most derived class)負責虛基類的初始化,而且負責整個繼承鏈上所有虛基類的初始化。

基于這些復雜性,Scott Meyers對于多繼承的建議是:

  1. 如果能不使用多繼承,就不用他;
  2. 如果一定要多繼承,盡量不在里面放數據,也就避免了虛基類初始化的問題。

接口類

這樣的一個不包含數據的虛基類和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

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/news/163302.shtml
繁體地址,請注明出處:http://hk.pswp.cn/news/163302.shtml
英文地址,請注明出處:http://en.pswp.cn/news/163302.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

3.1.2 Linux時間子系統 hrtimer示例使用

文章目錄 結構體定義接口初始化啟動修改取消示例示例1示例2示例3結構體定義 struct hrtimer {struct timerqueue_node node;ktime_t _softexpires;enum hrtimer_restart

生成目錄結構圖 tree命令

tree /f >info.txt tree命令可用于生成漂亮的目錄結構圖&#xff0c;在此之前&#xff0c;我一直以為是手打的…… .| index.html|\---static---css| bar.css| map.css| \---js

jQuery創建、插入、刪除對象

jQuery庫中的一些操作元素的方法 創建元素&#xff1a; $(htmlString)&#xff1a;這個構造器可以用來創建元素&#xff0c;其中htmlString是一個包含HTML標記的字符串。例如&#xff0c;$(<p>Hello, World!</p>)會創建一個<p>元素對象&#xff1b;$("&…

【uniapp】部分圖標點擊事件無反應

比如&#xff1a;點擊這個圖標在h5都正常&#xff0c;在小程序上無反應 css&#xff1a;也設置z-index&#xff0c;padding 頁面上也試過click.native.stop.prevent"changePassword()" 時而可以時而不行&#xff0c; 最后發現是手機里輸入鍵盤的原因&#xff0c;輸…

大型養殖場需要哪些污水處理設備

大型養殖場是一個涉及環境保護和可持續發展的關鍵行業&#xff0c;對于處理養殖場產生的污水有著明確的要求和標準。為了確保污水得到有效處理和處理效果達到國家排放標準&#xff0c;大型養殖場需要配備一系列污水處理設備。以下是幾種常見的污水處理設備&#xff1a; 1. 水解…

Python入門指南之基本概率和語法基礎

文章目錄 一、基本概念二、控制流三、函數四、模塊五、數據結構六、面向對象的編程七、輸入輸出八、異常九、Python標準庫關于Python技術儲備一、Python所有方向的學習路線二、Python基礎學習視頻三、精品Python學習書籍四、Python工具包項目源碼合集①Python工具包②Python實戰…

快速排序演示和代碼介紹

快速排序的核心是(以升序為例)&#xff1a;在待排序的數據中指定一個數做為基準數&#xff0c;把所有小于基準數的數據放到基準數的左邊&#xff0c;所有大于基準數的數據放在右邊&#xff0c;這樣的話基準數的位置就確定了&#xff0c;然后在兩邊的數據中重復上述操作

2023亞太地區數學建模B題思路分析+模型+代碼+論文

目錄 2023亞太地區數學建模A題思路&#xff1a;開賽后第一時間更新&#xff0c;獲取見文末名片 2023亞太地區數學建模B題思路&#xff1a;開賽后第一時間更新&#xff0c;獲取見文末名片 2023亞太地區數學建模C題思路&#xff1a;開賽后第一時間更新&#xff0c;獲取見文末名…

使用 Pinia 的五個技巧

在這篇文章中&#xff0c;想與大家分享使用 Pinia 的五大技巧。 以下是簡要總結&#xff1a; 不要創建無用的 getter在 Option Stores 中使用組合式函數&#xff08;composables&#xff09;對于復雜的組合式函數&#xff0c;使用 Setup Stores使用 Setup Stores 注入全局變量…

基于Python的新浪微博爬蟲程序設計與實現

完整下載&#xff1a;基于Python的新浪微博爬蟲程序設計與實現.docx 基于Python的新浪微博爬蟲程序設計與實現 Design and Implementation of a Python-based Weibo Web Crawler Program 目錄 目錄 2 摘要 3 關鍵詞 4 第一章 引言 4 1.1 研究背景 4 1.2 研究目的 5 1.3 研究意義…

2 使用React構造前端應用

文章目錄 簡單了解React和Node搭建開發環境React框架JavaScript客戶端ChallengeComponent組件的主要結構渲染與應用程序集成 第一次運行前端調試將CORS配置添加到Spring Boot應用使用應用程序部署React應用程序小結 前端代碼可從這里下載&#xff1a; 前端示例 后端使用這里介…

冷鏈運輸車輛GPS定位及溫濕度管理案例

1.項目背景 項目名稱&#xff1a;山西冷鏈運輸車輛GPS定位及溫濕度管理案例 項目需求&#xff1a;隨著經濟發展帶動物流行業快速發展&#xff0c;運輸規模逐步擴大&#xff0c;集團為了適應高速發展的行業現象&#xff0c;物流管理系統的完善成了現階段發展的重中之重。因此&…

eNSP-直連通信實驗

實驗拓撲&#xff1a; 實驗需求&#xff1a; 1. 按照圖中的設備名稱&#xff0c;配置各設備名稱 2. 按照圖中的IP地址規劃&#xff0c;配置IP地址 3. 測試R1與R2是否能ping通 4. 測試R2與R3是否能ping通 5. 測試R1與R3是否能ping通 實驗步驟&#xff1a; 1. 加入設備&…

Astute Graphics 2023(ai創意插件合集)

Astute Graphics 2023是一家專注于圖形編輯軟件的公司&#xff0c;以制作高質量、功能強大的圖像編輯工具而聞名。如Poser Pro、Poser 3D、Smart Shapes、Astute Sketch Pro等。 Astute Graphics的軟件具有以下特點&#xff1a; 強大的圖像編輯功能&#xff1a;Astute Graphi…

E-R圖與關系模式

1. E-R模型 英文全稱&#xff1a;Entity-relationship model&#xff0c;即實體關系模型 把現實世界的 實體模型通過建模轉換為信息世界的概念模型&#xff0c;這個概念模型就是E-R模型 2. 數據庫設計流程 一般設計數據庫分為三個步驟 把現實世界的實體模型&#xff0c;通…

大數據湖及應用平臺建設解決方案:PPT全39頁,附下載

關鍵詞&#xff1a;大數據湖建設&#xff0c;集團大數據湖&#xff0c;大數據湖倉一體&#xff0c;大數據湖建設解決方案 一、大數據湖定義 大數據湖是一個集中式存儲和處理大量數據的平臺&#xff0c;主要包括存儲層、處理層、分析層和應用層四個部分。 1、存儲層&#xff…

2. OpenHarmony源碼下載

OpenHarmony源碼下載(windows, ubuntu) 現在的 OpenHarmony 4.0 源碼已經有了&#xff0c;在 https://gitee.com/openharmony 地址中&#xff0c;描述了源碼獲取的方式。下來先寫下 windows 的獲取方式&#xff0c;再寫 ubuntu 的獲取方式。 獲取源碼前&#xff0c;還需要的準…

Linux之進程替換

創建子進程的目的 創建子進程的第一個目的是讓子進程執行父進程對應的磁盤代碼中的一部分, 第二個目的是讓子進程想辦法加載磁盤上指定的程序,讓子進程執行新的代碼和程序 一是讓子進程執行父進程代碼的一部分, 比如&#xff1a; 1 #include<stdio.h> 2 #include<…

數據分析基礎之《matplotlib(2)—折線圖》

一、折線圖繪制與保存圖片 1、matplotlib.pyplot模塊 matplotlib.pyplot包含了一系列類似于matlab的畫圖函數。它的函數作用于當前圖形&#xff08;figure&#xff09;的當前坐標系&#xff08;axes&#xff09; import matplotlib.pyplot as plt 2、折線圖繪制與顯示 展示城…

【實用】mysql配置 及將線上數據導入本地 問題解決及記錄

[ERR] 1292 - Incorrect datetime value: ‘0000-00-0000:00:00‘ for column ‘BIRTH_DATE‘ at row 1 此問題是mysql當前配置不支持日期為空&#xff0c;或者為‘0000-00-0000:00:00‘得情況 1、直接在數據庫執行 # 修改全局 set global.sql_mode ONLY_FULL_GROUP_BY,STR…