小類,大對象:C++

背景

時(shí)至今日,C++的核心戰(zhàn)場在于:對于性能,空間和實(shí)時(shí)性有高要求的系統(tǒng)。

而在這類系統(tǒng)上,也有其特定的約束和挑戰(zhàn):

  • 在這類系統(tǒng)上,內(nèi)存管理始終是個(gè)需要關(guān)注的問題。而通用內(nèi)存管理算法,要么容易導(dǎo)致內(nèi)存碎片,要么會導(dǎo)致內(nèi)存浪費(fèi)。而為了避免這樣的問題,最好是自己定義內(nèi)存管理器。

  • 內(nèi)存分配是可能失敗的。為了避免這樣的問題,高可靠系統(tǒng)的做法一般是按照系統(tǒng)定義的規(guī)格預(yù)先分配內(nèi)存。比如,系統(tǒng)承諾的規(guī)格是10000個(gè)Session,那么Session Instance相關(guān)的內(nèi)存,會按照規(guī)格,在系統(tǒng)啟動時(shí)就預(yù)先分配10000個(gè)Session Object需要的內(nèi)存。其背后的原則是:如果失敗,則盡早失敗fail fast);如果內(nèi)存不足,就應(yīng)該在未發(fā)布時(shí),通過系統(tǒng)加載就可以及時(shí)發(fā)現(xiàn);而不是把風(fēng)險(xiǎn)留到在業(yè)務(wù)現(xiàn)場運(yùn)行時(shí)發(fā)生失敗。

  • 對于需要大量銷售的設(shè)備,成本很重要。而內(nèi)存作為成本構(gòu)成,也是非常寶貴的資源。另外,由于競爭對手的存在,如何在讓客戶付出相同的成本下,能夠支撐更多的業(yè)務(wù),始終是一個(gè)重要的指標(biāo)。

  • 對于高性能計(jì)算系統(tǒng),所有的領(lǐng)域?qū)ο?/strong>,盡管部分?jǐn)?shù)據(jù)也可能會持久化,但在運(yùn)行時(shí),都會放在內(nèi)存之中,與另外一些純粹的運(yùn)行時(shí)狀態(tài)一起,構(gòu)成了運(yùn)行時(shí)領(lǐng)域?qū)ο蟮恼w。而對于有高可靠性要求的系統(tǒng),運(yùn)行時(shí)依然會對需要持久化的狀態(tài)進(jìn)行持久化——那怕只是采用主備方式其實(shí)也是一種持久化——所以系統(tǒng)事實(shí)上是無狀態(tài)的。

該如何在這類系統(tǒng)上應(yīng)用小類,大對象的設(shè)計(jì)模式,將是本文隨后討論的內(nèi)容。

委托

回到《小類,大對象》的文章里的例子,我們首先角色實(shí)現(xiàn)分割到了不同的類中,從而得到了四個(gè)類:

struct ConcreteChild : Child
{ 
  // 子女角色相關(guān)接口
  void getAdviceFromParent() {...}
  // ... 

private:
  // 子女角色所需的數(shù)據(jù)成員
  // ... 
};
struct ConcreteParent : Parent
   
{ 
  // 父母角色相關(guān)接口
  void tellStory() {...}
  void playGameWithChild() {...} 
  // ... 

private:
  // 父母角色所需的數(shù)據(jù)成員
  // ... 
};
struct ConcreteBoss : Boss
{ 
  // 老板角色相關(guān)接口 
  public void assignTask() {...}
  public void motivate() {...} 
  // ... 
  
private:
  // 老板角色所需的數(shù)據(jù)成員 
  // ... 
};
struct ConcreteUnderling : Underling
{ 
  // 下屬角色相關(guān)接口 
  public void acceptTask() {...}
  public void reportStatus() {...}
  // ... 
  
private:
  // 下屬角色所需的數(shù)據(jù)成員  
  // ... 
};

然后,我們根據(jù)不同對象的需要對這四個(gè)對象進(jìn)行組合。而首先進(jìn)入我們視野的,是我們最熟悉的委托Delegation):

struct TypeAPerson 
{ 
   // 父母角色相關(guān)接口 
   void tellStory() 
   { 
      parent.tellStory();
   }

