Rust 语言从 2015 年发布的首个开源版本开始,便获得了社区大量的关注。从 StackOverflow 上的开发者调查来看,Rust 也是 2016 年每年都最受开发者喜欢的编程语言。

Rust 由 Mozilla 设计,被定义为一个系统级编程语言(就像 C 和 C++)。Rust 没有垃圾处理器,因此性能极为优良。且其中的一些设计也常让 Rust 看起来很高级。

Rust 的学习曲线被普遍认为是较为艰难的。我并不是 Rust 语言的深入了解者,但在这篇教程中,我将尝试提供一些概念的实用方法,来帮助你更深入的理解。

我们将在这篇实战教程中构建什么?

我决定通过遵循 JavaScript 应用的悠久传统,来将一个 to-do app 当做我们的第一个 Rust 项目。我们将重点使用命令行,所以有关命令行的知识必须有所了解。同时,你还需要了解一些有关编程概念的基础知识。

这个程序将基于终端运行。我们将存储一些元素的集合,并在其中分别存储一个表示其活动状态的布尔值。

我们将会围绕哪些概念来讨论?

  • Rust 中的错误处理。
  • Options 和 Null 类型。
  • Structs 和 impl。
  • 终端 I/O。
  • 文件系统处理。
  • Rust 中的所有权(Ownership)和借用(borrow)。
  • 匹配模式。
  • 迭代器和闭包。
  • 使用外部的包(crates)。

在我们开始之前

对于来自 JavaScript 背景的开发者来说,这里有几个我们开始深入前的建议:

  • Rust 是一个强类型的语言。这意味着当编译器无法为我们推断类型时,我们需要时刻关注变量类型。
  • 同样和 JavaScript 不同的是,Rust 中没有 AFI。这意味着我们必须主动在语句后键入分号 (";")——除非它是函数的最后一条语句(此时可以省略分号 ; 来将其当做一条 return)。

译者注:AFI,Automatic semicolon insertion,自动分号插入。JavaScript 可以不用写分号,但某些语句也必须使用分号来保证正确地被执行。

事不宜迟,让我们开始吧!

Rust 如何从零开始

开始的第一步:下载 Rust 到你的电脑上。想要下载,可以在 Rust 官方文档中的入门篇中根据指导来安装。

译者注:通过 curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh 安装。

在上面的文档中,你还会找到有关如何将 Rust 与你熟悉的编辑器集成以获得更好开发体验的相关说明。

除了 Rust 编译器本身外,Rust 还附带了一个工具——Cargo。Cargo 是 Rust 的包管理工具,就像 JavaScript 开发者会用到的 npm 和 yarn 一样。

要开始一个新项目,请先在终端下进入到你想要创造项目的位置,然后只需运行 cargo new <project-name> 即可开始。就我而言,我决定将我的项目命名为“todo-cli”,所以有了如下命令:

$ cargo new todo-cli

现在切入到新创建的项目目录并打印出其文件列表。你应该会在其中看到这两个文件:

$ tree .
.
├── Cargo.toml
└── src
 └── main.rs

在本教程的剩余篇章中,我们将会主要关注在 src/main.rs 文件上,所以直接打开这个文件吧。

就像其它众多的编程语言一样,Rust 有一个 main 函数来当作一切的入口。fn 来声明一个函数,同时 println! 中的 ! 符号是一个(macro)。你很可能会立马看出来,这是 Rust 语言下的一个“Hello World”程序。

想要编译并运行这个程序,可以直接直接 cargo run

$ cargo run
Hello world!

如何读取命令行参数

我们的目标是让我们的 CLI 工具接收两个参数:第一个参数代表要执行的操作类型,第二个参数代表要操作的对象。

我们将从读取并打印用户输入的参数开始入手。

使用如下内容替换掉 main 函数里的内容:

let action = std::env::args().nth(1).expect("Please specify an action");
let item = std::env::args().nth(2).expect("Please specify an item");

println!("{:?}, {:?}", action, item);

来一起消化下代码里的重要信息:

  • let [文档] 给变量绑定一个值
  • std::env::args() [文档] 是从标准库的 env 模块中引入的函数,该函数返回启动程序时传递给其的参数。由于它是一个迭代器,我们可以使用 nth() 函数来访问存储在每个位置的值。位置 0 引向程序本身,这也是为什么我们从第一个元素而非第零个元素开始读取的原因。
  • expect() [文档] 是一个 Option 枚举定义的方法,该方法将返回一个需要给定的值,如果给定的值不存在,则程序立即会被停止,并打印出指定的错误信息。

