Rust 中的惯用回调

Posted

技术标签:

【中文标题】Rust 中的惯用回调【英文标题】:Idiomatic callbacks in Rust 【发布时间】:2017-04-26 03:52:12 【问题描述】:

在 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吗?一个例子会很棒。

【问题讨论】:

【参考方案1】:

简短回答:为了获得最大的灵活性,您可以将回调存储为装箱的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++ 中一样。由于闭包不是fnset_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 是只读取数据的闭包,可以安全地多次调用,可能来自多个线程。以上两个闭包都是FnFnMut 是修改数据的闭包,例如通过写入捕获的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++ 的读者会认识到实现 FnFn trait 对象的闭包之间的区别等同于 C++ 中通用函数对象和 std::function 值之间的区别。

通过使用&amp; 运算符借用对象并将其强制转换或强制为对特定特征的引用来创建特征对象。在这种情况下,由于Processor 需要拥有回调对象,我们不能使用借用,而是必须将回调存储在堆分配的Box&lt;dyn Trait&gt;std::unique_ptr 的 Rust 等价物)中,这在功能上等价于 trait对象。

如果Processor 存储Box&lt;dyn FnMut()&gt;,它不再需要是通用的,但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&lt;dyn FnMut()&gt;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 的定义之前,以确保字符串比处理器更长。

【讨论】:

哇,我认为这是我对 SO 问题的最佳回答!谢谢!完美解释。一件小事我不明白 - 为什么CB 在最后一个例子中必须是'static struct 字段中使用的Box&lt;FnMut()&gt; 表示Box&lt;FnMut() + 'static&gt;。粗略地说“盒装特征对象不包含任何引用/它包含的任何引用都超过了(或等于)'static”。它防止回调通过引用捕获本地人。 啊我明白了,我想! @Timmmm 有关'static 绑定在separate blog post 中的更多详细信息。 这是一个很棒的答案,感谢您提供@user4815162342。【参考方案2】:

如果你愿意处理生命周期并且负担不起堆分配,那么这里有一个使用引用来实现回调的实现:

use core::ffi::c_void;
use core::mem::transmute;
use core::ptr::null_mut;
use core::marker::PhantomData;

/// ErasedFnPointer can either points to a free function or associated one that
/// `&mut self`
struct ErasedFnPointer<'a, T, Ret> 
    struct_pointer: *mut c_void,
    fp: *const (),
    // The `phantom_*` field is used so that the compiler won't complain about
    // unused generic parameter.
    phantom_sp: PhantomData<&'a ()>,
    phantom_fp: PhantomData<fn(T) -> Ret>,


impl<'a, T, Ret> Copy for ErasedFnPointer<'a, T, Ret> 
impl<'a, T, Ret> Clone for ErasedFnPointer<'a, T, Ret> 
    fn clone(&self) -> Self 
        *self
    


impl<'a, T, Ret> ErasedFnPointer<'a, T, Ret> 
    pub fn from_associated<S>(struct_pointer: &'a mut S, fp: fn(&mut S, T) -> Ret)
        -> ErasedFnPointer<'a, T, Ret>
    
        ErasedFnPointer 
            struct_pointer: struct_pointer as *mut _ as *mut c_void,
            fp: fp as *const (),
            phantom_sp: PhantomData,
            phantom_fp: PhantomData,
        
    
    
    pub fn from_free(fp: fn(T) -> Ret) -> ErasedFnPointer<'static, T, Ret> 
        ErasedFnPointer 
            struct_pointer: null_mut(),
            fp: fp as *const (),
            phantom_sp: PhantomData,
            phantom_fp: PhantomData,
        
    
    
    pub fn call(&self, param: T) -> Ret 
        if self.struct_pointer.is_null() 
            let fp = unsafe  transmute::<_, fn(T) -> Ret>(self.fp) ;
            fp(param)
         else 
            let fp = unsafe  transmute::<_, fn(*mut c_void, T) -> Ret>(self.fp) ;
            fp(self.struct_pointer, param)
        
    


fn main() 
    let erased_ptr = ErasedFnPointer::from_free(|x| 
        println!("Hello, ", x);
        x
    );
    erased_ptr.call(2333);
    
    println!("size_of_val(erased_ptr) = ", core::mem::size_of_val(&erased_ptr));

    ErasedFnPointer::from_associated(
        &mut Test  x: 1,
        Test::f
    ).call(1);
    
    let mut x = None;
    ErasedFnPointer::from_associated(&mut x, |x, param| 
        *x = Some(param);
        println!(":#?", x);
    ).call(1);


struct Test 
    x: i32

impl Test 
    fn f(&mut self, y: i32) -> i32 
        let z = self.x + y;
        println!("Hello from Test, ", z);
        z
    

【讨论】:

以上是关于Rust 中的惯用回调的主要内容,如果未能解决你的问题,请参考以下文章

Rust 中的快速惯用 Floyd-Warshall 算法

如何使用适用于 DynamoDb 的 AWS Rust 开发工具包编写惯用的 Rust 错误处理?

测试私有函数的惯用方法是啥?

如何在 Rust 中惯用地将 bool 转换为 Option 或 Result?

接受 Result<T, E> 作为函数参数是惯用的 Rust 吗?

如何在 Rust 中以最惯用的方式将 Option<&T> 转换为 Option<T>?