   void playGameWithChild() 
   { 
      parent.playGameWithChild();
   }

   // 孩子角色相關(guān)接口
   void getAdviceFromParent() 
   { 
      child.getAdviceFromParent();
   }
   
   // 下屬角色相關(guān)接口 
   void acceptTask() 
   { 
      subordinate.acceptTask();
   }
   
   void reportStatus()
   { 
      subordinate.reportStatus();
   }
   
private:   // 通過委托關(guān)系進(jìn)行組合 
   ConcreteParent      parent;
   ConcreteChild       child;
   ConcreteSubordinate subordinate;
}; 

毫無疑問,這是令人生厭的中間人MiddleMan)實(shí)現(xiàn)(參見《重構(gòu)》)。

另外,TypeAPerson將自己作為一個(gè)整體展現(xiàn)給了所有客戶;而不同客戶真正需要的卻是不同的角色,因而,無論從依賴范圍角度,還是依賴穩(wěn)定度角度,都無疑增大了系統(tǒng)的耦合度(參見《變化驅(qū)動:正交設(shè)計(jì)》)。

因而,按照我們最初的意圖,如下的實(shí)現(xiàn)方式才是我們真正想要的:

struct TypeAPerson 
{ 
   Parent& getParent()
   {
     return parent;
   }
   
   Child& getChild()
   {
     return child;
   }
   
   Subordinate& getSubordinate()
   {
     return subordinate;
   }
   
private:
   // 通過委托關(guān)系進(jìn)行組合 
   ConcreteParent      parent;
   ConcreteChild       child;
   ConcreteSubordinate subordinate;
}; 

TypeAPerson僅僅應(yīng)該聚合(組合)其所需的角色實(shí)現(xiàn),其唯一的職責(zé)是當(dāng)做一個(gè)角色工廠,面對不同的客戶,將對象轉(zhuǎn)化為不同的角色:

// client of Parent
void f(Parent& parent)
{
   // ...
   parent.tellStory();
   // ... 
   parent.playGameWithChild();
   // ... 
}

// ...

// client of Subordinate
void g(Subordinate& subordinate)
{
  // ...
  subordinate.acceptTask();
  // ...
  subordinate.reportStatus();
  // ...
}

// object
TypeAPerson person1;

// cast to Parent role 
f(person1.getParent());

// cast to Subordinate role
g(person1.getSubordinate());

從這段示例代碼,我們可以清晰的看出,fg是完全不依賴TypeAPerson的,而只依賴自己真正需要依賴的角色。因而,如果TypeBPerson也實(shí)現(xiàn)了相關(guān)角色的話,它也可以和f,g配合。如下:

struct TypeBPerson
{
   Parent& getParent()
   {
     return parent;
   }
   
   Subordinate& getSubordinate()
   {
     return subordinate;
   }
   
private:
   // 通過委托關(guān)系進(jìn)行組合 
   ConcreteParent1      parent; // 與TypeAPerson的Parent實(shí)現(xiàn)不同
   ConcreteSubordinate subordinate;
};

// object
TypeBPerson person2;

// cast to Parent role 
f(person2.getParent());

// cast to Subordinate role
g(person2.getSubordinate());

通過這個(gè)例子,我們可以清晰的看出:將上帝類根據(jù)自己的上下文需要,分拆成多個(gè)角色類的好處:

  • 客戶代碼僅僅依賴的自己所需要依賴的角色,而不關(guān)心提供角色服務(wù)的對象,這解開了客戶與具體對象之間的耦合; 這不僅縮小了依賴范圍,也讓客戶向著穩(wěn)定的方向依賴;
客戶代碼只依賴某個(gè)角色
客戶代碼只依賴某個(gè)角色
  • 每個(gè)對象自身的不同角色實(shí)現(xiàn)是更加高內(nèi)聚,更加單一職責(zé)。角色與角色之間的耦合也從上帝類那種缺乏封裝,從而更容易導(dǎo)致高耦合的方式中解脫出來。讓各個(gè)角色代碼都更加正交;

  • 不同對象間的角色,如果實(shí)現(xiàn)是相同的,可以直接復(fù)用,這讓代碼復(fù)用更加容易;

  • 對象對多重職責(zé)的實(shí)現(xiàn)更加簡單,只需要通過一個(gè)承擔(dān)角色工廠職責(zé)的類來實(shí)例化對象。