由于程序可以不带参数直接运行,因此 Rust 通过给我们提供 Option 类型来要求我们检查是否确实提供了该值。

作为开发者,我们有责任确保在每种条件下都采取适当的措施。

目前我们的程序中,如果未提供参数,程序会被立即退出。

让我们通过如下命令运行程序的同时传递两个参数,记得参数要附加在 -- 之后。

$ cargo run -- hello world!
    Finished dev [unoptimized + debuginfo] target(s) in 0.01s
     Running target/debug/todo_cli hello 'world'\!''
"hello", "world!"

如何使用一个自定义类型插入和保存数据

让我们考虑一下我们想在这个程序中实现的目标:能够读取用户在命令行输入的参数,更新我们的 todo 清单,然后存储到某个地方来提供记录。

为了达到这个目标,我们将实现自定义类型,来在其中满足我们的业务。

我们将使用 Rust 中的 struct(结构体),它使开发者能设计有着更优良结构的代码,从而避免了必须在主函数中编写所有的代码。

如何定义我们的结构体

由于我们将在项目中会用到很多 HashMap,因此我们可以考虑将其纳入自定义结构体中。

在文件顶部添加如下行:

use std::collections::HashMap

这将让我们能直接地使用 HashMap,而无需每次使用时都键入完整的包路径。

在 main 函数的下方,让我们添加以下代码:

struct Todo {
  // 使用 Rust 内置的 HashMap 来保存 key - val 键值对。
  map: HashMap<String, bool>,
}

这将定义出我们需要的 Todo 类型:一个有且仅有 map 字段的结构体。

这个字段是 HashMap 类型。你可以将其考虑为一种 JavaScript 对象,在 Rust 中要求我们声明键和值的类型。

  • HashMap<String, bool> 表示我们具有一个字符串组成的键,其值是一个布尔值:在应用中来代表当前元素的活动状态。

如何给我们的结构体中新增方法

方法就像常规的函数一样——都是由 fn 关键字来声明,都接受参数且都可以有返回值。

但是,它们与常规函数不同之处在于它们是在 struct 上下文中定义的,并且它们的第一个参数始终是 self

我们将定义一个 impl(实现)代码块在上文新增的结构体下方。

impl Todo {
  fn insert(&mut self, key: String) {
    // 在我们的 map 中新增一个新的元素。
    // 我们默认将其状态值设置为 true
    self.map.insert(key, true);
  }
}

该函数内容十分简单明了:它通过使用 HashMap 内置的 insert 方法将传入的 key 插入到 map 中。

其中两个很重要的知识是:

  • mut  [doc] 设置一个可变变量
    • 在 Rust 中,每个变量默认是不可变的。如果你想改变一个值,你需要使用 mut 关键字来给相关变量加入可变性。由于我们的函数需要通过修改 map 来添加新的值,因此我们需要将其设置为可变值。
  • &  [doc] 标识一个引用。
    • 你可以认为这个变量是一个指针,指向内存中保存这个值的具体地方,并不是直接存储这个值。
    • 在 Rust 属于中,这被认为是一个借用(borrow),意味着函数并不拥有该变量,而是指向其存储位置。

Rust 所有权系统的简要概览

有了前面关于借用(borrow)和引用(reference)的知识铺垫,现在是个很好的时机来简要地讨论 Rust 里的所有权(ownership)。

所有权是 Rust 中最独特的功能,它使 Rust 程序员无需手动分配内存(例如在 C/C++ 中)就可以编写程序,同时仍可以在无需垃圾收集器(如 JavaScript 或 Python)的情况下运行,Rust 会不断查看程序的内存以释放未使用的资源。

所有权系统有如下三个规则:

  • Rust 中每个值都有一个变量:即为其所有者。
  • 每个值一次只能有一个所有者。
  • 当所有者超出范围时,该值将被删除。

Rust 会在编译时检查这些规则,这意味着是否以及何时要在内存中释放值需要被开发者明确指出。

思考一下如下示例:

