談 C++17 裡的 Observer 模式 - 4 - 訊號槽模式

hedzr 發表於 2021-09-27
C++
上上上回的 談 C++17 裡的 Observer 模式 介紹了該模式的基本構造。後來在 談 C++17 裡的 Observer 模式 - 補/2 裡面提供了改進版本,主要聚焦於針對多執行緒環境的暴力使用的場景。再後來又有一篇 談 C++17 裡的 Observer 模式 - 再補/3,談的是直接繫結 lambda 作為觀察者的方案。

Observer Pattern - Part IV

所以嘛,我覺得這個第四篇,無論如何也要復刻一份 Qt 的 Slot 訊號槽模型的獨立實現了吧。而且這次復刻做完之後,觀察者模式必須告一段落了,畢竟我在這個 Pattern 上真還是費了老大的神了,該結束了。

要不要做 Rx 輕量版呢?這是個問題。

原始參考

說起 Qt 的訊號槽模式,可以算是鼎鼎大名了。它強就強在能夠無視函式簽名,想怎麼繫結就怎麼繫結(也不是全然隨意,但也很可以了),從 sender 到 receiver 的 UI 事件推送和觸發顯得比較清晰乾淨,而且不必受制於確定性的函式簽名。

確定性的函式簽名嘛,Microsoft 的 MFC 乃至於 ATL、WTL 都愛在 UI 訊息泵部分採用,它們還使用巨集來解決繫結問題,著實是充滿了落後的氣息。

要說在當年,MFC 也要是當紅炸子雞了,Qt 只能悄悄地龜縮於一隅,哪怕 Qt 有很多好設計。那又怎麼樣呢?我們家 MFC 的優秀設計,尤其是 ATL/WTL 的優秀設計也多的是。所以這又是一個技術、市場認可的古老歷史。

好的,隨便吐槽一下而已。

Qt 的問題,在於兩點:一是模凌兩可一直曖昧的許可制度,再一是令人無法去愛的私有擴充套件,從 qmake 到 qml 到各種 c++ 上的 MOC 擴充套件,實在是令 Pure C++ 派很不爽。

當然,Qt 也並不像我們本以為的那麼小眾,實際上它的受眾群體還是很不小的,它至少佔據了跨平臺 UI 的很強的一部分,以及嵌入式裝置的 UI 開發的主要部分。

首先一點,訊號槽是 Qt 獨特的核心機制,從根本類 QObject 開始就受到基礎支援的,它實際上是為了完成物件之間的通訊,也不僅僅是 UI 事件的分發。然而,考慮到這個通訊機制的核心機理和邏輯呢,我們認為它仍然是一種觀察者模式的表現,或者說是一種訂閱者閱讀釋出者的特定訊號的機制。

訊號槽演算法的關鍵在於,它認為一個函式不論被怎麼轉換,總是可以變成一個函式指標並放在某個槽中,每個 QObject(Qt 的基礎類)都可以根據需要管理這麼一個槽位表。

bool QObject::connect ( const QObject * sender, const char * signal, const QObject * receiver, const char * member ) [static]

而在發射一個訊號時,這個物件就掃描每個 slot,然後根據需要對訊號變形(匹配為被繫結函式的實參表)並回撥那個被繫結函式,尤其是,如果被繫結函式是某個類例項的成員函式呢,正確的 this 指標也會被引用以確保回撥完成。

Qt 使用一個關鍵字 signals 來指定訊號:

signals: 
        void mySignal(); 
        void mySignal(int x); 
        void mySignalParam(int x,int y);

這顯然很怪異(習慣了就好了)。而 Qt 的怪異之處還很多,所以這也是它無法大紅的根本原因,太封閉自嗨了大家就不願意陪你玩噻。

那麼槽呢,槽函式就是一普通的 C++ 函式,它的不普通之處在於將會有一些訊號和它相關聯。關聯的方法是 QObject::connect 與 disconnect,上面已經給出了原型。

一個例子片段來展現訊號槽的使用方式:

QLabel     *label  = new QLabel; 
QScrollBar *scroll = new QScrollBar; 
QObject::connect( scroll, SIGNAL(valueChanged(int)), 
                  label,  SLOT(setNum(int)) );

SIGNAL 與 SLOT 是巨集,它們將會藉助 Qt 內部實現來完成轉換工作。

小小結

我們不打算教授 Qt 開發知識,更不關心 Qt 的內部實現機制,所以例子摘取這麼一個也就夠了。

如果你正在學習或想了解 Qt 開發知識,請查閱它們的官網,並可以著重瞭解 元物件編譯器 MOC(meta object compiler),Qt 依賴這個東西來解決它的專有的非 c++ 的 擴充套件,例如 signals 等等。

基本實現

現在我們來複刻一套訊號槽的 C++17 實現,當然就不考慮 Qt 的任何其它關聯概念,而是僅就訂閱、發射訊號、接受訊號這一套觀察者模式相關的內容進行實現。

復刻版本並不會原樣照搬 Qt 的 connect 介面樣式。

我們需要重新思考應該以何為主,採用什麼樣的語法。

可以首先肯定的是,一個 observable 物件也就是一個 slot 管理器、同時也是一個訊號發射器。作為一個 slot 管理器,每一個 slot 中可以包含 M 個被連線的 slot entries,也就是觀察者。由於一個 observable 物件管理一個單個到 slot,所以若是你想要很多槽(slots),你就需要派生出多個 observable 物件。

無論如何,找回訊號槽的本質之後,我們的 signal-slot 實現其實和上一篇的 談 C++17 裡的 Observer 模式 - 再補 幾乎完全相同——除了 signal-slot 需要支援可變的函式參數列之外。

signal<SignalSubject...>

既然是一個訊號發射器,所以我們的 observable 物件就叫做 signal,並且帶有可變的 SignalSubject... 模板引數。一個 signal<int, float> 的模板例項允許在發射訊號時帶上 int 和 float 兩個引數:sig.emit(1, 3.14f)。當然可以將 int 換為某個複合物件,由於是變參,所以甚至你也可以不帶具體引數,此時發射訊號就如同僅僅是觸發功能一般。

這就是我們的實現:

namespace hicc::util {

  /**
   * @brief A covered pure C++ implementation for QT signal-slot mechanism
   * @tparam SignalSubjects 
   */
  template<typename... SignalSubjects>
  class signal {
    public:
    virtual ~signal() { clear(); }
    using FN = std::function<void(SignalSubjects &&...)>;
    
    template<typename _Callable, typename... _Args>
    signal &connect(_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>
    signal &on(_Callable &&f, _Args &&...args) {
      FN fn = std::bind(std::forward<_Callable>(f), std::forward<_Args>(args)...);
      _callbacks.push_back(fn);
      return (*this);
    }

    /**
     * @brief fire an event along the observers chain.
     * @param event_or_subject 
     */
    signal &emit(SignalSubjects &&...event_or_subjects) {
      for (auto &fn : _callbacks)
        fn(std::move(event_or_subjects)...);
      return (*this);
    }
    signal &operator()(SignalSubjects &&...event_or_subjects) { return emit(event_or_subjects...); }

    private:
    void clear() {}

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

} // namespace hicc::util

connect() 模仿 Qt 的介面名,但我們更建議其同義詞 on() 來做函式實體的繫結。

上面的實現不像已知的開源實現那樣複雜。其實現在的很多精妙的 C++ 開源超程式設計程式碼有點走火入魔,traits 什麼的用的太多,拆分的過於厲害,我腦力記憶體小,有點跑不過來。

還是說回我們的實現,基本沒什麼好說的,秉承上一回的實現思路,拋棄顯式的 slot 實體的設計方案,簡單地將使用者函式包裝為 FN 就當作是槽函式了。這樣做沒有了 Qt 的某些全面性,但實際上現代社會裡並不需要哪些為了滿足 Qt 類體系而製造的精巧之處。純粹是過度設計。

Tests

然後再來看測試程式:

namespace hicc::dp::observer::slots::tests {

  void f() { std::cout << "free function\n"; }

