#闭包 #特性 #捕获 #函数 #移动

orx-closure

显式闭包,将捕获的数据与函数绝对分离

6 个版本

0.1.4 2024 年 2 月 28 日
0.1.3 2023 年 12 月 11 日
0.0.1 2023 年 11 月 30 日

#306Rust 模式

Download history 33/week @ 2024-04-02 2/week @ 2024-05-21

205 每月下载
用于 orx-funvec

MIT 许可证

275KB
972

orx_closure

显式闭包,将捕获的数据与函数绝对分离。

为什么

它旨在解决通过 fn_traits 使用常规闭包时观察到的一些问题,例如

  • 没有充分理由就要对闭包进行装箱,
  • 没有充分理由就要向包含闭包的结构体添加泛型参数,
  • 无法(希望只是现在)返回捕获数据的引用。

A. 动机

在这里讨论了其中一些问题以及使用 Closure 类型提出的解决方案。

A.1. expected closure, found a different closure

我们都曾在某个时候观察到这个错误信息。这是由于每个闭包都有一个独特的类型。此外,这些是由编译器生成的类型,我们无法直接写出,而是可以写出它们实现的 Fn 特性。

这导致高阶函数或简单地将函数条件性地赋给变量时出现问题。

考虑以下问题 https://github.com/rust-lang/rust/issues/87961。该问题建议澄清错误信息,但也演示了我们无法编写一个非常简单的函数。

fn returns_closure(hmm: bool, y: i32) -> impl Fn(i32) -> i32 {
    if hmm {
        move |x| x + y
    } else {
        move |x| x * y // doesn't compile!
    }
}

let add_three = returns_closure(true, 3);
assert_eq!(42, add_three(39));

let times_two = returns_closure(false, 2);
assert_eq!(42, times_two(21));

上面的代码无法编译,因为本节标题就是这样:)

= note: 预期闭包 [闭包@src\motiv.rs:6:17: 6:25] 找到闭包 [闭包@src\motiv.rs:8:17: 8:25]

因为返回位置上的 impl 不允许泛型返回。它允许我们返回一个无法类型化的具体类型,这个类型由函数的主体而不是调用者决定。

让我们将闭包分解为其组件

  • 捕获的数据,比如 Capture。在这里 Capture = i32
  • 非捕获函数;即,一个将 In 转换为 Out 的函数指针 fn,并额外访问 Capture。我们可以将其定义为 fn(&Capture, In) -> Out。这里就是 fn(&i32, i32) -> i32

&Capture 的选择是故意的,因为 FnFnFnOnceFnMut 中的绝对最爱。换句话说,我们希望能够多次调用闭包,并且不希望对其进行修改。如果我们想消费,我们可以简单地通过值捕获;回想一下,数据和 fn 是分开的。

如果我们把闭包看作是这两个组件的乘积;或者简单地看作是这对 (Capture, fn(&Capture, In) -> Out);很明显,两个 if-else 分支具有相同的类型 (i32, fn(&i32, i32) -> i32),没有理由将它们视为不同的类型。

这正是 Closure<Capture, In, Out> 结构所做的事情:它将捕获的数据与函数指针分开。然后,这些函数成为相同类型的不同值,因此,以下内容是有效的。

use orx_closure::*;

fn returns_closure(hmm: bool, y: i32) -> Closure<i32, i32, i32> {
    if hmm {
        Capture(y).fun(|y, x| x + y)
    } else {
        Capture(y).fun(|y, x| x * y)
    }
}
let add_three = returns_closure(true, 3);
assert_eq!(42, add_three.call(39));