fn main() {
  // String 的所有者是 x
  let x = String::from("Hello");

  // 我们将值移动到此函数中
  // 现在 doSomething 是 x 的所有者
  // 一旦超出 doSomething 的范围
  // Rust 将释放与 x 关联的内存。
  doSomething(x);

  // 由于我们尝试使用值 x,因此编译器将引发错误
  // 因为我们已将其移至 doSomething 内
  // 我们此时无法使用它,因为此时已经没有所有权
  // 并且该值可能已经被删除了
  println!("{}", x);
}

在学习 Rust 时,这个概念被广泛地认为是最难掌握的,因为它对许多程序员来说都是新概念。

你可以从 Rust 的官方文档中阅读有关所有权的更深入的说明。

我们不会深入研究所有权制度的来龙去脉。现在,请记住我上面提到的规则。尝试在每个步骤中考虑是否需要“拥有”这些值后删除它们,或者是否需要继续引用它以便可以保留它。

例如,在上面的 insert 方法中,我们不想拥有 map,因为我们仍然需要它来将其数据存储在某个地方。只有这样,我们才能最终释放被分配的内存。

如何将 map 保存到硬盘上

由于这是一个演示程序,因此我们将采用最简单的长期存储解决方案:将 map 写入文件到磁盘。

让我们在 impl 块中创建一个新的方法。

impl Todo {
  // [其余代码]
  fn save(self) -> Result<(), std::io::Error> {
    let mut content = String::new();
    for (k, v) in self.map {
      let record = format!("{}\t{}\n", k, v);
      content.push_str(&record)
    }
    std::fs::write("db.txt", content)
  }
}
  • -> 表示函数返回的类型。我们在这里返回的是一个 Result 类型。
  • 我们遍历 map,并分别格式化出一个字符串,其中同时包括 key 和 value,并用 tab 制表符分隔,同最后用新的一个换行符来结尾。
  • 我们将格式化的字符串放入到 content 变量中。
  • 我们将 content 容写入名为 db.txt 的文件中。

值得注意的是,save 拥有自 self 的_所有权_。此时,如果我们在调用 save 之后意外尝试更新 map,编译器将会阻止我们(因为 self 的内存将被释放)。

这是一个完美的例子,展示了如何使用 Rust 的内存管理来创建更为严格的代码,这些代码将无法编译(以防止开发过程中的人为错误)。

如何在 main 中使用结构体

现在我们有了这两种方法,就可以开始使用了。现在我们将继续在之前编写的 main 函数内编写功能:如果提供的操作是 add,我们将该元素插入并存储到文件中以供未来使用。

将如下代码添加到之前编写的两个参数绑定的下方:

fn main() {
  // ...[参数绑定代码]

  let mut todo = Todo {
    map: HashMap::new(),
  };
  if action == "add" {
    todo.insert(item);
    match todo.save() {
      Ok(_) => println!("todo saved"),
      Err(why) => println!("An error occurred: {}", why),
    }
  }
}

让我们看看我们都做了什么:

  • let mut todo = Todo 让我们实例化一个结构体,绑定它到一个可变变量上。
  • 我们通过 . 符号来调用 TODO insert 方法。
  • 我们将匹配 save 功能返回的结果,并在不同情况下载屏幕上显示一条消息。

让我们测试运行吧。打开终端并输入:

$ cargo run -- add "code rust"
todo saved

让我们来检查元素是否真的保存了:

$ cat db.txt
code rust true

你可以在这个 gist 中找到完整的代码片段。

如何读取文件

现在我们的程序有个根本性的缺陷:每次“add”添加时,我们都会重写整个 map 而不是对其进行更新。这是因为我们在程序运行的每一次都创造一个全新的空 map 对象,现在一起来修复它。

在 TODO 中新增 new 方法

我们将为 Todo 结构实现一个新的功能。调用后,它将读取文件的内容,并将已存储的值返回给我们的 Todo。请注意,这不是一个方法,因为它没有将 self 作为第一个参数。

我们将其称为 new,这只是一个 Rust 约定(请参阅之前使用的 HashMap::new())。

让我们在 impl 块中添加以下代码:

impl Todo {
  fn new() -> Result<Todo, std::io::Error> {
    let mut f = std::fs::OpenOptions::new()
      .write(true)
      .create(true)
      .read(true)
      .open("db.txt")?;
    let mut content = String::new();
    f.read_to_string(&mut content)?;
    let map: HashMap<String, bool> = content
      .lines()
      .map(|line| line.splitn(2, '\t').collect::<Vec<&str>>())
      .map(|v| (v[0], v[1]))
      .map(|(k, v)| (String::from(k), bool::from_str(v).unwrap()))
      .collect();
    Ok(Todo { map })
  }

// ...剩余的方法
}

