专业编程基础技术教程

网站首页 > 基础教程 正文

Rust中简单的for循环:性能与可读性的平衡

ccvgpt 2025-01-02 14:53:29 基础教程 34 ℃

在Rust这门注重性能和底层控制的编程语言中,其内置的函数式编程工具让我们在不牺牲性能的前提下,也能享受到编程的便利。尽管如此,有时候我们还是更倾向于使用传统的for循环。在许多情况下,for循环不仅性能出色,而且可读性极高。本文将探讨为什么在Rust中,for循环仍然是一个值得考虑的选择。

Rust中的迭代器和for循环

Rust的迭代器概念是其函数式编程的核心。通过迭代器,我们可以高效地处理集合数据结构,如向量(vectors)和哈希映射(hashmaps)。即使不是Rust专家,你也应该能够理解本文的大部分内容,因为我会逐步解释Rust的相关概念。

Rust中简单的for循环:性能与可读性的平衡

性能对比:for循环与map

首先,我们来看一个转换循环的例子:

let mut result = Vec::new();
for entry in list {
    result.push(transform(entry));
}

这个简单的循环在性能上已经很不错了,但如果我们使用with_capacity()预先分配空间,性能可以提高约30%:

let mut result = Vec::with_capacity(list.len());
for entry in list {
    result.push(transform(entry));
}

Vec在Rust中类似于JavaScript中的数组或Python中的列表,它是一个可扩展的数组。通过with_capacity(),我们可以避免在push操作中频繁的内存重新分配,因为内存的复制是昂贵的。

接下来,我们看看map()函数:

let result: Vec<i32> = list.into_iter().map(transform).collect();

这里有几个要点需要解释:

  • into_iter()将原始向量转换为迭代器。for循环也隐式地使用into_iter()。
  • map()并不返回一个向量,而是返回一个迭代器,这个迭代器内置了映射操作。调用map()本身并不花费太多时间,因为它只是在构建一个迭代器,而不是执行它。
  • collect()将迭代器转换回向量,这个操作会实际执行工作。

在Rust中,map()非常易于阅读。虽然使用for循环时不需要理解太多概念,但这些概念是Rust的基础,你迟早需要学习。因此,map()在声明性上更胜一筹。

令人惊讶的是,map()也可以更快。在我的机器上,对10000个整数进行循环时,map()的速度是for循环的6倍!

为什么map()这么快?我不确定。我怀疑Rust编译器可能发现可以通过直接覆盖原始向量来避免分配,而for循环则不能。或许它使用了SIMD?我尝试查看编译器探索器,但还不够熟练,无法弄清楚。也许有人可以解释!

这是一个使用函数式编程模式的好理由。更声明性,而且更快?让我们使用它!

fold(reduce)的对比

在JavaScript中,我们发现使用for循环构建新集合比使用reduce快得多,那么在Rust中是否也适用呢?毕竟,map()这么好!这是否意味着在Rust中我们应该使用fold(Rust中的reduce等价物)而不是for循环呢?

我们来看一个实现flatten的例子,这也是我在JavaScript帖子中使用的例子。这里是简单的for循环:

let mut flat = Vec::new();
for entry in list_of_lists {
    flat.extend(entry);
}

extend()是一次性推送多个项目的方法,类似于Python中的操作。

由于我们无法预先知道展开列表的总长度,所以我们不在这里使用预分配容量。

这里是fold版本的代码:

let flat = list_of_lists.into_iter().fold(Vec::new(),
  |accumulator, list| [accumulator, list].concat()
);

让我们来分解一下:

  • 我们使用fold()。Rust确实有reduce(),但fold是与JavaScript reduce最接近的等价物。在性能上没有区别。
  • |accumulator, list| ...是一个闭包,我们像在JS中一样传递它。
  • [accumulator, list].concat()是将两个向量合并成一个新的向量的方法。

简单的for循环在这方面大约快180倍(对于一个50,000项的列表,在展开后)。

如果你是一个Rust开发者,你可能会反对。这种方法太糟糕了!它到处分配内存,难怪它慢。

但在Rust中,我们可以避免到处实例化向量,因为我们有迭代器。让我们在迭代器级别尝试一下:

let flat = list_of_lists.into_iter().fold(Vec::new(), |accumulator, list| {
    accumulator.into_iter().chain(list).collect()
});

这里发生了什么?我们使用.chain()将accumulator迭代器与list迭代器结合起来,然后我们收集它。

这有帮助吗?并没有——它比concat版本快20%左右;远远比不上for循环。

等等,我们仍然在这里到处构建向量,因为我们在闭包内部收集!

我们可以避免这一点,只创建迭代器,最后再收集它们吗?

let iter: Box<dyn Iterator<Item = i32>> = Box::new(std::iter::empty());
let flat = list_of_lists.into_iter()
    .fold(iter, |accumulator, list| Box::new(accumulator.chain(list)))
    .collect()

是的,我们可以,但我们已经可以看到这更难阅读,实际上它比for循环慢了10倍以上!我不确定为什么——显然,构建链式迭代器和堆分配的开销损害了性能。

