You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
rust_basic_code/md_file/13 函数式语言特性迭代器和闭包.md

23 KiB

函数式语言特性:迭代器和闭包

Rust的设计灵感来自许多现有的语言和技术,其中一个重要的影响是函数式编程。函数式编程风格通常包括将函数作为值使用,方法包括在参数中传递它们、从其他函数返回它们、将它们分配给变量以供以后执行等等。

rust的主要特征与函数式的特性相似:

  • 闭包(Closures):一种类似于函数的构造,可以存储在变量中。
  • 迭代器(Iterators):一种处理一系列元素的方法。
  • 如何使用闭包和迭代器来改进第 12 章中的 I/O 项目。
  • 闭包和迭代器的性能(剧透:它们比你想象的要快!)。

其他的 Rust 特性,例如模式匹配和枚举类型,它们也受到了函数式编程风格的影响。闭包和迭代器是编写 Rust 代码的惯用方式,也是编写高效 Rust 代码的重要组成部分。

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

Rust的闭包是匿名函数,可以保存在变量中,也可以作为参数传递给其他函数。可以在一个地方创建闭包,然后再其他地方调用闭包,以便在不同上下文中计算。不同与函数,闭包从定义它们的作用域捕获值。

使用闭包捕获环境

如何使用闭包从定义它们的环境中捕获值,以供以后使用。

下面代码主要实现一个场景:t恤公司每隔一段时间就会向我们邮件列表上的某个人赠送一件独家限量版t恤作为促销活动。邮件列表中的用户可以选择将自己喜欢的颜色添加到个人资料中。如果选择免费衬衫的人有他们最喜欢的颜色,他们就会得到那件颜色的衬衫。如果这个人没有指定最喜欢的颜色,他们就会得到公司目前最常用的颜色。

#[derive(Debug, PartialEq, Copy, Clone)]
enum ShirtColor{
    Red,
    Blue,
}

struct Inventory{
    shirts: Vec<ShirtColor>,
}

impl Inventory{
    fn giveaway(&self, user_preference: Option<ShirtColor>) -> ShirtColor {
        user_preference.unwrap_or_else(|| self.most_stocked())
    }

    fn most_stocked(&self) -> ShirtColor {
        let mut num_red = 0;
        let mut num_blue = 0;

        for color in &self.shirts {
            match color {
                ShirtColor::Red => num_red += 1,
                ShirtColor::Blue => num_blue += 1,
            }
        }
        if num_red > num_blue {
            ShirtColor::Red
        }else {
            ShirtColor::Blue
        }
    }
}

fn main() {
    let store = Inventory{
        shirts: vec![ShirtColor::Blue, ShirtColor::Red, ShirtColor::Blue],
    };
    let user_pref1 = Some(ShirtColor::Red);
    let giveaway1 = store.giveaway(user_pref1);
    println!(
        "The user with preference {:?} get {:?}",
        user_pref1, giveaway1
    );

    let user_pref2 = None;
    let giveaway2 = store.giveaway(user_pref2);
    println!(
        "The user with preference {:?} get {:?}",
        user_pref2, giveaway2
    );
}

运行结果:

image-20230418093942714

闭包代码:

user_preference.unwrap_or_else(|| self.most_stocked()) 

Option类型的unwrap_or_else方法是由标准库定义的。它接受一个参数:一个没有参数并返回T类型值(在本例中是ShirtColor,与Option中Some变体中存储的类型相同)的闭包。如果Option是Some变体,则unwrap_or_else返回Some中的值。如果Option是None变体,则unwrap_or_else调用闭包并返回闭包返回的值。

我们将闭包表达式|| self.most_stocked()指定为unwrap_or_else的参数。这是一个不带参数的闭包(如果闭包有参数,则它们将出现在两个竖杠之间)。闭包的主体调用self.most_stocked()。我们在这里定义了闭包,如果需要结果,unwrap_or_else的实现将稍后评估闭包。

这里我们传递了一个闭包,该闭包在当前Inventory实例上调用self.most_stocked()方法。标准库不需要了解我们定义的Inventory或ShirtColor类型,或我们想在此情况下使用的逻辑。闭包捕获了对self Inventory实例的不可变引用,并将其与我们指定的代码一起传递给unwrap_or_else方法。另一方面,函数不能以这种方式捕获其环境。

闭包类型推断和声明

函数和闭包之间有很多的差异。闭包通常不需要像函数那样声明参数或返回值的类型。函数需要类型声明是因为类型是暴露给用户的显式接口的一部分。闭包不像这样在公开的接口中使用:它们存储在变量中,而且在不将它们命名并公开给我们库的用户的情况下使用。

闭包通常很短,并且仅在狭窄的上下文中相关,而不是在任意的场景中。在这些有限的上下文中,编译器可以推断参数的类型返回类型,类似于它能够推断大多数变量的类型(在极少数情况下,编译器也需要闭包类型注释)。

