1122 C++ 程式作業 3b:
        資料匣與檔案系統設計 II

簡介

這裡延續 作業 3a, 但是並沒有打算增加程式的功能, (當然如果你覺得你前面繳交的作業功能不全的, 還是可以新增), 我們要修改作業 3a 中相同功能程式碼重複多次的問題, 我們希望用上課時談到實作 double dispatch 功能的 Vistor 設計樣式來配合 Composite 設計樣式工作, 以便得到一個比較好的程式架構。

這個作業要寫的程式不多, 但是你應該由作業 3a 的程式開始修改, 為了不把一個已經測試完成的程式改壞掉, 你還是要花相當的時間, 耐心地修改相關的程式, 同時也應該要設計好單元測試的程式碼, 一邊修改一邊測試, 由於程式的外觀不會有明顯改變, 所以你也特別必須用心體會, 才能夠了解這些修改的真正意義, 以後遇見類似的問題才能夠以比較好的物件導向設計來應付。

做了這個作業以後, 應該會有進一層的體會, 設計程式不只著重在完成一些指定的功能而已, 不見得只是硬湊出一些功能而已, 是可以很有巧思的, 就像是蓋房子, 可以只蓋出四四方方、平平淡淡、足以遮風避雨的東西, 也可以設計成美觀有創意有價值、可重複欣賞與應用的真正工藝品。

範例執行程式 FileDirectory_v1.exe, 資料檔案 prog3b.in, 下載後可以直接透過鍵盤交談式執行,也可以用

直接執行資料檔案中的命令, 程式結束後會將所建立的資料匣及檔案架構存檔, 下次執行時會自動讀入, 不需要重新由鍵盤輸入。

由使用界面和功能上你應該看不到和上一次作業有什麼差別, 這也是這個測試資料檔案的主要目標: 保證具有和上一次作業完全一樣的功能。

作業目標:

  1. 了解在自己的程式中發現什麼狀況, 觀察到什麼現象時, 不要硬幹下去了, 需要改用 Visitor 設計樣式

  2. 練習使用 Visitor 設計樣式, 更改作業 3a 的程式架構, 使它比較有彈性, 更有擴充的能力

  3. 練習簡易的程式 Refactoring: 修改上一次已經測試完畢, 可以正確執行的作業, 在修改時運用 assert 以及測試程式來保證修改後程式的正確性 (萬一你上次在寫作業時沒有加入適當的 assert 敘述, 在你開始動手修改前趕緊加進測試的程式碼吧!)

