容器與封裝

在實際項目中,經常能夠看到容器被當作參數,在不同的對象間傳遞。這樣做有什么問題?

缺乏內聚性

在進一步討論之前,我們先來看看下面兩個表達式之間有何區別?

int value; 
std::list<int> values; 

經常得到的答案是:前者是一個primitive的數據,后者是一個對象。對于前者,你只能執行基本的數值演算;而后者的類型std::list<int>是一個類,你可以調用它的方法,比如:

values.push_back(5);

這個答案并沒有什么錯。那我們再來看一個問題:下面兩個表達式的區別在哪里?

Object object;
std::list<Object> objects;

對于這個問題,之前的答案就不再有效。因為在這個例子中,兩者都是對象。其不同之處在于,對Object對象的方法調用,是對一個具體的業務對象的操作;而后者卻是對容器對象的操作。

現在,我們將問題變為:這兩個例子中,前后兩個表達式的共同差異是什么?

如果你比較敏銳,應該已經得到答案:前者代表一個數據(對象),后者代表一組數據(對象)。

所以,雖然容器本身是一個對象,但更本質地,它代表著一組數據,圍繞著這組數據的業務邏輯,容器對象本身并沒有涉及。

所以,直接訪問一個容器,而不是將容器封裝在一個業務對象里;這和直接操作一個數據,而不是將數據封裝在一個抽象數據類型里,本質上沒有任何區別。它們都違反了數據和操作它的行為應該放在一起的高內聚原則。

缺乏穩定性

現在,我們再問一個問題:下面的三個表達式的共同之處是什么?

Object objects[100];
std::vector<Object> objects;
std::list<Object>   objects;

答案很簡單:都代表多個Object對象的集合。它們之間的實現技術上雖然不同,但從抽象層面上,這三個實現方式所要表達的概念并無任何不同。

實現技術可以隨著約束的變化而變化,但只要用戶的抽象需求沒有發生變化,用戶的代碼就不應該受到具體實現技術變化的影響。

因此,直接讓用戶訪問容器對象,不僅僅違反了高內聚的原則,還違反了“向著穩定的方向依賴”原則。

對容器進行封裝

基于上述的討論,我們可以得出如下結論:當系統中存在一個集合概念時,應考慮包含這個集合概念的單一概念是什么,并根據這個單一概念對集合進行封裝。

比如:一個班包含許多學生。糟糕的做法是:

typedef std::list<Student> SchoolClass; 

當在另外一個對象需要計算一個班的平均成績時,就會出現類似于下面的代碼:

struct Foo 
{ 
  void f(const SchoolClass& cls) 
  { 
    unsigned int averageScore = getAverageScoreOfClass(cls); 
    
    // ... 
  }
   
private: 
  unsigned int getAverageScoreOfClass(const SchoolClass& cls) 
  { 
    unsigned int totalScore = 0;    
    for( SchoolClass::const_iterator i=cls.begin(); i != cls.end(); ++i)
    {
      totalScore += (*i).getScore();
    }
    
    return totalScore/cls.size(); 
  } 
  // ... 
};

而一個合理的做法則是:

struct SchoolClass 
{ 
  unsigned int getAverageScore() const 
  { 
    // ... 
  } 
  // ... 
  
private:  
  std::list<Student> students; 
};
 
struct Foo 
{ 
  void f(const SchoolClass& cls) 
  { 
    unsigned int averageScore = cls.getAverageScore(); 
    // ... 
  } 
  // ... 
};

如果有一天,設計者認為使用定長數組是更好的選擇(因為std::list有可能因為內存問題而帶來的不確定性),那么所有的修改都被控制在SchoolClass內部,對于Foo,以及任何其它SchoolClass的客戶都毫無影響(局部化影響)。

多級容器

另外,在實際項目中,經常能夠看到類似于下面的定義:

typedef std::map<std::string, std::map<std::string, std::string> 
             > ConfigFile;

這還算輕微的。事實上,在我經歷過的項目中,三級甚至四級容器也并不罕見。

相對于單級容器,多級容器帶來的問題更多:這樣復雜的數據結構定義本身就非?;逎?,而其處理代碼也往往互相交織在一起,不僅難以理解,還極其脆弱:其中任何一個級別的容器發生變化都會給整個數據結構的處理代碼帶來影響。

比如,上述數據結構完全可以改變為:

typedef std::map<std::string, std::list<std::pair<std::string, std::string> > 
             > ConfigFile;

對于多級容器,其處理方法和單級容器的方法并沒有什么兩樣:將每一級容器都進行封裝。比如,對于剛才這個例子,至少可以進行類似于下面的封裝:

struct ConfigFile 
{ 
  // ... 
private:  
  std::map<std::string, ConfigSection> sections; 
};
 
struct ConfigSection 
{ 
  // ... 
private:  
  std::map<std::string, std::string> items; 
}; 

用意不明的數據子集

當一個數據集合被封裝在一個類中之后,對于這個數據集合的需求可能變化非常劇烈。比如,客戶代碼可以基于各種各樣的目的,從數據集合中過濾出一個數據子集,并對這個數據子集執行自己所需的操作。

如果將所有客戶的意圖,都堆積在數據集合所在的類中實現,將會造成這個類極其不穩定,也容易造成上帝類。同時,也會降低客戶代碼的內聚度。

這種情況下,數據集合類提供查詢接口,由客戶自定義一個過濾條件,數據集合類根據客戶自定義的過濾條件,得到客戶所需的數據子集,由客戶代碼對數據子集定義所需的操作,反而是個更好的選擇。