如果你对Rust不熟悉,抱歉。Box<dyn Iterator<Item = i32>>的东西是告诉Rust使用动态分派,以便我可以将我们开始的空迭代器与基于chain的迭代器结合起来。这些在Rust中有不同类型的,这是将它们组合在一起的方法。

那么,在Rust中有没有一种声明性的方式来表达这一点并且仍然保持性能?我的意思是,除了.flatten()之外,Rust也有这个功能?我们可以使用flat_map:

let flat = list_of_lists.into_iter().flat_map(|list| list).collect()

flat_map允许你映射,但也产生结构,然后立即被展开。这比我们的concat方法快得多,但仍然比for循环慢4倍。

所以模式仍然成立:即使是在Rust中,平凡的for循环也值得你考虑。如果使用fold()可以让你的代码更容易编写,那就使用它,而不是因为它一定更快。

错误处理与map

在代码中,一个操作失败并希望处理这种失败是很常见的。与许多编程语言使用异常不同,Rust有一个非常好的系统来传播错误值,就像你传播任何返回值一样。我不会在这里详细说明,但希望你能跟上。

让我们再次考虑map(),但这次带有错误。

struct Error;

fn transform(entry: Entry) -> Result<Transformed, Error> {
    // 从Entry创建Transformed。可能会失败
}

fn transform_list(list: Vec<Entry>) -> Result<Vec<Transformed>, Error> {
    let mut result = Vec::with_capacity(list.len());
    for entry in list {
        result.push(transform(entry)?);
    }
    result
}

我们可以看到transform函数返回一个Result,它要么是一个Transformed对象(成功!),要么是一个错误(描述错误的某个对象)。

由于transform可能会失败,transform_list也会失败,因此需要返回Result。我们要么得到一个成功转换的列表,要么得到一个错误。

一旦我们遇到一个错误,我们就想立即用它退出。这就是transform(entry)?之后的?操作符所做的;它等同于一个if语句,检查评估的值是否是错误,如果是,它立即使用错误值从函数返回。

使用map()时是什么样子呢?

fn transform_list(list: Vec<Entry>) -> Result<Vec<Transformed>, Error> {
    list.into_iter().map(transform).collect()
}

这在功能上与上述相同,信不信由你。首先,我们将列表转换为迭代器,以便我们可以遍历其中的值。然后我们使用函数式map与transform函数。然后我们将其收集回向量。你可能会认为这将是Vec<Result<Transformed>, Error>;一个结果列表——要么是值,要么是错误对象。

但由于我们已经声明函数的返回值为Result<Vec<Transformed>, Error>,一个不同的collect被选中,它在找到第一个错误时立即短路并退出,就像在for循环中一样。

那么with_capacity呢?我们不需要它,因为实现足够智能,可以询问迭代器是否有大小提示,由于它基于已知长度的向量,所以它有一个。

所以我们免费获得很多聪明的东西。

更复杂的错误处理

再次,我们了解到map()很棒!使用map()!然而,有时候for循环使错误处理更加方便。有些东西很难用既高效又声明性的模式来捕捉。让我们考虑一个可能失败的展开。

pub fn fallible_flatten(list_of_lists: Vec<Vec<Result<i32, Error>>>) -> Result<Vec<i32>, Error> {
    let mut flat = Vec::new();
    for entry in list_of_lists {
        for j in entry {
            flat.push(j?);
        }
    }
    Ok(flat)
}

我们正在展开一个列表的列表,但列表中的每个条目可能是一个错误。如果在展开过程中遇到这样的错误,我们想立即退出。我们不能在这里使用extend,但其他方面相当直接。

使用fold怎么样?如果我们这样做,聪明的Rust Clippy立即建议我们使用try_fold,这是为错误处理设计的。

pub fn fallible_flatten_fold(
    list_of_lists: Vec<Vec<Result<i32, Error>>>,
) -> Result<Vec<i32>, Error> {
    list_of_lists
        .into_iter()
        .try_fold(Vec::new(), |accumulator, entry| {
            let mut result = accumulator.clone();
            for j in entry {
                result.push(j?);
            }
            Ok(result)
        })
}

如果我们忽略这种在折叠集合时固有的性能差异,我仍然认为for循环在这种情况下更容易阅读和编写。

结论

如果你正在处理的是转换集合,使用map()几乎不会出错。

但fold就更加棘手。这是否意味着fold没用?绝对不是。我在构建结构的复杂场景中使用fold,在那里声明性路线真的值得。有时,提高抽象层次可以让你做出算法上的改进,甚至比for循环的性能还要好。但平凡的for循环仍然有很多优点。

for循环有break。你可以从for循环中间return。复杂的错误处理是用一个小?表达的。

为了避免使用for,我可以挠头,经历一个更大的概念目录,伸展我的数学推理能力。虽然肯定有时和地方需要这样做,但在许多情况下,对于代码的编写者和阅读者来说,权衡倾向于平凡的for循环,在Rust中也是如此。让这指导我们作为程序员使用的启发式方法。

?科技脉搏,每日跳动。

与敖行客 Allthinker一起,创造属于开发者的多彩世界。

- 智慧链接 思想协作 -

最近发表
标签列表