【Rust 学习记录】13. 闭包与迭代器

TwoSix Lv3

闭包:能够捕获环境的匿名函数

如这个小节的标题所示,闭包其实就是一个匿名函数,可以接收变量,也可以返回值,主要是用来实现一些代码复用和自定义的行为。

概述

首先,闭包的基本定义方法如下

1
2
3
4
5
6
7
fn main() {
let a = 1;
let b = 2;
let c = |a, b|{a+b};
let d = |a, b| a-b;
println!("{}, {}", c(a, b), d(a, b));
}
  1. 通过**||**包裹住传入的参数,通过逗号隔开
  2. 花括号内定义函数的行为。当然,对于简短的函数,例如a+b这种一条表达式直接返回值的,也可以不用花括号,如d

闭包与函数不同的是

  1. 闭包内不强迫显示标注类型,因为考虑到闭包这种通常使用的场景就是一个狭窄的上下文内,进行一个暂时的函数定义,而不会被广泛的定义。So, rust的设计者认为这种场景下干脆默认直接交给编译器推理就行。所以,闭包也有编译期固定类型的特性,闭包第一次被调用的时候,就按照第一次传入的参数被固定了,那第二次调用的时候是无法使用其他类型的参数传入的。如下面的代码会报错:error[E0308]: arguments to this function are incorrect

    1
    2
    3
    4
    5
    6
    7
    8
    9
    fn main() {
    let a = 1;
    let b = 2;
    let closure = |a, b|{a+b};
    println!("{}", closure(a, b));
    let c = 1.1;
    let d = 2.2;
    println!("{}", closure(c, d));
    }

    如果要实现复杂的闭包,用泛型等,自行指定类型也是没有问题的

  2. 闭包可以作为变量存储,这也就增加了更多代码复用的写法,例如作为变量存到结构体里,在结构体里按需调用之类的

  3. 闭包可以捕获环境上下文;这是一个与函数相比最大的不同点,这个就稍微展开来讲讲。

使用闭包捕获上下文环境

首先,捕获上下文是什么意思?看代码就知道了:

1
2
3
4
5
fn main() {
let a = 1;
let closure = |num| num==a;
println!("{}", closure(1));
}

在代码里可以看到,我们的闭包在没有传入a的情况下,获取到了闭包外的值a来进行一系列的处理。而普通函数如果不对a进行显示的传入的话,是不可能获得a的值的,这就是所谓的捕获上下文的能力。

那么问题来了,rust里的核心机制就是所有权,参数到了函数里,所有权也会转到函数中,那a在闭包内的所有权是怎么变化的呢?

我们换个例子来讲,因为a是整型,是存储在栈里的变量,在传入传出的时候是无脑的复制一份所有权,所以不具有代表性,我们换成数组。

1
2
3
4
5
6
fn main() {
let a = vec![1,2,3];
let closure = |num| num ==a[0];
println!("{}", closure(1));
println!("{:?}", a);
}

闭包里面包含了三个trait,分别是FnOnce, FnMut, Fn,而对于每一个闭包,至少实现了这三个trait中的一个。

  1. FnOnce是在闭包定义时会调用的,而且也只会调用一次的trait,他会把捕获到的环境变量所有权转移到闭包环境内。
  2. FnMut是可变的借用捕获的变量,不夺取所有权
  3. Fn则是不可变的借用

闭包会对变量实现哪个trait是自动的,主要取决于你在闭包内用变量来干了什么。例如我的数组例子里,是可以运行成功的,因为我只简单的使用了一下数组内的元素进行==的比较,并没有设计数值修改,所以闭包自动选择了不可变借用的形式。如以下形式,就是可变借用:

1
2
3
4
5
6
7
8
9
fn main() {
let mut a = vec![1, 2, 3]; // 注意a要改成可变
let closure = |tmp| {
a = tmp;
a
};
let b = vec![3, 4, 5];
println!("{:?}", closure(b));
}

如果你希望夺取所有权的话,有一个关键词move可以实现这么一个功能。

