Stories

Detail Return Return

談 C++17 裏的 Observer 模式 - 4 - 信號槽模式 - Stories Detail

上上上回的 談 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 的計劃出乎意料的加長了。

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

這一批觀察者模式的完整的代碼,請直達 repo 的 hz-common.hh 和 dp-observer.cc。忽略 github actions 常常 hung up 的超時問題。

user avatar donnytab Avatar saltymilk Avatar artificer Avatar easynvr Avatar vksfeng Avatar OasisPioneer Avatar dalideshoushudao Avatar gocpplua Avatar huikaichedemianbao Avatar hlinleanring Avatar xingchenheyue Avatar hello_5adf4e51b4f3e Avatar
Favorites 13 users favorite the story!
Favorites

Add a new Comments

Some HTML is okay.