6 个版本
0.1.4 | 2024 年 2 月 28 日 |
---|---|
0.1.3 | 2023 年 12 月 11 日 |
0.0.1 | 2023 年 11 月 30 日 |
#306 在 Rust 模式
205 每月下载
用于 orx-funvec
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
的选择是故意的,因为 Fn
是 Fn
、FnOnce
和 FnMut
中的绝对最爱。换句话说,我们希望能够多次调用闭包,并且不希望对其进行修改。如果我们想消费,我们可以简单地通过值捕获;回想一下,数据和 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 对象。然而,它也有一些缺点
- 这会增加间接引用。这将导致额外的分配。
- 此外,也许更重要的是,编译器优化的可能性显著降低。
- 这纯粹是个人喜好;代码中出现了诸如
dyn
、Box
和Box::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_var1
、Closure::into_oneof3_var2
等是类型安全和显式的函数,但不美观。
总的来说,一旦将这种丑陋隐藏在一个小盒子中,Closure
提供了介于两种极端之间的方便的第三种选择。
- 将闭包作为泛型参数,允许单态化,但向父级添加了泛型参数,并且
- 将闭包作为
dyn Fn
特性对象,增加了间接引用但不需要泛型参数。
这种折衷方案非常适合具有特定功能(如 Precedence
)的闭包。
C. 使用特性对象对捕获数据的抽象
我们无法在稳定版 Rust 中实现 fn_traits
;然而,如前所述,对捕获数据类型的抽象是闭包的核心力量。为了实现这种灵活性,此crate提供了所需的特性 Fun
、FunRef
、FunOptRef
和 FunResRef
。下表提供了实现这些特性的完整特性列表和类型。
特性 | 转换 | 结构体 |
---|---|---|
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
特性表示返回引用的特性。
- 我们不允许在稳定版 Rust 中为在此 crate 中定义的
- 它们允许从闭包创建特性对象,例如
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
结构体的任何两个实例都具有相同的类型。
Closure
和Box<dyn Fn ...>
方法表现相当,略慢于泛型版本的二倍。这表明它们都不能从某些内联优化中受益。这是由于只有当函数具有泛型参数时,我们才能有不同版本/实现。另一方面,对于Closure
,我们不使用泛型参数,将闭包视为同一类型的不同值。然后,我们传递闭包的函数的实现是通用的,它通过函数指针调用闭包,没有任何内联的机会。
从另一个角度来看,实际上令人惊讶的是,Closure
和Box<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。