1
2
3
4
5
6
fn main() {
let a = vec![1, 2, 3];
let closure = move |num| num == a[0];
println!("{}", closure(1));
println!("{:?}", a);
}

定义为move后因为a的所有权被移到了闭包内,因此代码报错,无法运行。

除了自动实现的以外,你也可以采用之前讲的trait相关内容,为闭包指定trait类型,也可以重载实现trait以自定义更多的行为,这里就不多做讲解了。

迭代器:处理元素序列

迭代器也并不是一个新概念了,简单来说,迭代器主要就是用来遍历序列的,主要是解决传统遍历方式需要程序员自己判断序列是什么时候结束的问题,用迭代器就可以省去每次遍历都要定义一个=0的量,获取一次序列大小的逻辑。

迭代器的本质是一个Iterator的trait,如下:

1
2
3
4
5
6
7
pub trait Iterator {
type Item;

fn next(&mut self) -> Option<Self::Item>;

// 这里省略了由Rust给出的默认实现方法
}

所以迭代器本质上就是不断的调用next方法,生成下一个元素的值,包装成Option然后返回。

迭代器的生成是lazy的,和python的yield一样,只有当你遍历到那个元素的时候,迭代器才会使用next生成一个元素,然后返回到外面提供用户使用,在没有使用到的时候什么也不会发生,这在一定程度上节约了内存的使用。

上面的代码有一些新的语法,type ItemSelf::Item等,这个Item的功能是用来指定一个数据类型,后面会讲。

创建一个迭代器

.iter()

创建一个迭代器有很多方法,最基础的就是.iter()

1
2
3
4
5
6
7
fn main() {
let a = vec![1, 2, 3];
let a_iter = a.iter();
for i in a_iter {
println!("{}", i);
}
}

以上代码就是一个最简单的,使用.iter()创建一个列表的迭代器来遍历列表的示例。

当然,既然说过迭代器的本质是不断调用next方法,所以我们也可以使用next方法来使用迭代器。

1
2
3
4
5
6
7
8
fn main() {
let a = vec![1, 2, 3];
let mut a_iter = a.iter();
assert_eq!(a_iter.next(), Some(&1));
assert_eq!(a_iter.next(), Some(&2));
assert_eq!(a_iter.next(), Some(&3));
assert_eq!(a_iter.next(), None);
}

这里需要注意的是,a_iter必须定义为可变,因为调用next是会不断的吃掉上一个变量,替换成下一个变量的迭代器。

使用迭代器

因为next方法是会不断“吃掉”上一个元素的,因此迭代器的使用也伴随着迭代器的消耗。基本的for循环就是使用迭代器的一个方法。

1
2
3
4
5
6
7
8
9
10
fn main() {
let a = vec![1, 2, 3];
let a_iter = a.iter();
for i in a_iter {
println!("{}", i);
}
for i in a_iter {
println!("{}", i);
}
}

这段代码运行将会报错,可以看到,a_iter在被第一个for循环使用完之后,就无法继续使用了。

除了基本的循环外,还有很多提供便捷功能的迭代器相关方法,它们也同样会消耗迭代器。

  1. sum方法会自动生成所有迭代器,并返回其中所有元素的和。

    1
    2
    3
    4
    5
    6
    fn main() {
    let a = vec![1, 2, 3];
    let a_iter = a.iter();
    let total: i32 = a_iter.sum();
    println!("{}", total);
    }

    需要注意的是,这里必须把a_iter.sum()的结果存储到变量中打印,并显示标注total的类型,但这是后面再讲的问题了

  2. collect方法可以自动收集所有迭代器生成的元素,返回一个列表。

