事件、信号及其中的一切
目录
简介
信号 (signals) 通常是游戏和一般软件架构的核心部分。
它们有助于解耦系统的各个部分,同时允许它们以某种方式相互通信。
所谓的 现代 C++ 提供了一个在这方面可能有用的工具,即 std::function。例如,它可以用于创建 delegates。
然而,无法保证 std::function 不会在底层执行内存分配,这在某些情况下可能会出现问题。此外,它解决了一个问题,但可能无法很好地适应不时出现的其他需求。
如果不需要 std::function 的灵活性和强大功能,或者为其付出的代价太高,EnTT 提供了一整套轻量级类来解决相同及许多其他问题。
Delegate
Delegate 可用作通用调用器,对于自由函数、lambda 以及附带实例的成员函数,均无内存开销。
它不声称是 std::function 的无缝替代品 (drop-in replacement),因此不要期望在任何 std::function 适用的地方都使用它。话虽如此,在许多情况下,它很可能比 std::function 更适合,因此无论如何都期望大量使用它。
其接口非常简单。它提供一个默认构造函数来创建空 delegates:
entt::delegate<int(int)> delegate{};
创建实例所需的是指定 delegate 接受 的函数类型,即它所建模的函数的签名。
然而,尝试通过调用其函数调用运算符来使用空 delegate 会导致未定义行为,或最有可能导致崩溃。
connect 成员函数有几个重载用于初始化 delegate:
int f(int i) { return i; }
struct my_struct {
int f(const int &i) const { return i; }
};
// 将自由函数绑定到 delegate
delegate.connect<&f>();
// 将成员函数绑定到 delegate
my_struct instance;
delegate.connect<&my_struct::f>(instance);
如果需要,delegate 类也接受数据成员。在这种情况下,delegate 的函数类型使得参数列表为空,并且数据成员的值至少可转换为返回类型。
类型等效于 void(T &, args...) 的自由函数也被接受。第一个参数 T & 被视为有效负载 (payload),函数每次被调用时都会接收它。换句话说,这与上述定义完全兼容:
void g(const char &c, int i) { /* ... */ }
const char c = 'c';
delegate.connect<&g>(c);
delegate(42);
函数 g 使用对 c 的引用和 42 调用。然而,delegate 的函数类型仍然是 void(int)。这也是其函数调用运算符的签名。
delegate 类的另一个有趣方面是,它接受参数列表比其函数类型短的函数:
void g() { /* ... */ }
delegate.connect<&g>();
delegate(42);
其中 delegate 的函数类型如上所述为 void(int)。不言而喻,额外的参数在内部会被静默丢弃。这在许多情况下是一个很好的特性,例如当 delegate 类用作信号 - 槽 (signal-slot) 系统的构建块时。
事实上,这种过滤是双向的。该类尝试 首先 传递其前 count 个参数,然后是最后 count 个参数。如果在连接 listener 时有疑问,请注意转换规则!
相反,不支持从 delegate 列表中任意提取参数的函数。其他特性更受青睐,例如支持参数列表兼容但不完全等于 delegate 的函数。
要一次性创建并初始化 delegate,有几个专门的构造函数。由于语言规则,listener 通过 entt::connect_arg 变量模板提供:
entt::delegate<int(int)> func{entt::connect_arg<&f>};
除了 connect 之外,未提供对应的 disconnect。相反,存在一个 reset 成员函数用于清空 delegate。
要判断 delegate 是否为空,可以在任何条件语句中显式使用它:
if(delegate) {
// ...
}
最后,要调用 delegate,应使用函数调用运算符,如上例所示:
auto ret = delegate(42);
在所有情况下,listener 不必严格遵循 delegate 的签名。只要 listener 可以使用给定的参数调用并产生可转换为给定返回类型的结果,一切都能正常工作。
顺便提一下,类的成员函数可能与实例关联,也可能不关联。如果不关联,函数类型的第一个参数必须是成员函数所操作的类的类型,并且在调用 delegate 时显然必须传递该类的实例:
entt::delegate<void(my_struct &, int)> delegate;
delegate.connect<&my_struct::f>();
my_struct instance;
delegate(instance, 42);
在这种情况下,无法 推导 函数类型,因为第一个参数不一定必须是引用(例如,它可以是指针,也可以是 const 引用)。
因此,对于未绑定的成员函数,必须显式声明函数类型。
运行时参数
delegate 类主要设计用于模板参数。然而,由于其设计,它也提供对运行时参数的最小支持。
当这样使用时,某些功能不受支持。特别是:
- 不支持柯里化 (curried) 函数。
- 不支持参数列表与 delegate 不同的函数。
- 返回类型和参数类型 必须 与 delegate 的类型一致,至少可转换 不再足够。
此外,对于给定的函数类型 Ret(Args...),在运行时连接的函数的签名必须为 Ret(const void *, Args...)。
运行时参数既可以传递给 delegate 的构造函数,也可以传递给 connect 成员函数。在这两种情况下都接受一个可选参数。此参数用于在调用时作为 const void * 来回传递任意用户数据。
要 以困难的方式 将函数连接到 delegate:
int func(const void *ptr, int i) { return *static_cast<const int *>(ptr) * i; }
const int value = 42;
// 使用构造函数 ...
entt::delegate delegate{&func, &value};
// ... 或使用 connect 成员函数
delegate.connect(&func, &value);
如果可能,delegate 的类型从函数推导。在这种情况下,由于第一个参数是实现细节,推导出的函数类型是 int(int)。
调用以此方式构建的 delegate 遵循先前解释的相同规则。
Lambda 支持
通常,delegate 类并不在其所有细微差别中完全支持 lambda 函数。原因很简单:delegate 不是 std::function 的无缝替代品。相反,它试图克服后者的问题。
话虽如此,非捕获 (non-capturing) lambda 函数是受支持的,尽管在这种情况下某些功能不可用。
这是支持在运行时连接函数的逻辑结果。因此,lambda 函数遵循相同的规则和限制。
事实上,由于非捕获 lambda 函数会退化为函数指针,它们可以像 普通函数 一样与 delegate 一起使用,并带有可选的有效负载:
my_struct instance;
// 使用构造函数 ...
entt::delegate delegate{+[](const void *ptr, int value) {
return static_cast<const my_struct *>(ptr)->f(value);
}, &instance};
// ... 或使用 connect 成员函数
delegate.connect([](const void *ptr, int value) {
return static_cast<const my_struct *>(ptr)->f(value);
}, &instance);
如上所述,第一个参数 (const void *) 不是 delegate 函数类型的一部分,用于来回分发任意用户数据。换句话说,上述 delegate 的函数类型是 int(int)。
原始访问
虽然不推荐,但 delegate 也允许直接访问存储的可调用函数目标和底层数据(如果有)。
这使得可以绕过 delegate 本身的行为,并在不同实例上强制调用:
my_struct other;
delegate.target(&other, 42);
不言而喻,这种方法 非常 危险,尤其是因为无法知道所包含的函数最初是某个类的成员函数、自由函数还是 lambda。
此功能的另一个可能(且有意义的)用途是通过其描述性 特征 (traits) 来识别特定的 delegate。
Signals
信号处理器 (signal handlers) 使用对类、函数指针和成员指针的引用。Listener 可以是任何类型的对象,用户负责连接和断开它们与 signal 的连接,以避免因不同生命周期而导致的崩溃。另一方面,这种信号处理器的存在不应过多影响性能。
Signals 在内部使用 delegates,因此遵循相同的规则并提供类似的功能。咨询 delegate 类的文档以获取更多信息可能是个好主意。
信号处理器可以作为私有数据成员使用,而无需向类的客户端暴露任何 发布 (publish) 功能。
基本思想是在 signal 本身和 sink 类之间强加清晰的分离,后者是一个用于动态连接和断开 listener 的工具。
信号处理器的 API 很简单。如果在发布内容时向 signal 提供了一个收集器 (collector),则其 listener 返回的所有值都会被字面上 收集 起来供调用者稍后使用。否则,该类就像一个普通的 signal 一样,不时地发出事件。
要创建信号处理器的实例,只需提供它们所引用的函数类型:
entt::sigh<void(int, char)> signal;
Signals 提供所有基本功能,用于了解它们包含多少 listener (size) 或是否至少包含一个 listener (empty),以及用于交换处理器的函数 (swap)。
除此之外,还有成员函数用于通过 sink 以所有形式连接和断开 listener:
void foo(int, char) { /* ... */ }
struct listener {
void bar(const int &, char) { /* ... */ }
};
// ...
entt::sink sink{signal};
listener instance;
sink.connect<&foo>();
sink.connect<&listener::bar>(instance);
// ...
// 断开自由函数
sink.disconnect<&foo>();
// 断开实例的成员函数
sink.disconnect<&listener::bar>(instance);
// 断开实例的所有成员函数(如果有)
sink.disconnect(&instance);
// 一次性丢弃所有 listener
sink.disconnect();
如上所示,listener 不必严格遵循 signal 的签名。只要 listener 可以使用给定的参数调用并产生可转换为给定返回类型的结果,一切都能正常工作。
在所有情况下,connect 成员函数默认返回一个 connection 对象,可用作通过其 release 成员函数断开连接的替代方案。
也可以从 connection 创建 scoped_connection。在这种情况下,一旦对象超出作用域,链接就会自动断开。
一旦附加了 listener(或者即使根本没有 listener),也可以通过 signal 的 publish 成员函数发布事件和一般数据:
signal.publish(42, 'c');
要收集数据,则使用 collect 成员函数:
int f() { return 0; }
int g() { return 1; }
// ...
entt::sigh<int()> signal;
entt::sink sink{signal};
sink.connect<&f>();
sink.connect<&g>();
std::vector<int> vec{};
signal.collect([&vec](int value) { vec.push_back(value); });
assert(vec[0] == 0);
assert(vec[1] == 1);
收集器必须公开一个函数调用运算符,该运算符接受一个参数,其类型是 listener 返回类型可转换的类型。此外,它可以选择返回一个布尔值,true 表示停止收集数据,false 表示继续。这样可以避免在不必要时调用所有 listener。
Functor 也可以代替 lambda 使用。由于收集器在调用 collect 成员函数时被复制,因此在这种情况下应使用 std::ref:
struct my_collector {
std::vector<int> vec{};
bool operator()(int v) {
vec.push_back(v);
return true;
}
};
// ...
my_collector collector;
signal.collect(std::ref(collector));
Event Dispatcher
事件分发器 (event dispatcher) 类允许用户触发即时事件,或稍后一起排队并发布它们:
// 定义通用分发器
entt::dispatcher dispatcher{};
此类延迟实例化其队列。因此,无需提前 声明 事件类型。
Connect、Disconnect、Publish
向分发器注册的 listener 主要有两种类型:自由函数和成员函数。作为模板函数的 lambda 也被接受,并属于第一组。
在所有情况下,listener 接受任何事件类型的 Event & 参数,无论返回值如何。
Listener 通过 connect 直接链接到 sink 对象:
struct an_event { int value; };
struct another_event {};
void on_event(const an_event &event) { /* ... */ }
struct listener {
// 成员函数 listener
void on_event(const another_event &) { /* ... */ }
};
// ...
// 自由函数 listener
dispatcher.sink<an_event>().connect<&on_event>();
listener listener;
// 成员函数 listener
dispatcher.sink<another_event>().connect<&listener::on_event>(listener);
请注意,在事件处理程序内连接 listener 可能会导致未定义行为。
disconnect 成员函数用于一次移除一个 listener 或一次性移除所有 listener:
// 断开自由函数
dispatcher.sink<an_event>().disconnect<&on_event>();
// 断开实例的成员函数
dispatcher.sink<another_event>().disconnect<&listener::on_event>(listener);
// 断开实例的所有成员函数(如果有)
dispatcher.sink<another_event>().disconnect(&listener);
trigger 成员函数用于向迄今为止注册的所有 listener 发送即时事件:
dispatcher.trigger(an_event{42});
dispatcher.trigger(another_event{});
Listener 会立即被调用,执行顺序不保证。此方法可用于推送紧急消息,例如移动应用上的 正在终止 通知。
另一方面,enqueue 成员函数将消息一起排队,并有助于控制它们发送给 listener 的时机:
dispatcher.enqueue<an_event>(42);
dispatcher.enqueue(another_event{});
事件被存储在一旁,直到调用 update 成员函数:
// 一次性发出给定类型的所有事件
dispatcher.update<an_event>();
// 一次性发出迄今为止排队的所有事件
dispatcher.update();
这样,用户可以将分发器嵌入循环中,并字面上每 tick 向系统分发一次事件。
Named Queues
分发器内的所有队列默认与事件类型关联,然后从中检索。
然而,可以创建具有不同 名称 的队列(因此也可以为单个类型创建多个队列)。事实上,几乎所有函数也都接受一个额外的参数。例如:
dispatcher.sink<an_event>("custom"_hs).connect<&listener::receive>(listener);
在这种情况下,术语 名称 被误用,因为这些是 id_type 类型的实际数字标识符。
此规则的一个例外是 enqueue 函数。它没有额外的参数,而是有一个不同的函数:
dispatcher.enqueue_hint<an_event>("custom"_hs, 42);
这主要是由于模板参数推导规则,并且没有真正(优雅)的方法来避免它。
Event Emitter
一个通用的事件发射器 (event emitter),主要考虑用于处理异步内容的情况。
最初设计用于满足 uvw(用现代 C++ 编写的 libuv 包装器)的需求,后来经过调整以包含在本库中。
要创建发射器类型,派生类必须如下继承基类:
struct my_emitter: emitter<my_emitter> {
// ...
}
不同事件的处理程序在运行时动态创建。无需提前指定可接受事件的完整列表。
此外,每当发布事件时,发射器还会将其自身的引用传递给其 listener。
要创建发射器的新实例,不需要任何参数:
my_emitter emitter{};
Listener 是可移动且可调用的对象(自由函数、lambda、functor、std::function 等),其函数类型与以下内容兼容:
void(Type &, my_emitter &)
其中 Type 是它们想要接收的事件类型。
要将 listener 附加到发射器,存在 on 成员函数:
emitter.on<my_event>([](const my_event &event, my_emitter &emitter) {
// ...
});
同样,reset 成员函数用于断开给定类型的 listener,而 clear 用于一次性断开所有 listener:
// 重置 my_event 的 listener
emitter.erase<my_event>();
// 重置所有 listener
emitter.clear()
要将事件发送给在给定类型上注册的 listener,应使用 publish 函数:
struct my_event { int i; };
// ...
emitter.publish(my_event{42});
最后,empty 成员函数测试事件发射器是否至少注册了一个 listener,而 contains 用于检查给定事件类型是否与有效 listener 关联:
if(emitter.contains<my_event>()) {
// ...
}
此类引入了一个基于事件和 listener 的 nice-to-have 模型。
更一般地说,当派生类 包装 异步操作时,它是一个方便的工具,但并不局限于此类用途。