改进我们的 I/O 项目

Beginner

This tutorial is from open-source community. Access the source code

简介

欢迎来到「改进我们的 I/O 项目」实验。本实验是 《Rust 程序设计语言》 的一部分。你可以在 LabEx 中练习 Rust 技能。

在本实验中,我们将探索如何使用迭代器来改进第 12 章 I/O 项目中 Config::build 函数和 search 函数的实现。

改进我们的 I/O 项目

有了关于迭代器的这些新知识,我们可以通过使用迭代器来让第 12 章中的 I/O 项目代码中的某些地方更清晰、更简洁。让我们来看看迭代器如何改进我们对 Config::build 函数和 search 函数的实现。

使用迭代器消除克隆操作

在清单 12-6 中,我们添加了一些代码,这些代码获取了一个 String 值的切片,并通过对切片进行索引和克隆这些值来创建 Config 结构体的实例,从而使 Config 结构体拥有这些值。在清单 13-17 中,我们重现了清单 12-23 中 Config::build 函数的实现。

文件名:src/lib.rs

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,
        })
    }
}

清单 13-17:重现清单 12-23 中的 Config::build 函数

当时,我们说过不用担心效率低下的 clone 调用,因为我们会在将来移除它们。嗯,现在就是那个时候了!

我们在这里需要 clone,是因为在参数 args 中有一个包含 String 元素的切片,但 build 函数并不拥有 args。为了返回 Config 实例的所有权,我们必须从 Configqueryfilename 字段克隆值,这样 Config 实例才能拥有它自己的值。

有了关于迭代器的新知识,我们可以将 build 函数修改为接受一个迭代器的所有权作为参数,而不是借用一个切片。我们将使用迭代器功能来替代检查切片长度并索引到特定位置的代码。这将使 Config::build 函数的功能更加清晰,因为迭代器会访问这些值。

一旦 Config::build 获得了迭代器的所有权并停止使用借用的索引操作,我们就可以将迭代器中的 String 值移动到 Config 中,而不是调用 clone 并进行新的分配。

直接使用返回的迭代器

打开你 I/O 项目的 src/main.rs 文件,它应该看起来像这样:

文件名:src/main.rs

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    --snip--
}

我们首先将清单 12-24 中的 main 函数开头部分改为清单 13-18 中的代码,这次使用了一个迭代器。在我们更新 Config::build 之前,这段代码无法编译。

文件名:src/main.rs

fn main() {
    let config =
        Config::build(env::args()).unwrap_or_else(|err| {
            eprintln!("Problem parsing arguments: {err}");
            process::exit(1);
        });

    --snip--
}

清单 13-18:将 env::args 的返回值传递给 Config::build

env::args 函数返回一个迭代器!现在我们不再将迭代器的值收集到一个向量中,然后将一个切片传递给 Config::build,而是直接将 env::args 返回的迭代器的所有权传递给 Config::build

接下来,我们需要更新 Config::build 的定义。在你的 I/O 项目的 src/lib.rs 文件中,让我们将 Config::build 的签名改为清单 13-19 中的样子。这仍然无法编译,因为我们需要更新函数体。

文件名:src/lib.rs

impl Config {
    pub fn build(
        mut args: impl Iterator<Item = String>,
    ) -> Result<Config, &'static str> {
        --snip--

清单 13-19:更新 Config::build 的签名以接受一个迭代器

env::args 函数的标准库文档显示,它返回的迭代器类型是 std::env::Args,并且该类型实现了 Iterator 特性并返回 String 值。

我们已经更新了 Config::build 函数的签名,这样参数 args 就有了一个通用类型,其特性边界为 impl Iterator<Item = String>,而不是 &[String]。我们在“作为参数的特性”中讨论过的 impl Trait 语法的这种用法意味着 args 可以是任何实现了 Iterator 类型并返回 String 项的类型。

因为我们获取了 args 的所有权,并且我们将通过迭代它来修改 args,所以我们可以在 args 参数的规范中添加 mut 关键字,使其可变。

使用迭代器特性方法而非索引

接下来,我们将修复 Config::build 的函数体。因为 args 实现了 Iterator 特性,所以我们知道可以对它调用 next 方法!清单 13-20 更新了清单 12-23 中的代码以使用 next 方法。

文件名:src/lib.rs

impl Config {
    pub fn build(
        mut args: impl Iterator<Item = String>,
    ) -> Result<Config, &'static str> {
        args.next();

        let query = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a query string"),
        };

        let file_path = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a file path"),
        };

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

清单 13-20:更改 Config::build 的函数体以使用迭代器方法

记住,env::args 返回值中的第一个值是程序的名称。我们想要忽略它并获取下一个值,所以首先我们调用 next 并且不处理返回值。然后我们调用 next 来获取我们想要放入 Configquery 字段中的值。如果 next 返回 Some,我们使用 match 来提取值。如果它返回 None,这意味着没有提供足够的参数,我们就提前返回一个 Err 值。对于 filename 值,我们做同样的事情。

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

我们还可以在 I/O 项目的 search 函数中利用迭代器,清单 13-21 重现了清单 12-19 中的 search 函数。

文件名:src/lib.rs

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
}

清单 13-21:清单 12-19 中 search 函数的实现

我们可以使用迭代器适配器方法以更简洁的方式编写这段代码。这样做还能让我们避免使用可变的中间 results 向量。函数式编程风格倾向于尽量减少可变状态的数量,以使代码更清晰。移除可变状态可能会为未来的增强功能提供便利,比如实现并行搜索,因为我们无需管理对 results 向量的并发访问。清单 13-22 展示了这一变化。

文件名:src/lib.rs

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

清单 13-22:在 search 函数的实现中使用迭代器适配器方法

回想一下,search 函数的目的是返回 contents 中所有包含 query 的行。与清单 13-16 中的 filter 示例类似,这段代码使用 filter 适配器只保留那些 line.contains(query) 返回 true 的行。然后我们使用 collect 将匹配的行收集到另一个向量中。简单多了!你也可以对 search_case_insensitive 函数做同样的更改,以使用迭代器方法。

在循环和迭代器之间做出选择

接下来合乎逻辑的问题是,在你自己的代码中应该选择哪种风格以及原因:是清单 13-21 中的原始实现,还是清单 13-22 中使用迭代器的版本。大多数 Rust 程序员更喜欢使用迭代器风格。一开始可能有点难掌握,但一旦你熟悉了各种迭代器适配器及其功能,迭代器就会更容易理解。代码不再纠结于循环的各个部分以及构建新向量,而是专注于循环的高层次目标。这抽象掉了一些常见代码,因此更容易看出这段代码特有的概念,比如迭代器中的每个元素必须通过的过滤条件。

但是这两种实现真的等效吗?直观的假设可能是底层循环会更快。让我们来谈谈性能。

总结

恭喜你!你已经完成了“改进我们的 I/O 项目”实验。你可以在 LabEx 中练习更多实验来提升你的技能。