Can We Do Better?

委托的實(shí)現(xiàn)方式看,已經(jīng)基本上達(dá)到了我們的意圖。

但是,我們也注意到:我們不得不讓TypeAPerson提供一組get接口,來暴露自己實(shí)現(xiàn)的角色。這些get接口,像SingletongetInstance()一樣。因而,上述的實(shí)現(xiàn)模式也是一種創(chuàng)建者模式。這也正是我們將其稱之為角色工廠的原因。

那么,是否還有更為簡便的方法來實(shí)現(xiàn)角色工廠的職責(zé)?

我們不難發(fā)現(xiàn):TypeAPerson與它所承擔(dān)的角色之間存在IS-A關(guān)系;而且,由于TypeAPerson沒有任何業(yè)務(wù)邏輯代碼,從而也沒有改寫任何一個(gè)父類(角色)的行為,因此這種IS-A關(guān)系必然是滿足里氏替換原則。那我們?yōu)楹尾辉囋嚩嘀乩^承:

struct TypeAPerson 
   : ConcreteParent
   , ConcreteChild
   , ConcreteSubordinate
{    
}; 

這樣的方式,在組合TypeAPerson時(shí),明顯比委托的實(shí)現(xiàn)方式更為簡潔。

而在調(diào)用側(cè)使用角色時(shí),由于IS-A這種關(guān)系的存在,其角色轉(zhuǎn)換也可以自動完成,從而也更為簡潔:

// object
TypeAPerson person1;

// naturally cast to Parent role.
f(person1);

// naturally cast to Subordinate role.
g(person1);

當(dāng)然,多重繼承的實(shí)現(xiàn)方式,相對于委托方式,還是存在一點(diǎn)缺點(diǎn):你無法阻止ParentAPerson可以向ConcreteParent這個(gè)更具體的角色轉(zhuǎn)化;但在委托方式下,由于ParentAPerson的具體組合的對象都作為了私有實(shí)現(xiàn)細(xì)節(jié),然后通過getter這種更有彈性的函數(shù)方式,將具體的角色實(shí)現(xiàn),比如ConcreteParent,轉(zhuǎn)化為更抽象的角色Parent;從而具備更好的封裝性。

對于這類問題,其解法有二:一則可以通過語言提供的機(jī)制進(jìn)行強(qiáng)制約束,二則通過人為的約定。多重繼承的方式,在C++里沒有強(qiáng)制禁止向一個(gè)代表某種具體實(shí)現(xiàn)的父類進(jìn)行轉(zhuǎn)換的手段,但站在便利性的角度,我們更傾向于選擇通過人為的約定。畢竟我們清晰的知道我們的設(shè)計(jì)意圖是什么。

角色依賴

在之前的例子中,為了突出要點(diǎn),給出的各個(gè)角色的實(shí)現(xiàn)都是孤立的。但在實(shí)際項(xiàng)目中,角色之間存在依賴關(guān)系,也是一種常見的現(xiàn)象。

比如,在我們的例子中,TypeBPerson下屬角色的某個(gè)實(shí)現(xiàn),需要調(diào)用上司角色所提供的服務(wù)。如下圖所示:

角色依賴
角色依賴

這個(gè)難不倒我們,我們可以讓下屬角色持有一個(gè)指向上司角色的指針,然后在構(gòu)造下屬角色時(shí)進(jìn)行依賴注入。如下是一種委托方式的實(shí)現(xiàn):

struct ConcreteUnderling : Underling
{ 
  ConcreteUnderling(Boss& boss)
     : boss(boss) 
  {}

  void acceptTask() 
  { 
    boss.assignTask();
    // ...
  }
  
  // ... 
private:
  Boss& boss; // 引用會消耗一個(gè)指針寬度的內(nèi)存
};
 
struct TypeBPerson 
{ 
  TypeBPerson()
    : underling(boss)
  {}

  Boss& getBoss() 
  {
    return boss;
  }
  
