談 C++17 裡的 Command 模式

hedzr發表於2021-10-26
命令模式:介紹相關概念。實作參考上回的 Memento

Command Pattern

關於本系列文章

這次的 談XX模式 系列,並不會逐個全部介紹 GoF 的 23 個模式,也不限於 GoF。有的模式可能是沒有模板化複用的必要性的,另外有的模式卻並不包含在 GoF 中,所以有時候會有正文的補充版本,像上次的 談 C++17 裡的 Observer 模式 - 4 - 訊號槽模式 就是如此。

因為本系列的重心在於模板化實作上面,以工程實作為目標,所以我們並不會像一般的設計模式文章那樣規規矩矩地介紹動機、場景什麼的(有時候又未必),而是會以我們的經驗和對模式的理解,用自己的話來做闡述,我覺得它可能會有點用,當然快消的世界這樣做是很愚蠢。

這對於我們來講,對個人來講,也是一個審視和再思考的過程。而對於你來說,換個角度看看他人的理解,說不定其實是有用處的。

關於命令模式

談 C++17 裡的 Memento 模式 一文中我提到過備忘錄模式和命令模式往往是聯動協同工作的,並且在給出的傳統實現以及 Undo Manager 實現(類庫 undo-cxx)中居包含了命令模式部分。

所以本文算是湊數的意思。

動機

命令模式是一種行為模式。這種設計模式把多種多樣的動作抽象為命令,Client 通過執行器 Caller/Invoker/Executor 執行這些命令而不必關心呼叫的細節。一個具體的命令物件 ConcreteCommand 負責解釋命令執行動作的全部細節,包括命令的接收者。接收者 Receiver 是命令執行的承受者,例如在字處理器中,接收者是當前編輯器的當前選擇文字,而字型樣式命令會對該接收者做出樣式設定。

這段描述的 UML 圖是這樣的:

img

FROM: here: svg file

另一張圖很漂亮,摘取在這裡供對照:

img

FROM: The Command Pattern - fjp.github.io

場景

在一個餐廳中,顧客點餐後,點餐的動作可以被視為 Client 在通知 Executor 該要執行命令了。命令被執行的上下文包含了顧客點餐的選單(Receipt)。選單被送到後廚,被指派給恰當的廚師烹飪,這相當於命令被具體執行。

在一個音樂播放器中,Play,Pause,Stop,Forward,Rewind 是相應的命令,Invoker 執行命令時,導致接收者 Receriver 即錄音機被控制。

在一個字處理器中,當前編輯器的當前選擇文字通常被視作接收者,Bold,Italic 等使用者的 UI 操作會觸發到相應命令的執行。

向量作圖的場景類似於字處理器。

桌面視窗應用中的選單和快捷鍵系統,也是命令模式的典型體現。

遊戲開發中 Command 模式也非常常用,基本上是必需品。

通訊協議解析器是另一種你可能沒有深思的命令模式運用場景。通訊協議通常都包含一系列 tokens 的識別,一系列指令指示,一系列資料彙報資訊,這些內容都可以被抽象和組織到命令模式中去進行具體處理。

程式碼實現

關於命令模式的設計思路,關鍵之處無非是如下幾點:

  1. cmd_t 的類體系應該如何構建,物件例項的管理與銷燬問題
  2. 命令分組問題,即 composite_cmd_t 應該如何設計的問題
  3. 上下文問題:預先固化一個接收器,對於類庫來說是行不通的,所以接收器甚至於 sender 都可以被放在一個所謂的上下文容器中,在命令被執行的過程中被傳遞。而類庫的使用者能夠有能力擴充套件這個上下文容器以容納其他想要的資料。
  4. undo/redo 問題
  5. 命令的管理問題,command_id 的分配問題
  6. 命令的呼叫問題,好的呼叫語法能簡化使用者的負擔

有了這些前提或約束,再來設計命令系統就比較有方向性了。

我們採取的策略有:

  1. 請看上一篇系列文章 memento pattern 以及 undo-cxx 的原始碼部分。
  2. :)

本文中就不重複摘取片段了。

Tricks

protected virtual function

作為一個 class 編寫的準則,不要將 public function 設計為 virtual 的。

這個準則似乎並不為人所重視。

但是作為一種慣用法和 Trick,virtual function 總是 protected 的,這是程式設計師之間的一種隱語:看到了保護的虛擬函式,派生類就知道這是應該被過載的。

關於這個準則的深入討論,本文就算了,你可以聽聽 Herb Sutter 的說法。在 C++ FAQ 中也有相關的討論:

在 cmd_t 的實現中,嚴格地遵守了這樣的準則。用我的話來說呢,大概是這樣:能被過載的虛擬函式代表著具體實現和能力,所以它當然不應該被 public 不是嗎?如果一個介面必須被公開卻又允許被過載,大抵是代表著你的設計上拆分的不充分。

不過,也未必一定要拆分:這個思路也間接導致了另一個慣用法,即將一個應該被過載的虛擬函式拆分為普通成員函式與虛實現函式:

class X {
  public:
  void chilling_out() { this->chilling_out_impl(); }
  
  protected:
  virtual void chilling_out_impl() = 0;
};

算不算很無理?

或許吧。

private virtual function

BTW,介紹一個你可能忽視的小知識,虛擬函式是可以被設定為 private 的。

這聽起來彷佛有點荒謬,但它是真的:

namespace {
  struct base { virtual ~base(){} };

  template<class T>
    struct base_t : public base {
      virtual T t() = 0;
      protected:
      void chilling_out() { this->chilling_out_impl(); }
      private:
      virtual void chilling_out_impl() = 0;
    };

  template<class T>
    struct A : public base_t<T> {
      A(){}
      A(T const& t_): _t(t_) {}
      ~A(){}
      T _t{};
      virtual T t() override { std::cout << _t << '\n'; return _t; }
      private:
      virtual void chilling_out_impl() override {}
    };
}

這段程式碼編譯、執行都毫無問題。

虛擬函式是 private 的,意味著派生類不能呼叫它,但基類自己能呼叫就行。而且,你可以在派生類中過載它。不僅如此,你甚至可以在派生類中過載它的同時修改其訪問特性:

namespace {
  struct base { virtual ~base(){} };

  template<class T>
    struct base_t : public base {
      virtual T t() = 0;
      protected:
      void chilling_out() { this->chilling_out_impl(); }
      private:
      virtual void chilling_out_impl() = 0;
    };

  template<class T>
    struct A : public base_t<T> {
      A(){}
      A(T const& t_): _t(t_) {}
      ~A(){}
      T _t{};
      virtual T t() override { std::cout << _t << '\n'; return _t; }
      protected:
      // private:
      virtual void chilling_out_impl() override {}
    };

    struct B: public A<int> {
        virtual int t() override { std::cout << _t << '\n'; return _t; chilling_out_impl(); }
    };
}

如上,在 struct A 中過載了 chilling_out_impl() 並改為 protected,所以在派生類 B 中要直接使用它也不會報錯了。這是一個有用的特性,別人的類庫有時候我們也可以有條件地過載某些細節;同時這也是一個有用的 Bug,話說這樣的漏洞真的不會帶來隱患嗎?

Refs

後記

圍繞著 virtual function 還有著眾多的技巧,不過很多時候,良好的設計會讓你根本無需額外特別的技巧,堂堂正正地就把錢錢給掙了。

那樣很好。

相關文章