如果看到上面的代码感到头疼的话,请不用担心。我们这里使用了一种更具函数式的编程风格,主要是用来展示 Rust 支持许多其他语言的范例,例如迭代器,闭包和 lambda 函数。

让我们看看上面代码都具体发生了什么:

  • 我们定义了一个 new 函数,其会返回一个 Result 类型,要么是 Todo 结构体要么是 io:Error
  • 我们通过定义各种 OpenOptions 来配置如何打开“db.txt”。最显著的是 create(true) 标志,这代表如果该文件不存在则创建这个文件。
  • f.read_to_string(&mut content)? 读取文件中的所有字节,并将它们附加到 content 字符串中。
    • 注意:记得添加使用 std:io::Read 在文件的顶部以及其他 use 语句来使用 read_to_string 方法。
  • 我们需要将文件中的 String 类型转换为 HashMap。为此我们将 map 变量与此行绑定:let map: HashMap<String, bool>
    • 这是编译器在为我们推断类型时遇到麻烦的情况之一,因此我们需要自行声明。
  • lines [文档] 在字符串的每一行上创建一个 Iterator 迭代器,来在文件的每个条目中进行迭代。因为我们已在每个条目的末尾使用了 /n 格式化。
  • map [文档] 接受一个闭包,并在迭代器的每个元素上调用它。
  • line.splitn(2, '\t') [文档] 将我们的每一行通过 tab 制表符切割。
  • collect::<Vec<&str>>() [文档] 是标准库中最强大的方法之一:它将迭代器转换为相关的集合。
    • 在这里,我们告诉 map 函数通过将 ::Vec<&str> 附加到方法中来将我们的 Split 字符串转换为借来的字符串切片的 Venctor,这回告诉编译器在操作结束时需要哪个集合。
  • 然后为了方便起见,我们使用 .map(|v| (v[0], v[1])) 将其转换为元祖类型。
  • 然后使用 .map(|(k, v)| (String::from(k), bool::from_str(v).unwrap())) 将元祖的两个元素转换为 String 和 boolean。
    • 注意:记得添加 use std::str::FromStr; 在文件顶部以及其它 use 语句,以便能够使用 from_str 方法。
  • 我们最终将它们收集到我们的 HashMap 中。这次我们不需要声明类型,因为 Rust 从绑定声明中推断出了它。
  • 最后,如果我们从未遇到任何错误,则使用 Ok(Todo { map }) 将结果返回给调用方。
    • 注意,就像在 JavaScript 中一样,如果键和变量在结构内具有相同的名称,则可以使用较短的表示法。

phew!

dancing-ferris

你做的很棒!图片来源于 https://rustacean.net/

另一种等价方式

尽管通常认为 map 更为好用,但以上内容也可以通过基本的 for 循环来使用。你可以选择自己喜欢的方式。

fn new() -> Result<Todo, std::io::Error> {
  // 打开 db 文件
  let mut f = std::fs::OpenOptions::new()
    .write(true)
    .create(true)
    .read(true)
    .open("db.txt")?;
  // 读取其内容到一个新的字符串中
  let mut content = String::new();
  f.read_to_string(&mut content)?;
  
  // 分配一个新的空的 HashMap
  let mut map = HashMap::new();
  
  // 遍历文件中的每一行
  for entries in content.lines() {
    // 分割和绑定值
    let mut values = entries.split('\t');
    let key = values.next().expect("No Key");
    let val = values.next().expect("No Value");
    // 将其插入到 HashMap 中
    map.insert(String::from(key), bool::from_str(val).unwrap());
  }
  // 返回 Ok
  Ok(Todo { map })
}

上述代码和之前的函数式代码是功能性等价的关系。

如何使用这个新方法

在 main 中,只需要用以下代码块来初始化 todo 变量:

let mut todo = Todo::new().expect("Initialisation of db failed");

现在如果我们回到终端并执行若干个如下“add”命令,我们应该可以看到我们的数据库被正确的更新了。

$ cargo run -- add "make coffee"
todo saved
$ cargo run -- add "make pancakes"
todo saved
$ cat db.txt
make coffee     true
make pancakes   true

