Rust 程序设计语言学习——函数式语言功能:迭代器和闭包

Rust 的闭包(closures)是可以保存在一个变量中或作为参数传递给其他函数的匿名函数。可以在一个地方创建闭包,然后在不同的上下文中执行闭包运算。不同于函数,闭包允许捕获被定义时所在作用域中的值。

迭代器(iterator)负责遍历序列中的每一项和决定序列何时结束的逻辑。当使用迭代器时,我们无需重新实现这些逻辑。

一、闭包

闭包,一个可以储存在变量里的类似函数的结构。

1.1 闭包会捕获其环境

在 Rust 中,闭包是一种匿名函数,它们可以捕获外部环境中的变量。闭包的捕获行为取决于变量的类型和闭包如何使用这些变量。

通过值捕获(By Value)

当闭包通过值捕获一个变量时,它获取了该变量的所有权。这意味着在闭包被创建之后,原始变量不能再被使用。

fn main() {
    let text = "Hello".to_string();
    // 使用 move 来显式地表示闭包将获取 text 的所有权
    let closure = move || println!("{}", text);
    // 这里 text 不能被使用,因为其所有权已经被闭包获取
    // println!("{}", text); // 这将导致编译错误
    closure(); // 打印 "Hello"
}

通过引用捕获(By Reference)

当闭包通过引用捕获一个变量时,它借用了该变量。这意味着原始变量仍然可用,但闭包只能借用它,不能获取所有权。

fn main() {
    let text = "Hello";
    // 闭包通过引用捕获 text
    let closure = || println!("{}", text);
    // text 仍然可用,因为它没有被移动
    println!("{}", text); // 打印 "Hello"
    closure(); // 再次打印 "Hello"
}

可变捕获(Mutable Capture)

闭包可以捕获一个可变引用,允许它修改原始变量的值。

fn main() {
    let mut count = 0;
    // 闭包通过可变引用捕获 count
    let mut closure = || {
        count += 1; // 修改 count 的值
        println!("Count: {}", count);
    };
    closure(); // 打印 "Count: 1"
    closure(); // 打印 "Count: 2"
    // count 的值现在是 2
    println!("Final count: {}", count);
}

如果移除闭包的 mut 修饰,编译不通过。

fn main() {
    let mut count = 0;
    // 闭包通过可变引用捕获 count
    let closure = || {
        count += 1; // 修改 count 的值
        println!("Count: {}", count);
    };
    closure(); // 打印 "Count: 1"
    closure(); // 打印 "Count: 2"
    // count 的值现在是 2
    println!("Final count: {}", count);
}

报错信息提示:由于借用了可变的 count,调用 closure 需要可变的绑定。

   Compiling playground v0.0.1 (/playground)
error[E0596]: cannot borrow `closure` as mutable, as it is not declared as mutable
 --> src/main.rs:4:9
  |
