改进 I/O 项目

ch13-03-improving-our-io-project.md
commit 0608e2d0743951d8e628b6e130c6b5744775a783

在我们上一章实现的grep I/O 项目中,其中有一些地方的代码可以使用迭代器来变得更清楚简洁一些。让我们看看迭代器如何能够改进Config::new函数和search函数的实现。

使用迭代器并去掉clone

回到列表 12-8 中,这些代码获取一个String slice 并创建一个Config结构体的实例,它检查参数的数量、索引 slice 中的值、并克隆这些值以便Config可以拥有他们的所有权:

impl Config {
    fn new(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let filename = args[2].clone();

        Ok(Config {
            query: query,
            filename: filename,
        })
    }
}

当时我们说不必担心这里的clone调用,因为将来会移除他们。好吧,就是现在了!所以,为什么这里需要clone呢?这里的问题是参数args中有一个String元素的 slice,而new函数并不拥有args。为了能够返回Config实例的所有权,我们需要克隆Config中字段queryfilename的值,这样Config就能拥有这些值了。

现在在认识了迭代器之后,我们可以将new函数改为获取一个有所有权的迭代器作为参数。可以使用迭代器来代替之前必要的 slice 长度检查和特定位置的索引。因为我们获取了迭代器的所有权,就不再需要借用所有权的索引操作了,我们可以直接将迭代器中的String值移动到Config中,而不用调用clone来创建一个新的实例。

首先,让我们看看列表 12-6 中的main函数,将env::args的返回值改为传递给Config::new,而不是调用collect并传递一个 slice:

fn main() {
    let config = Config::new(env::args());
    // ...snip...

如果参看标准库中env::args函数的文档,我们会发现它的返回值类型是std::env::Args。所以下一步就是更新Config::new的签名使得参数args拥有std::env::Args类型而不是&[String]

impl Config {
    fn new(args: std::env::Args) -> Result<Config, &'static str> {
        // ...snip...

之后我们将修复Config::new的函数体。因为标准库文档也表明,std::env::Args实现了Iterator trait,所以我们知道可以调用其next方法!如下就是新的代码:

# struct Config {
#     query: String,
#     filename: String,
# }
#
impl Config {
    fn new(mut args: std::env::Args) -> Result<Config, &'static str> {
        args.next();

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

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

        Ok(Config {
            query: query,
            filename: filename,
        })
    }
}

还记得env::args返回值的第一个值是程序的名称吗。我们希望忽略它,所以首先调用next并不处理其返回值。第二次调用next的返回值应该是希望放入Configquery字段的值。使用match来在next返回Some时提取值,而在因为没有足够的参数(这会造成next调用返回None)而提早返回Err值。

filename值也进行相同处理。稍微有些可惜的是queryfilenamematch表达式是如此的相似。如果可以对next返回的Option使用?就好了,不过目前?只能用于Result值。即便我们可以像Result一样对Option使用?,得到的值也是借用的,而我们希望能够将迭代器中的String移动到Config中。

使用迭代器适配器来使代码更简明

另一部分可以利用迭代器的代码位于列表 12-15 中实现的search函数中:

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 vector,像这样使用迭代器适配器方法来实现:

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

这里使用了filter适配器来只保留line.contains(query)为真的那些行。接着使用collect将他们放入另一个 vector 中。这就简单多了!

也可以对列表 12-16 中定义的search_case_insensitive函数使用如下同样的技术:

fn search_case_insensitive<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let query = query.to_lowercase();

    contents.lines()
        .filter(|line| {
            line.to_lowercase().contains(&query)
        }).collect()
}

看起来还不坏!那么到底该用哪种风格呢?大部分 Rust 程序员倾向于使用迭代器风格。开始这有点难以理解,不过一旦你对不同迭代器的工作方式有了直觉上的理解之后,他们将更加容易理解。相比使用很多看起来大同小异的循环并创建一个 vector,抽象出这些老生常谈的代码将使得我们更容易看清代码所特有的概念,比如迭代器中用于过滤每个元素的条件。

不过他们真的完全等同吗?当然更底层的循环会更快一些。让我们聊聊性能吧。