简短回答:为了获得最大的灵活性,您可以将回调存储为装箱的FnMut 对象,回调设置器在回调类型上是通用的。答案的最后一个示例中显示了此代码。如需更详细的说明,请继续阅读。
“函数指针”:回调为fn
问题中与 C++ 代码最接近的等效项是将回调声明为 fn 类型。 fn 封装了 fn 关键字定义的函数,很像 C++ 的函数指针:
type Callback = fn();
struct Processor {
callback: Callback,
}
impl Processor {
fn set_callback(&mut self, c: Callback) {
self.callback = c;
}
fn process_events(&self) {
(self.callback)();
}
}
fn simple_callback() {
println!("hello world!");
}
fn main() {
let p = Processor {
callback: simple_callback,
};
p.process_events(); // hello world!
}
此代码可以扩展为包含Option<Box<Any>> 以保存与函数关联的“用户数据”。即便如此,它也不会是惯用的 Rust。 Rust 将数据与函数关联的方法是在匿名 closure 中捕获它,就像在现代 C++ 中一样。由于闭包不是fn,set_callback 将需要接受其他类型的函数对象。
作为通用函数对象的回调
在 Rust 和 C++ 中,具有相同调用签名的闭包具有不同的大小,以适应它们可能捕获的不同值。此外,每个闭包定义都会为闭包的值生成一个唯一的匿名类型。由于这些限制,该结构不能命名其callback 字段的类型,也不能使用别名。
在结构字段中嵌入闭包而不引用具体类型的一种方法是使结构通用。该结构将自动调整其大小和回调类型,以适应您传递给它的具体函数或闭包:
struct Processor<CB>
where
CB: FnMut(),
{
callback: CB,
}
impl<CB> Processor<CB>
where
CB: FnMut(),
{
fn set_callback(&mut self, c: CB) {
self.callback = c;
}
fn process_events(&mut self) {
(self.callback)();
}
}
fn main() {
let s = "world!".to_string();
let callback = || println!("hello {}", s);
let mut p = Processor { callback };
p.process_events();
}
和以前一样,set_callback() 将接受用fn 定义的函数,但这个也接受|| println!("hello world!") 的闭包,以及捕获值的闭包,例如|| println!("{}", somevar)。因此,处理器不需要userdata 来伴随回调; set_callback 的调用者提供的闭包将自动从其环境中捕获所需的数据,并在调用时使其可用。
但是FnMut 是怎么回事,为什么不只是Fn?由于闭包保存捕获的值,因此在调用闭包时必须应用 Rust 通常的变异规则。根据闭包对它们所持有的值的作用,它们被分为三个系列,每个系列都标有一个特征:
-
Fn 是只读取数据的闭包,可以安全地多次调用,可能来自多个线程。以上两个闭包都是Fn。
-
FnMut 是修改数据的闭包,例如通过写入捕获的mut 变量。它们也可以被多次调用,但不能并行调用。 (从多个线程调用 FnMut 闭包会导致数据争用,因此只能通过互斥锁的保护来完成。)调用者必须将闭包对象声明为可变。
-
FnOnce 是消耗它们捕获的一些数据的闭包,例如通过将捕获的值传递给按值获取它的函数。顾名思义,它们只能被调用一次,调用者必须拥有它们。
有点违反直觉,当为接受闭包的对象类型指定特征绑定时,FnOnce 实际上是最宽松的。声明泛型回调类型必须满足 FnOnce 特征意味着它将接受字面上的任何闭包。但这是有代价的:这意味着持有者只能调用一次。由于process_events() 可能会选择多次调用回调,并且由于方法本身可能会被多次调用,因此下一个最宽松的界限是FnMut。请注意,我们必须将 process_events 标记为 mutating self。
非泛型回调:函数特征对象
尽管回调的通用实现非常高效,但它有严重的接口限制。它要求每个Processor 实例都使用具体的回调类型进行参数化,这意味着单个Processor 只能处理单个回调类型。鉴于每个闭包都有不同的类型,通用 Processor 无法处理 proc.set_callback(|| println!("hello")) 后跟 proc.set_callback(|| println!("world"))。扩展结构以支持两个回调字段将需要将整个结构参数化为两种类型,随着回调数量的增加,这将很快变得笨拙。如果回调的数量需要是动态的,则添加更多类型参数将不起作用,例如实现一个add_callback 函数,该函数维护一个不同回调的向量。
要删除类型参数,我们可以利用 trait objects,这是 Rust 的特性,它允许基于特征自动创建动态接口。这有时被称为类型擦除,是 C++[1][2] 中的一种流行技术,不要与 Java 和 FP 语言对该术语的稍微不同的使用混淆。熟悉 C++ 的读者会认识到实现 Fn 和 Fn trait 对象的闭包之间的区别等同于 C++ 中通用函数对象和 std::function 值之间的区别。
通过使用& 运算符借用对象并将其强制转换或强制为对特定特征的引用来创建特征对象。在这种情况下,由于Processor 需要拥有回调对象,我们不能使用借用,而是必须将回调存储在堆分配的Box<dyn Trait>(std::unique_ptr 的 Rust 等价物)中,这在功能上等价于 trait对象。
如果Processor 存储Box<dyn FnMut()>,它不再需要是通用的,但set_callback 方法现在通过impl Trait argument 接受通用c。因此,它可以接受任何类型的可调用对象,包括带状态的闭包,并在将其存储到 Processor 之前对其进行适当的装箱。 set_callback 的通用参数不限制处理器接受的回调类型,因为接受的回调类型与存储在 Processor 结构中的类型是分离的。
struct Processor {
callback: Box<dyn FnMut()>,
}
impl Processor {
fn set_callback(&mut self, c: impl FnMut() + 'static) {
self.callback = Box::new(c);
}
fn process_events(&mut self) {
(self.callback)();
}
}
fn simple_callback() {
println!("hello");
}
fn main() {
let mut p = Processor {
callback: Box::new(simple_callback),
};
p.process_events();
let s = "world!".to_string();
let callback2 = move || println!("hello {}", s);
p.set_callback(callback2);
p.process_events();
}
盒装闭包内引用的生命周期
set_callback 接受的c 参数类型上的'static 生命周期是一种简单的方法,可以让编译器相信c 中包含引用,这可能是引用其环境的闭包仅引用全局值,因此在回调的整个使用过程中将保持有效。但是静态绑定也非常严厉:虽然它接受拥有对象的闭包很好(我们在上面通过使闭包move 确保了这一点),但它拒绝引用本地环境的闭包,即使它们只引用到比处理器寿命更长并且实际上是安全的值。
由于只要处理器还活着,我们就只需要回调活着,我们应该尝试将它们的生命周期与处理器的生命周期联系起来,这比'static 的限制更宽松。但是如果我们只是从set_callback 中删除'static 生命周期绑定,它就不再编译了。这是因为set_callback 创建了一个新框并将其分配给定义为Box<dyn FnMut()> 的callback 字段。由于定义没有为装箱的 trait 对象指定生命周期,因此隐含了 'static,并且赋值将有效地扩大生命周期(从回调的未命名的任意生命周期到 'static),这是不允许的。解决方法是为处理器提供一个明确的生命周期,并将该生命周期与盒子中的引用和set_callback 收到的回调中的引用联系起来:
struct Processor<'a> {
callback: Box<dyn FnMut() + 'a>,
}
impl<'a> Processor<'a> {
fn set_callback(&mut self, c: impl FnMut() + 'a) {
self.callback = Box::new(c);
}
// ...
}
明确这些生命周期后,不再需要使用'static。闭包现在可以引用本地的s 对象,即不再必须是move,前提是s 的定义放在p 的定义之前,以确保字符串比处理器更长。