let times_two = returns_closure(false, 2);
assert_eq!(42, times_two.call(21

甚至下面的也是允许的 :)

fn returns_closure(hmm: bool, y: i32) -> Closure<i32, i32, i32> {
    Capture(y).fun(if hmm { |y, x| x + y } else { |y, x| x * y })
}

错误信息正确地说 即使相同,也没有两个闭包具有相同的类型。但这并不是匿名函数的限制;只要它们不捕获环境并且具有相同的签名,即使它们不同,它们实际上也具有相同的类型。fn 很好。

以下是一个更现实一点的例子,其中我们可以很好地定义 Closure 的类型

use orx_closure::*;
    
fn create_closure(slice: &[i32], exclude_evens: bool) -> Closure<&[i32], i32, Option<i32>> {
    Capture(slice).fun(if exclude_evens {
        |x, lb| x.iter().filter(|&x| x % 2 == 1 && *x > lb).min().cloned()
    } else {
        |x, lb| x.iter().filter(|&x| *x > lb).min().cloned()
    })
}

let numbers: Vec<i32> = vec![1, 2, 3, 4, 5, 6];

let closure = create_closure(&numbers, true);
let fun = closure.as_fn(); // not to call 'call'

assert_eq!(fun(1), Some(3));
assert_eq!(fun(5), None);

为什么不直接用 Box 呢?

确实,额外的间接引用解决了这里提到的大部分问题,但不是所有问题。例如,以下代码可以编译并正常工作。

fn returns_closure(hmm: bool, y: i32) -> Box<dyn Fn(i32) -> i32> {
    if hmm {
        Box::new(move |x| x + y)
    } else {
        Box::new(move |x| x * y)
    }
}
let add_three = returns_closure(true, 3);
assert_eq!(42, add_three(39));

let times_two = returns_closure(false, 2);
assert_eq!(42, times_two(21));

这带来的最大好处是能够忘记捕获的数据。它不在签名中,完全抽象化。我们不知道它的大小,因为我们现在有一个 trait 对象。然而,它也有一些缺点

  • 这会增加间接引用。这将导致额外的分配。
  • 此外,也许更重要的是,编译器优化的可能性显著降低。
  • 这纯粹是个人喜好;代码中出现了诸如 dynBoxBox::new 这样的单词,这些单词需要在每个分支中重复。

有时,我们会被一系列具有这种间接引用的事件驱动。

  • 如上所述,我们注意到我们必须使用特例对象,所以我们选择了 Box<dyn Fn(i32) -> i32>
  • 作为一个一等公民,我们将这个函数作为其参数之一传递给另一个函数,该参数是泛型类型 F: Fn(i32) -> i32
  • 一切正常。
  • 在某个时候,我们要求能够轻松且低成本地 Clone 和共享闭包。因此,我们将间接引用更改为 Rc<dyn Fn(i32) -> i32>
  • 突然之间,我们无法将这个闭包传递给另一个函数,因为 Fn<(i32,)> 没有为 Rc<dyn Fn(i32) -> i32> 实现。
  • 无法将闭包作为无点值传递。
  • 我们无奈地写了一个什么也不做,只是调用这个闭包的另一个闭包。
  • 这不算什么大事,但会让你问为什么。

A.2. 生命周期!

当闭包开始返回引用时,很容易让编译器对生命周期感到愤怒。

最简单的返回引用的闭包

...将是返回捕获值引用的那个。这看起来可能不是特别有用,但它实际上对展示问题很有用。

let x = 42;
let return_ref_to_capture = move || &x;
// <- won't compile: ^^ returning this value requires that `'1` must outlive `'2`

由于不同的原因,尽管有相同的错误消息,下面的闭包版本也无法编译

let x = 42;
let return_ref_to_capture = Capture(x).fun(|x: &i32, _: ()| x); // <- won't compile

生命周期和省略很复杂,难以有一个单一的签名 fn(&Capture, In) -> Out 在所有情况下都起作用(至少现在是这样:https://github.com/rust-lang/rfcs/pull/3216)。

因此,我们需要有不同的签名,因此需要不同的结构体 ClosureRef,其中函数指针是 fn(&Capture, In) -> &Out。这立即解决了所有问题,以下情况很好地工作

let x = 42;
let return_ref_to_capture = Capture(x).fun_ref(|x: &i32, _: ()| x); // all good :)

关于返回 M<&T> 怎么样?

我们通过使用 ClosureRef 解决了返回引用的问题。然而,我们经常返回 Option<&Out>。返回类型本身不是引用,但它与闭包的生命周期相关。因此,我们需要其他东西。即 ClosureOptRef,它具有以下函数指针签名:fn(&Capture, In) -> Option<&Out>。一旦我们切换到这个签名,一切又都正常工作。

let x = 42;
let return_ref_to_capture = Capture(x).fun_option_ref(|x: &i32, _: ()| Some(x)); // all good :)

我们还经常返回 Result<&Out, Error>,为此我们有了 ClosureResRef

let x = 42;
let return_ref_to_capture: ClosureResRef<i32, (), i32, String> = // <- String is the error type here.
    Capture(x).fun_result_ref(|x: &i32, _: ()| Ok(x));

我们应该在这里停止,因为将所有具有包装引用 M<&T> 的类型列出来似乎不是一个好主意。最终我们有了以下签名的四个类型:

闭包结构 数据 函数指针 结果的 Fn 特征签名
闭包<捕获、输入、输出> 捕获 fn(&捕获,输入) ->输出 Fn(输入) ->输出
ClosureRef<捕获、输入、输出> 捕获 fn(&捕获,输入) -> &输出 Fn(输入) -> &输出
ClosureOptRef<捕获、输入、输出> 捕获 fn(&捕获,输入) -> Option<&输出> Fn(输入) -> Option<&输出>
ClosureResRef<捕获、输入、输出、错误> 捕获 fn(&捕获,输入) -> Result<&输出、错误> Fn(输入) -> Result<&输出、错误>

决定使用哪个闭包变体是直接的

  • 如果我们通过引用捕获数据,Capture(&data),我们可以使用 Closure 适用于任何返回类型。
  • 如果我们返回一个与闭包生命周期无关的值,我们仍然可以独立于我们如何捕获数据使用 Closure
  • 然而,如果我们捕获数据时保留其所有权,Capture(data),并希望返回一个其生命周期依赖于闭包生命周期的值
    • 如果我们想返回 &Out,我们使用 ClosureRef
    • 如果我们想返回 Option<&Out>,我们使用 ClosureOptRef
    • 如果我们想返回 Result<&Out, _>,我们使用 ClosureResRef

希望我们最终只需要 Closure

A.3. 通过引用捕获时的生命周期

A.2 中解释的问题,导致我们实现了四种变体,只有在通过值捕获数据时才相关。编译器允许我们使用 Closure 签名来表示上述所有情况。

let x = 42;

let closure_ref = Capture(&x).fun(|x, _: ()| *x);
assert_eq!(closure_ref.call(()), &42);

let closure_opt_ref = Capture(&x).fun(|x, _: ()| Some(*x));
assert_eq!(closure_opt_ref.call(()), Some(&42));

let closure_res_ref: Closure<_, _, Result<&i32, String>> = Capture(&x).fun(|x, _: ()| Ok(*x));
assert_eq!(closure_res_ref.call(()), Ok(&42));

B. 捕获数据的抽象

如前所述,使用dyn Fn(In) -> Out特征对象作为闭包存在一些缺点,例如需要分配和失去某些编译器优化机会。然而,它也提供了灵活性,允许忘记捕获的数据。这正是闭包如此有用的主要原因之一。

另一方面,Closure必须知道捕获的数据,这是一个很大的限制。

我们可以通过以下方式使用总结类型在一定程度上改善这种情况。

如果闭包将捕获几种可能类型中的一种,那么闭包仍然可以像枚举一样进行大小调整。然而,我们需要知道所有可以使用的类型。这并不是dyn Fn拥有的超级功能,但它覆盖了一定类别的案例。

一个实际例子

假设我们想要一个闭包,它可以由某个图算法使用,用于查询在排序中节点i是否可以在节点j之前。可能会有很多有趣的变体和实现,以下是一些:

  • 是的,每个节点都可以在所有其他节点之前。这是一个简单的函数,对所有输入都返回true(希望会被优化掉)。
  • 我们使用每个节点的可能后继集的向量,例如allowed: Vec<HashSet<Node>>>。然后,只有当allowed[i].contains(j)时,节点i才可以在节点j之前。
  • 存在一组禁忌对,例如taboo: HashSet<(Node, Node)>。只有当这对不存在于禁忌列表中时,答案才是肯定的。

因此,我们期望我们的闭包有三个相关的捕获:()Vec<HashSet<Node>>>HashSet<(Node, Node)>。然后,我们可以使用ClosureOneOf3

use orx_closure::*;
use std::collections::HashSet;

type Node = usize; // for brevity
type Edge = (Node, Node); // for brevity

type PrecedenceClosure = ClosureOneOf3<(), Vec<HashSet<Node>>, HashSet<Edge>, Edge, bool>;

struct Precedence(PrecedenceClosure);

impl Precedence {
    fn new_variant1(closure: Closure<(), Edge, bool>) -> Self {
        Self(closure.into_oneof3_var1())
    }
    fn new_variant2(closure: Closure<Vec<HashSet<Node>>, Edge, bool>) -> Self {
        Self(closure.into_oneof3_var2())
    }
    fn new_variant3(closure: Closure<HashSet<Edge>, Edge, bool>) -> Self {
        Self(closure.into_oneof3_var3())
    }

    fn can_precede(&self, edge: Edge) -> bool {
        self.0.call(edge)
    }
}

let allow_all = Precedence::new_variant1(Capture(()).fun(|_, _| true));
assert_eq!(allow_all.can_precede((0, 1)), true);
assert_eq!(allow_all.can_precede((10, 21)), true);

let disallow_all = Precedence::new_variant1(Capture(()).fun(|_, _| false));
assert_eq!(disallow_all.can_precede((0, 1)), false);
assert_eq!(disallow_all.can_precede((10, 21)), false);

let allowed: Vec<HashSet<Node>> = vec![
    HashSet::from_iter([1, 2, 3].into_iter()),
    HashSet::from_iter([2, 3].into_iter()),
    HashSet::from_iter([3].into_iter()),
    HashSet::from_iter([0].into_iter()),
];
let from_allowed = Precedence::new_variant2(
    Capture(allowed).fun(|allowed, edge| allowed[edge.0].contains(&edge.1)),
);
assert_eq!(from_allowed.can_precede((1, 3)), true);
assert_eq!(from_allowed.can_precede((2, 1)), false);

let taboo = HashSet::from_iter([(0, 3), (1, 2)].into_iter());
let from_taboo =
    Precedence::new_variant3(Capture(taboo).fun(|taboo, edge| !taboo.contains(&edge)));
assert_eq!(from_taboo.can_precede((0, 3)), false);
assert_eq!(from_taboo.can_precede((2, 1)), true);

这种方法有以下优点

  • 它的大小和存储方式与常规结构体一样简单。
  • 由于所有可能的捕获类型都实现了Clone,它自动实现了Clone
  • Precedence结构体不需要任何泛型参数。

然而,以下是一些缺点

  • ClosureOneOf3<C1, C2, C3, In, Out>是一个长的类型;希望我们只输入一次。
  • Closure::into_oneof3_var1Closure::into_oneof3_var2等是类型安全和显式的函数,但不美观。

总的来说,一旦将这种丑陋隐藏在一个小盒子中,Closure提供了介于两种极端之间的方便的第三种选择。

  • 将闭包作为泛型参数,允许单态化,但向父级添加了泛型参数,并且
  • 将闭包作为 dyn Fn 特性对象,增加了间接引用但不需要泛型参数。

这种折衷方案非常适合具有特定功能(如 Precedence)的闭包。

C. 使用特性对象对捕获数据的抽象

我们无法在稳定版 Rust 中实现 fn_traits;然而,如前所述,对捕获数据类型的抽象是闭包的核心力量。为了实现这种灵活性,此crate提供了所需的特性 FunFunRefFunOptRefFunResRef。下表提供了实现这些特性的完整特性列表和类型。

特性 转换 结构体
Fun<输入,输出> 输入->输出 TwhereT: Fn(输入) ->输出
闭包<捕获、输入、输出>
ClosureOneOf2<C1, C2, 输入,输出>
ClosureOneOf3<C1, C2, C3, 输入,输出>
ClosureOneOf4<C1, C2, C3, C4, 输入,输出>
FunRef<输入,输出> 输入-> &输出 ClosureRef<捕获、输入、输出>
ClosureRefOneOf2<C1, C2, 输入,输出>
ClosureRefOneOf3<C1, C2, C3, 输入,输出>
ClosureRefOneOf4<C1, C2, C3, C4, 输入,输出>
FunOptRef<输入,输出> 输入-> Option<&输出> ClosureOptRef<捕获、输入、输出>
ClosureOptRefOneOf2<C1, C2, 输入,输出>
ClosureOptRefOneOf3<C1, C2, C3, 输入,输出>
ClosureOptRefOneOf4<C1, C2, C3, C4, 输入,输出>
FunResRef<输入,输出,错误> 输入-> Result<&输出、错误> ClosureResRef<捕获、输入、输出、错误>
ClosureResRefOneOf2<C1, C2, 输入,输出,错误>
ClosureResRefOneOf3<C1, C2, C3, 输入,输出,错误>
ClosureResRefOneOf4<C1, C2, C3, C4, 输入,输出,错误>

fun 特性之所以有用,原因如下

  • 它们被用作泛型参数,可以由任何实现类型填充。这正是 Fn 特性的目的。我们不直接使用 Fn 特性的两个原因如下
    • 我们不允许在稳定版 Rust 中为在此 crate 中定义的 Capture 类型实现 Fn 特性。
    • 除了 Fun 之外,由于生命周期错误,我们无法使用 Fn 特性表示返回引用的特性。
  • 它们允许从闭包创建特性对象,例如 dyn Fun<In, Out> 等,当我们不知道捕获类型时。

D. 与 Fn 特性的关系

请注意,Closure<Capture, In, Out> 有一个方法 fn call(&self, input: In) -> Out。因此,它可以实现 Fn(In) -> Out。但编译器告诉我 手动实现 Fn 是实验性的,并添加了 使用不稳定库特性 'fn_traits' 错误。为了保持稳定性,Closure 不实现 Fn 特性。

相反,Closure及其所有变体都具有as_fn方法,例如fn as_fn(&self) -> impl Fn(In) -> Out + '_ ,这为我们提供了编译器生成的实现Fn特质的闭包。

E. 性能测试与性能

假设我们有一个需求,即在一个结构体的字段中持有函数。在/benches/fun_as_a_field中定义的示例情况下,我们持有访问交错数组两个索引的函数。

变体如下所示

use orx_closure::*;

type Weight = i32;
type WithClosure<Weight> = Closure<Vec<Vec<Weight>>, (usize, usize), Weight>; // no generics required

struct HoldingFn<F: Fn((usize, usize)) -> Weight> { // requires the generic parameter F
    fun: F,
}

struct HoldingBoxDynFn { // no generics required
    fun: Box<dyn Fn((usize, usize)) -> Weight>,
}

结果如下

FunAsAField/closure/10000
                        time:   [126.07 ms 126.63 ms 127.23 ms]
FunAsAField/holding_fn/10000
                        time:   [55.149 ms 55.372 ms 55.604 ms]
FunAsAField/holding_box_dyn_fn/10000
                        time:   [127.27 ms 128.24 ms 129.40 ms]

正如预期的那样,将闭包作为实现Fn的泛型字段持有,性能最佳。泛型参数允许进行单态化和编译器优化。然而,并非HoldingFn结构体的任何两个实例都具有相同的类型。

ClosureBox<dyn Fn ...>方法表现相当,略慢于泛型版本的二倍。这表明它们都不能从某些内联优化中受益。这是由于只有当函数具有泛型参数时,我们才能有不同版本/实现。另一方面,对于Closure,我们不使用泛型参数,将闭包视为同一类型的不同值。然后,我们传递闭包的函数的实现是通用的,它通过函数指针调用闭包,没有任何内联的机会。

从另一个角度来看,实际上令人惊讶的是,ClosureBox<dyn Fn ...>仅比一个极小函数的完全内联版本慢约两倍,而这个极小函数所做的只是data[i][j],其中data: Vec<Vec<i32>>

总之

  • 在性能关键的情况下,我们更愿意使用泛型参数和impl Fn方法以获得最佳性能;
  • 然而,当闭包完成的工作足够大,使得间接引用微不足道时,我们可以选择使用Box<dyn Fn ...>Closure方法
    • 例如,如果闭包所做的只是允许访问数据,就像我们在基准测试中看到的那样,间接引用将会很显著;
    • 然而,如果它执行矩阵乘法,则肯定不会显著。

F. 结语

上述基准测试大致确定了使用场景

  • 当我们可以添加泛型参数,并且不返回捕获数据的引用时,implFn 是性能最佳的选项,并且不需要堆分配。
  • 否则
    • Box<dynFn ...>:
      • 不需要泛型参数,
      • 在抽象捕获数据方面具有最大的灵活性,完全隐藏了它,
      • 但是,需要堆分配,
      • 当返回捕获数据的引用时,我们会遇到生命周期问题。
    • 闭包
      • 不需要泛型参数,
      • 必须记住捕获的数据类型:它在一定程度上允许对捕获数据进行抽象;然而,并没有像 Fn 特性那样优雅和神奇,
      • 不需要堆分配,
      • 如果捕获的数据实现了 Clone,则自动实现 Clone
      • 解决了我们无法使用 Fn 特性解决的问题集。

注意:这个包是一个非常简单想法的实验,它帮助我更好地理解了 Rust 闭包的底层魔法以及当前的限制。可能还有更多的事情需要探索。无论如何,它最终形成了 Closure 结构体,这是我解决另一个问题所需要的(实际上,这就是我最初开始尝试的原因)。简而言之,问题、评论、纠正、建议、有趣的实验想法都非常受欢迎。

许可协议

本库采用 MIT 许可协议。有关详细信息,请参阅 LICENSE。

无运行时依赖