Rust中的惯用回调


99

在C / C ++中,我通常使用简单的函数指针进行回调,也许也可以传递void* userdata参数。像这样:

typedef void (*Callback)();

class Processor
{
public:
    void setCallback(Callback c)
    {
        mCallback = c;
    }

    void processEvents()
    {
        for (...)
        {
            ...
            mCallback();
        }
    }
private:
    Callback mCallback;
};

在Rust中这样做的惯用方式是什么?具体来说,我的setCallback()函数应该mCallback采用哪种类型,应该采用哪种类型?应该Fn吗?也许FnMut吧?我要保存Boxed吗?一个例子将是惊人的。

Answers:


193

简短的答案:为了获得最大的灵活性,您可以将回调存储为盒装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方法是在匿名闭包中捕获数据,就像在现代C ++中一样。由于闭包不是fnset_callback因此需要接受其他类型的函数对象。

回调作为通用函数对象

在具有相同调用签名的Rust和C ++闭包中,大小不同,以适应它们可能捕获的不同值。此外,每个闭包定义都会为闭包的值生成一个唯一的匿名类型。由于这些限制,该结构无法命名其callback字段的类型,也不能使用别名。

在不引用具体类型的情况下将闭包嵌入struct字段的一种方法是使struct泛型。该结构将为传递给它的具体函数或闭包自动调整其大小和回调类型:

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: callback };
    p.process_events();
}

和以前一样,新的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对象(Rust的功能),该特性允许基于特征自动创建动态接口。这有时被称为类型擦除,并且是C ++ [1] [2]中的一种流行技术,不要与Java和FP语言对该术语的使用有所不同。熟悉C ++的读者会认识到实现的闭包FnFntrait对象之间的区别等同std::function于C ++中通用函数对象和值之间的区别。

通过与&运算符借用对象并将其强制转换或强制为对特定特征的引用,可以创建特征对象。在这种情况下,由于Processor需要拥有回调对象,因此我们不能使用借用,而必须将回调存储在分配给堆的功能Box<dyn Trait>(在Rust上等效于std::unique_ptr),在功能上等效于trait对象。

如果Processor存储Box<dyn FnMut()>,则不再需要泛型,但是该set_callback 方法现在c通过impl Trait参数接受泛型。这样,它可以接受任何类型的可调用对象(包括带状态的闭包),并在将其存储在中之前正确装箱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();
}

盒装封闭中引用的生命周期

接受'staticc参数类型的生存期限制set_callback是一种简单的方法,可以使编译器确信包含在中的引用(c可能是引用其环境的闭包,仅引用全局值),因此在使用过程中始终保持有效。打回来。但是静态绑定也非常笨拙:虽然它接受拥有对象的闭包就好了(我们在上面通过闭包来确保了这一点move),但它拒绝引用本地环境的闭包,即使它们仅引用了那些超过处理器的寿命,实际上是安全的。

由于只要处理器处于活动状态,我们只需要使回调保持活动状态,就应该尝试将其生存期与处理器的生存期联系起来,这比严格限制'static。但是,如果我们仅从中删除'static有效期限制set_callback,它将不再进行编译。这是因为set_callback创建一个新框并将其分配给callback定义为的字段Box<dyn FnMut()>。由于该定义未为装箱的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以确保字符串的寿命超过处理器。


15
哇,我认为这是我对某个SO问题的最佳答案!谢谢!完美的解释。我没有得到一件事-为什么CB必须'static在最后一个示例中出现?
Timmmm '16

9
Box<FnMut()>在struct场的装置使用Box<FnMut() + 'static>。大致是“装箱的特征对象不包含引用/它包含未使用(或相等)的任何引用'static”。它防止回调通过引用捕获本地对象。
bluss '16

我明白了,我想!
Timmmm '16

1
@Timmmm有关'static绑定的更多详细信息,请参见单独的博客文章
user4815162342

3
这是一个很好的答案,谢谢您提供@ user4815162342。
Dash83'4
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.