乐趣区

关于c++17:谈-C17-里的-Observer-模式-4-信号槽模式

上上上回的 谈 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 的超时问题。

退出移动版