上上上回的 談 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 的超時問題。