  Underling& getUnderling()
  {
    return underling;
  }
  
private:
  ConcreteBoss      boss; // 確保 Boss 在 Underling 之前 
  ConcreteUnderling underling;
  // ... 
};

這樣的實(shí)現(xiàn),存在如下問題:

首先,需要確保不同角色的構(gòu)造順序,一旦角色Underling依賴了角色Boss,那么,在TypeBPerson里,就最好確保boss定義在Underling之前,以免由于構(gòu)造順序所造成的調(diào)用問題。

其次,一旦一個(gè)角色引用了另外一個(gè)角色,那就需要通過引用進(jìn)行依賴注入。這會增加由于引用所消耗的內(nèi)存,對象間的關(guān)聯(lián)越多,那么指針消耗的空間就越大。

尤其是當(dāng)我們追求高內(nèi)聚,低耦合的設(shè)計(jì)時(shí),伴隨而生的是很多很小單一職責(zé)的類,類與類之間會通過引用進(jìn)行職責(zé)委托。這對于那些內(nèi)存不是一個(gè)重要問題的系統(tǒng)而言,或許并不重要。在內(nèi)存珍貴的嵌入式設(shè)備上,這會是一個(gè)問題。

而這個(gè)問題,也會反過來約束C++程序員即便知道高內(nèi)聚,低耦合是正確的,在內(nèi)存約束面前,也只能采取更糟糕的實(shí)現(xiàn)方式。

How About Inheritance?

對于角色關(guān)聯(lián)所導(dǎo)致的問題,換成多重繼承也不會讓情況變得更好:

struct TypeBPerson 
  : ConcreteBoss // 確保 Boss 在 Underling 之前 
  , ConcreteUnderling
  , 
{ 
  TypeBPerson()
    : ConcreteUnderling(*this)
  {}
};

這兩種實(shí)現(xiàn),差別在于從成員變量便為繼承,而不變的是:都要注意聲明順序,都會造成由引用帶來的內(nèi)存消耗。

工廠方法

幸運(yùn)的是,相對于委托,通過繼承,我們可以擁有更多的武器。

對外部的依賴,在繼承體系下,我們可以通過著名的工廠方法來引入,而不是通過經(jīng)典的構(gòu)造時(shí)依賴注入方式。比如:

struct ConcreteUnderling : Underling
{ 
  void acceptTask() 
  { 
    getBoss().assignTask();
    // ... 
  } 

private: 

  // 通過工廠方法引入依賴 
  virtual Boss& getBoss() = 0; 
};
 
struct TypeBPerson  : ConcreteBoss 
  , ConcreteUnderling 
{  
  Boss& getBoss() override
  { 
    return *this; 
  } 
};

通過這樣的方法,首先解決了角色構(gòu)造順序的問題。因?yàn)?,一個(gè)角色對于另外一個(gè)角色的引用,只有到整個(gè)對象構(gòu)造結(jié)束后,運(yùn)行時(shí)才會進(jìn)行獲取。當(dāng)然你需要避免任何在構(gòu)造函數(shù)里對于其它角色的引用,而事實(shí)上,根據(jù)多個(gè)項(xiàng)目的實(shí)踐,這種構(gòu)造時(shí)引用關(guān)系都可以合理的避免。

內(nèi)存優(yōu)勢

但這個(gè)例子還不能彰顯工廠方法的內(nèi)存優(yōu)勢。讓我們換個(gè)例子:

struct Role1
{
   Role1
    ( Role2& role2
    , Role3& role3
    , Role4& role4) 
    : role2(role2)
    , role3(role3)
    , role4(role4)
  {}
   
  // methods
   
private:
   Role2& role2;
   Role3& role3;
   Role4& role4;
};
 
struct Object  : Role1 
  , Role2
  , Role3
  , Role4 
{
  Object()
    : Role1(*this, *this, *this)
  {}
};

這種通過直接引用的方式,讓Role1需要消耗三個(gè)引用的空間開銷。

現(xiàn)在將其換成基于工廠方法的實(shí)現(xiàn):


struct Role1
{
  // methods
   
private:
   virtual Role2& getRole2() = 0; 
   virtual Role3& getRole3() = 0;
   virtual Role4& getRole4() = 0;
};
 