与变量一样,如果我们想要增加明确性和清晰度,我们可以添加类型声明,代价是比严格必要的更冗长。下面定义了一个对闭包声明类型并将其存储在一个变量中,不同与上面将闭包作为参数。

image-20230418111424743

添加类型注释后,闭包语法与函数语法更相似。下面顶一个给形参加一的函数和与之相同行为的闭包代码:

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;							     // 只有一个表达式,故可以删除括号

image-20230418141236317

对于闭包定义,编译器将为它们的每个参数和返回值推断一个具体类型。下面显示了一个短闭包的定义,它只返回作为参数接收到的值。仅用来表示rust推断具体类型的过程。

image-20230418145539072

切换二者顺序后:

image-20230418145644369

捕获引用或移动所有权

闭包可以通过三种方式从环境中捕获值,这直接映射到函数获取参数的三种方式:不可变地借用可变地借用获得所有权。闭包将根据函数体对捕获的值所做的事情来决定使用其中的哪一个。

多个不可变借用:

image-20230418153843851

可变地借用:

image-20230418160635363

获得所有权:如果希望强制闭包获得它在环境中使用的值的所有权,可以在参数列表之前使用 move 关键字。当将闭包传递给新线程以移动数据以使其属于新线程时,这种技术非常有用。

image-20230418162956631

若闭包主题只是打印列表,其实只需要一个不可变引用即可,但是因为生成了一个新线程,新线程可能在主线程的其他线程完成之前完成,或者主线程可能先完成。如果主线程保持列表的所有权,但在新线程完成之前结束并删除列表,则线程中的不可变引用将无效。所以需要在闭包定义的开头位置防止move关键字。主要就是为了防止多线程下引用失效也即新线程生命周期更长的情况。 故而move关键字不可缺少,同时因为转移了所有权在主线程也无法在使用list。

将捕获的值移出闭包和 Fn 特征

将捕获的引用或所有权从闭包定义的环境中捕获(从而影响是否移动到闭包中的任何内容),当闭包稍后被评估时,闭包体中的代码定义了引用或值的发生情况(从而影响是否移出闭包中的任何内容)。闭包体可以执行以下任何操作:将捕获的值从闭包中移出改变捕获的值既不移动也不改变值,或者从环境中未捕获任何值开始。

闭包捕获和处理环境中的值的方式影响闭包实现的特性,而特性是函数和结构体可以指定可以使用哪些类型的闭包的方式。闭包将以增加的方式自动实现以下这三种Fn特性中的一种、两种或全部,具体取决于闭包的体如何处理这些值:

  • Fn 特质描述了一个闭包可以通过引用捕获外部变量的能力,这种捕获方式被称为“不可变借用捕获”,意味着闭包可以读取外部变量但不能修改它们。Fn 特质的闭包可以在其环境不可变时被调用,例如一个只读数据结构。
  • FnMut 特质描述了一个闭包可以通过可变借用捕获外部变量的能力,这意味着闭包可以读取和修改它们。FnMut 特质的闭包可以在其环境可变时被调用,例如一个可变数据结构。
  • FnOnce 特质描述了一个闭包可以通过移动语义(move semantics)将所有捕获的外部变量拥有并取走的能力,这意味着闭包可以获取它捕获的变量的所有权并对它们进行任意操作。FnOnce 特质的闭包只能被调用一次,之后它将拥有并占用它捕获的变量,不能再被调用。
impl<T> Option<T> {
    pub fn unwrap_or_else<F>(self, f: F) -> T
    where
        F: FnOnce() -> T
    {
        match self {
            Some(x) => x,
            None => f(),
        }
    }
}

T是泛型类型,表示Option的Some变体中的值的类型。T类型也是unwrap_or_else函数的返回类型:例如,在Option上调用unwrap_or_else函数的代码将得到一个String。

unwrap_or_else函数具有附加的泛型类型参数F。F类型是名为F的参数的类型,F是我们在调用unwrap_or_else时提供的闭包。

泛型类型F上指定的trait边界是FnOnce() -> T,这意味着F必须能够被调用一次,不接受参数,并返回T。在trait边界中使用FnOnce表示unwrap_or_else最多只调用F一次的约束。在unwrap_or_else函数体中,我们可以看到如果Option为Some,则不会调用f。如果Option为None, f将被调用一次。因为所有闭包都实现了FnOnce,所以unwrap_or_else可以接受大多数不同类型的闭包,并且尽可能地灵活。

闭包(closure)和函数(function)之间的区别