  struct s {
    void m(char *, int &) { std::cout << "member function\n"; }
    static void sm(char *, int &) { std::cout << "static member function\n"; }
    void ma() { std::cout << "member function\n"; }
    static void sma() { std::cout << "static member function\n"; }
  };

  struct o {
    void operator()() { std::cout << "function object\n"; }
  };

  inline void foo1(int, int, int) {}
  void foo2(int, int &, char *) {}

  struct example {
    template<typename... Args, typename T = std::common_type_t<Args...>>
    static std::vector<T> foo(Args &&...args) {
      std::initializer_list<T> li{std::forward<Args>(args)...};
      std::vector<T> res{li};
      return res;
    }
  };

} // namespace hicc::dp::observer::slots::tests

void test_observer_slots() {
  using namespace hicc::dp::observer::slots;
  using namespace hicc::dp::observer::slots::tests;
  using namespace std::placeholders;
  {
    std::vector<int> v1 = example::foo(1, 2, 3, 4);
    for (const auto &elem : v1)
      std::cout << elem << " ";
    std::cout << "\n";
  }
  s d;
  auto lambda = []() { std::cout << "lambda\n"; };
  auto gen_lambda = [](auto &&...a) { std::cout << "generic lambda: "; (std::cout << ... << a) << '\n'; };
  UNUSED(d);

  hicc::util::signal<> sig;

  sig.on(f);
  sig.connect(&s::ma, d);
  sig.on(&s::sma);
  sig.on(o());
  sig.on(lambda);
  sig.on(gen_lambda);

  sig(); // emit a signal
}

void test_observer_slots_args() {
  using namespace hicc::dp::observer::slots;
  using namespace std::placeholders;

  struct foo {
    void bar(double d, int i, bool b, std::string &&s) {
      std::cout << "memfn: " << s << (b ? std::to_string(i) : std::to_string(d)) << '\n';
    }
  };

  struct obj {
    void operator()(float f, int i, bool b, std::string &&s, int tail = 0) {
      std::cout << "obj.operator(): I was here: ";
      std::cout << f << ' ' << i << ' ' << std::boolalpha << b << ' ' << s << ' ' << tail;
      std::cout << '\n';
    }
  };

  // a generic lambda that prints its arguments to stdout
  auto printer = [](auto a, auto &&...args) {
    std::cout << a << std::boolalpha;
    (void) std::initializer_list<int>{
      ((void) (std::cout << " " << args), 1)...};
    std::cout << '\n';
  };

  // declare a signal with float, int, bool and string& arguments
  hicc::util::signal<float, int, bool, std::string> sig;

  connect the slots
  sig.connect(printer, _1, _2, _3, _4);
  foo ff;
  sig.on(&foo::bar, ff, _1, _2, _3, _4);
  sig.on(obj(), _1, _2, _3, _4);

  float f = 1.f;
  short i = 2; // convertible to int
  std::string s = "0";

  // emit a signal
  sig.emit(std::move(f), i, false, std::move(s));
  sig.emit(std::move(f), i, true, std::move(s));
  sig(std::move(f), i, true, std::move(s)); // emit diectly
}

同樣的,熟悉的 std::bind 支撐能力,不再贅述。

test_observer_slots 就是無引數訊號的示例,而 test_observer_slots_args 演示了帶有四個引數時訊號如何發射,稍稍有點遺憾的是你可能有時候不得不帶上 std::move ,這個問題我可能未來某一天才會找個時間段來解決,但歡迎通過 hicc-cxx 的 ISSUE 系統投 PR 給我。

優化

這一次,函式形參表是可變的,並非僅有一個 _1,也不能預測會有多少引數,所以上一回我們使用的有賴手段現在就行不通了。只能老老實實地謀求有無辦法自動繫結 placeholders。不幸的是對於 std::bind 來說,std::placeholders 是一個絕對不能缺少的支撐,因為 std::bind 允許你在繫結時指定繫結引數順序以及提前綁入預置值。由於這個設計目標,因而你不可能抹去 _1 等等的使用。

萬一當你找到一個辦法時,那麼同時也就意味著你放棄了 _1 等佔位符帶來的全部利益。

所以這將是一個艱難的決定。對了,BTW,英語根本不會有“艱難的決定”一詞,它只會說“那個決定會是非常難”。總之,英語實際上不能精確地描述出決定的艱難程度,例如:“有點艱難的”,“有點點艱難的”,“有那麼些艱難的”,“略有些艱難的”,“仿若在過九曲十八彎般的艱難的”,……。一開始還可以“a little”,“a little bit”,但到後面時肯定它死了,對吧……我是不是又跑題了。

關於 std::bind 和 std::placeholders 的不可不說的故事,SO 早有人在不停吐槽了。不過支持者總是在說 A (partial) callable entity 的重要性,而不考慮另一方面的實用性:完全可以來個 std::connect 或者 std::link 這樣的介面以允許 Callbacks 的自動繫結和自動填充形參的省缺值(即零值)。

能夠行得通的方式大概有兩種。

一種是分解不同的函式物件,分別進行繫結以及變參轉發,這將是個有點龐大的小工程——因為它將會是重新實現一份 std::bind 並且提供自動繫結的額外能力。

另一種是我們將要採用的方法,我們大體上保持藉助於 std::bind 的原有能力,但是也沿用上一回的追加佔位符實參的手段。

cool::bind_tie

不過,剛才前文也說了,現在根本不知道使用者準備例項化多少個 SignalSubjects 模板引數,所以簡單的新增佔位符是不行的。所以我們略略調轉思路,一次性加上 9 個佔位符,但是增多一層模板函式的展開,在新的一層模板函式中僅僅從 callee 那裡取出正好 SubjectsCount 那麼多的引數包,再傳遞給 std::bind 就滿意了。

一個可資驗證的原型是:

template<typename Function, typename Tuple, size_t... I>
auto bind_N(Function &&f, Tuple &&t, std::index_sequence<I...>) {
  return std::bind(f, std::get<I>(t)...);
}
template<int N, typename Function, typename Tuple>
auto bind_N(Function &&f, Tuple &&t) {
  // static constexpr auto size = std::tuple_size<Tuple>::value;
  return bind_N(f, t, std::make_index_sequence<N>{});
}

auto printer = [](auto a, auto &&...args) {
        std::cout << a << std::boolalpha;
        (void) std::initializer_list<int>{
                ((void) (std::cout << " " << args), 1)...};
        std::cout << '\n';
    };

// for signal<float, int, bool, std::string> :

template<typename _Callable, typename... _Args>
auto bind_tie(_Callable &&f, _Args &&...args){
  using namespace std::placeholders;
  return bind_N<4>(printer, std::make_tuple(args...));
}

bind_tie(printer, _1,_2,_3,_4,_5,_6,_7,_8,_9);

在這裡我們假設了一些前提以模擬 signal<...> 類的展開場所。