struct Object  : Role1 
  , Role2
  , Role3
  , Role4 
{
  Role2& getRole2() override { return *this; }
  Role3& getRole3() override { return *this; }
  Role4& getRole4() override { return *this; }
};

現(xiàn)在我們可以清晰的看出,對于Role1,無論其對外部有多少個(gè)角色引用,都只需要耗費(fèi)一個(gè)指針內(nèi)存的開銷,那就是虛表指針vptr)。如果Role1本來就存在其它virtual函數(shù),那么這些外部引用,無論存在多少,都沒有增加任何額外的空間開銷。

簡化工廠管理成本

我們知道,按照高內(nèi)聚,低耦合的實(shí)現(xiàn)方式,會導(dǎo)致一堆小類。如果不用小類,大對象的方式,而是讓一個(gè)個(gè)小類可獨(dú)立實(shí)例化。那么這些小類之間如果存在引用關(guān)系,一則需要更多的內(nèi)存消耗,二則,你還不得不需要寫很多工廠來對這些小對象進(jìn)行構(gòu)造和關(guān)聯(lián)。比如:

struct A
{
   A(B* b, C* c) : b(b), c(c) {}
   
   // ...
   
private:
   B* b;
   C* c;
};

struct AFactory
{
   static A* create()
   {
       B* b = ... // get b;
       C* c = ... // get c;
       
       return new A(b, c);
   }
}

當(dāng)對象種類很多時(shí),這樣的承擔(dān)工廠職責(zé)的代碼就會很多。而這類的代碼是極其無趣而令人厭煩的。

當(dāng)然對于其它應(yīng)用語言,往往會提供一個(gè)框架,來管理這類工廠職責(zé)。比如JavaSpring。程序員需要做的是:將對象,及對象間的關(guān)聯(lián)關(guān)系,通過xml配置文件進(jìn)行描述,Spring框架會根據(jù)這個(gè)配置文件來履行工廠職責(zé)。

可是,這樣的方法在嵌入式C++下不是一個(gè)可行的途徑,程序員們還是不得不親自去實(shí)現(xiàn)。

而在小類,大對象的實(shí)現(xiàn)模式下,固定模式的工廠方法就完成了這些,程序員不會比Java下用XML進(jìn)行配置,需要付出的努力更多。

struct Object  
  : Role1 
  , Role2
  , Role3
  , Role4 
{
  // 這些工廠方法即屬于工廠職責(zé)的實(shí)現(xiàn)
  Role2& getRole2() override { return *this; }
  Role3& getRole3() override { return *this; }
  Role4& getRole4() override { return *this; }
};

Once For All

更妙的是,在一個(gè)對象上,無論一個(gè)角色被多少其它角色引用,最后都只需要實(shí)現(xiàn)一次。比如:

struct Role1
{
  // ...
private:
  virtual Role4& getRole4() = 0;  
};

struct Role2
{
  // ...
private:
  virtual Role4& getRole4() = 0;  
};

struct Role3
{
  // ...
private:
  virtual Role4& getRole4() = 0;  
};

struct Object 
  : Role1 
  , Role2
  , Role3
  , Role4
{
private:
  // 只實(shí)現(xiàn)一次,就滿足了Role1,Role2,Role3的需要。
  Role4& getRole4() override { return *this; }
};

即便對于一個(gè)來自于外部的角色,也是如此:

struct Object 
  : Role1 
  , Role2
  , Role3
{
  // Role4是External Role,因而需要在對象構(gòu)造時(shí)注入
   Object(Role4& role4) : role4(role4) {}
   
private:
  // 只實(shí)現(xiàn)一次,就滿足了Role1,Role2,Role3的需要。
  Role4& getRole4() override { return role4; }
  
  Role4& role4;
};

更清晰的依賴語義描述

使用工廠方法來表達(dá)依賴的例子如下:

struct Role1
{
  void f()
  {
    // ..
    // 對依賴的引用
    getRole2().doSth();
    // ...
    getRole3().blah();
    // ...
  }
   
private:
  // 對依賴的聲明
  virtual Role2& getRole2() = 0;
  virtual Role3& getRole3() = 0;  
  
};

不難看出,這段代碼的語義如下:

use語義
use語義