對于數據集合類而言,這些數據子集的語意是不明的,因為客戶才知道它的用途。所以,如果需要對這些數據子集進行封裝的話,也應該是客戶的責任。如果客戶將數據子集封裝為語意明確的類,并將這個類作為輸出參數傳遞給數據集合類的話,既會造成數據集合類對這些數據子集類型的依賴,同時仍然會造成數據集合類接口的不穩定。

所以,設計者們往往選擇給數據集合類提供類似與下面的接口與實現:

struct SchoolClass 
{ 
  void getStudentsByFilter 
    ( const Filter& filter // 輸入參數:過濾器 
    , std::list<Student>& result // 輸出參數:查詢結果 
    ) const 
  { 
    for( SchoolClass::const_iterator i=cls.begin() ; i != cls.end(); ++i)
    {      
      if(filter.matches(*i))
      { 
        result.push_back(*i);
      }
    } 
  } 
  // ... 

private:  
  std::list<Student> students; 
};

這樣的方法,幾乎可以保證數據集合類接口和實現的穩定。之所以說“幾乎”,是因為std::list作為雙方交換數據的契約,仍然過于具體。一旦因為某種原因發生變化,則雙方代碼都會受到影響。

但是,我們之前已經得出過結論:std::list雖然很具體,但也不能對其進行業務層面的封裝。我們似乎陷入了黔驢技窮的處境。

5 Why分析法告訴我們,如果我們多問幾個為什么,就能找到更加穩定的抽象。

客戶在拿到數據子集之后,一定有自己的意圖,我們如果讓接口反映的是用戶自己的意圖,而不是數據子集這么具體的實現細節,那么數據子集將會變成一個無用的中間層。

那客戶的意圖是什么呢?不知道。但我們有多態這門進行抽象的強大武器,借助于它,客戶的確切意圖對我們便不再重要。

所以,我們可以將上述代碼修改為:

struct Visitor 
{ 
  virtual void visit(const Student& student) = 0; 
  virtual ~Visitor {} 
};
 
struct SchoolClass 
{ 
  void visitStudentsByFilter 
     ( const Filter& filter  // 輸入參數:過濾器
     , Visitor&      visitor // 輸入參數:對過濾結果的處理 
     ) const 
  {
    for( SchoolClass::const_iterator i = cls.begin() ; i != cls.end(); ++i) 
    {
      if(filter.matches(*i)) 
      { 
        visitor.visit(*i);
      }
    } 
  } 
  // ... 
private:  
  std::list<Student> students; 
};

這樣的實現方式,幫助我們更加直接的滿足客戶的意圖。這不僅讓雙方的代碼更加穩定,在很多場合下,由于客戶并不需要存儲查詢的結果,繞開std::list這樣的數據集合,還可以提高性能,并降低內存管理方面的負擔。

比如,一個客戶想過濾出所有及格的學生,只是為了統計及格學生的數量,那么它就可以將Visitor實現為:

unsigned int Foo::getNumOfPassStudents(const SchoolClass& cls) const 
{ 
  struct PassStudentFilter : Filter 
  { 
    bool matches(const Student& student) const 
    {
      return student.isPass(); 
    }
  } filter; 

  struct PassStudentsCounter : Visitor 
  { 
    PassStudentsCounter() : numOfPassStudents(0) {} 
    
    void visit(const Student& student) { numOfPassStudents++; }
    
    unsigned int numOfPassStudents; 
  } counter; 

  cls.visitStudentsByFilter(filter, counter); 

  return counter.numOfPassStudents; 
} 

通過這個實現,我們注意到一個重要事實:Filter是不必要的,因為客戶可以在Visitor里自己進行過濾。所以,我們將之前數據集合類的實現修改為簡化版本的訪問者模式(由于沒有多種類型的元素,所以不需要雙重派發)。而這是一個更加通用的抽象,借助于它,可以簡化雙方的實現。

struct SchoolClass 
{ 
  void accept(Visitor& visitor) const 
  { 
    for( SchoolClass::const_iterator i=cls.begin() ; i != cls.end(); ++i) 
    {
      visitor.visit(*i);
    } 
  } 
  // ... 
private:  
  std::list<Student> students; 
};

而之前的客戶代碼也得到簡化:

unsigned intFoo::getNumOfPassStudents(const SchoolClass& cls) const 
{ 
  struct PassStudentsCounter : Visitor 
  { 
    PassStudentsCounter() : numOfPassStudents(0) {} 
    void visit(const Student& student)
    { 
      if(student.isPass()) numOfPassStudents++; 
    } 
    unsigned int numOfPassStudents; 
  } counter; 
  
  cls.accept(counter);

  return counter.numOfPassStudents; 
}

而對于確實需要保存下來過濾結果的客戶,仍然可以輕松達到目標:

struct Bar : private Visitor 
{ 
  void savePassedStudents(const SchoolClass& cls) 
  { 
    cls.accept(*this); 
  } 
  
  // ... 

private:  // 對 visit 方法的實現 

  void visit(const Student& student) 
  { 
    if(student.isPass()) passedStudents.push_back(student); 
  } 
  
private:
  // 注意,這不是 std::list,而是用戶根據自己需要而采用的數據結構 
  std::vector<Student> passedStudents;
  // ... 
};

在這個實現中,使用了私有繼承。關于其用法的詳細討論,請參考《Virtues of Bastard》。

總結

本文探討了直接暴露容器所帶來的問題,以及如何進行封裝,以提高可維護性。關于封裝,請參考《類與封裝》

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,501評論 6 544
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,673評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,610評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,939評論 1 318
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,668評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,004評論 1 329
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,001評論 3 449
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,173評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,705評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,426評論 3 359
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,656評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,139評論 5 364
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,833評論 3 350
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,247評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,580評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,371評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,621評論 2 380

推薦閱讀更多精彩內容