  • 對於 printer 來說,它需要 4 個引數,但我們給它配上 9 個。
  • 然後在 bind_tie() 中,9 個佔位符被收束成一個 tuple,這是為了下一層能夠接續處理。
  • 下一層 bind_N() 的帶 N 版本,主要是為了產生一個編譯期的自然數序列,這是通過 std::make_index_sequence<N>{} 來達成的,它產生 1..N 序列
  • bind_N() 不帶 N 的版本中,利用了引數包展開能力,它使用 std::get<I>(t)... 展開式將 tuple 中的 9 個佔位符抽出 4 個來
  • 我們的目的達到了

這個過程,有一點點記憶體和時間上的損耗,因為需要 make_tuple 嘛。但是和語法的語義性相比這點損耗給得起。

如此,我們可以改寫 signal::connect()bind_tie 版本了:

static constexpr std::size_t SubjectCount = sizeof...(SignalSubjects);

template<typename _Callable, typename... _Args>
signal &connect(_Callable &&f, _Args &&...args) {
  using namespace std::placeholders;
  FN fn = cool::bind_tie<SubjectCount>(std::forward<_Callable>(f), std::forward<_Args>(args)..., _1, _2, _3, _4, _5, _6, _7, _8, _9);
  _callbacks.push_back(fn);
  return (*this);
}

注意我們從 signal 的模板引數 SignalSubjects 抽出了個數,採用 sizeof...(SignalSubjects) 語法。

也支援成員函式的繫結

仍有最後一個問題,面對成員函式時 connect 會出錯:

sig.on(&foo::bar, ff);

解決的辦法是做第二套 bind_N 特化版本,允許通過 std::is_member_function_pointer_v 識別到成員函式並特殊處理。為了讓兩套特化版本正確共存,需要提供 std::enable_if 的模板引數限定語義。最終的 cool::bind_tie 完整版本如下:

namespace hicc::util::cool {