并且這樣的實(shí)現(xiàn)方式也是完全模式化的,因而我們定義如下兩個(gè)宏:

 #define USE_ROLE(RoleType) \\\\
   virtual RoleType& get##RoleType() = 0 

 #define ROLE(RoleType) get##RoleType() 

通過它們,之前的例子就可以修改為:

struct Role1
{
   void f()
   {
      // 對依賴的引用
      ROLE(Role2).doSth();
      // ...
      ROLE(Role3).blah();
   }
   
private:
  // 對依賴的聲明
  USE_ROLE(Role2);
  USE_ROLE(Role3);
};

不難發(fā)現(xiàn),一個(gè)角色,通過USE_ROLE語義,僅僅聲明自己對另外一個(gè)角色的依賴,卻完全無需關(guān)心這個(gè)角色的實(shí)現(xiàn)來自何處,也完全無需關(guān)注誰會注入給它。這實(shí)現(xiàn)了與經(jīng)典依賴注入方式完全相同的語義,達(dá)到了完全相同的解耦效果。

而對于工廠的實(shí)現(xiàn),同樣也有明確的模式和清晰的語義:

struct Object 
  : Role1 
  , Role2
  , Role3
  , Role4 
{
  // 這些工廠方法即屬于工廠職責(zé)的實(shí)現(xiàn)
  Role2& getRole2() override { return *this; }
  Role3& getRole3() override { return *this; }
  Role4& getRole4() override { return *this; }
};

因而,我們可以定義如下的宏:

#define IMPL_ROLE(RoleType) \\\\
  RoleType& get##RoleType() override { return *this; }

利用它,我們就可以將工廠代碼改寫為:

struct Object  
  : Role1 
  , Role2
  , Role3
  , Role4 
{
private:
  IMPL_ROLE(Role2);
  IMPL_ROLE(Role3);
  IMPL_ROLE(Role4);
};

直接引用,還是工廠方法

直接引用,相對于工廠方法,會帶來更多的內(nèi)存成本,以及工廠管理成本。

直接引用,會存在微弱的性能優(yōu)勢。根據(jù)我們的項(xiàng)目經(jīng)驗(yàn),這些性能優(yōu)勢微乎其微。但如果在你的項(xiàng)目中,經(jīng)過事后測量,確實(shí)發(fā)現(xiàn)熱點(diǎn)處可以通過直接引用提升性能,那就可以在那個(gè)點(diǎn),將工廠方法,改為直接引用的方式。而這個(gè)改動,并不困難。

繼承樹倒置

當(dāng)使用單根繼承時(shí),如果子類沒有任何代碼,這樣的繼承是沒有太多意義的。比如:

struct Base 
{ 
  void foo(); 
  void bar(); 
  
private: 
  int a;  
  int b; 
}; 

//  這樣的繼承,由于子類沒有任何代碼, 
//  如果不是出于某些特定的目的,是沒有任何意義的。

struct Derived : Base 
{};

但是,當(dāng)使用多重繼承時(shí),子類沒有任何實(shí)現(xiàn)代碼,卻表達(dá)了一個(gè)非常有價(jià)值的語義:組合。

TypeAPerson有效的將多個(gè)類的數(shù)據(jù)和行為都組合到一個(gè)對象上。最重要的是,這個(gè)沒有任何實(shí)現(xiàn)代碼的子類,恰恰是我們設(shè)計(jì)時(shí)所追求的單一職責(zé) —— TypeAPerson的唯一職責(zé)是:將所有角色組合到一個(gè)對象身上。

這樣的設(shè)計(jì)是一種以組合的方式,最終聚合到單個(gè)對象類。它和經(jīng)典的單根繼承方式所導(dǎo)致的繼承樹正好相反。因而,我們也稱它為繼承樹倒置模式。下圖是來自于一個(gè)項(xiàng)目的例子:

繼承樹倒置
繼承樹倒置

繼承優(yōu)于委托

通常在設(shè)計(jì)中,我們得到的建議往往是:委托優(yōu)于繼承。其原因在于:委托是黑盒復(fù)用,而繼承是一種白盒復(fù)用。