4 |     let closure = || {
  |         ^^^^^^^ not mutable
5 |         count += 1; // 修改 count 的值
  |         ----- calling `closure` requires mutable binding due to mutable borrow of `count`
...
8 |     closure(); // 打印 "Count: 1"
  |     ------- cannot borrow as mutable
9 |     closure(); // 打印 "Count: 2"
  |     ------- cannot borrow as mutable
  |
help: consider changing this to be mutable
  |
4 |     let mut closure = || {
  |         +++

For more information about this error, try `rustc --explain E0596`.
error: could not compile `playground` (bin "playground") due to 1 previous error

闭包作为参数

闭包可以作为参数传递给其他函数,捕获环境中的变量。

fn main() {
    // 创建一个整数变量
    let number = 10;

    // 创建一个闭包,它接受一个 i32 类型的参数并返回其平方
    // 这里使用 || 表示这是一个闭包
    let square = || number * number;

    // 定义一个函数,它接受一个闭包作为参数并调用它
    // 闭包作为参数需要指定其类型,这里使用 || -> i32 表示闭包没有参数并返回 i32 类型的值
    fn call_closure<F>(f: F)
    where
        F: Fn() -> i32, // 使用 trait bound 指定闭包的签名
    {
        // 调用闭包并打印结果
        let result = f();
        println!("The result is: {}", result);
    }

    // 调用 `call_closure` 函数,并将闭包 `square` 作为参数传递
    // 由于闭包 `square` 没有参数,我们可以直接传递
    call_closure(square);
}

在这个示例中:

  1. 我们定义了一个闭包 square,它通过引用捕获了 main 函数中的 number 变量,并计算其平方。
  2. 我们定义了一个名为 call_closure 的函数,它接受一个符合 Fn() -> i32 trait bound 的闭包作为参数,这意味着闭包没有参数并返回一个 i32 类型的值。
  3. 我们调用 call_closure 函数,并将 square 闭包作为参数传递。由于 square 闭包没有参数,它可以直接作为参数传递给 call_closure
  4. call_closure 函数调用闭包并打印出结果。

1.2 闭包类型推断和注解

函数与闭包还有更多区别。闭包并不总是要求像 fn 函数那样在参数和返回值上注明类型。函数中需要类型注解是因为它们是暴露给用户的显式接口的一部分。严格定义这些接口对保证所有人都对函数使用和返回值的类型理解一致是很重要的。与此相比,闭包并不用于这样暴露在外的接口:它们储存在变量中并被使用,不用命名它们或暴露给库的用户调用。

闭包通常很短,并只关联于小范围的上下文而非任意情境。在这些有限制的上下文中,编译器能可靠地推断参数和返回值的类型,类似于它是如何能够推断大部分变量的类型一样(同时也有编译器需要闭包类型注解的罕见情况)。

类似于变量,如果我们希望增加明确性和清晰度也可以添加类型标注,坏处是使代码变得更啰嗦(相对于严格必要的代码)。

fn main() {
    let a = 100;
    let add_one = |x: i32| -> i32 { x + 1 };
    let b = add_one(a);
    println!("{}", b); 
}

有了类型注解闭包的语法就更类似函数了。如下是一个对其参数加一的函数的定义与拥有相同行为闭包语法的纵向对比。这里增加了一些空格来对齐相应部分。这展示了除了使用竖线以及一些可选语法外,闭包语法与函数语法有多么地相似:

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  ;

所以上例可以简化为:

fn main() {
    let a = 100;
    let add_one = |x| x + 1;
    let b = add_one(a);
    println!("{}", b); 
}

编译器会为闭包定义中的每个参数和返回值推断一个具体类型。

再来看一个示例:

fn main() {
    let a = 100i32;
    let a1 = 100f32;
    let closure = |x| x;
    let b = closure(a);
    let b1 = closure(a1);
    println!("{}", b); 
    println!("{}", b1); 
}

注意这个闭包定义没有增加任何类型注解,所以我们可以用任意类型来调用这个闭包。但是如果尝试调用闭包两次,第一次使用 i32,第二次使用 f32 就会报错:预期的,因为之前使用“i32”类型的参数调用了闭包。

   Compiling playground v0.0.1 (/playground)
error[E0308]: mismatched types
 --> src/main.rs:6:22
  |
6 |     let b1 = closure(a1);
  |              ------- ^^ expected `i32`, found `f32`
  |              |
  |              arguments to this function are incorrect
  |
note: expected because the closure was earlier called with an argument of type `i32`
 --> src/main.rs:5:21
  |
5 |     let b = closure(a);
  |             ------- ^ expected because this argument is of type `i32`
  |             |
  |             in this closure call
note: closure parameter defined here
 --> src/main.rs:4:20
  |
4 |     let closure = |x| x;
  |                    ^

For more information about this error, try `rustc --explain E0308`.
error: could not compile `playground` (bin "playground") due to 1 previous error

1.3 将被捕获的值移出闭包和 Fn trait

一旦闭包捕获了定义它的环境中一个值的引用或者所有权(也就影响了什么会被移进闭包,如有),闭包体中的代码定义了稍后在闭包计算时对引用或值如何操作(也就影响了什么会被移出闭包,如有)。闭包体可以做以下任何事:将一个捕获的值移出闭包,修改捕获的值,既不移动也不修改值,或者一开始就不从环境中捕获值。

闭包捕获和处理环境中的值的方式影响闭包实现的 trait。Trait 是函数和结构体指定它们能用的闭包的类型的方式。取决于闭包体如何处理值,闭包自动、渐进地实现一个、两个或三个 Fn trait。

  1. FnOnce 适用于能被调用一次的闭包,所有闭包都至少实现了这个 trait,因为所有闭包都能被调用。一个会将捕获的值移出闭包体的闭包只实现 FnOnce trait,这是因为它只能被调用一次。
  2. FnMut 适用于不会将捕获的值移出闭包体的闭包,但它可能会修改被捕获的值。这类闭包可以被调用多次。
  3. Fn 适用于既不将被捕获的值移出闭包体也不修改被捕获的值的闭包,当然也包括不从环境中捕获值的闭包。这类闭包可以被调用多次而不改变它们的环境,这在会多次并发调用闭包的场景中十分重要。

下面是一个示例,展示如何在函数中使用 FnOnce 作为泛型约束,并且确保闭包只被调用一次:

fn call_once<F, T>(f: F) -> T
where
    F: FnOnce() -> T, // 约束 F 为 FnOnce trait,意味着它接受一个空参数并返回 T 类型
{
    f() // 调用闭包并返回结果
}

fn main() {
    // 创建一个闭包,它捕获了 `value` 的所有权
    let value = 42;
    let consume = move || {
        let result = value; // 移动 `value`
        println!("The value is: {}", result);
        result // 返回结果
    };

    // 调用 `call_once` 函数,传入闭包
    let result = call_once(consume);
    println!("Result of the closure: {}", result);

    // 尝试再次使用 `consume` 将会导致编译错误,因为它已经消耗了 `value`
    // call_once(consume);
}

运行结果

The value is: 42
Result of the closure: 42

在这个示例中,我们定义了一个泛型函数 call_once,它接受一个类型为 F 的参数 f,其中 F 必须实现 FnOnce trait。这意味着 f 是一个闭包,它接受空参数并返回类型 T 的结果。

main 函数中,我们创建了一个闭包 consume,它捕获了 value 的所有权。然后,我们调用 call_once 函数,传入 consume 闭包。call_once 函数调用闭包并返回其结果。由于 consume 闭包已经消耗了 value,尝试再次调用 call_once 传入相同的闭包将会导致编译错误。

下面是一个使用 FnMut trait 的示例。

fn apply_mut<F, T>(func: &mut F, num: i32) -> T
where
    F: FnMut(i32) -> T, // F 是一个可变闭包,接受一个 i32 类型的参数并返回类型为 T 的结果
{
    func(num) // 调用闭包并返回结果
}

fn main() {
    let mut count = 0;

    // 创建一个闭包,它接受一个 i32 类型的参数并将其加到 count 上
    let mut increment = |num: i32| -> i32 {
        count += num;
        count
    };

    // 使用 apply_mut 函数和 increment 闭包的引用
    let result: i32 = apply_mut(&mut increment, 5);
    println!("Result after applying increment: {}", result);

    // 再次使用 apply_mut 函数和 increment 闭包的引用
    let result: i32 = apply_mut(&mut increment, 10);
    println!("Result after applying increment again: {}", result);
}

运行结果

Result after applying increment: 5
Result after applying increment again: 15

在这个例子中,apply_mut 函数接受一个 F 类型的可变引用 &mut F 作为参数(这样,increment 闭包就不会被移动,可以被多次使用)和一个 i32 类型的参数 numwhere 子句指定了 F 必须是一个实现了 FnMut 的闭包,它接受一个 i32 类型的参数并返回一个类型为 T 的结果。main 函数中创建了一个可变闭包 increment,它修改了一个捕获的变量 count,然后使用 apply_mut 函数来调用这个闭包。每次调用 apply_mut 都会使用 increment 闭包,并且 increment 闭包都会修改 count 的值。

在 Rust 中,Fn trait 表示闭包不会获取它捕获变量的所有权,也不会改变这些变量。下面的例子展示了如何定义一个接受 Fn 闭包作为参数的函数,并在并发场景中使用它。

use std::thread;

// 定义一个函数,它接受一个实现了 Fn(i32) -> i32 的闭包,并调用它
fn call_once<F, T>(func: F) -> T
where
    F: Fn(i32) -> T, // 指定 F 是一个接受 i32 并返回 T 的 Fn 闭包
{
    let result = func(42); // 调用闭包,传入一个 i32 类型的值
    result // 返回闭包的执行结果
}

fn main() {
    // 定义一个简单的 Fn 闭包,它接受一个 i32 类型的参数并返回两倍的该值
    let double = |x: i32| -> i32 {
        x * 2
    };

    // 创建多个线程,每个线程都使用相同的闭包
    let handles: Vec<_> = (0..5).map(|i| {
        let func = double; // 闭包可以被复制,因为它是 Fn 类型的
        thread::spawn(move || {
            let result = call_once(func); // 调用 call_once 函数,并传入闭包
            println!("Thread {} result: {}", i, result); // 打印结果
        })
    }).collect();

    // 等待所有线程完成
    for handle in handles {
        handle.join().unwrap();
    }
}

不是每次运行都是这个顺序,具体哪个线程先运行,要看系统的本次调度。

运行结果

Thread 1 result: 84
Thread 2 result: 84
Thread 0 result: 84
Thread 4 result: 84
Thread 3 result: 84

在这个例子中:

  1. 我们定义了一个 call_once 函数,它接受一个泛型参数 F,并且 F 必须满足 Fn(i32) -> T 的 trait bounds。这意味着 F 是一个闭包,它接受一个 i32 类型的参数并返回一个类型为 T 的结果。
  2. main 函数中,我们定义了一个简单的闭包 double,它接受一个 i32 类型的参数 x 并返回 x * 2 的结果。
  3. 我们使用 map 创建了 5 个线程,每个线程都复制了 double 闭包,并在新线程中调用 call_once 函数,将闭包作为参数传递给 call_once
  4. 在每个线程中,call_once 函数被调用,执行闭包,并打印出结果。
  5. 最后,我们使用 join 方法等待所有线程完成。

二、迭代器

迭代器允许你对一个序列的项进行某些处理。接下来主要介绍使用迭代器处理元素序列和循环 VS 迭代器性能比较。在 Rust 中,迭代器是惰性的(lazy),这意味着在调用消费迭代器的方法之前不会执行任何操作。

2.1 Iterator trait 和 next 方法

迭代器都实现了一个叫做 Iterator 的定义于标准库的 trait。这个 trait 的定义看起来像这样:

pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;
    // 此处省略了方法的默认实现
}

type ItemSelf::Item,它们定义了 trait 的关联类型(associated type)。不过现在只需知道这段代码表明实现 Iterator trait 要求同时定义一个 Item 类型,这个 Item 类型被用作 next 方法的返回值类型。换句话说,Item 类型将是迭代器返回元素的类型。

nextIterator 实现者被要求定义的唯一方法。next 一次返回迭代器中的一个项,封装在 Some 中,当迭代器结束时,它返回 None

2.2 消费迭代器的方法

Iterator trait 有一系列不同的由标准库提供默认实现的方法;你可以在 Iterator trait 的标准库 API 文档中找到所有这些方法。一些方法在其定义中调用了 next 方法,这也就是为什么在实现 Iterator trait 时要求实现 next 方法的原因。

这些调用 next 方法的方法被称为消费适配器(consuming adaptors),因为调用它们会消耗迭代器。

fn main() {
    let numbers = vec![1, 2, 3, 4, 5];
    // 使用into_iter()将Vec转换为消费迭代器
    let numbers_iter = numbers.iter();
    let mut sum: i32 = numbers_iter
        // 使用sum适配器计算迭代器中所有元素的总和
        .sum();

    // 打印总和
    println!("The sum is: {}", sum);
}

如果再去使用 numbers_iter 就会报错。 sum 方法获取迭代器的所有权并反复调用 next 来遍历迭代器,因而会消费迭代器。当其遍历每一个项时,它将每一个项加总到一个总和并在迭代完成时返回总和。调用 sum 之后不再允许使用 numbers_iter,因为调用 sum 时它会获取迭代器的所有权。

2.3 产生其他迭代器的方法

Iterator trait 中定义了另一类方法,被称为迭代器适配器(iterator adaptors),它们允许我们将当前迭代器变为不同类型的迭代器。可以链式调用多个迭代器适配器。不过因为所有的迭代器都是惰性的,必须调用一个消费适配器方法以便获取迭代器适配器调用的结果。

fn main() {
    let v1: Vec<i32> = vec![1, 2, 3];
    let v2: Vec<_> = v1.iter().map(|x| x * x).collect();
    assert_eq!(v2, vec![1, 4, 9]);
}

collect 方法消费迭代器并将结果收集到一个数据结构中。因为 map 获取一个闭包,可以指定任何希望在遍历的每个元素上执行的操作。

2.4 使用捕获其环境的闭包

很多迭代器适配器接受闭包作为参数,而通常指定为迭代器适配器参数的闭包会是捕获其环境的闭包。

fn main() {
    let numbers = vec![1, 2, 3, 4, 5];

    // 使用into_iter()将Vec转换为消费迭代器
    let filtered_and_squared: Vec<i32> = numbers.into_iter()
        // 使用filter适配器过滤元素,接受一个闭包作为参数
        // 闭包捕获其环境,这里指numbers的元素
        .filter(|&x| x % 2 == 0) // 保留偶数
        // 使用map适配器对过滤后的元素进行变换,也接受一个闭包
        .map(|x| x * x) // 对每个元素进行平方
        // 使用collect适配器将结果收集到一个新的Vec中
        .collect();

    // 打印过滤和平方后的结果
    println!("The filtered and squared numbers are: {:?}", filtered_and_squared);
}

运行结果

The filtered and squared numbers are: [4, 16]

在这个例子中,filtermap 都是迭代器适配器,它们接受闭包作为参数。这些闭包可以捕获其环境,即迭代器中的元素。在 filter 适配器中,闭包 |&x| x % 2 == 0 用于检查元素是否为偶数,如果是,则元素会被保留。在 map 适配器中,闭包 |x| x * x 用于对每个元素进行平方操作。由于这些适配器都是消费性的,它们会消耗原始的迭代器,因此不能再次使用。最后,collect 适配器将处理后的元素收集到一个新的 Vec 中。

2.5 循环 VS 迭代器性能比较

迭代器,作为一个高级的抽象,被编译成了与手写的底层代码大体一致性能的代码。迭代器是 Rust 的零成本抽象(zero-cost abstractions)之一,它意味着抽象并不会引入运行时开销。

fn main() {
    let numbers1 = (0..1000000).collect::<Vec<i64>>();
    let numbers2 = (0..1000000).collect::<Vec<i64>>();

    let mut sum1 = 0i64;
    let mut sum2 = 0i64;

    // 测量for循环的性能
    let start = std::time::Instant::now();
    for val in numbers1 {
        sum1 += val;
    }
    let loop_duration = start.elapsed();

    // 测量迭代器的性能
    let start = std::time::Instant::now();
    for val in numbers2.iter() {
        sum2 += val;
    }
    let iterator_duration = start.elapsed();

    println!("Iterator took: {:?}", iterator_duration);
    println!("For loop took: {:?}", loop_duration);
}

运行结果

Iterator took: 12.796012ms
For loop took: 11.559512ms

Iterator took: 12.817732ms
For loop took: 11.687655ms

Iterator took: 12.75484ms
For loop took: 11.89468ms

Iterator took: 12.812022ms
For loop took: 11.785106ms

Iterator took: 12.78293ms
For loop took: 11.528941ms

从这个例子可以看出迭代器还是稍微慢一些。

参考链接

  1. Rust 官方网站:https://www.rust-lang.org/zh-CN
  2. Rust 官方文档:https://doc.rust-lang.org/
  3. Rust Play:https://play.rust-lang.org/
  4. 《Rust 程序设计语言》

相关推荐

  1. C#语言进阶(四) 枚举

    2024-07-10 20:22:01       32 阅读

最近更新

  1. docker php8.1+nginx base 镜像 dockerfile 配置

    2024-07-10 20:22:01       66 阅读
  2. Could not load dynamic library ‘cudart64_100.dll‘

    2024-07-10 20:22:01       70 阅读
  3. 在Django里面运行非项目文件

    2024-07-10 20:22:01       57 阅读
  4. Python语言-面向对象

    2024-07-10 20:22:01       68 阅读

热门阅读

  1. OpenHarmony移植小型系统exynos4412(一)

    2024-07-10 20:22:01       24 阅读
  2. 适合selenium的防自动化检测的方法

    2024-07-10 20:22:01       25 阅读
  3. 使用Spring Boot和HBase实现大数据存储

    2024-07-10 20:22:01       21 阅读
  4. 华为机考真题 -- 篮球游戏

    2024-07-10 20:22:01       23 阅读
  5. Docker 入门篇(十 一)-- 网络配置总结

    2024-07-10 20:22:01       25 阅读
  6. 设计模式——原型模式

    2024-07-10 20:22:01       23 阅读