談 C++17 裡的 Observer 模式 - 3

hedzr發表於2021-09-27
上上回的 談 C++17 裡的 Observer 模式 介紹了該模式的基本構造。後來在 談 C++17 裡的 Observer 模式 - 補 裡面提供了改進版本,主要聚焦於針對多執行緒環境的暴力使用的場景。
也可以前往 部落格原文

Observer Pattern - Part III

然後我們提到了,對於觀察者模式來說,GoF 的原生定義當然是採用一個 observer class 的方式,但對於差不多 15 年後的 C++11 來說,觀察者使用一個 class 定義的方式有點落伍了。特別是到了幾乎 23 年後的 C++14/17 之後,lambda 以及 std::function 的支援力度變得較為穩定,無需太多“高階”手法也能輕鬆地包裝閉包或者函式物件,在加上摺疊表示式對變參模板的加成能力。所以現在是有一種呼聲認為,直接在被觀察者上繫結匿名函式物件、或者函式物件,才是觀察者模式的正確開啟方式。

那麼是不是如此呢?

我們首先要做的是實現這樣的想法,然後用一段測試用例來展示這種模態下編碼的可能性。再然後才來看看它的優缺點。

基本實現

這一次的核心類别範本我們將其命名為 observable_bindable,因為這在你修改自己的實現程式碼時有利——只需要新增字尾就可以。這個模板類仍然使用一個單一的結構 S 作為事件/訊號實體:

namespace hicc::util {

  /**
   * @brief an observable object, which allows a lambda or a function to be bound as the observer.
   * @tparam S subject or event will be emitted to all bound observers.
   * 
   */
  template<typename S>
  class observable_bindable {
    public:
    virtual ~observable_bindable() { clear(); }
    using subject_t = S;
    using FN = std::function<void(subject_t const &)>;

    template<typename _Callable, typename... _Args>
    observable_bindable &add_callback(_Callable &&f, _Args &&...args) {
      FN fn = std::bind(std::forward<_Callable>(f), std::forward<_Args>(args)...);
      _callbacks.push_back(fn);
      return (*this);
    }
    template<typename _Callable, typename... _Args>
    observable_bindable &on(_Callable &&f, _Args &&...args) {
      return add_callback(f, args...);
    }

    /**
     * @brief fire an event along the observers chain.
     * @param event_or_subject 
     */
    void emit(subject_t const &event_or_subject) {
      for (auto &fn : _callbacks)
        fn(event_or_subject);
    }

    private:
    void clear() {}

    private:
    std::vector<FN> _callbacks{};
  };

}

首先,我們不提供解除 observer 繫結的成員函式。我們覺得這是不必要的,因為這一設計的目標本來就是衝著 lambda 函式去的,解除 lambda 函式的繫結,沒有多大意義。當然你可以實現一份 remove_observer,這並沒有什麼技術性難度,甚至你都不必多麼懂 c++,照貓畫虎也能弄一份。

然後藉助於 std::bind 函式(整個繫結乃至於型別推導等等至少 c++14,建議 c++17)的推導能力,我們提供了一個強力有效的 add_callback 實現,另外,on() 是它的同義詞。

所謂強力有效,是指,我們在這個單一的函式簽名上實現了各種各樣的函式物件的通用繫結,匿名函式也好、成員函式也好、或者普通函式等,都可以藉助於 add_callback() 被繫結到 observable_bindable 物件中去。

新的測試程式碼

到底有多麼有力,還是要看測試程式碼:

namespace hicc::dp::observer::cb {

  struct event {
    std::string to_string() const { return "event"; }
  };

  struct mouse_move_event : public event {};

  class Store : public hicc::util::observable_bindable<event> {};

} // namespace hicc::dp::observer::cb

void fntest(hicc::dp::observer::cb::event const &e) {
  hicc_print("event CB regular function: %s", e.to_string().c_str());
}

void test_observer_cb() {
  using namespace hicc::dp::observer::cb;
  using namespace std::placeholders;

  Store store;

  store.add_callback([](event const &e) {
    hicc_print("event CB lamdba: %s", e.to_string().c_str());
  }, _1);
  
  struct eh1 {
    void cb(event const &e) {
      hicc_print("event CB member fn: %s", e.to_string().c_str());
    }
    void operator()(event const &e) {
      hicc_print("event CB member operator() fn: %s", e.to_string().c_str());
    }
  };
  
  store.on(&eh1::cb, eh1{}, _1);
  store.on(&eh1::operator(), eh1{}, _1);

  store.on(fntest, _1);

  store.emit(mouse_move_event{});
}

