在Rust、Haskell和Scala中遍历教程

299 阅读11分钟

在函数式编程社区有一个笑话。任何Scala程序都可以通过组合traverse 函数的正确次数来编写。这篇博文就是专门针对这个笑话的。

在Rust中,Iterator 特质定义了一个特定类型的值流。许多常见的类型提供了一个Iterator 接口。而内置的for 循环结构直接与Iterator trait一起工作。利用这一点,我们可以很容易地做一些事情,比如 "打印一个Vec 中的所有数字":

fn main() {
    let myvec: Vec<i32> = vec![1, 2, 3, 4, 5];

    for num in myvec {
        println!("{}", num);
    }
}

假设我们想做一些不同的事情:把Vec 中的每一个值都翻倍。 在Rust中,最习以为常和最有效的方法是使用可变的引用,例如:

fn main() {
    let mut myvec: Vec<i32> = vec![1, 2, 3, 4, 5];

    for num in &mut myvec {
        *num *= 2;
    }

    println!("{:?}", myvec);
}

既然我们要把这篇文章献给函数式程序员,那么值得注意的是:这看起来绝对不是函数式的。"取一个集合并在每个值上应用一个函数 "在FP圈子里被理解为一个map ,而且在非FP圈子里也越来越被理解为一个Functor 。或者用更多类别理论的术语来说,这是一个map 。幸运的是,Rust为Iterators提供了一个 方法。不幸的是,与Scala或Haskell不同,map 对数据类型不适用,比如Vec 。让我们来比较一下,使用Haskell。

list :: [Int]
list = [1, 2, 3, 4, 5]

main :: IO ()
main = do
  let newList :: [Int]
      newList = map (* 2) list
  print newList

Functor 类型中的map 函数直接作用于一个 list。它产生一个新的列表,并对每个值应用该函数。让我们尝试在 Rust 中做最等效的事情。

fn main() {
    let myvec: Vec<i32> = vec![1, 2, 3, 4, 5];

    let new_vec: Vec<i32> = myvec.map(|x| x * 2);

    println!("{:?}", new_vec);
}

这失败了,出现了错误信息。

no method named `map` found for struct `std::vec::Vec<i32>` in the current scope

这是因为,在Rust中,map 适用于Iterator 本身,而不是底层数据结构。为了在一个Vec ,我们必须使用map

  1. Vec 转换为Iterator
  2. map 上进行操作。Iterator
  3. Iterator 转换回为一个Vec

(1)可以使用IntoIterator 特质来执行,它提供了一个名为into_iter 的方法。对于(3),我们可以编写自己的for 循环,以填充一个Vec 。但正确的方法是使用FromIterator 特质。而最简单的方法是使用Iterator 上的collect 方法来做到这一点。

使用FromIterator和collect

让我们写一个正确使用map 的程序。

fn main() {
    let myvec: Vec<i32> = vec![1, 2, 3, 4, 5];

    let new_vec: Vec<i32> = myvec
        .into_iter()
        .map(|x| x * 2)
        .collect();

    println!("{:?}", new_vec);
}

相当直接,我们的3个步骤变成了3个连锁的方法调用。不幸的是,在实践中,使用collect 往往并不像这样直截了当。这是因为类型推理的原因。为了理解我的意思,让我们把上面的程序中所有的类型注释拿出来。

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

    let new_vec = myvec
        .into_iter()
        .map(|x| x * 2)
        .collect();

    println!("{:?}", new_vec);
}

这就给了我们一个非常友好的信息。

error[E0282]: type annotations needed
 --> src\main.rs:4:9
  |
4 |     let new_vec = myvec
  |         ^^^^^^^ consider giving `new_vec` a type

这里的问题是,我们不知道我们应该使用FromIterator 的哪个实现。这是一个在纯FP世界中不存在的问题,即mapFunctor 。在那个世界中,Functor'smap 总是 "保形 "的。当你在Haskell中对一个列表进行map ,结果总是一个列表。

IntoIterator/FromIterator 的组合却不是这样。IntoIterator 破坏了原来的数据结构,完全消耗了它并产生了一个Iterator 。同样地,FromIterator 凭空产生了一个全新的数据结构,没有对原来的数据结构进行任何引用。因此,一个明确的类型注释说明输出的类型是必要的。在我们上面的程序中,我们通过注释new_vec 来做到这一点。另一种方法是使用 "turbofish "来注释要使用的collect

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

    let new_vec = myvec
        .into_iter()
        .map(|x| x * 2)
        .collect::<Vec<_>>();

    println!("{:?}", new_vec);
}

注意,我们只需要指出我们正在收集到一个Vec 。Rust的正常类型推理能够弄清楚。

  • 用哪种数字类型来表示这些值
  • 原始的myvec 应该是一个Vec ,因为它是由vec! 宏产生的。

副作用和遍历

好了,我想向全世界宣布,我将把这些数值翻倍。在Rust中修改我们的map-使用的代码来做到这一点很容易。

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

    let new_vec = myvec
        .into_iter()
        .map(|x| {
            println!("About to double {}", x);
            x * 2
        })
        .collect::<Vec<_>>();

    println!("{:?}", new_vec);
}