但正如我們之前討論的,在多角色對象的實(shí)現(xiàn)中,最終的對象類,沒有任何業(yè)務(wù)實(shí)現(xiàn)代碼,因此不會對父類產(chǎn)生任何實(shí)現(xiàn)上的依賴。角色類的所有實(shí)現(xiàn),對對象類類而言,在邏輯上和一個(gè)黑盒無異。

而反過來,繼承式組合,相對于委托式組合,至少有如下優(yōu)勢:

  • 簡化了組合方式;
  • 大大降低了內(nèi)存開銷;
  • 消除了角色構(gòu)造順序問題;
  • 大大簡化了依賴管理問題;
  • 對象到角色的自動轉(zhuǎn)換;

因而,在多角色對象的場景下,繼承式組合要優(yōu)于委托式組合

為何這樣的多重繼承不邪惡

過去,多重繼承在面向?qū)ο笊鐓^(qū)內(nèi)一直頗有爭議。大多數(shù)書籍都會建議:盡量避免使用多重繼承,要謹(jǐn)慎的使用多重繼承。于是多重繼承就逐漸變?yōu)槌绦騿T唯恐避之不及的東西。

多重繼承的邪惡之處主要體現(xiàn)在幾個(gè)方面:

  1. 菱形繼承所帶來的數(shù)據(jù)重復(fù),以及名字二義性。因此,C++引入了virtual繼承來解決這類問題;

  2. 即便不是菱形繼承,多個(gè)父類之間的名字也可能存在沖突,從而導(dǎo)致的二義性;

  3. 如果子類需要擴(kuò)展改寫多個(gè)父類的方法時(shí),造成子類的職責(zé)不明,語義混亂;

  4. 相對于委托繼承是一種白盒復(fù)用,即子類可以訪問父類的protected成員, 這會導(dǎo)致更強(qiáng)的耦合。而多重繼承,由于耦合了多個(gè)父類,相對于單根繼承,這會產(chǎn)生更強(qiáng)的耦合關(guān)系。

但我們看看TypeAPerson,它沒有任何代碼。因而它沒有操作任何父類的數(shù)據(jù)和方法,所以,第3點(diǎn)和第4點(diǎn)的缺點(diǎn)并不存在。

關(guān)于第2點(diǎn)所描述的二義性問題,這需要從兩個(gè)方面來看:子類的內(nèi)部和外部。

從子類內(nèi)部的角度,由于無需訪問父類,所以,多個(gè)父類之間即便存在名字沖突,在子類內(nèi)部也不會造成二義性問題。

而從子類外部來看,如果直接通過子類的實(shí)例來調(diào)用成員函數(shù),這種二義性確實(shí)可能存在。但對于一個(gè)多角色對象,所有外部訪問都應(yīng)該是基于角色的。而對于每個(gè)角色,名字的對應(yīng)關(guān)系是明確的,沒有任何二義性。所以,多角色對象特定的訪問模式,決定了在外部也不會造成二義性。

至于第1點(diǎn),菱形繼承帶來的兩個(gè)問題:數(shù)據(jù)重復(fù)二義性。

我們首先應(yīng)該避免不符合我們需要的菱形繼承。

對于由設(shè)計(jì)而自然產(chǎn)生的菱形繼承,我們無需使用virtual繼承來避免數(shù)據(jù)重復(fù)。這分為兩種情況:

  1. 基類數(shù)據(jù)的重復(fù)正是每個(gè)角色實(shí)現(xiàn)的需要。對于每個(gè)角色,它確實(shí)需要有自己的一份數(shù)據(jù)拷貝,即便這些數(shù)據(jù)和另外一個(gè)角色是重復(fù)的。這些“重復(fù)數(shù)據(jù)”在每個(gè)角色那里都有自己的不同狀態(tài)。另外,由于外部訪問是基于某個(gè)具體角色的,所以不會造成二義性問題。

  2. 如果基類數(shù)據(jù)是共享的,那也不應(yīng)該使用virtual繼承,而是通過委托關(guān)系來共享數(shù)據(jù)。這樣,就可以更加合理的避免數(shù)據(jù)重復(fù)。