  template<typename _Callable, typename... _Args>
  auto bind(_Callable &&f, _Args &&...args) {
    return std::bind(std::forward<_Callable>(f), std::forward<_Args>(args)...);
  }

  template<typename Function, typename Tuple, size_t... I>
  auto bind_N(Function &&f, Tuple &&t, std::index_sequence<I...>) {
    return std::bind(f, std::get<I>(t)...);
  }
  template<int N, typename Function, typename Tuple>
  auto bind_N(Function &&f, Tuple &&t) {
    // static constexpr auto size = std::tuple_size<Tuple>::value;
    return bind_N(f, t, std::make_index_sequence<N>{});
  }

  template<int N, typename _Callable, typename... _Args,
  std::enable_if_t<!std::is_member_function_pointer_v<_Callable>, bool> = true>
    auto bind_tie(_Callable &&f, _Args &&...args) {
    return bind_N<N>(f, std::make_tuple(args...));
  }

  template<typename Function, typename _Instance, typename Tuple, size_t... I>
  auto bind_N_mem(Function &&f, _Instance &&ii, Tuple &&t, std::index_sequence<I...>) {
    return std::bind(f, ii, std::get<I>(t)...);
  }
  template<int N, typename Function, typename _Instance, typename Tuple>
  auto bind_N_mem(Function &&f, _Instance &&ii, Tuple &&t) {
    return bind_N_mem(f, ii, t, std::make_index_sequence<N>{});
  }

  template<int N, typename _Callable, typename _Instance, typename... _Args,
  std::enable_if_t<std::is_member_function_pointer_v<_Callable>, bool> = true>
    auto bind_tie_mem(_Callable &&f, _Instance &&ii, _Args &&...args) {
    return bind_N_mem<N>(f, ii, std::make_tuple(args...));
  }
  template<int N, typename _Callable, typename... _Args,
  std::enable_if_t<std::is_member_function_pointer_v<_Callable>, bool> = true>
    auto bind_tie(_Callable &&f, _Args &&...args) {
    return bind_tie_mem<N>(std::forward<_Callable>(f), std::forward<_Args>(args)...);
  }

} // namespace hicc::util::cool

經過 bind_tie 的展開和截斷之後,我們解決了自動繫結佔位符的問題,而且並沒有大動干戈,只是使用了最常見的、最不復雜的展開手段,所以還是很得意的。

現在測試程式碼面對多 subjects 訊號觸發可以簡寫為這樣了:

    // connect the slots
    // sig.connect(printer, _1, _2, _3, _4);
    // foo ff;
    // sig.on(&foo::bar, ff, _1, _2, _3, _4);
    // sig.on(obj(), _1, _2, _3, _4);

    sig.connect(printer);
    foo ff;
    sig.on(&foo::bar, ff);
    sig.on(obj(), _1, _2, _3, _4);
    sig.on(obj());

對於靜態成員函式,沒有做額外測試,但它和普通函式物件是相同的,所以也能正確工作。

後記

這一次,Observer Pattern 的計劃出乎意料的加長了。

不過這才是我的本意,我自己也順便梳理一下知識結果,尤其是橫向縱向一起梳理才有意思。

這一批觀察者模式的完整的程式碼,請直達 repohz-common.hhdp-observer.cc。忽略 github actions 常常 hung up 的超時問題。