但哈斯克人会警告你,这不是那么简单的。map 在哈斯克中是一个纯函数,意味着它不允许有任何副作用(比如打印到屏幕上)。你可以相当容易地看到这个动作。

list :: [Int]
list = [1, 2, 3, 4, 5]

main :: IO ()
main = do
  let newList :: [Int]
      newList =
        map
          (\x -> do
            putStrLn ("About to double " ++ show x)
            pure (x * 2))
          list
  print newList

这段代码不会被编译,因为Int (一个纯数字)和IO Int (一个有副作用的动作,产生一个Int )之间不匹配。

Couldn't match type 'IO Int' with 'Int'
Expected type: [Int]
Actual type: [IO Int]

相反,我们需要使用map 的更强大的表亲,traverse (又称mapM ,或 "单体地图")。traverse 允许我们执行一系列的动作,并产生一个包含所有结果的新列表。这看起来像:

list :: [Int]
list = [1, 2, 3, 4, 5]

main :: IO ()
main = do
  newList <-
    traverse
      (\x -> do
        putStrLn ("About to double " ++ show x)
        pure (x * 2))
      list
  print newList

那么为什么Haskell和Rust在这里有区别呢?这是因为Rust不是一种纯语言。任何函数都可以执行副作用,比如打印到屏幕上。而Haskell则不允许这样做,因此我们需要像traverse 这样的特殊辅助函数来说明潜在的副作用。

我不会去讨论这两种语言之间的哲学差异。我只想说,这两种方法都有其优点,而且都有优点和缺点。让我们来看看Rust的方法在哪里 "失效",以及FromIterator 是怎么做的。

处理失败

在上面Haskell的例子中,我们通过IO 类型使用了副作用。然而,traverse 并不限于与IO 。它可以与许多不同的类型一起工作,任何被认为是Applicative 。而这涵盖了许多不同的普通需求,包括错误处理。例如,我们可以改变我们的程序,不允许将大于5的 "大 "数字加倍。

list :: [Int]
list = [1, 2, 3, 4, 5, 6]

main :: IO ()
main = do
  let newList =
        traverse
          (\x ->
            if x > 5
              then Left "Not allowed to double big numbers"
              else Right (x * 2))
          list
  case newList of
    Left err -> putStrLn err
    Right newList' -> print newList

Either 是一个和类型,就像Rust中的 。它等同于Rust中的 ,但名称不同。取代了 和 ,我们有 (按惯例用于成功)和 (按惯例用于失败)。用于它的 实例将在遇到第一个 时停止处理。所以我们上面的程序最终会产生输出 。你可以在 中的 后面放任意多的值,它都会产生相同的输出。事实上,它甚至不会检查这些数字。enum Result Ok Err Right Left Applicative Left Not allowed to double big numbers list 6

回到Rust,让我们首先简单地把我们所有的Results集合到一个Vec ,以确保基本的工作。

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

    let new_vec: Vec<Result<i32, &str>> = myvec
        .into_iter()
        .map(|x| {
            if x > 5 {
                Err("Not allowed to double big numbers")
            } else {
                Ok(x * 2)
            }
        })
        .collect();

    println!("{:?}", new_vec);
}

这是有道理的。我们已经看到,.collect() 可以把一个Iterator's stream中的所有值塞进一个Vec 。而map 方法现在正在生成Result<i32, &str> 的值,所以所有东西都是一致的。

但这并不是我们想要的行为。我们希望有两个变化:

  • new_vec 应该产生一个 。换句话说,它应该产生一个单一的 ,或者一个成功结果的向量。现在,它有一个成功或失败的值的向量。Result<Vec<i32>, &str> Err
  • 一旦我们看到一个太大的值,我们应该立即停止处理原始Vec

为了更清楚一些,用一个for 循环来实现这一点是很容易的。

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

    let mut new_vec = Vec::new();

    for x in myvec {
        if x > 5 {
            println!("Not allowed to double big numbers");
            return;
        } else {
            new_vec.push(x);
        }
    }

    println!("{:?}", new_vec);
}

但是现在我们已经完全失去了我们的map ,我们正在下降到使用显式循环、变异和短路(通过return )。换句话说,这段代码让我觉得不那么优雅了。

事实证明,我们原来的代码几乎是完美的。让我们来看看一点神奇的东西,然后解释一下它是如何发生的。我们之前的代码版本使用了map ,结果是Vec<Result<i32, &str>> 。而我们想要Result<Vec<i32>, &str> 。如果我们简单地把类型改成我们想要的,会发生什么?

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

    let new_vec: Result<Vec<i32>, &str> = myvec
        .into_iter()
        .map(|x| {
            if x > 5 {
                Err("Not allowed to double big numbers")
            } else {
                Ok(x * 2)
            }
        })
        .collect();

    match new_vec {
        Ok(new_vec) => println!("{:?}", new_vec),
        Err(e) => println!("{}", e),
    }
}

多亏了FromIterator 的力量,这才得以实现!为了了解原因,让我们看看关于FromIterator 的一些文档。