你可以在这个 gist 中找到目前阶段下所有的完整代码。

如何在集合中更新一个值

正如所有的 todo app 一样,我们希望不仅能够添加项目,而且能够对齐进行状态切换并将其标记为已完成。

如何新增 complete 方法

我们需要在 Todo 结构体中新增一个 complete 方法。在其中,我们获取到 key 的引用值,并更新其值。在 key 不存在的情况下,返回 None

impl Todo {
  // [其余的 TODO 方法]

  fn complete(&mut self, key: &String) -> Option<()> {
    match self.map.get_mut(key) {
      Some(v) => Some(*v = false),
      None => None,
    }
  }
}

让我们看看上面代码发生了什么:

  • 我们声明了方法的返回类型:一个空的 Option
  • 整个方法返回 Match 表达式的结果,该结果将为空 Some() None
  • 我们使用 * [文档] 运算符来取消引用该值,并将其设置为 false。

如何使用 complete 方法

我们可以像之前使用 insert 一样使用 “complete” 方法。

main 函数中,我们使用 else if 语句来检查命令行传递的动作是否是“complete”。

// 在 main 函数中

if action == "add" {
  // add 操作的代码
} else if action == "complete" {
  match todo.complete(&item) {
    None => println!("'{}' is not present in the list", item),
    Some(_) => match todo.save() {
      Ok(_) => println!("todo saved"),
      Err(why) => println!("An error occurred: {}", why),
    },
  }
}

是时候来分析我们在上述代码中做的事了:

  • 如果我们检测到返回了 Some 值,则调用 todo.save 将更改永久存储到我们的文件中。
  • 我们匹配由 todo.complete(&item) 方法返回的 Option。
  • 如果返回结果为 None,我们将向用户打印警告,来提供良好的交互性体验。
    • 我们通过 &item 将 item 作为引用传递给“todo.complete”方法,以便 main 函数仍然拥有该值。这意味着我们可以再接下来的 println! 宏中继续使用到这个变量。
    • 如果我们不这样做,那么该值将由“complete”用于,最终被意外丢弃。
  • 如果我们检测到返回了 Some 值,则调用 todo.save 将此次更改永久存储到我们的文件中。

和之前一样,你可以在这个 gist 中找到目前阶段下的所有相关代码。

运行这个程序吧

现在是时候在终端来完整运行我们开发的这个程序了。让我们通过先删除掉之前的 db.txt 来从零开始这个程序:

$ rm db.txt

然后在 todos中进行新增和修改操作:

$ cargo run -- add "make coffee"
$ cargo run -- add "code rust"
$ cargo run -- complete "make coffee"
$ cat db.txt
make coffee     false
code rust       true

这意味着在这些命令执行完成后,我们将会得到一个完成的元素(“make coffee”),和一个尚未完成的元素(“code rust”)。

假设我们此时再重新新增一个喝咖啡的元素“make coffee”:

$ cargo run -- add "make coffee"
$ cat db.txt
make coffee     true
code rust       true

番外:如何使用 Serde 将其存储为 JSON

该程序即使很小,但也能正常运行了。此外,我们可以稍微改变一些逻辑。对于来自 JavaScript 世界的我,决定将值存储为 JSON 文件而不是纯文本文件。

我们将借此机会了解如何安装和使用来自 Rust 开源社区的名为 creates.io 的软件包。

如何安装 serde

要将新的软件包安装到我们的项目中,请打开 cargo.toml 文件。在底部,你应该会看到一个 [dependencies] 字段:只需要将以下内容添加到文件中:

[dependencies]
serde_json = "1.0.60"

这就够了。下次我们运行程序的时候,cargo 将会编译我们的程序并下载和导入这个新的包到我们的项目之中。

如何改动 Todo::New

我们要使用 Serde 的第一个地方是在读取 db 文件时。现在,我们要读取一个 JSON 文件而非“.txt”文件。

impl 代码块中,我们更像一下 new 方法:

// 在 Todo impl 代码块中

fn new() -> Result<Todo, std::io::Error> {
  // 打开 db.json
  let f = std::fs::OpenOptions::new()
    .write(true)
    .create(true)
    .read(true)
    .open("db.json")?;
  // 序列化 json 为 HashMap
  match serde_json::from_reader(f) {
    Ok(map) => Ok(Todo { map }),
    Err(e) if e.is_eof() => Ok(Todo {
      map: HashMap::new(),
    }),
    Err(e) => panic!("An error occurred: {}", e),
  }
}