除了消耗迭代器返回元素的方法外,还有一种方法可以消耗旧的迭代器,生成新的符合一定要求的迭代器。这种方法成为迭代器适配器

  1. map方法可以接收一个闭包作为参数,自定义一个新的迭代器出来。

    1
    2
    3
    4
    5
    6
    7
    fn main() {
    let a = vec![1, 2, 3];
    let a_iter = a.iter();
    let a2_iter = a_iter.map(|x| x * 2);
    let b: Vec<i32> = a2_iter.collect();
    println!("{:?}", b);
    }

    这个示例中,我们使用map方法,生成一个会把原来元素*2再返回的迭代器,再使用collect方法收集成新的列表并打印输出

  2. filter方法同样也是接收闭包为参数,但这个闭包必须是返回一个bool值,利用闭包的bool结果,filter方法会过滤掉返回结果不为true的迭代器。

    1
    2
    3
    4
    5
    6
    7
    fn main() {
    let a = vec![1, 2, 3, 4];
    let a_iter = a.into_iter();
    let a2_iter = a_iter.filter(|x| x % 2 == 0);
    let b: Vec<i32> = a2_iter.collect();
    println!("{:?}", b);
    }

    这里使用的是into_iter,因为a.iter()返回的是一个引用,所以在执行%运算的时候会报类型不匹配的错误;要么解引用,要么用into_iter直接夺取所有权,这里我偷了个懒。(PS:上面的乘法能编译通过是因为乘法这种基本运算会自动解引用。)

    当然,既然filter和map的传入参数是闭包,自然也可以捕获环境上下文,感兴趣可以自己试试,这里就不多写演示代码了。

还有很多方法,例如zip(),skip(),这里就没办法一一展开了

自定义迭代器

迭代器本质上是一个Iterator trait,所以我们自然也可以定义一个结构体,为他实现一个Iterator trait,进而实现一个我们自定义的迭代器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
struct Counter {
count: u32,
}
impl Counter {
fn new() -> Counter {
Counter { count: 0 }
}
}
impl Iterator for Counter {
type Item = u32;
fn next(&mut self) -> Option<Self::Item> {
self.count += 1;
if self.count < 6 {
Some(self.count)
} else {
None
}
}
}
fn main() {
let counter = Counter::new();
for i in counter {
println!("{}", i);
}
}

这段示例里,我们就定义了一个结构体Counter,内置一个u32变量count,初始值为0,然后为Counter实现了一个Iterator trait,让Counter结构体变成了一个迭代器,迭代的逻辑就是不断的递增结构体内的count变量,直到count>=6。

迭代器的性能分析

例如说我们现在有一个功能,需要在一堆字符串中,找到所有包含某个子串的字符串。那最简单的,利用循环的写法可以写成如下形式:

1
2
3
4
5
6
7
8
9
fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
let mut results = Vec::new();
for line in contents.lines() {
if line.contains(query) {
results.push(line);
}
}
results
}

那在知道迭代器之后,我们自然也可以想到可以通过迭代器的filter方法,过滤出只包含这个子串的迭代器,迭代器的版本可以这么写:

1
2
3
4
5
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
contents.lines()
.filter(|line| line.contains(query))
.collect()
}

那这两种写法有什么区别呢,多抽象一层迭代器的话,会不会有性能损失?

答案是不会的。

Rust官方书籍里用一本小说做了一次bench mark,结果表面,迭代器的写法在经过优化后比for循环更快。

1
2
test bench_search_for  ... bench:  19,620,300 ns/iter (+/- 915,700)
test bench_search_iter ... bench: 19,234,900 ns/iter (+/- 657,200)

迭代器是Rust里的一种零开销抽象,所以我们在使用的时候根本不需要担心会不会引入额外的运行成本。

至于快了的那一点,本质上是因为迭代器在编译过程中会运行一次展开优化,例如本来要运行12次的循环代码,如果是迭代器的话,在编译之后会展开成重复执行12次的代码,减少了循环控制时候的开销。

但个人觉得可以忽略不计,大可依照个人习惯的去选择使用这两种写法,benchmark本质上只是证明了iter并不会引入额外开销,放心使用。

  • 标题: 【Rust 学习记录】13. 闭包与迭代器
  • 作者: TwoSix
  • 创建于 : 2024-01-18 20:57:44
  • 更新于 : 2024-07-04 23:52:28
  • 链接: https://twosix.page/2024/01/18/【Rust-学习记录】13-闭包与迭代器/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论