闭包、头等函数和高阶函数是 Rust 中的一个核心组件。C 和 C++ 带有函数指针(特别是 C++ 中的成员函数类型,我一直没理解)。然而函数指针的使用相对少见一些,而且用起来不太方便。C++11 引入了 Lambda 表达式,和 Rust 的闭包相近,特别是二者的实现方式非常类似。
进入本文内容前,我想建立一下这些东西的直觉,然后深入细节。
假设有函数 foo
:pub fn foo() -> u32 {42}
。假设有另一个函数 bar
有个函数类型的形参(函数签名之后再写):fn bar(f: ...) { ... }
。可以将 foo
传递给 bar
,这和 C 中传递函数指针类似:bar(foo)
。bar
的函数体中,可以将 f
视作函数并调用:let x = f()
。
我们说 Rust 的函数是头等公民,因为和其他值一样,我们可以传递并使用函数。我们将 bar
称作高阶函数,因为它会将函数作为形参接收,也就是说,它是个操纵函数的函数。
Rust 中的闭包是匿名函数,语法美观。闭包 |x| x + 2
接收实参,将其加 2
并返回。注意,我们无需指定闭包形参的类型(因为通常可以推导出来)。我们也用不着指定返回类型。如果想让闭包函数体不只写一个表达式,可以使用花括号:|x: i32| {let y = x + 2; y }
(译者注:类似现代 C++ 的 [](std::int32_t x) { const auto y = x + 2; return y; }
)。可以将闭包像函数一样传递:bar(|| 42)
(译者注:类似现代 C++ 中的 bar([]() { return 42; })
)。
闭包和其他函数的显著区别在于,闭包会捕获环境。这意味着我们可以在闭包内指涉闭包外的变量。例如:
let x = 42;
bar(|| x);
注意看 x
是如何进入闭包的作用域的。(译者注:对于非全局变量,现代 C++ 需要显式捕获或使用默认捕获,但 Rust 通过闭包函数体自动确定要捕获的内容。)
我们已经见过了闭包,与迭代器共用,而这正是闭包的常见用法。例如,让可扩张数组的每个元素与一个值相加:
fn baz(v: Vec<i32>) -> Vec<i32> {
let z = 3;
v.iter().map(|x| x + z).collect()
}
(译者注:上例类似 C++ 标准库的 std::for_each
,准确地说,更像 C++20 的 std::for_each
。)
此处的 x
是闭包的参数(argument,译者注:对应 C++ 中的“实参”一词,但此处似乎应使用“形参”一词才说得通),v
中的每个成员均作为 x
传入。z
在闭包外声明,但由于是个闭包,因此可被指涉。也可以将函数传入 map
中:
fn add_two(x: i32) -> i32 {
x + 2
}
fn baz(v: Vec<i32>) -> Vec<i32> {
v.iter().map(add_two).collect()
}
注意,Rust 也允许在函数内声明函数。这种函数不是闭包,无法访问环境。这种函数只是方便管理作用域:
fn qux(x: i32) {
fn quxx() -> i32 {
x // 错误:x 不在作用域内
}
let a = quxx();
}
我们来引入一个新的函数示例:
fn add_42(x: i32) -> i64 {
x as i64 + 42
}
和之前一样,可以将函数存入变量:let a = add_42
。a
的具体类型无法在 Rust 代码中写出(译者注:类似 C++ 中的 Lambda 表达式)。编译器会在错误信息中将其指代为 fn(i32) -> i64 {add_42}
,各位有时会注意到。每个函数均拥有独特的匿名类型。fn add_41(x: i32) -> i64
拥有不同类型,尽管函数签名一致。
可以写出不那么准确的类型,例如 let a: fn(i32) -> i64 = add_42
。所有签名相同的函数类型都能转换为程序员可以写出的 fn
类型。
a
由编译器表示为函数指针,然而,如果编译器知道精确类型,实际上不会用那个函数指针。形如 a()
的调用会根据 a
的类型静态派发。如果函数不知道精确类型(例如,只知道 fn
类型),则调用会使用值中的函数指针派发。
Rust 中还有个 Fn
类型(注意 F 是大写的)。Fn
类型和特征一样是绑定项(其实 Fn
本身是特征,后面会看到)。Fn(i32) -> i64
是绑定到所有与其签名一致的函数型对象类型的绑定项。解引用函数指针时,我们实际上是创建了一个由胖指针表示的特征对象(参见动态大小类型)。
要将函数传到另一个函数,或者将函数存入字段,必须写清类型。我们有几种选择,要么用 fn
类型,要么用 Fn
类型。后者更好,因为包含了闭包类型(以及其他的形如函数的对象类型),而 fn
不包含。Fn
类型动态确定大小,因此无法将其用于值类型。我们要么得传递函数对象,要么得使用泛型。先来看泛型的方式。例如:
fn bar<F>(f: F) -> i64
where F: Fn(i32) -> i64
{
f(0)
}
bar
接收任意签名为 Fn(i32) -> i64
的函数,即能将类型形参 F
实例化为任意形如函数的类型。可以调用 bar(add_42)
从而将 add_42
传给 bar
,而 bar
会将 F
实例化为 add_42
的匿名类型。也可以调用 bar(add_41)
,同样可以正常工作。
可以将闭包传递给 bar
,例如 bar(|x| x as i64)
。这段代码能用,因为闭包类型也可以绑定到匹配签名的 Fn
绑定项(和函数一样,每个闭包都拥有自己的匿名类型)。
最后,还可以传递函数或闭包的引用:bar(&add_42)
或者 bar(&|x| s as i64)
。
也可以将 bar
写成 fn bar(f: &Fn(i32) -> i64) ...
。这两种方式(泛型和函数 / 特征对象)的语义相差甚远。使用泛型时,bar
会被单态处理(monomorphise),因此生成代码时,编译器知道 f
的确切类型,意味着可以静态派发。若使用函数对象,函数不会被单态处理。不知道 f
的确切类型,因此编译器必须生成虚派发。后者更慢,但前者会产生更多代码(每种类型实参生成一个单态函数)。(译者注:此处的“单态处理”和 C++ 中模板形参的实例化含义相同。)
函数特征不只 Fn
一种,还有 FnMut
和 FnOnce
。二者使用方式和 Fn
相同,例如 FnOnce(i32) -> i64
。FnMut
指的是可以被调用,还可以在调用时被修改的对象。这一性质不适用于一般的函数,但对于闭包而言,这一点意味着闭包可以修改自身的环境。FnOnce
则是(至多)只能调用一次的函数。此性质同样只适用于闭包。
Fn
、FnMut
和 FnOnce
构成了子特征的层次结构。Fn
是 FnMut
(因为可以在调用 Fn
时进行修改而不造成负面影响,但反过来不行)。Fn
和 FnMut
都是 FnOnce
(因为一般的函数只调用一次没有副作用,但反过来不行)。
因此,要确保高阶函数尽可能灵活,应使用 FnOnce
绑定,而非使用 Fn
绑定(如果必须多次调用函数的话,使用 FnMut
绑定)。
方法的使用方式和函数一样:取指针,存入变量等。不能使用 .
语法,必须使用完全明确的命名形式(有时称作通用函数调用语法,universal function call syntax,简称为 UFCS)给方法明确命名。形参 self
是方法的第一个参数。例如:
struct Foo;
impl Foo {
fn bar(&self) {}
}
trait T {
fn baz(&self);
}
impl T for Foo {
fn baz(&self) {}
}
fn main() {
// 内在方法
let x = Foo::bar;
x(&Foo);
// 特征方法,注意完全明确的命名形式
let y = <Foo as T>::baz;
y(&Foo);
}
不能取泛型函数的指针,且无法表示泛型函数类型。然而,可以引用所有类型形参均被实例化的函数类型。例如:
fn foo<T>(x: &T) {}
fn main() {
let x= &foo::<i32>;
x(&42);
}
无法定义泛型闭包。如果需要创建适用于多种类型的闭包,可以使用特征对象、宏(用于生成闭包)、或传递一个返回闭包的闭包(每种返回的闭包适用于不同的类型)。
可以存在生存期泛型的函数类型和闭包。
想象下,有个接收借用引用的闭包。无论引用的生存期如何,这个闭包都能以同样的方式工作(当然,在编译后的代码中,生存期就被擦除了)。但这种类型长什么样?
例如:
fn foo<F>(x: &Bar, f: F) -> &Baz
where F: Fn(&Bar) -> &Baz
{
f(x)
}
引用的生存期是什么?在这个简单的例子中,可以使用单个生存期(无需泛型闭包):
fn foo<'b, F>(x: &'b Bar, f: F) -> &'b Baz
where F: Fn(&'b Bar) -> &'b Baz
{
f(x)
}
如果我们想让 f
用于生存期各异的形参呢?我们就需要泛型函数类型了:
fn foo<'b, 'c, F>(x: &'b Bar, y: &'c Bar, f: F) -> &'b Baz
where F: for<'a> Fn(&'a Bar) -> &'a Baz
{
(f(x), f(y))
}
这一例子的新东西是 for<'a>
语法,用于标注生存期泛型的函数类型。这一写法读作“对于 'a 等所有项”(译者注:类似 C++ 的 template<typename... Args>
)。理论上说,函数类型是全局量化(universally quantify)的。
注意,上例中我们无法将 'a
提升至 foo
。反例:
fn foo<'a, 'b, 'c, F>(x: &'b Bar, y: &'c Bar, f: F) -> (&'b Baz, &'c Baz)
where F: Fn(&'a Bar) -> &'a Baz
{
(f(x), f(y))
}
此代码无法编译,因为编译器推导用于调用 foo
的生存期时,它必须为 'a
选一个生存期,如果 'b
和 'c
不同的话就选不出来。
这种泛型函数类型称作高阶类型。外层的生存期变量是一阶。由于上例的 'a
无法移至外层,因此阶数高于一。
调用带高阶函数类型参数的函数很简单,因为编译器会推导生存期形参。例如 foo(&Bar { ... }, &Bar {...}, |b| &b.field)
。
其实,大多数情况下都不用操心这些事。编译器允许你省略量化的生存期,方式和允许省略多数函数实参的生存期的方式一样。例如,上例可以写成
fn foo<'b, 'c, F>(x: &'b Bar, y: &'c Bar, f: F) -> (&'b Baz, &'c Baz)
where F: Fn(&Bar) -> &Baz
{
(f(x), f(y))
}
(只要写 'b
和 'c
就行,因为这是个特意编写的示例。)
当 Rust 发现带有借用引用的函数类型时,它就会应用常用的省略规则,在函数(即高阶函数)的作用域中量化省略的变量。
你也许会想,为了这个看起来挺小众的用例,至于上这么复杂的东西吗?这一机制其实是用于这样一种函数,其接收另一个操纵外部函数提供的数据的函数。例如:
fn foo<F>(f: F)
where F: Fn(&i32) // 完整类型名:for<'a> Fn(&'a i32)
{
let data = 42;
f(&data)
}
这种情况下,我们是需要高阶类型的。如果我们给 foo
加了个生存期形参,我们就推不出正确的生存期了。要了解原因,我们来看下工作方式。考虑 fn foo<'a, F: Fn(&'a i32)> ...
。Rust 要求,生存期参数比如比用于声明的实体活得久(如果不这么限制,便可以在函数内使用这一生存期的实参,而无法保证这一实参存活)。foo
的函数体中,我们使用了 f(&data)
,Rust 为引用推导的生存期(至多)以从声明 data
开始,以 data
离开作用域结束。由于 'a
必须比 foo
活得久,但推导出的生存期并不满足这一点,因此无法这样调用 f
。
然而,有了高阶生存期,f
便可以接收任意生存期,因此 &data
的匿名生存期可以用,函数类型能通过检查。
这段内容有点跑题,不过有时候这一招挺有用。枚举中所有的变体都定义了传入字段类型参数并返回枚举类型的函数。例如:
enum Foo {
Bar,
Baz(i32),
}
上例定义了两个函数,Foo::Bar: Fn() -> Foo
和 Foo::Baz: Fn(i32) -> Foo
。我们平常不会这么使用变体,会将其视作数据类型,而非函数。不过这么做有时也有用,例如,有个 i32
类型的列表,便可以用一下代码创建一个 Foo
类型的列表:
list_of_i32.iter().map(Foo::Baz).collect()
闭包有两种写法:一种是显式传递参数,一种是从所在环境中捕获变量。通常,两种写法都会做完推导工作,不过程序员想的话也可以多些控制权。
对参数而言,可以声明类型,不让 Rust 推导。也可以声明返回类型。不写 |x| { ... }
的话,可以写出 |x: i32| -> String { ... }
。至于参数是由闭包拥有的,还是借来的,这一点由类型决定(无论是声明的类型还是推导的类型)。
对捕获的变量而言,变量类型多从环境中得知,但 Rust 还会多做一些神奇的操作。变量是按引用捕获还是按值捕获?Rust 会根据闭包体推导此处的选择。Rust 会尽可能按引用捕获。例如:
fn Foo(x: Bar) {
let f = || { ... x ... }
}
如果一切正常,f
闭包体中,x
的类型为 &Bar
,生存期绑定至 foo
的作用域。然而,如果 x
被修改,则 Rust 会将捕获推导为按可变引用捕获,即 x
类型为 &mut Bar
。若 x
被移动到 f
中(例如,存储至值类型的变量或字段),则 Rust 认为变量必须按值捕获,即类型为 Bar
。
推导结果可由程序员覆盖,有时,如果闭包要被存入字段或从函数中返回,便有必要覆盖。在闭包前添加 move
关键字,则所有捕获的变量均按值捕获。例如,对于 let f = move || { ... x ... };
,x
的类型永远是 Bar
。
之前提到了几种不同的函数类型:Fn
、FnMut
和 FnOnce
。现在就可以解释下为何需要这些东西了。对于闭包而言,能否修改和能够重用取决于捕获的变量。如果闭包要修改捕获的变量,则类型为 FnMut
(注意这一点完全由编译器推导,无需进行标注)。如果变量被移入闭包,即按值捕获(无论是因为显式的 move
还是由编译器推导),则闭包类型为 FnOnce
。重复调用这种闭包是不安全的,因为捕获的变量会被移走若干次。
如果条件允许,Rust 会尽力为闭包推导出最灵活的类型。
闭包实现为匿名结构体。结构体中为每个捕获到的变量设立一个字段。它由一个由捕获变量的生存期约束的生存期参数作生存期参数化。这一匿名结构体实现了 call
方法,调用此方法即可执行闭包。
例如,考虑下例:
fn main() {
let x = Foo { ... };
let f = |y| x.get_number() + y;
let z = f(42);
}
编译器会将其视作
struct Closure14<'env> {
x: &'env Foo,
}
// 实现其实不是这样,见下文
impl<'env> Closure14<'env> {
fn call(&self, y: i32) -> i32 {
self.x.get_number() + y
}
}
fn main() {
let x = Foo { ... };
let f = Closure14 { x: x }
let z = f.call(42);
}
如上文所述,函数特征有三种:Fn
、FnMut
和 FnOnce
。真实情况下,call
方法是由这些特征要求的,而不是内在实现的。Fn
带有 call
方法,将 self
按引用捕获,FnMut
带有 call_mut
方法,将 self
按可变引用捕获,FnOnce
带有 call_once
方法,将 self
按值捕获。
我们见到上述的函数类型时,这些类型形如 Fn(i32) -> i32
,不像是特征类型。此处进行了一些特殊操作。Rust 只允许函数类型使用这种圆括号的语法糖。要褪去这层糖衣,获得一般类型(“尖括号类型”),参数类型被视作元组类型,作为类型形参传入,返回类型作为名为 Output
的关联类型传入。这样一来,Fn(i32) -> i32
变为 Fn<(i32,), Output=i32>
,Fn
特征定义形如
pub trait Fn<Args> : FnMut<Args> {
fn call(&self, args: Args) -> Self::Output;
}
因此,之前的 Closure14
更像是
impl<'env> FnOnce<(i32,)> for Closure14<'env> {
type Output = i32;
fn call_once(self, args: (i32,)) -> i32 {
...
}
}
impl<'env> FnMut<(i32,)> for Closure14<'env> {
fn call_mut(&mut self, args: (i32,)) -> i32 {
...
}
}
impl<'env> Fn<(i32,)> for Closure14<'env> {
fn call(&self, args: (i32,)) -> i32 {
...
}
}
可以在 core::ops 中找到这些函数特征。
之前提到,使用泛型会得到静态派发,使用特征对象会得到虚派发。这里稍微解释一下原因。
当调用 call
时,它是个静态派发的方法调用,没有进行虚派发。如果将其传入单态函数,我们仍然可以静态获取类型,且仍可以静态派发。
可以将闭包构造为特征对象,例如,&f
或 Box::new(f)
,对应类型为 &Fn(i32)->i32
或 Box<Fn(i32)->i32>
。这些都是指针类型,由于二者都是指向特征类型的指针,因此指针是胖指针。这意味着,这些指针由指向数据本身的指针和指向虚表的指针组成。
有时,读者会听到两种闭包的写法被称作装箱闭包(boxed closure)和非装箱闭包(unboxed closure)。非装箱闭包是使用静态派发,按值捕获的版本。装箱闭包则是使用动态派发的特征对象版本。过去的 Rust 只有装箱闭包,而且闭包体系与现在相差甚远。
- RFC 114 - Closures
- Finding Closure in Rust blog post
- RFC 387 - Higher ranked trait bounds
- Purging proc blog post
原文待修复:关联 C++11 中闭包的内容