值得注意的改动是:

  • 文件选项不再需要 mut f 来绑定,因为我们不需要像以前一样手动将内容分配到 String 中。Serde 会来处理相关逻辑。
  • 我们将文件拓展名更新为了 db.json
  • serde_json::from_reader [文档] 将为我们反序列化文件。它会干扰 map 的返回类型,并会尝试将 JSON 转换为兼容的 HashMap。如果一切顺利,我们将像以前一样返回 Todo 结构。
  • Err(e) if e.is_eof() 是一个匹配守卫,可让我们优化 Match 语句的行为。
    • 如果 Serde 作为错误返回一个过早的 EOF(文件结尾),则意味着该文件完全为空(例如,在第一次运行时,或如果我们删除了该文件)。在那种情况下,我们从错误中恢复并返回一个空的 HashMap。
  • 对于其它所有错误,程序会立即被中断退出。

如何改动 Todo.save

我们要使用 Serde 的另一个地方是将 map 另存为 JSON。为此,将 impl 块中的 save 方法更新为:

// 在 Todo impl 代码块中
fn save(self) -> Result<(), Box<dyn std::error::Error>> {
  // 打开 db.json
  let f = std::fs::OpenOptions::new()
    .write(true)
    .create(true)
    .open("db.json")?;
  // 通过 Serde 写入文件
  serde_json::to_writer_pretty(f, &self.map)?;
  Ok(())
}

和以前一样,让我们看看这里所做的更改:

  • Box<dyn std::error::Error>。这次我们返回一个包含 Rust 通用错误实现的 Box
    • 简而言之,Box 是指向内存中分配的指针。
    • 由于打开文件时可能会返回 Serde 错误,所以我们实际上并不知道函数会返回这两个错误里的哪一个。
    • 因此我们需要返回一个指向可能错误的指针,而不是错误本身,以便调用者处理它们。
  • 我们当然已经将文件名更新为 db.json 以匹配文件名。
  • 最后,我们让 Serde 承担繁重的工作:将 HashMap 编写为 JSON 文件。
  • 请记得从文件顶部删除 use std::io::Read; 和 use std::str::FromStr;,因为我们不再需要它们了。

这就搞定了。

现在你可以运行你的程序并检查输出是否保存到文件中。如果一切都很顺利,你会看到你的 todos 都保持为 JSON 了。

你可以在这个 gist 中阅读当前阶段下完整的代码。

结语、技巧和更多资源

这是一段漫长的旅程,很荣幸你能阅读到这里。

我希望你能在这个教程中学到一些东西,并产生了更多的好奇心。别忘了我们在这里介绍的是一门非常“底层”的语言。

这是 Rust 吸引我的重要原因——Rust 使我能够编既快速又具有内存效率的代码,而不必畏惧承担过多的编码责任:我知道编译器会帮我优化更多,在运行前可能会出现错误的情况下提前中断运行。

在结束前,我想向你分享一些其他技巧和资源,以帮助你在 Rust 的旅途中继续前行:

  • Rust fmt 是一个非常方便的工具,你可以按照一致的模式运行以格式化代码。不必再浪费时间配置你喜欢的 linter 插件。
  • cargo check [文档] 将尝试在不运行的情况下编译代码:这在你只想在不实际运行时检查代码正确性的情况下,会变得很有用。
  • Rust 带有集成的测试套件和生成文档的工具:cargo testcargo doc。这次我们没有涉及它们,因为本教程内容量已经足够多了,或许未来会有所涉及。

想要了解有关 Rust 的更多内容,我认为这些资源真的很棒:

  • 官方 Rust 网站,所有重要信息的聚集地。
  • 如果你喜欢通过聊天来互动交流,Rust 的 Discord 服务器是个很活跃和有用的社区。
  • 如果你想要通过读书来学习,“Rust 程序设计语言”一书是个很好的选择。
  • 如果你更喜欢视频类型的资料,Ryan Levick 的“Rust 介绍”视频系列是个很棒的资源。

你可以在 GitHub 中找到本文的相关源码。

文中的插图来自于 https://rustacean.net/

感谢阅读,祝你编码愉快!

原文:Rust Programming Language Tutorial – How to Build a To-Do List App,作者:Claudio Restifo