【百例RUST - 015】闭包
第一章 基础概述
第01节 基础介绍
快速入门案例
rust
fn main() {
// 闭包的快速入门案例
// 定义闭包
let use_closure = ||{
println!("This is a closure");
};
// 使用闭包
use_closure();
}
// This is a closure
第02节 闭包作用
闭包有什么作用?
1、捕获环境:
A. 介绍:
闭包和普通函数 fn 最大的区别。
普通函数只能使用其签名中定义的参数, 而闭包可以 "捕获" 定义它的时候作用域内的变量。
B. 说明:
灵活的代码组织:
我们不需要手动将每一个变量, 都作为参数传递给函数。
三种捕获的方式:
a. FnOnce 消耗捕获的变量(获取所有权)
b. FnMut 可变地借用变量(修改值)
c. Fn 不可变借用变量(只读)
2、作为高阶函数的参数
A. 介绍:
Rust 的函数式编程特性是大量依赖闭包。它们常用于迭代器处理, 集合操作等场景中。
B. 三点说明:
a. 数据转换: 使用 .map() 讲一个序列, 转换为另一个序列
b. 过滤筛选: 使用 .filter() 根据条件保留元素
c. 延迟执行: 只有在真正需要结果时 如.collect() 闭包内的逻辑才会执行。
3、实现抽象与封装
A. 介绍:
闭包允许库的开发者定义 "逻辑模板" 由用户来提供具体的 "执行细节"
B. 两点说明:
a. 回调机制: 在一步编程或者 GUI 开发中, 我们可以传入一个闭包, 告诉系统 "当某件事情发生的时候, 执行这段逻辑"
b. 自定义行为: 例如 unwrap_or_elese(||{....}) 只有当 Option 为None的时候, 才会执行闭包中复杂的逻辑, 避免昂贵的计算机开销
4、性能优势
A. 介绍:
Rust 的闭包在编译时, 会生成独特的匿名结构体 和 trait 实现。
B. 两点说明:
a. 静态开发: 编译器通常能够内联闭包的代码, 意味着使用闭包的性能通常与手写循环或硬编码逻辑一样快。
b. 内存优化: 闭包只捕获它们真正需要的变量, 且捕获方式尽可能最小化 (优先借用, 最后才移动所有权)
第03节 闭包简写
rust
fn main() {
// 闭包的简化写法
// 第一种 函数的书写方式:
fn add_one_v1(x: u32) -> u32 { x + 1 }
// 第二种 闭包的完整写法
let add_one_v2 = |x: u32| -> u32 { x + 1 };
// 第三种 闭包的推导写法 (省略类型)
let add_one_v3 = |x| { x + 1 };
// 第四种 闭包的全省略写法
let add_one_v4 = |x| x + 1;
// 调用上面的结果
println!("{}", add_one_v1(11));
println!("{}", add_one_v2(22));
println!("{}", add_one_v3(33));
println!("{}", add_one_v4(44));
}
// 12
// 23
// 34
// 45
第04节 可推导能推导
说明
闭包当中, 如果省略的过于简单之后,需要注意的几点问题是: "必须是可推导 能推导"
错误案例1
rust
fn main() {
// 闭包的错误案例
// 规则 "闭包省略之后, 必须是可推导能推导"的
let result = |x| x + 1;
}
// 报错了!
// 这里直接报错了, 因为现在没有上下文环境, 无法确定x的数据类型
修改案例1
rust
fn main() {
// 闭包的错误案例
// 规则 "闭包省略之后, 必须是可推导能推导"的
let result = |x| x + 1;
println!("{}", result(10));
}
// 11
// 因为下面的输出语句给出了类型, 可以推导出当前类型是整数, 所以不会报错
错误案例2
rust
fn main() {
// 闭包的错误案例
// 规则 "闭包省略之后, 必须是可推导能推导"的
let result = |x| x + 1;
println!("{}", result(10));
println!("{}", result(3.66));
}
// 报错了!
// 闭包操作, 只能推断一次, result(3.66) 报错了。因为上面已经推断出是 整数
修改案例2
rust
fn main() {
// 闭包的错误案例
// 规则 "闭包省略之后, 必须是可推导能推导"的
let result = |x| x + 1;
println!("{}", result(10));
println!("{}", result(366));
}
// 11
// 367
// 因为下面的输出语句给出了类型, 可以推导出当前类型是整数, 后续的使用只能是整数
第二章 闭包捕获环境
第01节 基础介绍
在前面闭包作用中,提及了一个词汇。 闭包捕获环境: 三种捕获
1. FnOnce 消耗捕获的变量(获取所有权)
2. FnMut 可变地借用变量(修改值)
3. Fn 不可变借用变量(只读)
第02节 案例代码
那么下面,将介绍这里的三种捕获方式。
案例1
rust
fn call_once(c: impl FnOnce()) {
c();
}
fn main() {
// 第一种情况
let s :String = String::from("hello");
// 构建闭包
let use_closure1 = move||{
let s1 = s;
println!("{}", s1);
};
// use_closure1(); // 这段代码, 只能调用一次, 如果后面调用, 将会报错!
call_once(use_closure1);
}
// hello
案例2
rust
fn call_mut(c: &mut impl FnMut()) {
c();
}
fn main() {
// 第二种情况
let mut s :String = String::from("hello");
// 构建闭包
let mut use_closure2 = ||{
s.push_str(",world");
println!("{}", s);
};
use_closure2();
use_closure2();
call_mut(&mut use_closure2);
call_mut(&mut use_closure2);
}
// hello,world
// hello,world,world
// hello,world,world,world
// hello,world,world,world,world
案例3
rust
fn call_once(c: impl FnOnce()) {
c();
}
fn call_mut(c: &mut impl FnMut()) {
c();
}
fn call_fn(c: impl Fn()) {
c();
}
fn main() {
// 第三种情况
let s :String = String::from("hello");
// 构建闭包
let mut use_closure3 = ||{
println!("{}", s);
};
use_closure3();
use_closure3();
call_once(use_closure3);
call_mut(&mut use_closure3);
call_fn(use_closure3);
}
// hello
// hello
// hello
// hello
// hello
第03节 详细说明
如何理解上面的三种情况。
闭包: 实际上就是一个自动生成的结构体,它捕获的变量就是结构体的成员。
说明
1、 FnOnce 消耗捕获的变量。
A. 含义说明:
FnOnce 表示闭包可以被调用一次, 也只能调用一次。
B. 特征说明:
a. 行为核心: 它获取了 捕获变量的 所有权!
b. 原因分析: 一旦闭包被调用, 它内部捕获的变量, 可能就被移动(MOVE)或者销毁。变量已经不存在了,闭包就无法再次被执行
c. 触发条件: 只要闭包体中将捕获的变量移除了闭包(例如返回了该变量, 或者将其传给了一个接收所有权的函数)
2、 FnMut 可变借用变量
A. 含义说明:
FnMut 表示闭包可以被调用多次, 而且可以修改环境中的变量。
B. 特征说明:
A. 行为核心: 它获取的是变量的 可变借用(租用)
B. 要求: 闭包变量本身必须声明为 mut 因为它在执行时, 会改变其内部状态(即捕获的变量)
C. 限制: 在闭包生命周期内, 该变量不能在其他地方借用。
3、 Fn 不可变借用变量
A. 含义说明:
Fn 是最常见的类型, 表示闭包可以被多次调用, 且 不改变环境
B. 特征说明:
A. 行为核心: 它获取的是变量的 不可变借用(引用)就像普通引用 &T 一样
B. 适用场景: 闭包只是读取数据, 不移动所有权, 也不修改值
C. 并发性: 因为它不修改数据, 所以 Fn 闭包通常是线程安全的
三者之间的关系
它们之间, 存在着一种 "包含" 关系。
1、所有的闭包都实现了 FnOnce 因为任何闭包至少都能运行一次。
2、如果一个闭包实现了 FnMut 那么它一定也实现了 FnOnce
3、如果一个闭包实现了 Fn 它一定也实现了 FnMut 和 FnOnce
关系对比
| Trait | 捕获方式 | 能否多次调用 | 权限级别 |
|---|---|---|---|
| FnOnce | 移动 MOVE | 只能1次 | 最高(拥有所有权) |
| FnMut | 可变借用 (&mut) 租用 | 可以多次 | 中等(可读,可写) |
| Fn | 不可变借用 (&) 引用 | 可以多次 | 最低(仅可读) |
如何选择呢?
1、 如果我们想要 【带走】变量, 那么使用 FnOnce
2、 如果我们想要 【修改】变量, 那么使用 FnMut
3、 如果我们只想 【看看】变量, 那么使用 Fn
第三章 闭包在函数上使用
第01节 闭包作为函数参数
案例代码
rust
// 闭包作为函数的参数
fn wrapper_func<T>(t:T, v:i32)->i32 where T:Fn(i32)->i32 {
t(v)
}
fn func(v:i32)->i32{
v+1
}
fn main() {
let a = wrapper_func(|x|x+1, 1);
println!("{}", a);
let b = wrapper_func(func, a);
println!("{}", b);
}
// 2
// 3
核心说明
高阶函数:
函数可以接收另一个函数或者闭包作为参数。
核心代码:
fn wrapper_func<T>(t:T, v:i32)->i32 where T:Fn(i32)->i32 {
t(v)
}
理解下面的几点内容:
1、 <T> 这是一个泛型, 代表某种类型
2、 where T:Fn(i32)->i32
这是关键约束, 在 rust 当中, 闭包和函数没有统一的类型名称。
它们都实现了特定的 Trait 这里要求的是 T 必须使用了 Fn 的 Trait 并且输入输出均为 i32 类型。
3、 t(v) 像调用普通函数一样, 调用传入的参数。
闭包和普通函数的通用性
在 main 函数当中, 我们可以发现 wrapper_func 展现出来了极强的适配能力。
1、传入闭包类型。
代码:
let a = wrapper_func(|x|x+1, 1);
说明:
这里传入一个 匿名闭包, 在 rust当中会自动推导并且生成一个唯一的类型来实现 Fn(i32)->i32
2、传入普通函数。
代码:
let b = wrapper_func(func, a);
说明:
这里传入了定义的命名函数 func
在 rust 当中, 函数指针也实现了 Fn 系列的Trait 因此可以无缝传递给需要闭包的泛型函数。
这样做的原因
| 特性 | 说明 |
|---|---|
| 逻辑解耦 | wrapper_func 只是负责 调用,并不会关系具体的逻辑是什么。 具体逻辑由调用者决定 |
| 零成本抽象 | 泛型配合 Trait 约束在编译时会单态化,意味着编译器会闭包和 func 分别生成专门的代码版本,没有运行时性能开销 |
总结
1、这段代码当中, 演示了 Trait Bound (Trait 约束) 是如何统一闭包和函数行为的。
2、这种操作, 展示出 函数能够像处理数据一样的 处理逻辑。
3、T:Fn(i32)->i32 就像一份合同, 无论我们传入进来的是什么, 只要它可以接收 i32 并且返回 i32 那么 wrapper_func 就可以正确运行。
第02节 闭包作为函数返回值
案例代码
rust
// 闭包作为函数的返回值
fn returns_closure() -> Box<dyn Fn(i32) -> i32> {
Box::new(|x| x + 1)
}
fn main() {
let c = returns_closure();
println!("c = {}", c(1));
}
// 2
核心说明
1、为什么使用 Box<Dyn ... >
A. 唯一性
在 rust 当中, 每个闭包都有一个编译器生成的, 唯一的匿名类型。
这就意味着即使两个闭包的代码一模一样, 它们的类型也是不同的。
B. 大小不确定
因为闭包的类型在编写代码的时候是未知的, 而且可能捕获环境中的变量。
所以编译器在编译的过程中, 无法确定它的大小 (Unsized)
C. 解决方案
a. dyn Fn(i32)->i32
使用 dyn 关键字声明这是一个 动态分发的 Trait Object
它告诉了编译器: "我不在乎具体的类型, 只要它实现了这个 Trait 即可"
b. Box<...>
由于 dyn 类型的大小是不固定的, 无法直接从函数返回(Rust函数返回值必须在编译时确定大小)
通过 Box 将闭包分配在 堆(Heap)上,而在栈上只是返回一个指向堆的指针,指针的大小是固定的。
2、生命周期与所有权
返回闭包的时候, 通常涉及到所有权的转移。
A. Box::new(|x|x+1)
创建了一个新的堆对象, 并且将所有权交给了调用者 (main函数当中的c)
B. 如果闭包当中使用了 move 关键字(例如 move|x|x+count) 它会将环境当中的变量所有权也一并 "打包"带走
确保闭包在离开当前作用域之后, 依然有效。
3、Fn Trait 的选择
在代码当中, 使用了 Fn
A、 Fn 可以多次调用, 并且不修改捕获的环境
B、 FnMut 可以多次调用, 并且可能修改环境
C、 FnOnce 只能调用一次, 因为它会小伙捕获的变量
由于 |x|x+1 只是简单的数学运算, 不涉及到环境状态的修改, 因此使用 Fn 是最通用的做法。
核心对比总结
| 维度 | 闭包作为函数的参数 | 闭包作为函数的返回值 |
|---|---|---|
| 类型处理 | 使用泛型 <T: Fn...> |
使用 Trait Object Box<Dyn Fn...> |
| 内存分配 | 通常在栈上(单态化) | 必须在堆上(Box) |
| 性能 | 零成本抽象,静态分发 | 略有开销,动态分发(虚函数表调用) |
总结
因为闭包类型未知 而且 大小不定, 返回闭包时必须通过 Box 把它装起来, 并且使用 dyn 来开启动态绑定。
第03节 闭包在泛型上的使用
案例代码
rust
// 闭包和泛型的结合在一起使用
// 定义第一种情况 Fn
fn returns_closure1<T>(f:T) ->T where T:Fn(i32)->i32{
f
}
// 定义第二种情况 FnMut
fn returns_closure2<T>(f:T)->T where T:FnMut(){
f
}
// 定义第三种情况 FnOnce
fn returns_closure3<T>(f:T)->T where T:FnOnce(){
f
}
fn main() {
// 定义闭包
let closure1 = |x| x+1;
let c = returns_closure1(closure1);
println!("{}", c(1));
// T 实现了 FnMut FnOnce
let mut s = String::from("hello");
let closure2 = ||{
s.push_str(", world!");
};
let mut c = returns_closure2(closure2);
c();
println!("{}",s);
let s = String::from("hello");
let closure3 = move ||{
let s1 = s;
println!("s1 = {}", s1);
};
let c = returns_closure3(closure3);
c();
}
// 2
// hello, world!
// s1 = hello
这段代码,展示了 Rust 当中 泛型与闭包 Trait 结合的高级用法。 这里使用了 静态分发。
核心说明
1、为什么这里不需要使用 Box
这里的闭包是作为参数传入, 再原样返回的。
A. 泛型T的作用:
当我们调用 return_closure1(closure1) 的时候, 编译器已经知道了 closure1 的具体类型
B. 类型穿透:
泛型T 捕捉到了闭包的具体类型, 因此函数签名 fn returns_closure1<T>(f: T)->T
我们可以明确的知道返回值的内存大小。 这就是为什么不需要 Box 的原因。
2、核心结论
在该例子当中 return_closure<T> 函数充当了一个 "类型过滤器"。
通过 where 子句, 你限制了传入的泛型 T 必须具备某种特定的捕获能力。
由于使用了泛型, 编译器在编译时, 会为每一种闭包生成专门的代码(单态化) 这保证了运行效率是最高的。
第四章 补充概念
第01节 单态化
基础介绍
1、什么是单态化?
单态化: 编译器将泛型代码转化为具体类型代码的过程。
2、单词解释
Monomorphization
Mono "单一"
Morph "形态"
单态化就是把 "多种形态" 的泛型变成 "单一形态" 的具体实现。
相关案例
rust
use std::fmt::Display;
// 我们写了一个泛型的函数
fn print_me<T: Display>(item: T) {
println!("{}", item);
}
// 如果我们在代码当中, 分别使用了 i32 和 String 去调用它
fn main() {
print_me(10);
print_me(String::from("Hello"));
}
// 10
// Hello
在编译时,编译器会 机械拆解 我们的泛型,生成两个独立的函数
rust
// 编译器生成的伪代码
fn print_me_for_i32(item: i32){ .... }
fn print_me_for_string(item: String){ .... }
为什么 rust 喜欢 "单态化"
rust 的核心哲学是 "零成本抽象"
通过单态化, 虽然我们的源代码, 看起来很抽象 (用了泛型 和 Trait )
但是最终生成的二进制机器码和我们在C语言中手写的特定类型的函数一模一样, 没有任何的性能损耗。
比喻:
"单态化" 是 "工厂模具"
我们想要插三相电, 那么工厂就会直接给你一个三相电的插座。
我们想要插两相电,就再打造一个两相的。
虽然模具多(编译久、占地方)但是插上去的一瞬间, 电就通了, 没有任何的转换损失。
我们之前的代码里面 returns_closure1<T>(f: T)-> T 走的就是这条路。
编译器为每一个不同的闭包都复制了一份专属的函数代码, 所以速度飞快。
第02节 静态分发
基础介绍
1、什么是静态分发?
静态分发:在编译期, 就确定了调用哪个函数版本。
2、特征说明
当我们使用泛型(如 fn func<T>(arg:T) ) 的时候,
编译器在编译的代码阶段, 就会查看到 我们到底使用哪些具体的类型, 调用了这个函数。
两种对比
1、动态分发:
例如: Box<dyn Trait> 就像是在运行的时候, 查字典。
A. 这个对象是谁?
B. 它有这个方法吗?
C. 在那, 去调用它?
2、静态分发:
就像是指向了固定的地址:
比如: "我已经知道你是 i32的了, 我直接给你生成一段处理 i32的机器码"
核心优缺点对比
| 特性 | 静态分发(单态化) | 动态分发(dyn Trait) |
|---|---|---|
| 执行速度 | 极快,编译器可以进行内联优化 | 稍慢。需要查找虚函数表,无法内联 |
| 编译速度 | 较慢,编译器要为每种类型生成一份代码 | 较快。只需要编译一份代码 |
| 二进制体积 | 较大,代码膨胀(Code Bloat) 重复生成逻辑 | 较小。只有一份函数体 |