闭包是一个匿名函数,函数也被视为一种闭包,它们可以被转换为一个实现了 Fn、FnMut 或 FnOnce 的闭包对象(不需要配置任何东西,Rust 的标准库已经包含了 Fn、FnMut 和 FnOnce 特质的默认实现。只要你的函数签名符合这些特质的要求,你的函数就可以自动实现这些特质。)。这意味着我们可以使用函数来代替闭包,只要函数的签名与所需的 trait 相匹配即可。Rust 中的闭包和函数都是可以执行代码的机制,但它们在一些方面有不同的限制和特性,需要根据具体的情境选择合适的实现方式

例如,在一个 Option<Vec> 值上,我们可以调用 unwrap_or_else(Vec::new) 来获取一个新的空向量,如果该值为 None 的话。

image-20230419102100633

使用闭包实现:

image-20230420090020479

当需要修改变量内容时,闭包和函数也有各自的实现方式:

image-20230420091637609

sort_by_key FnMut特征

sort_by_key 实现了 FnMut 特征是因为它会多次调用闭包,每次调用闭包时都会修改元素的顺序,因此需要能够修改闭包环境中的变量。具体来说,闭包会在排序过程中根据键值来比较元素的顺序,而这个键值可能是通过对闭包环境中的变量进行一系列操作计算出来的,因此需要能够对闭包环境中的变量进行修改。

image-20230420092436219

计算闭包执行次数:

image-20230420100325150

迭代器

在 Rust 中,我们可以使用迭代器(iterator)处理集合中的每个元素。迭代器是一个实现了 Iterator trait 的对象,该 trait 提供了一些处理集合的方法,比如 map 和 filter。 Iterator trait 有一个 next 方法,该方法将迭代器的当前元素移动到下一个元素,返回一个 Option 类型,其中 Some(T) 表示迭代器已经找到了下一个元素并将其返回,而 None 表示迭代器已经结束,没有更多元素可供迭代。迭代器是一种“惰性”的数据结构。

image-20230420111555472

Iterator trait 和 next()

Iterator trait 在标准库中。具体代码如下:

pub trait Iterator {
    type Item;

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

    // methods with default implementations elided
}

这个定义使用了一些新的语法:type Item 和 Self::Item,这些定义了一个带有关联类型的特征。故实现 Iterator 特征需要定义一个 Item 类型,而这个 Item 类型被用在 next 方法的返回类型中。换句话说,Item 类型将是迭代器返回的类型。

Iterator 特征只要求实现者定义一个方法:next 方法,它一次返回迭代器的一个元素,包装在 Some 中,当迭代结束时,返回 None。

image-20230420115216269

请注意,我们需要将v1_iter设为可变的:在迭代器上调用next方法会更改迭代器内部状态,用于追踪它在序列中的位置。换句话说,这段代码会消耗掉(使用掉)迭代器,每次调用next都会消耗掉一个迭代器项。当我们使用for循环时,我们不需要将v1_iter设置为可变的,因为循环在幕后获取了v1_iter的所有权并使其可变

另请注意,从对next的调用中获得的值是对向量中的不可变引用iter方法生成一个不可变引用的迭代器。如果我们想要创建一个迭代器,它获取v1的所有权并返回所拥有的值,则可以调用into_iter而不是iter。同样,如果我们想要迭代可变引用,我们可以调用iter_mut而不是iter。

fn main() {
	let mut v1 = vec![1, 2, 3];
    // 创建一个可变引用迭代器
    let mut v1_iter = v1.iter_mut();  
    for val in v1_iter{
        *val += 1;
    }
    println!("v1: {:?}", v1);    // v1: [2, 3, 4]

    let v1 = vec![1, 2, 3];  // shadow
    // 获取v1的所有权的迭代器
    let v1_iter = v1.into_iter();
    for val in v1_iter{
        println!("Got: {}", val);
    }
    // println!("v1: {:?}", v1);  // borrow of moved value: `v1`
}


使用迭代器的方法

Iterator特性有许多不同的方法,由标准库提供默认实现。其中一些方法在其定义中调用next方法,这就是为什么在实现Iterator特性时需要实现next方法的原因。调用next的方法称为消费适配器,因为调用它们会耗尽迭代器。

#[test]
fn iterator_sum() {
    let v1 = vec![1, 2, 3];
    let v1_iter = v1.iter();
    let total: i32 = v1_iter.sum();
    assert_eq!(total, 6);
    // 在调用sum之后不允许使用v1_iter,因为sum将获得调用它的迭代器的所有权。
    // assert_eq!(v1_iter.next(), None);  
}

生成迭代器的方法

迭代器适配器是定义在Iterator trait上的不消耗迭代器的方法。相反,它们通过改变原始迭代器的某些方面来生成不同的迭代器。

map方法返回一个新的迭代器,用于生成修改后的项。这里的闭包创建了一个新的迭代器,其中vector中的每一项都加1:

image-20230420151735714

为了修复这个警告并消费迭代器,我们将使用collect方法,此方法使用迭代器并将结果值收集到集合数据类型中