至于行為重復(fù),由于角色與角色之間的需求是不應(yīng)該重疊的。所以,對于同一個(gè)對象,很難出現(xiàn)兩個(gè)角色之間有相同的行為子集。如果出現(xiàn),則說明這兩個(gè)角色的職責(zé)都不單一。將重疊的行為子集定義為一個(gè)新的角色,是一個(gè)更合理的設(shè)計(jì)選擇。

綜上所屬,對于多角色對象而言,這種組合方式不會從實(shí)質(zhì)上帶來多重繼承所引起的任何問題。

簡化內(nèi)存管理成本

在開篇時(shí),我們已經(jīng)提到:很多通信設(shè)備,為了避免內(nèi)存管理所導(dǎo)致的問題:比如碎片化,浪費(fèi),以及運(yùn)行時(shí)內(nèi)存分配失敗,會對領(lǐng)域?qū)ο?/strong>自定義自己的內(nèi)存管理器,并在系統(tǒng)加載時(shí),就會預(yù)先分配所需的所有內(nèi)存。如下:

struct Object  
  : Role1 
  , Role2
  , Role3
  , Role4 
{
  // 重載 new, delete
  void* operator new(size_t);
  void free(void* p);
  
private:
  IMPL_ROLE(Role2);
  IMPL_ROLE(Role3);
  IMPL_ROLE(Role4);
};

namespace
{
  // 定義數(shù)量為500的Object對象池。
  ObjectAllocator<Object, 500> allocator;
}

// 大對象從自己的對象池分配
void* Object::operator new(size_t) 
{
  return allocator.alloc();
}

 void Object::free(void* p)
 {
   return allocator.free(p);
 }

但如果系統(tǒng)由于高內(nèi)聚低耦合的方式而導(dǎo)致了很多小對象,就不得不為每個(gè)小對象都定義自己的內(nèi)存管理器,并且要按照其最大數(shù)量來預(yù)先分配,這會隨著小對象種類的增多,而大大加重內(nèi)存管理的負(fù)擔(dān)。

另外,在高性能計(jì)算領(lǐng)域,為了降低cache miss rate,一般的做法是將關(guān)聯(lián)訪問數(shù)據(jù)都盡可能的放在一起。如果分隔為很多小對象,它們都從不同的內(nèi)存區(qū)域進(jìn)行存放的話,對于性能會造成不同程度的負(fù)面影響。

而通過大對象的方式,所有的數(shù)據(jù)最后都聚集在一個(gè)對象身上,它們的內(nèi)存是連續(xù)的。這對于性能及性能優(yōu)化都有幫助。

其它問題

數(shù)據(jù)該怎樣存放

數(shù)據(jù)按照高內(nèi)聚,低耦合的原則,歸屬于各個(gè)不同的角色。然后,角色間根據(jù)需要,引用并訪問對方的接口。

私有角色

有些角色,純粹是因?yàn)橐粋€(gè)對象自身的需要,并不需要公開給外部,則可以通過private繼承(參見《The Virtues Of Bastard》)進(jìn)行組合。其它角色對它的引用,依然通過USE_ROLE(Role)的方式獲取。

總結(jié)

本文介紹了使用C++,在高性能計(jì)算領(lǐng)域,內(nèi)存受限系統(tǒng)下,對于小類,大對象實(shí)現(xiàn)方式的主要方面。

對于這類系統(tǒng),小類,大對象,會帶來各方面的幫助:

  • 清晰:有助于建立與領(lǐng)域清晰映射的領(lǐng)域模型;
  • 彈性:在滿足性能,空間的約束前提下,遵從高內(nèi)聚低耦合的設(shè)計(jì)原則,讓軟件易于理解,易于變化;
  • 簡單:在滿足領(lǐng)域特定約束的前提下,降低了諸多偶發(fā)成本。

因此,小類,大對象設(shè)計(jì)模式,成為我們最近幾個(gè)電信產(chǎn)品的設(shè)計(jì)的基石。幾年前,我當(dāng)時(shí)所在的團(tuán)隊(duì),設(shè)計(jì)了一款t-shirt,表達(dá)了小類,大對象對于我們那個(gè)項(xiàng)目的重要性,以及我們對它的喜愛:

團(tuán)隊(duì)T-Shirt
團(tuán)隊(duì)T-Shirt
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容