写(无)在(关)前(紧)面(要)的话
原计划会先写完Rust宏编程,但最近工作实在抽不出手,而且过程宏需要有一些编译理论支撑(比如抽象语法树), 还没有想好怎么用比较简单易懂的方式把这个讲清楚(主要是自己还没理清楚)。
最近在改一些项目工程,在实际项目中,闭包和迭代使用也是极其频繁;并且,在rust函数式链式调用过程中,配合闭包的使用,可以写出非常简洁的代码,艺术成分很高(恐怕有三四楼那么高啦)。另一方面,在工程中,去阅读别人写的这样的代码时,如果对这块内容掌握不太熟练,难免会觉得晦涩,比如下面这段代码:
如截图的函数,对入参engin_state进行一些列链式处理,中间掺杂了三次闭包用于数据处理和映射转换;看是一脉相承,但,于数据而言,却是“轻舟已过万重山”!
如果尝试去理解每一个转变的细节(当需要去理解数据处理细节、调整之前的数据转换模式时)就必须熟练掌握 Option工具套件、Result工具套件、闭包处理原理、迭代工具套件。
本着学以致用的目的,我们就随遇而安吧;遇都遇上了,就先整闭包吧,宏编程下集就下期再补......
闭包的应用场景
(1)封装私有变量和方法
通过闭包,可以创建私有变量和函数,只需要暴露必要的接口给外包,从而实现信息隐藏和封装。最经典的就是迭代器,迭代器通过暴露少量接口(iter,next 等)来支持数据循环获取,但隐藏了内部数据存储、获取、迭代的实现细节。
(2)函数式编程
闭包在函数式编程中是非常有用的工具,可以用于创建数据过滤、数据映射、高阶函数、延迟函数等。
(3)作为入参或回调函数
闭包可以作为入参传递到函数内部,在合适的时机进行执行;属于标准的注册、回调模型。这有利于实现异步处理中,状态存储和关联(相比于一般函数)。比如,在事件处理的回调函数中传递闭包,该闭包可能会携带事件初始化时的一些环境信息,这有助于提高事件处理过程中的信息完备性。
(4)数据缓存
利用闭包可以缓存计算结果,避免重复计算,从而提高性能。
总的来说,闭包在Rust中有着广泛的应用,能够帮助开发人员编写更加模块化、灵活和高效的代码(这句话感觉像是AI生成的呢)。
闭包的语法
一个典型的闭包,如下面示例:
let inc= |x:i32| {
let mut result: i32 = x;
result += 1;
result
};
一个闭包通常包括两部分:其一,由“||”所包裹的参数描述;其二,有“{}”所包裹的表达式区域。如下:
| 【参数区域】| {【表达式区域】}
参数区域:描述0个或多个闭包函数的参数;参数描述与函数参数描述类似, 【参数名称】:【参数类型】
,如果多个参数用 ,
分割。
表达式区域:描述闭包的逻辑实现,其中最后一个表达式的值作为闭包的返回值。
注意:
(1)在书写闭包时,通常会省略闭包参数的类型。这是因为闭包通常运行在函数内部,存在上下文,rust编辑器可以进行类型推导。值得强调的是,闭包的参数(返回值)推导第一次完成后就固定了,不可变更,属于编译时的行为,而非运行时的行为。例如:
let self_x= |x| x;
let s = self_x(String::from("hello"));
let n = self_x(5);
上述代码,编译运行时报错:在编译self_x(5)
提示类型不匹配(期望是String,而传入i32)。原因是该闭包在 上一句编译时就完成类型推导:闭包的入参和返回都是String类型。
(2)如果表达式内容本身为一句话表达式,则可以省略“{}”。例如 上面示例中的self_x。另外,本节开始的inc闭包可以简化为:
let inc = |x| x+1;
捕获变量
闭包与函数的另一个重要的差异在于,闭包可以捕获其环境中的值(变量)。之所以把它称为“闭包”是因为它们“包含在环境中”(close over their environment)。
例如:
// 变量捕获案例01
#[test]
fn test_closer_ref_use_env_var() {
let mut num = 5;
let num_plus = |x: i32| {
num + x // 这里使用了外部的num变量
};
assert_eq!(num_plus(1), 6);
}
上述代码可以通过测试,num_plus捕获并使用环境中的num的值,与参数x运算后返回。
对应于rust函数参数传递的:引用传递,可变引用传递,值传递(移交所有权)三种模式,闭包在捕获环境中的变量时也有三种模式:
(1)不可变引用方式捕获
(2)可变引用方式捕获
(3)所有权方式捕获
接下来,我们逐一探讨。
以不可变引用的方式捕获环境变量
对案例01代码改进,尝试在闭包存活期间,修改环境中的变量num:
// 变量捕获案例02
#[test]
fn test_closer_ref_use_env_var() {
let mut num = 5;
let num_plus = |x: i32| {
num + x
};
assert_eq!(num_plus(1), 6);
assert_eq!(num, 5);
num = 10;
assert_eq!(num_plus(1), 11);
}
乍一看,上面的代码没有问题,但编译运行时会报错:
原因是: 在闭包中,num被借用(不可变借用),而在次期间尝试对num进行修改,进而违反了Rust的借用检测要求。
注意:当变量被闭包借用后,其引用关系会保持在闭包函数变量的生命周期内(案例中num_plus),在一些场景中,会导致引用被意外持有而影响数据更新!
案例02中, 对num进行后续赋值会报错,原因是num_plus的生命周期在此刻还未结束,删除最后一行(解除对num_plus引用,使得num_plus在num=10前结束生命周期,解除对num的引用),则num变得可以修改
// 变量捕获案例03
#[test]
fn test_closer_ref_use_env_var() {
let mut num = 5;
let num_plus = |x: i32| {
num + x
};
assert_eq!(num_plus(1), 6);
assert_eq!(num, 5);
num = 10;
// assert_eq!(num_plus(1), 11);
assert_eq!(num, 10);
}
更改后,案例03正常执行。
以可变引用的方式捕获环境变量
如下代码:
// 变量捕获案例04
#[test]
fn test_closer_modify_env_var() {
let mut num = 5;
let mut add_num = |x: i32| {
num += x;
num
};
assert_eq!(add_num(1), 6);
assert_eq!(add_num(1), 7);
assert_eq!(num, 7);
}
以可变引用的方式捕获环境中的变量,与上一节讲的不可变引用捕获类似,差异在于:
(1)以可变引用捕获的变量,在闭包内可以修改该变量的值;
(2)以可变引用方式捕获变量的闭包,其绑定变量本身需要设置为mut
;这是因为,对于闭包变量本身而言(add_num),在调用过程中,其所依赖的环境发生了变更(闭包 = 逻辑+环境, 环境变更被视为闭包内容变更)。
以获取所有权的方式捕获环境变量
见以下测试代码:
// 变量捕获案例05
#[test]
fn test_closer_capture_env_var() {
let mut num = 5;
let add_num = move |x: i32| {
let mut inner = num;
inner += x;
inner
};
assert_eq!(add_num(1), 6); // line-54:call once
assert_eq!(add_num(1), 6); // line-55:call again
assert_eq!(num, 5);
num = 10;
assert_eq!(num, 10);
assert_eq!(add_num(1), 6); // line-60: call after change
}
与不可变引用、可变引用等捕获方式不同,获取所有权捕获的方式,会获取环境变量中的所有权!
当然,获取所有权的方式(即便关键字是move
),遵循rust正常的变量绑定语义。在案例5中,i32
类型实现了Copy
,所以闭包中inner
取得一个5
的拷贝的所有权。如果,捕获的变量未实现Copy,则其所有权被闭包变量所持有,而原环境中的变量将不可用。示例如下:
// 变量捕获案例06
#[test]
fn test_closer_capture_env_var2() {
let prompt = "Hello, ".to_string();
let call_some_one = move |s| {
let mut inner = prompt;
inner.push_str(s);
inner
};
assert_eq!(call_some_one("Mir Wang"), "Hello, Mir Wang");
assert_eq!(prompt, "Hello, ");
}
该代码编译运行会报错,原因是prompt被move到闭包内内部,原作用域内已经不可用了:
对比案例5与案例4,以可变引用捕获环境变量,在闭包中可以修改环境变量,该修改可以直接反馈到环境中;以捕获变量的方式,捕获发生在闭包创建的那一刻,捕获后保持不变(理解案例05中, line-54、line-55、line-60三次调用add_num返回值一致);并且,以捕获的方式捕获变量后,调用闭包,环境的值保持不变(对闭包无影响),因此闭包绑定的变量也无需加mut
。
闭包的实现与三种Fn trait
Rust 的闭包实现与其它语言有些许不同。它们实际上是trait的语法糖。
首先,Rust为闭包描述了三种模式,并将其定义trait:
pub trait Fn<Args: Tuple>: FnMut<Args> {
/// Performs the call operation.
#[unstable(feature = "fn_traits", issue = "29625")]
extern "rust-call" fn call(&self, args: Args) -> Self::Output;
}
pub trait FnMut<Args: Tuple>: FnOnce<Args> {
/// Performs the call operation.
#[unstable(feature = "fn_traits", issue = "29625")]
extern "rust-call" fn call_mut(&mut self, args: Args) -> Self::Output;
}
pub trait FnOnce<Args: Tuple> {
/// The returned type after the call operator is used.
#[lang = "fn_once_output"]
#[stable(feature = "fn_once_output", since = "1.12.0")]
type Output;
/// Performs the call operation.
#[unstable(feature = "fn_traits", issue = "29625")]
extern "rust-call" fn call_once(self, args: Args) -> Self::Output;
}
细心的读者会注意到这些 trait 之间的些许区别,不过一个大的区别是self
:Fn
获取&self
,FnMut
获取&mut self
,而FnOnce
获取self
。关键的是,这个self
是什么结构?我们在rust使用过程中,并未发行其定义!
要解释这一点,我们先要理解:在Rust中,object实现了某个trait,那么可以将这类object当做trait-object 使用(如果这点不理解,需要看一下rust trait 和trait object章节)。
而上面所述的,在Fn trait 中的这个self
,就是每个特定闭包的临时结构体!(注意:这里“临时”这个描述不准确,因为我没有阅读过rust源码,不清楚rust在这里的处理细节,但我们可以这样简单的理解)
可以理解为:|...| {...}
是 Fn trait的语法糖,编译器在处理这样的语句时,会做以下几个事情:
(1) 生成一个特定的结构体 obj
,用于记录该闭包对环境变量的捕获情况;
(2)根据闭包表达式块中对环境变量的捕获方式,决定为该obj 实现 Fn 或者FnMut 或者FnOnce,并实现之;
(3)生成该结构体的实例,进行变量绑定。
其中,根据闭包表达式对环境变量的捕获方式,决定为该闭包实现的FnX trait的决策方式:
(1)如果闭包未捕获任何环境变量,或者只以不可变借用的方式捕获环境变量,那么为该闭包实现Fn ;
(2)如果闭包以可变引用的方式捕获环境变量,那么为该闭包实现FnMut;
(3)如果闭包已获取所有权的方式捕获环境变量,那么为该闭包实现FnOnce。
进一步,FnX trait 的一些特性也简要说明如下:
Fn 是一个能以不可变引用方式捕获环境变量的类函数调用接口。其特征包括(翻译自Fn trait 源码注释):
(1) Fn类型的实例可以被多次调用,而不改变状态。
(2)当闭包仅捕获不可变引用的变量或不捕获任何变量时,Fn会自动实现。
(3)一般地,安全的函数指针(有一些限制,详情请参考其文档)也会自动实现Fn。
(4)如果类型F实现了Fn,那么引用类型&F也实现了Fn。
(5)由于FnMut和FnOnce都是Fn的父特性,所以任何Fn实例都可以作为申明为FnMut或FnOnce参数的函数参数。
FnMut 是一个能以可变借用方式捕获环境变量的类函数调用特性。其特征包括:
(1) FnMut类型的实例可以被多次调用,并且可能改变状态。
(2) 当闭包捕获可变引用的变量时,FnMut会自动实现。
(3)所有实现Fn的类型(例如,安全的函数指针,因为FnMut是Fn的超特性)也会自动实现FnMut。
(4)对于任何实现FnMut的类型F,&mut F也实现了FnMut。
(5)由于FnOnce是FnMut的超特性,任何FnMut实例都可以用在期望FnOnce参数的地方。
FnOnce 是一个能够以获取所有权方式捕获环境变量的类函数调用特性。其特征包括:
(1) FnOnce类型的实例可以被调用,但可能无法多次调用。因此,如果只知道类型实现了FnOnce,那么它只能被调用一次。
(2)当闭包可能会消耗捕获的变量时,FnOnce会自动实现,以及所有实现FnMut的类型(例如,安全的函数指针,因为FnOnce是FnMut的超特性)。
(3)由于Fn和FnMut都是FnOnce的子特性,任何Fn或FnMut实例都可以用在期望FnOnce参数的地方。
总结而言,区分使用这三种Fn trait的方法为:
(1)当需要接受类似函数的参数类型,且只需要调用一次时,可以使用FnOnce作为约束。
(2)如果需要多次调用,使用FnMut作为约束;如果还需要保持无状态,使用Fn作为约束。
另外,需要再明确一点,Fnxx trait 是规则,而闭包是该规则的一些特殊实现;前面在在Fn trait特性说中,也提到了,一些安全的函数指针,也会实现Fn 或者FnMut;因此我们经常看到在一些接口中,入参采用Fn 限制,但实际使用时既可以传一般函数,也可以传闭包,其原因就在于此!
至此,闭包和Fn trait 的关系应该算是理清楚了!
下课之前,看一点代码巩固一下(编程人,好像脱离的代码总感觉空落落的)
代码示意
首先,定义了一些warpper 函数:
fn exec<'a, F: Fn(&'a str)>(mut f: F) {
f("world")
}
fn exec_mut<'a, F: FnMut(&'a str)>(mut f: F) {
f("world")
}
fn exec_once<'a, F: FnOnce(&'a str)>(f: F) {
f("world");
}
测试 自动实现Fn的闭包可以用于 FnMut 及 FnOnce:
#[test]
fn test_exec() {
let str = "hello".to_string();
exec(|s| {
let join = format!("{str} {s}");
println!("{join}");
assert_eq!(join.len(), 11);
});
let str2 = "hello".to_string();
let closer_use = |s| {
let join = format!("{str2} {s}");
println!("{join}");
};
closer_use(" world");
exec(closer_use);
exec_mut(closer_use);
exec_once(closer_use);
}
上述代码,closer_use 以不可变借用的方式借用str2, 该闭包实现Fn,因此可以用于exec函数接口;同时,Fn 自动实现FnMut及FnOnce,因此也可以用于exec_mut及exec_once!
测试自动实现FnMut的闭包用于 Fn 及 FnOnce
fn test_exec_mut() {
let mut str = "hello ".to_string();
exec_mut( |s| {
str.push_str(s);
println!("{str}")
});
let mut str2 = "hello ".to_string();
let mut closer_mut = |s| {
str.push_str(s);
println!("{str}")
};
exec(closer_mut);
exec_mut(closer_mut);
exec_once(closer_mut);
}
编译运行该代码报错:
错误提示,当closer_mut 应用于exec函数时,提示特性不匹配,要求是Fn,但实际上只实现了FnMut,这个也符合预期!
其他的演示就不贴上来了,感兴趣的童鞋自己试一下吧......