【问题标题】:Struggling with closures and lifetimes in Rust在 Rust 中与闭包和生命周期作斗争
【发布时间】:2016-03-23 01:56:30
【问题描述】:

我正在尝试将一个小基准从 F# 移植到 Rust。 F# 代码如下所示:

let inline iterNeighbors f (i, j) =
  f (i-1, j)
  f (i+1, j)
  f (i, j-1)
  f (i, j+1)

let rec nthLoop n (s1: HashSet<_>) (s2: HashSet<_>) =
  match n with
  | 0 -> s1
  | n ->
      let s0 = HashSet(HashIdentity.Structural)
      let add p =
        if not(s1.Contains p || s2.Contains p) then
          ignore(s0.Add p)
      Seq.iter (fun p -> iterNeighbors add p) s1
      nthLoop (n-1) s0 s1

let nth n p =
  nthLoop n (HashSet([p], HashIdentity.Structural)) (HashSet(HashIdentity.Structural))

(nth 2000 (0, 0)).Count

它从潜在无限图中的初始顶点计算第 n 个最近邻壳。我在攻读博士学位期间使用了类似的东西来研究无定形材料。

我花了很多时间尝试将其移植到 Rust,但未能成功。我设法让一个版本工作,但只能通过手动内联闭包并将递归转换为带有本地可变变量的循环(yuk!)。

我尝试像这样编写iterNeighbors 函数:

use std::collections::HashSet;

fn iterNeighbors<F>(f: &F, (i, j): (i32, i32)) -> ()
where
    F: Fn((i32, i32)) -> (),
{
    f((i - 1, j));
    f((i + 1, j));
    f((i, j - 1));
    f((i, j + 1));
}

我认为这是一个接受闭包(它本身接受一对并返回单位)和一对并返回单位的函数。我似乎必须双括号:对吗?

我试着写一个这样的递归版本:

fn nthLoop(n: i32, s1: HashSet<(i32, i32)>, s2: HashSet<(i32, i32)>) -> HashSet<(i32, i32)> {
    if n == 0 {
        return &s1;
    } else {
        let mut s0 = HashSet::new();
        for &p in s1 {
            if !(s1.contains(&p) || s2.contains(&p)) {
                s0.insert(p);
            }
        }
        return &nthLoop(n - 1, s0, s1);
    }
}

请注意,我还没有为iterNeighbors 打电话而烦恼。

我认为我正在努力使参数的生命周期正确,因为它们在递归调用中被轮换。如果我希望 s2returns 之前被释放并且我希望 s1 在返回或进入递归调用时仍然存在,我应该如何注释生命周期?

调用者看起来像这样:

fn nth<'a>(n: i32, p: (i32, i32)) -> &'a HashSet<(i32, i32)> {
    let s0 = HashSet::new();
    let mut s1 = HashSet::new();
    s1.insert(p);
    return &nthLoop(n, &s1, s0);
}

我放弃了这一点,并将其编写为 while 循环,而使用可变的本地变量:

fn nth<'a>(n: i32, p: (i32, i32)) -> HashSet<(i32, i32)> {
    let mut n = n;
    let mut s0 = HashSet::new();
    let mut s1 = HashSet::new();
    let mut s2 = HashSet::new();
    s1.insert(p);
    while n > 0 {
        for &p in &s1 {
            let add = &|p| {
                if !(s1.contains(&p) || s2.contains(&p)) {
                    s0.insert(p);
                }
            };
            iterNeighbors(&add, p);
        }
        std::mem::swap(&mut s0, &mut s1);
        std::mem::swap(&mut s0, &mut s2);
        s0.clear();
        n -= 1;
    }
    return s1;
}

如果我手动内联闭包,这会起作用,但我不知道如何调用闭包。理想情况下,我想在这里进行静态调度。

main 函数是:

fn main() {
    let s = nth(2000, (0, 0));
    println!("{}", s.len());
}

那么……我做错了什么? :-)

另外,我只在 F# 中使用了HashSet,因为我假设 Rust 没有提供具有高效集合论运算(联合、交集和差异)的纯函数式 Set。我的假设是否正确?