image-20230420153353776

Vec<_> 中的下划线是一个占位符表示编译器应该根据上下文自动推断出向量中元素的类型。这种写法被称为类型占位符。因为已知类型 v1 向量的元素类型是 i32,故 v2 向量中的元素类型也是 i32可以将Vec<_> 写成Vec<i32> 但如果我们将 Vec<_> 替换为 Vec<i64>,则会导致类型错误,因为 i32 类型和 i64 类型不匹配。

使用闭包捕获它们的环境

许多迭代器适配器将闭包作为参数,通常我们将指定作为迭代器适配器参数的闭包捕获其环境。在这个例子中,我们将使用filter方法,它接受一个闭包作为参数。这个闭包从迭代器获取一个元素,并返回一个布尔值。如果闭包返回true,则该值将包含在由filter生成的迭代中。如果闭包返回false,则该值不会被包含。

在代码中,将使用一个闭包来捕获shoe_size变量所在的环境,并用它来遍历一组Shoe结构的实例。这个闭包将只返回指定尺码的鞋子。

image-20230420155944576

在函数体内部,我们首先使用into_iter方法将传入的向量转换成一个迭代器。然后,我们使用filter方法对这个迭代器进行适配,只保留鞋码与函数参数shoe_size相同的元素。这里的闭包捕获了shoe_size参数,并将其与每个鞋子的鞋码进行比较,以保留符合要求的鞋子。最后,我们调用collect方法将所有符合要求的元素收集起来,生成一个新的向量作为函数的返回值。

改进 I/O 项目

使用迭代器如何改进项目中Config::build函数和search函数的实现。

使用iterator替代clone

在12章中,我们添加了获取String值切片的代码,并通过索引切片和克隆值来创建Config结构体的实例,从而允许Config结构体拥有这些值。

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}
impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str>{
        if args.len() < 3{
            return Err("not enough arguments");
        }
        let query = args[1].clone();
        let file_path = args[2].clone();
        let ignore_case = env::var("IGNORE_CASE").is_ok();
        Ok(Config {query, file_path, ignore_case})
    }
}

因为参数args类型为String数组的slice,所有权不在build()中。为了使Config实例获得所有权,对值调用clone方法来获得数据的完整副本。

修改构建函数,使其接受迭代器的所有权作为参数,而不是借用slice。我们将使用迭代器功能,而不是检查切片长度和索引到特定位置的代码。这将澄清Config::build函数在做什么,因为迭代器将访问这些值。一旦Config::build获得了迭代器的所有权并停止使用借位的索引操作,我们就可以将String值从迭代器移到Config中,而不是调用clone并进行新的分配。

Using the Returned Iterator Directly

首先修改main.rs中的文件

image-20230420172223457

修改lib.rs代码:

image-20230421092219406

env::args函数返回的迭代器类型是std::env::Args,它实现了Iterator trait并返回String值。Config::build函数的参数args的类型已经修改为impl Iterator<Item = String>,也就是实现了Iterator trait并返回String值的类型。这个修改使得参数args可以接收任何实现了Iterator trait并返回String值的类型作为输入,而不仅仅是原来的&[String]类型。

pub fn args() -> Args 
//其中Args是一个结构体,实现了Iterator trait,并返回String类型的值。

使用迭代器适配器使代码更清晰

在I/O项目的搜索函数中利用迭代器

pub 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
}

使用迭代器适配器方法来简化代码,并避免使用可变状态。函数式编程风格倾向于最小化可变状态的使用,以使代码更加清晰易懂。移除可变状态可以为将来的增强功能(如并行搜索)铺平道路,因为我们不必管理对results向量的并发访问。在多线程或并发编程中,多个线程或进程同时访问共享的数据结构或变量时,可能会出现数据不一致或竞态条件等问题。

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

迭代器与循环

循环(Loops)和迭代器(Iterators)是两种不同的控制结构,它们都用于对一个集合类型的元素进行遍历和处理,但是有一些区别。

循环是一种基本的控制结构,用于在程序中执行重复的操作,例如 for 循环和 while 循环等。迭代器是一种更加高级的控制结构,它提供了一种链式调用的方式来处理集合类型的元素。循环和迭代器的实现方式和使用场景有所不同,应该根据具体的需求选择更加合适的控制结构来处理集合类型的元素。在 Rust 中,许多 Rust 程序员更喜欢使用迭代器,因为它们可以更好地抽象出遍历和处理的高级目标,使得代码更加易于理解和维护。

性能比较:循环与迭代器

比较循环与迭代器的性能通常可以通过运行基准测试来进行。

闭包和迭代器是受到函数式编程语言思想启发的Rust特性。它们有助于Rust以低级性能清晰地表达高级思想的能力。闭包和迭代器的实现不会影响运行时性能。这是 Rust 努力提供零成本抽象的目标之一。