程式說明

  1. 問題說明: 請回憶在作業 3a 中, 不管是在實作 這些功能的時候, 你一定會發現一個不斷出現的程式架構與設計流程:

    例如在計算檔案/資料匣大小的時候, 你需要完成下列這三件事情:

    1. 首先你會在 Folder 類別中加入一個成員函式 int getSize() 在裡面加總所有檔案以及子資料匣的大小, 例如:
      int Folder::getSize()
      {
          vector<Entry *>::iterator iter;
          int sum=0;
          for (iter=m_entries.begin(); iter<m_entries.end(); iter++)
              sum += (*iter)->getSize();
          return sum;
      }
      這段程式假設每一個子資料匣和檔案都會計算它的大小, 然後以多型指標 (*iter) 來委託容器中每一個物件計算它自己的大小。

    2. 然後你需要在 Entry 類別加入一個虛擬的成員函式, virtual int getSize()=0, 如此多型指標才會正確地運作。

    3. 接下來需要在 File 類別中也加入一個成員函式 int getSize(), 覆蓋 (override) int Entry::getSize(), 在裡面計算檔案內容的大小, 例如:
      int File::getSize()
      {
          return m_content.size();
      }
    這樣子修改三個類別的設計才能夠完成一個功能, 雖然不算是很複雜, 也還蠻有規律的, 不幸的是 這些步驟不斷地重複出現, 讓人覺得不太容易掌握, 讓設計者很容易東忘西忘的, 以後修改的時候更容易出現不一致的問題, 出現這樣的狀況往往代表 設計上的缺陷

  2. 問題歸納:
    • 完成一個功能時需要修改三個類別, 一個功能的相關實作卻分散在三個類別裡
    • 一直在增加 Entry 抽象類別的界面, 每一個新增功能都需要新增 Entry 類別的界面, 否則多型指標沒有辦法正常運作
    • 每增加一個功能, 就會擔心它和其它的功能混雜在一起, 使得 Entry - File - Folder 這個架構越來越複雜

  3. 上述問題的解決方法: 當然就是我們在簡介裡所提到的 Visitor 設計樣式, 這是前人設計的結晶, 本來不是很好瞭解, 但是因為你已經完成了作業 3a, 走過有問題的那一條路, 有特別好的基礎可以了解這個 Visitor 設計樣式, 請用心體會類別間的分工, 以及透過這樣子角色的轉換所得到的好處。

    Composite 設計樣式所描述的異質的樹狀資料架構中, 我們要加入一些功能時, 會依照不同的類別而有不同的作法, 我們把這些功能直接加進去時會使得 Composite 設計樣式不但要管理異質的樹狀資料架構, 還要同心協力來完成指定的功能, 於是 Composite 設計樣式中的各個成員變得越來越多也越來越複雜。

    用 Visitor 設計樣式和 Composite 設計樣式合作時, 最主要就是希望讓 Composite 設計樣式專心管理異質的樹狀資料架構, 其它的演算法和不斷增加的新功能讓 Visitor 設計樣式來處理, 增加功能時 Composite 設計樣式的各個類別根本不需要修改

    用一個比喻來說, Visitor 和 Composite 設計樣式的合作, 就好像是一個專業的廣告公司 (Visitor) 和它的客戶 (Composite), 如果每一個客戶都要負責自己公司的廣告設計, 用很多的人力卻不一定能把工作做好, 每一個客戶為自己做廣告設計, 也會使得自己公司內人員的業務變得比較複雜, 比較容易出錯, 這時候如果出錯的話其實不是個別人員的問題, 常常是公司管理以及組織架構的問題。 這個時候其實需要一個專業的廣告公司, 在這個廣告公司裡當然比較會是任務導向的, 針對每一種客戶都有一個專門的設計小組 (例如 Visitor 類別中所實作的 visit(File *) 界面就是一個為了處理 File 這個類別的工作小組, visit(Folder *) 界面就是一個為了處理 Folder 這個類別的工作小組), Acceptor 所定義的 accept(Visitor &) 界面最主要的功能就是當專業的廣告公司人員來到客戶公司內部時, 必須有適當的接待人員來將公司的需求和廣告公司的設計人員溝通, 以這個客戶的屬性來挑選廣告公司內專門的設計小組來為其服務。

    Visitor 設計樣式的骨幹如下圖中紅色部份所示:

    Visitor 設計樣式主要包括兩個抽象類別, 一個是 Visitor 類別, 一個是 Acceptor 類別, 基本上它們兩個類別裡都只定義抽象的界面, 當然也藉由這個界面來定義相對應的運作方式:

    1. Acceptor 類別只定義一個純虛擬的 accept(Visitor &) 的界面函式如下:
      class Acceptor
      {
      public:
          virtual void accept(Visitor &visitor)=0;
      };
      要求 Composite 設計樣式中的 Entry 類別繼承它, 也就是要求每一個底層的類別包括 File 和 Folder 都要實作這個界面函式, 不過這個界面函式不論在 File 類別內或是在 Folder 類別內的實作都一樣也都很簡單, 如下所示:
      void File::accept(Visitor &visitor)
      {
          visitor.visit(this);
      }
      
      void Folder::accept(Visitor &visitor)
      {
          visitor.visit(this);
      }
      這兩個函式看起來內容完全一樣, 但是其實因為 this 指標所指到物件的型態不一樣, 使它可以完成適當的功能: 它最主要的目的是根據不同物件的類別來決定用 Visitor 類別物件中相對應的 visit() 成員函式來處理實際的功能。

    2. 第二個抽象類別是 Visitor, 它的定義如下:
      class Visitor
      {
      public:
          virtual void visit(File *file)=0;
          virtual void visit(Folder *folder)=0;
      };
      由於所要拜訪的 Composite 設計樣式中只有兩種實際的類別, 所以只需要定義兩個不同的純虛擬函式。

    3. 以下我們以計算檔案及資料匣的大小這個功能為例, 實作 SizeVisitor 的類別
      class SizeVisitor : public Visitor
      {
      public:
          SizeVisitor():m_totalSize(0) {}
          void visit(File *file);
          void visit(Folder *folder);
          int getTotalSize();
      private:
          int m_totalSize;
      };
      
      
      void SizeVisitor::visit(File *file)
      {
          m_totalSize += file->getSize();
      }
      
      void SizeVisitor::visit(Folder *folder)
      {
          vector<Entry *>::iterator iter;
          for (iter=folder->begin(); iter!=folder->end(); iter++)
              (*iter)->accept(*this);
      }
      
      int SizeVisitor::getTotalSize()
      {
          return m_totalSize;
      }
      其中需要使用 Folder 類別特別定義的 begin() 和 end() 兩個界面,
      vector<Entry *>::iterator Folder::begin()
      {
          return m_entries.begin();
      }
      
      vector<Entry *>::iterator Folder::end()
      {
          return m_entries.end();
      }
      假設 entry 為你打算計算大小的物件指標, 使用這個 SizeVisitor 的方法如下:
      SizeVisitor sv;
      entry->accept(sv);
      cout << "The size is " << sv.getSize() << " bytes." << endl;
      

  4. 程式修改時請注意下列事項:

    1. 和上次作業一樣, 請運用 memory_leak.h, memory_leak.cpp 檢查記憶體的錯誤

    2. 你可以參考在上面類別圖中我所列的界面函式來實作, 但是並不限定於這些界面。

    3. 在修改的時後, 你應該要一步一步地修改, 測試, 例如新增 SizeVisitor 的時候, 不要先把原來的功能拿掉, 而是應該先做好運用 SizeVisitor 完成計算大小的功能, 和原來的比對, 一切都正確以後, 才把原來的程式刪除, 這樣子才不會弄得一團亂以後還要重新花很多時間 debug, 我在改的時候就是這樣子做的。

      在物件導向的軟體發展流程中調整軟體的架構我們叫做軟體重構 (refactoring), 這是常常需要做的, 有的時候是為了程式的效率, 有的時候是為了和其它程式發展者建立一致的溝通界面, 有的時候是為了程式的彈性和擴充性: 我們在設計軟體的時候很難設計出一個萬能的架構, 在新的需求出現的時候常常需要更改舊的程式架構來應付。

  5. 書面報告之要求:

    1. 基本的心得與程式架構說明

    2. 請至少列出一種 Visitor 類別的實作, 並且說明程式的邏輯 (參考上面的 SizeVisitor 類別)

    3. 請特別分析一下你的程式實作時是不是真的有解決我們原先遇見的問題呢? 全部解決了嗎? 還是只有部份解決了? 還有哪些問題是沒有解決的呢? 有沒有製造出其它新的問題呢?

    4. 設計的時候常常沒有完美的解決方案, 常常需要由多方面去權衡, 在這個作業裡不知道你有沒有感覺到呢? 有沒有哪些設計有一點顧此失彼的感覺呢, 你選擇顧及什麼呢?

C++ 物件導向程式設計課程 首頁

製作日期: 05/01/2024 by 丁培毅 (Pei-yih Ting)
E-mail: [email protected] TEL: 02 24622192x6615
海洋大學 電機資訊學院 資訊工程學系 Lagoon