跳轉至內容

更多 C++ 慣用法/非虛擬介面

來自 Wikibooks,開放書籍,為開放的世界

非虛擬介面

[編輯 | 編輯原始碼]
  • 將整個類層次結構的通用前後程式碼片段(例如,不變式檢查、獲取/釋放鎖)模組化/重構到一個位置。

也稱為

[編輯 | 編輯原始碼]
  • 模板方法 - 來自四人幫的設計模式書籍的更通用的模式。

前置條件和後置條件檢查被認為是一種有用的面向物件程式設計技術,尤其是在開發階段。前置條件和後置條件確保類層次結構(以及通常的抽象)的不變式在程式執行期間的指定點不被破壞。在開發階段或除錯構建期間使用它們有助於儘早捕獲違規。為了保持一致性和易於維護此類前置條件和後置條件,它們應該理想地模組化到一個位置。在一個類層次結構中,其不變式必須在子類中的每個方法之前和之後都保持,模組化變得很重要。

類似地,在類層次結構中獲取和釋放對公共資料結構的鎖可以被認為是前置條件和後置條件,即使在生產階段也必須確保。將鎖獲取和釋放的責任從子類中分離出來,並將它們放在一個地方 - 可能是基類中,這很有用。

解決方案和示例程式碼

[編輯 | 編輯原始碼]

非虛擬介面 (NVI) 慣用法允許我們將前後程式碼片段重構到一個方便的位置 - 基類。NVI 慣用法基於 Herb Sutter 在他名為“虛擬函式”的文章 [2] 中概述的 4 個準則。引用 Herb 的話

  • 準則 #1:優先使用模板方法設計模式使介面非虛擬。
  • 準則 #2:優先使虛擬函式私有。
  • 準則 #3:只有當派生類需要呼叫虛擬函式的基類實現時,才將虛擬函式設為保護的。
  • 準則 #4:基類解構函式應該要麼是公共的並且是虛擬的,要麼是受保護的並且是非虛擬的。- 引用結束。

這裡有一些程式碼實現了遵循上述 4 個準則的 NVI 慣用法。

class Base {
private:
    ReaderWriterLock lock_;
    SomeComplexDataType data_;
public:
    void read_from( std::istream & i)  { // Note non-virtual
      lock_.acquire();
      assert(data_.check_invariants()); // must be true

      read_from_impl(i);

      assert(data_.check_invariants()); // must be true
      lock_.release();
    }
    void write_to( std::ostream & o) const { // Note non-virtual
      lock_.acquire();
      write_to_impl(o);
      lock_.release();
    }
    virtual ~Base() {}  // Virtual because Base is a polymorphic base class.
private:
    virtual void read_from_impl( std::istream & ) = 0;
    virtual void write_to_impl( std::ostream & ) const = 0;
};
class XMLReaderWriter : public Base {
private:
    virtual void read_from_impl (std::istream &) {
      // Read XML.
    }
    virtual void write_to_impl (std::ostream &) const {
      // Write XML.
    }
};
class TextReaderWriter : public Base {
private:
    virtual void read_from_impl (std::istream &) {}
    virtual void write_to_impl (std::ostream &) const {}
};

基類的上述實現捕捉了幾個設計意圖,這些意圖對於實現 NVI 慣用法的優勢至關重要。此類意在用作基類,因此它具有一個虛解構函式和一些純虛擬函式(read_from_impl、write_to_impl),所有具體的派生類都必須實現這些函式。客戶端的介面(即 read_from 和 write_to)與子類的介面(即 read_from_impl 和 write_to_impl)是分開的。雖然 read_from_impl 和 write_to_impl 是兩個私有函式,但基類可以使用動態排程呼叫相應的派生類函式。這兩個函式為派生類族提供了必要的擴充套件點。但是,它們被禁止擴充套件客戶端介面(read_from 和 write_to)。請注意,可以從派生類呼叫客戶端的介面,但是,這會導致遞迴。最後,NVI 慣用法建議每個公共非虛擬函式使用一個私有虛擬擴充套件點。

客戶端只調用公共介面,該介面反過來呼叫虛 *_impl 函式,就像在模板方法設計模式中一樣。在呼叫 *_impl 函式之前和之後,基類會執行鎖操作和不變式檢查操作。透過這種方式,層次結構範圍內的前後程式碼片段可以集中在一個地方,簡化維護。即使客戶端沒有直接呼叫虛擬函式,Base 層次結構的客戶端仍然可以獲得多型行為。派生類應確保透過在派生類中也將它們設為私有來禁止客戶端直接訪問實現函式 (*_impl)。

如果不小心,使用 NVI 慣用法可能會導致脆弱的類層次結構。如 [1] 中所述,在脆弱基類 (FBC) 介面問題中,當基類實現發生更改而沒有通知時,子類的虛擬函式可能會意外地被呼叫。例如,以下程式碼片段(受 [1] 啟發)使用 NVI 慣用法來實現 CountingSet,它以 Set 作為基類。

class Set {
    std::set<int> s_;
  public:
    void add (int i) {
      s_.insert (i);
      add_impl (i); // Note virtual call.
    }
    void addAll (int * begin, int * end) {
      s_.insert (begin, end);   //  --------- (1)
      addAll_impl (begin, end); // Note virtual call.
    }
  private:
    virtual void add_impl (int i) = 0;
    virtual void addAll_impl (int * begin, int * end) = 0;
};
class CountingSet : public Set {
  private:
    int count_;
    virtual void add_impl (int i) {
      count_++;
    }
    virtual void addAll_impl (int * begin, int * end) {
      count_ += std::distance(begin,end);
    }
};

上面的類層次結構很脆弱,因為在維護期間,如果 addAll 函式(由 (1) 指示)的實現被更改為對從 begin 到 end 的每個整數呼叫公共非虛擬 add 函式,那麼派生類 CountingSet 就會失效。由於 addAll 呼叫 add,因此派生類的擴充套件點 add_impl 被呼叫以處理每個整數,最後 addAll_impl 也被呼叫,導致對整數範圍的計數兩次,這在派生類中默默地引入了錯誤!解決方法是嚴格遵循編碼紀律,在基類的任何公共非虛擬介面中呼叫恰好一個私有虛擬擴充套件點。但是,解決方案取決於程式設計師的紀律,因此在實踐中很難遵循。

請注意,NVI 慣用法如何將每個類層次結構視為一個微小的(有些人可能喜歡稱之為微不足道)面向物件框架,其中通常觀察到控制反轉 (IoC) 流。框架控制程式的流程,而不是客戶端編寫的函式和類,這就是為什麼它被稱為控制反轉。在 NVI 中,基類控制程式流程。在上面的示例中,Set 類執行必要的公共插入工作,然後呼叫 *_impl 虛擬函式(擴充套件點)。Set 類不得呼叫其任何自己的公共介面,以防止 FBC 問題。

最後,NVI 慣用法會導致類層次結構中一定程度的程式碼膨脹,因為當應用 NVI 時,函式數量會翻倍。基類中重構程式碼的大小應該足夠大,以證明使用 NVI 的合理性。

已知用途

[編輯 | 編輯原始碼]
[編輯 | 編輯原始碼]

參考文獻

[編輯 | 編輯原始碼]

[1] 選擇性開放遞迴:關於元件和繼承的模組化推理 - Jonathan Aldrich、Kevin Donnelly。

[2] 虛擬函式! -- Herb Sutter

[3] 對話:虛擬函式的你 -- Jim Hyslop 和 Herb Sutter

[4] 我應該使用受保護的虛擬函式而不是公共虛擬函式嗎? -- Marshall Cline

華夏公益教科書