获取Iterator 中的每个元素:如果它是一个Err ,就不再获取其他元素,并返回Err 。如果没有Err ,则会返回一个包含每个Result 的值的容器。

突然间,似乎Rust一直都在实现traverse!FromIterator 设置中的这种额外的灵活性使我们能够重新获得FP人所熟悉的traverse 中的短路错误处理行为。

traverse 相比,我们仍然在处理两个不同的特征(IntoIteratorFromIterator ),而且没有任何东西可以阻止这些特征成为不同的类型。因此,某种类型的注解仍然是必要的。一方面,这可以被看作是Rust方法的一个弊端。另一方面,它允许我们更灵活地生成什么类型,我们将在下一节中讨论这个问题。

最后,事实证明,我们可以用turbofish来再次拯救我们。比如说:

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

    let new_vec = myvec
        .into_iter()
        .map(|x| {
            if x > 5 {
                Err("Not allowed to double big numbers")
            } else {
                Ok(x * 2)
            }
        })
        .collect::<Result<Vec<_>, _>>();

    match new_vec {
        Ok(new_vec) => println!("{:?}", new_vec),
        Err(e) => println!("{}", e),
    }
}

不同的FromIterator impls

到目前为止,我们只看到了FromIterator 的两种实现:VecResult 。还有很多可用的。我最喜欢的一个是HashMap ,它可以让你把一连串的键/值对收集成一个映射。

use std::collections::HashMap;

fn main() {
    let people = vec![
        ("Alice", 30),
        ("Bob", 35),
        ("Charlies", 25),
    ].into_iter().collect::<HashMap<_, _>>();

    println!("Alice is: {:?}", people.get("Alice"));
}

由于FromIterator impl forResult 的工作方式,你可以将这两者叠加起来,将一串Result的对收集到一个Result<HashMap<_, _>, _>

use std::collections::HashMap;

fn main() {
    let people = vec![
        Ok(("Alice", 30)),
        Ok(("Bob", 35)),
        Err("Uh-oh, this didn't work!"),
        Ok(("Charlies", 25)),
    ].into_iter().collect::<Result<HashMap<_, _>, &str>>();

    match people {
        Err(e) => println!("Error occurred: {}", e),
        Ok(people) => {
            println!("Alice is: {:?}", people.get("Alice"));
        }
    }
}

验证

在Haskell世界中,我们有两个不同的错误收集概念。

  • Either一个是 "在第一个错误时停止",另一个是 "收集所有的错误"。
  • Validation意思是 "把所有的错误收集在一起"。

Validation 对于解析Web表单这样的事情来说是非常有用的。你不想只生成第一个失败,而是把所有的失败收集在一起,以产生更友好的用户体验。为了好玩,我决定在Rust中也实现这个功能。

我很想用Rust写一个Validation "Applicative",用一个FromIterator implator来收集多个Err值。我没有真正的需要,但它似乎仍然很有趣。

- Michael Snoyman (@snoyberg)2020年10月1日

正如你从该主题的其余部分所看到的,这篇博文的很多动机来自Twitter的回复。

在Rust中的实现是相当直接的,而且相当容易理解。我已经把它放在Github上了。如果有兴趣将其作为一个板块,请在问题追踪器中告诉我。

为了看到它的作用,让我们修改上面的程序。首先,我将在我的Cargo.toml 文件中添加该依赖关系。

[dependencies.validation]
git = "https://github.com/snoyberg/validation-rs"
rev = "0a7521f7022262bb00aea61761f76c3dd5ccefb5"

然后修改代码,使用Validation 枚举而不是Result

use std::collections::HashMap;
use validation::Validation;

fn main() {
    let people = vec![
        Ok(("Alice", 30)),
        Ok(("Bob", 35)),
        Err("Uh-oh, this didn't work!"),
        Ok(("Charlies", 25)),
        Err("And neither did this!"),
    ].into_iter().collect::<Validation<HashMap<_, _>, Vec<&str>>>();

    match people.into_result() {
        Err(errs) => {
            println!("Errors:");
            errs.into_iter().map(|x| println!("{}", x)).collect()
        }
        Ok(people) => {
            println!("Alice is: {:?}", people.get("Alice"));
        }
    }
}

奖金注意有点厚颜无耻地使用mapcollect 来打印出错误。这是在利用FromIterator() 隐含值,它将一系列() 的值收集在一起,成为一个单一的值。

总结

我意识到这是一个有点漫无边际的旅程,但希望对Rustaceans、Haskellers和Scala人来说是一个有趣的旅程。以下是我的一些收获:

  • collect 方法是非常灵活的
  • collect ,没有任何魔法,只有FromIterator 特质和实现它的类型的行为。
    • 这对我来说是一个很大的收获。几个月前,我不知不觉地忘记了FromIterator ,并对collect 的 "秘密 "行为感到紧张。
  • collect 的缺点是,由于它不像maptraverse 那样保存结构,所以有时你需要类型注释。
    • 习惯于使用turbofish!
  • 有很多有用的FromIterator 的impls。