注意,這個 on()/add_callback() 的繫結語法類似於 std::bind,你可能需要 std::placeholder::_1, _2, .. 等等佔位符。你還需要注意到這個繫結語法完全沿襲了 std::bind 的各種特殊能力,例如提前繫結技術。不過這些能力有的在 on() 中無法直接體現,有的雖然有用但是卻又用不到,此外這裡畢竟還在談論 observer pattern,現有的展示已經足夠了。

輸出結果類似於如此:

--- BEGIN OF test_observer_cb                         ----------------------
09/19/21 08:38:02 [debug]: event CB lamdba: event ...
09/19/21 08:38:02 [debug]: event CB member fn: event ...
09/19/21 08:38:02 [debug]: event CB member operator() fn: event 
09/19/21 08:38:02 [debug]: event CB regular function: event 
--- END OF test_observer_cb                           ----------------------

It took 465.238us

所以沒有什麼意外,甚至於無論是 observable_bindable 還是用例程式碼都是出乎意料的簡潔明快,符合直覺。

這個例子能夠提醒我們,GoF 當然是經典裡的經典(沒辦法,它出的早,它出的時候我們的腦子根本沒往這種總結方向上去轉,只好是它經典了,我那時候在幹啥哩,哦,我在貴州一個鳥不生蛋的深山裡的變電所搞什麼 scada 除錯吧,陷入細節之中),但也未必就要祖宗之法不可易

改進

上面顯得頗為完美了,然而還有一個小小的問題。康康測試程式碼的繫結語法,例如:

store.on(fntest, _1);

那個醜陋的 _1 很是刺眼。能不能消滅它呢?

由於我們約定的回撥函式的介面為:

using FN = std::function<void(subject_t const &)>;

所以這個 _1 是對應於 subject_t const &,這是 std::bind 的呼叫約定。注意到回撥函式的簽名是固定的,所以我們確實有一個方法能夠消除 _1,即修改 add_callback() 的程式碼:

template<typename _Callable, typename... _Args>
observable_bindable &add_callback(_Callable &&f, _Args &&...args) {
  FN fn = std::bind(std::forward<_Callable>(f), std::forward<_Args>(args)..., std::placeholders::_1);
  _callbacks.push_back(fn);
  return (*this);
}

我們加上它,使用者程式碼中就不必寫它了對嗎。

這法子有點點無賴,但它管用。然後測試程式碼中是這樣:

    store.add_callback([](event const &e) {
        hicc_print("event CB lamdba: %s", e.to_string().c_str());
    });

    store.on(&eh1::cb, eh1{});
    store.on(&eh1::operator(), eh1{});

    store.on(fntest);

是不是好看多了?

這個例子告訴我們,寫 C++ 有時候根本不需要那些所謂的精巧的、精緻的、絕妙的、奇思妙想的奇技淫巧。

善戰者赫赫無功,說的是就是平平無奇。

優缺點

總之,我們已經實現了這個主打函式式風格的觀察者模式。你仍然需要從 observable_bindable 派生出來你的被觀察者,但你可以隨時隨地就地建立對其的觀察,你可以使用匿名的 lambda,也可以使用各種風格的具名的函式物件。

至於缺點,不算太多,或許,採用 lambda 的時候就不那麼容易 remove_callback 了,這對於有的需要重入的場景可能有點不妥,但這可以用顯式具名的方式輕鬆解決。

有兩種顯式具名的方法,一是將 lambda 付給一個變數:

auto my_observer = [](event const &e) {
  hicc_print("event CB lamdba: %s", e.to_string().c_str());
};
store.on(my_observer);
store.emit(event{});
store.remove(my_observer);
remove() 需要你自行實現

另一種方法是普通函式物件、或者類成員函式物件、或者類靜態成員函式物件,這些都是天然具名的。例子略過。

後記

整個過程很簡單,甚至比我料想的還簡單,當然我還是遇到了點麻煩的。

主要的麻煩在於函式簽名中的形參列表需要被正確地傳導到 emit 中的呼叫部分去。但是你也看到了,最終的程式碼其實完全藉助了自動推導的能力,出人意料地直接解決了該問題。我當然遇到了麻煩,走了些彎路,設法想要求助於變參模板能力以及 traits 的力量,事實證明不必那麼累,也沒有那麼複雜的形參表的必要。而且 auto 常常可以打天下。

Callable 技術,我在 pool,ticker 等之中也有同樣的應用,但是那些時候有一點點不同,那時候待繫結的函式物件是沒有參數列的。而在 cmdr-cxx 中做函式繫結時,我面臨的是確定格式的參數列。

不過這些也都無所謂。

相關文章