【问题讨论】:

    标签: rust


    【解决方案1】:

    我认为这是一个接受闭包(它本身接受一对并返回单位)和一对并返回单位的函数。我似乎必须双括号:对吗?

    您需要双括号,因为您将 2 元组传递给闭包,这与您的原始 F# 代码相匹配。

    我认为我正在努力使参数的生命周期正确,因为它们在递归调用中被轮换。如果我希望 s2 在返回之前被释放并且我希望 s1 在返回时或进入递归调用时仍然存在,我应该如何注释生命周期?

    问题在于,当您应该直接使用 HashSets 时,您使用的是对 HashSets 的引用。您对nthLoop 的签名已经正确;您只需要删除几次出现的&amp;

    要释放s2,你可以写drop(s2)。请注意,Rust 没有保证尾调用,因此每个递归调用仍会占用一些堆栈空间(您可以使用 mem::size_of 函数查看多少),但 drop 调用将清除堆上的数据.

    调用者看起来像这样:

    同样,您只需在此处删除&amp;

    请注意,我什至还没有为调用 iterNeighbors 而烦恼。


    如果我手动内联闭包,但我无法弄清楚如何调用闭包,这会起作用。理想情况下,我想在这里进行静态调度。

    Rust 中有三种类型的闭包:FnFnMutFnOnce。它们的不同之处在于 self 参数的类型。区别很重要,因为它限制了允许闭包做什么以及调用者如何使用闭包。 Rust 书有 a chapter on closures 已经很好地解释了这一点。

    你的闭包需要改变s0。但是,iterNeighbors 被定义为期望 Fn 闭包。你的闭包不能实现Fn,因为Fn接收&amp;self,但是要改变s0,你需要&amp;mut selfiterNeighbors 不能使用FnOnce,因为它需要多次调用闭包。因此,您需要使用FnMut

    此外,没有必要通过引用 iterNeighbors 来传递闭包。您可以按值传递它;每次调用闭包只会借用闭包,不会消耗它。

    另外,我只在 F# 中使用了 HashSet,因为我假设 Rust 没有提供具有有效集合论运算(并、交和差)的纯函数 Set。我的假设是否正确?

    标准库中没有纯粹的功能集实现(也许crates.io 上有一个?)。虽然 Rust 包含函数式编程,但它也利用其所有权和借用系统来使命令式编程更安全。功能集可能会强制使用某种形式的引用计数或垃圾收集,以便跨集共享项目。

    但是,HashSet 确实实现了集合论运算。有两种使用方式:迭代器(differencesymmetric_differenceintersectionunion),它们懒惰地生成序列,或者运算符(|&amp;^、@987654366 @,如trait implementations for HashSet 中所列),它会生成新的集合,其中包含来自源集合的值的克隆。


    这是工作代码:

    use std::collections::HashSet;
    
    fn iterNeighbors<F>(mut f: F, (i, j): (i32, i32)) -> ()
    where
        F: FnMut((i32, i32)) -> (),
    {
        f((i - 1, j));
        f((i + 1, j));
        f((i, j - 1));
        f((i, j + 1));
    }
    
    fn nthLoop(n: i32, s1: HashSet<(i32, i32)>, s2: HashSet<(i32, i32)>) -> HashSet<(i32, i32)> {
        if n == 0 {
            return s1;
        } else {
            let mut s0 = HashSet::new();
            for &p in &s1 {
                let add = |p| {
                    if !(s1.contains(&p) || s2.contains(&p)) {
                        s0.insert(p);
                    }
                };
                iterNeighbors(add, p);
            }
            drop(s2);
            return nthLoop(n - 1, s0, s1);
        }
    }
    
    fn nth(n: i32, p: (i32, i32)) -> HashSet<(i32, i32)> {
        let mut s1 = HashSet::new();
        s1.insert(p);
        let s2 = HashSet::new();
        return nthLoop(n, s1, s2);
    }
    
    fn main() {
        let s = nth(2000, (0, 0));
        println!("{}", s.len());
    }
    

    【讨论】:

    • 一个罕见的使用drop 的例子实际上是有意义的。令人兴奋!对于 OP,我要指出将 return 作为方法/块中的最后一个语句是非惯用的 Rust;通常你以一个表达式结束。此外,rust 使用 snake_case 标识符,而不是 camelCase
    • "但是,HashSet 确实实现了集合论运算。有两种使用它们的方法:迭代器(difference、symmetric_difference、intersection、union),它懒惰地生成序列,或运算符(|、& , ^, -,如 HashSet 的 trait 实现中所列),它们生成包含源集中值的克隆的新集”。与纯功能的 Set 相比,这将非常缓慢。
    • 功能集可能会强制使用某种形式的引用计数或垃圾收集,以便跨集共享项目。 => 引用计数对于“功能- like" 集,利用新节点无法引用 newer 节点的事实,因此您获得了 DAG。
    • @JonHarrop:与纯函数 Set 相比,这将非常慢。 => 这取决于克隆的内容。如果集合中的数据实现Copy,这将是一个简单的按位复制,速度非常快。否则,Clone 实现可能会很昂贵,但是将它们包装在 Rc 中可以缓解这种情况,因为Rc 通过复制指针并增加引用计数来实现Clone,这相当快,而且可能比说更快,克隆VecString,这需要新的堆分配。
    • @FrancisGagné:是的,但是按位复制只是一个常数因子更快,而纯函数 Set 渐近更快,因为它不复制元素,它只是引用它们。而且Rc 会比跟踪 GC 慢很多。
    【解决方案2】:

    我似乎不得不双括号:对吗?

    否:双括号是因为您选择使用元组并调用采用元组的函数需要先创建元组,但可以有带有多个参数的闭包,例如F: Fn(i32, i32)。也就是说,可以将该函数写为:

    fn iterNeighbors<F>(i: i32, j: i32, f: F)
    where
        F: Fn(i32, i32),
    {
        f(i - 1, j);
        f(i + 1, j);
        f(i, j - 1);
        f(i, j + 1);
    }
    

    但是,对于这种情况,保留元组似乎是有意义的。

    我认为我正在努力使参数的生命周期正确,因为它们在递归调用中被轮换。如果我希望 s2 在返回之前被释放并且我希望 s1 在返回时或进入递归调用时仍然存在,我应该如何注释生命周期?

    不需要引用(因此不需要生命周期),只需直接传递数据:

    fn nthLoop(n: i32, s1: HashSet<(i32, i32)>, s2: HashSet<(i32, i32)>) -> HashSet<(i32, i32)> {
        if n == 0 {
            return s1;
        } else {
            let mut s0 = HashSet::new();
            for &p in &s1 {
                iterNeighbors(p, |p| {
                    if !(s1.contains(&p) || s2.contains(&p)) {
                        s0.insert(p);
                    }
                })
            }
            drop(s2); // guarantees timely deallocation
            return nthLoop(n - 1, s0, s1);
        }
    }
    

    这里的关键是你可以按值做任何事情,按值传递的东西当然会保留它们的值。

    但是,编译失败:

    error[E0387]: cannot borrow data mutably in a captured outer variable in an `Fn` closure
      --> src/main.rs:21:21
       |
    21 |                     s0.insert(p);
       |                     ^^
       |
    help: consider changing this closure to take self by mutable reference
      --> src/main.rs:19:30
       |
    19 |               iterNeighbors(p, |p| {
       |  ______________________________^
    20 | |                 if !(s1.contains(&p) || s2.contains(&p)) {
    21 | |                     s0.insert(p);
    22 | |                 }
    23 | |             })
       | |_____________^
    

    也就是说,闭包试图改变它捕获的值 (s0),但 Fn 闭包 trait 不允许这样做。可以以更灵活的方式调用该特征(共享时),但这对闭包可以在内部执行的操作施加了更多限制。 (如果你有兴趣,I've written more about this

    幸运的是,有一个简单的解决方法:使用 FnMut 特征,它要求闭包只有在具有唯一访问权限时才能调用,但允许内部发生变异。

    fn iterNeighbors<F>((i, j): (i32, i32), mut f: F)
    where
        F: FnMut((i32, i32)),
    {
        f((i - 1, j));
        f((i + 1, j));
        f((i, j - 1));
        f((i, j + 1));
    }
    

    调用者看起来像这样:

    值也可以在这里工作:在这种情况下返回一个引用将返回一个指向s0 的指针,它存在于函数返回时被销毁的堆栈帧。也就是说,引用指向死数据。

    修复只是不使用引用:

    fn nth(n: i32, p: (i32, i32)) -> HashSet<(i32, i32)> {
        let s0 = HashSet::new();
        let mut s1 = HashSet::new();
        s1.insert(p);
        return nthLoop(n, s1, s0);
    }
    

    如果我手动内联闭包,但我无法弄清楚如何调用闭包,这会起作用。理想情况下,我想在这里进行静态调度。

    (我不明白这是什么意思,包括您遇到问题的编译器错误消息可以帮助我们帮助您。)

    另外,我只在 F# 中使用了 HashSet,因为我假设 Rust 没有提供具有有效集合论运算(并、交和差)的纯函数 Set。我的假设是否正确?

    取决于你想要什么,不,例如HashSetBTreeSet 都提供各种集合论运算,如 methods which return iterators


    一些小点:

    • 显式/命名生命周期允许编译器推断数据的静态有效性,但它们不控制它(即它们允许编译器在您做错了什么时指出,但语言仍然具有相同类型的静态资源C++ 的使用/生命周期保证)
    • 带有循环的版本在编写时可能更高效,因为它直接重用内存(交换集合,加上s0.clear(),但是,通过传递s2,递归版本可以实现相同的好处放下重复使用,而不是丢弃它。
    • while 循环可以是for _ in 0..n
    • 没有必要通过引用传递闭包,但无论有没有引用,仍然有静态调度(闭包是类型参数,而不是 trait 对象)。
    • 通常,闭包参数是最后的,而不是通过引用来获取,因为它使内联定义和传递它们更容易阅读(例如,foo(x, |y| bar(y + 1)) 而不是 foo(&amp;|y| bar(y + 1), x)
    • return 关键字对于尾随返回不是必需的(如果省略了 ;):

      fn nth(n: i32, p: (i32, i32)) -> HashSet<(i32, i32)> {
          let s0 = HashSet::new();
          let mut s1 = HashSet::new();
          s1.insert(p);
          nthLoop(n, s1, s0)
      }
      

    【讨论】:

      猜你喜欢
      • 2014-10-13
      • 2018-07-28
      • 2020-12-10
      • 1970-01-01
      • 1970-01-01
      • 2013-07-03
      • 1970-01-01
      • 2021-12-16
      • 2017-05-03
      相关资源
      最近更新 更多