Richard's Blog

Rust编程语言教程——如何使用Rust构建一个To-Do List应用

字数统计: 6.5k阅读时长: 24 min
2021/02/01 Share

原文链接Rust Programming Language Tutorial – How to Build a To-Do List App

自从Rust语言在2015的第一次开源,就获得了来自开源社区的大量关注。并且从2016年后,每一年都会被StackOverflow上的程序员票选为最喜爱的编程语言。

Rust由Mozilla开发并设计成一款系统编程语言(像C或C++)。它没有垃圾回收器,所以性能非常好。同时Rust设计得让Rust看起来和使用起来非常的”高级语言”。

Rust的学习曲线被认为有些陡峭。我不是一个精通Rust的人,但是在这个教程中我将尝试教给你一些概念和实际的方法,帮助你深入理解。

我们将在这个实战教程中做什么

我决定跟随传统的JavaScript应用做一个to-do应用做为我们的第一个项目。我们将用到命令行,所以必须了解一些命令行的基本操作。你当然也需要知道一些普遍的编程语言基础知识。

这个应用将运行在命令行中。我们将把待办项目和一个表示状态的布尔值存放在集合里。

项目将包含什么

  • Rust中的错误处理
  • 可选类型和空
  • 结构体和结构体的impl
  • 终端的I/O
  • 文件系统的使用
  • Rust中所有权和借取
  • Match模式
  • 迭代器和闭包
  • 使用外部库

开始之前

在开始之前有一些建议给从事JavaScript的程序员:

  • Rust是一个强类型的编程语言。这代表当编译器无法推断出变量的类型时,我们就必须标明此变量的类型。
  • 而且不同于JavaScript,这里没有AFI。这代表我们必须写分号(;),除非这条语句在一个方法中的最后一行。当一条语句在方法的最后一行并且结尾没有分号时代表此方法的返回值。

事不宜迟,我们开始吧。

Rust项目是如何开始的

开始的第一步当然是在你的电脑上下载并安装Rust。此步骤你可以跟随Rust的官方网站中的开始指引完成。

在那里你也可以找到一些很好的方式将Rust整合进你喜欢的编辑器中。

有一个跟Rust编译器一起安装的工具叫Cargo.Cargo是Rust的包管理工具,就像JavaScript程序员常用的npm或yarn一样。

创建工程的步骤很简单,只要去到你希望创建的工程目录下简单的运行cargo new <项目名>。在这个项目里我决定把项目命名为”todo-cli”,所以我需要运行:

1
$ cargo new todo-cli

现在进入刚刚创建的项目目录下,你会看到两个文件:

1
2
3
4
5
$ tree .
.
├── Cargo.toml
└── src
└── main.rs

我们的教程只需要编写src/main.rs文件,所以直接打开它。

像很多其他语言一样,Rust也有一个main方法。fn是用来定义一个函数的,println!中的!表示这是一个。正如你所想的一样,现在这个刚生成的程序是一个Rust版的”hello world!”。

构建和执行只需要简单的运行cargo run命令。

1
2
$ cargo run
Hello world!

如何读取参数

我们的目标是让我们的命令接收两个参数:第一个是操作指令,第二个是待办事项。

我们从读取用户输入的参数并打印在控制台开始。

用以下的内容替换main函数中的内容:

1
2
3
4
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[doc]为变量绑定数据。
  • std::env::args()[doc]是从基础包的env模块带来方法,此方法返回程序开始执行时所代的参数。由于它是一个迭代器,我们能使用nth()方法通过下标访问它储存的数据。下标为0的参数是程序本身,所以我们从第1个参数开始读取。
  • expect()[doc]是一个为了Option枚举而定义的一个方法,当值存在时它会返回值,当值不存在时它将会立刻停止程序(Rust概念中的Panic),并返回预设的信息。

因为我们可以不填写参数直接运行程序,Rust需要我们去检查是否确实提供了参数,所以返回给我们可选类型(Option type):有参数,或者没有。

作为程序员,我们有责任确保在每种情况下都采取适当的措施。

当没有参数时,我们暂且立刻退出程序。

来让我们运行程序并带上两个参数。将需要带上的参数写在--后面。比如:

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

如何使用自定义的数据类型添加和保存数据

让我们花一点时间想一下我们的程序想做到的目标。我们想读取用户传递的参数,然后更新我们的列表,并保存在某个地方以备使用。

为了实现这些功能,我们将实现我们自己的数据类型,并为类型定义符合业务逻辑的方法。

我们将使用Rust的结构体(struct)],它可以让我们清晰的实现以上的想法。它也可以避免我们把所有的代码都写在main函数内。

如果定义我们的结构体

由于我们在之后步骤中要经常操作HashMap,所以我们可以将它引入我们当前文件的作用域中,之后可以少打一些字。

在我们的文件顶部添加以下内容:

1
use std::collections::HashMap

这样做可以让我们直接使用HashMap,不需要每次都写完整的路径。

在main函数下方添加以下代码:

1
2
3
4
struct Todo {
// 使用rust内置的HashMap存储键值对
map: HashMap<String, bool>,
}

这样就定义了我们的Todo类型:一个结构体,结构体中有一个叫”map”的属性。

这个属性是一个HashMap。你可以把它想像成JavaScript中的object对象,不过rust要求我们必须声明它的key和value的类型。

  • HashMap<String, bool>表示我们的键(key)是String类型,值(value)是布尔类型(bool),值会被用于表示状态。

如果在我们的结构体中添加方法

方法就像普通的函数一样,通过fn关键字定义方法、参数和返回值。

然而与普通函数有所区别的地方是方法定义在结构体的上下文中,并且他们的第一个参数总是self对象。

我们在下面的代码中会向刚创建的结构体中添加一个impl(implementation)块。

1
2
3
4
5
6
7
impl Todo {
fn insert(&mut self, key: String) {
// 向我们的map中添加一个新的待办事项。
// 我们传一个true作为value
self.map.insert(key, true);
}
}

这个函数非常简单:它简单获取一个当前结构体的引用和一个键(key),使用HashMap自带的insert方法添加到我们的map中。

这里有两个非常重要的信息:

  • mut说明可以使一个变量可变。
    在Rust中所有的变量默认是不可变的。如果你希望更新变量的值,你需要使用mut关键字使变量可变。由于我们的函数需要向我们的map中添加新的数据,所以我们需要声明这个变量是可变的。
  • &说明表示这是一个引用。
    你可以将此变量想象为一个指针,指向数据所在的内存地址,而不是数据本身。

在Rust的概念中这相当于一个借用,意思是这个函数并不实际拥有变量的值,只是指向值所在的存储地址。

简单概述一下Rust的所有权系统

由于先前的提示中有关于借用与引用的内容,所以现在可以简单的说一下所有权的概念。

所有权是Rust最独特的功能。它令Rust在写程序的时候不需要人为的申请内存空间(不像C/C++那样),但同时运行的时候也不需要垃圾回收器(类似JavaScript或Python)不断地监控程序的内存并把不使用的内存释放。

所有权系统有三条规则:

  • 每一个在Rust中的值都有一个变量:它自己。
  • 每一个值只能在同一时间内存在唯一一个所有者。
  • 当值的所有者离开了它所在的区域,值将会被废弃。

Rust会在编译的时候检查这些规则,所以你必须清楚你需要的值的内存在什么时候被释放。

想一下这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fn main() {
// String的所有者是x
let x = String::from("Hello");

// 我们将值移动至函数中
// 现在doSomething是x的所有者。
// 当它离开doSomething的区域时
// Rust将释放与x相关的内存。
doSomething(x);

// 如果我们尝试再次使用x时,编译器会抛出一个错误
// 由于我们已经把它移动到"doSomething"里面了
// 所以我们不能使用它,因为我们没有它的所有权
// 因此x的值将被丢弃。
println!("{}", x);
}

这个概念被认为是在学习Rust的过程中最难掌握的,可能是因为这是一个与其他编程语言不同的全新概念。

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

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

例如,在上面的insert方法中,我们不想拥有map对象,因为我们仍然需要它来存储数据。只有这样,我们才能在方法的最后正确的释放内存。

如何将map中的数据存到硬盘上

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

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

1
2
3
4
5
6
7
8
9
10
11
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,格式化每个字符串,用制表符(\n)分隔键和值,并在最后换到新的一行。
  • 我们将已经格式化好的字符串推入content变量中。
  • 我们将content写入名为db.txt的文件内。

重要的是要注意,save拥有自己的所有权
这是一个故意为之的设计,这样编译器会阻止我们在调用save之后尝试更新map(因为self的内存已被释放)。

这是个人决定”强制”把save方法为最后使用的方法。这是一个完美的例子,展示了如何使用Rust的内存管理来创建更严格的代码。违反此规则的代码将无法通过编译(这有助于防止开发过程中的人为错误)。

如何在main函数中使用结构体

现在我们的结构体有了两个方法,我们可以开始使用它们了。从main函数读取参数开始。现在如果提供的操作是”add”,我们会将待办事项插入文件中并存储以供以后使用。

将这些行添加到两个参数绑定下面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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方法返回的结果,并在每种情况下都会在屏幕上打印一条消息。

让我们测试一下。导航到您的终端并输入:

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

让我们查看一下已保存的待办事项:

1
2
$ cat db.txt
code rust true

到目前为止,您可以在这找到完整的代码片段。

如何读取文件

现在,我们的程序有一个根本性的缺陷:每次”add”时,我们都会覆盖map而不是对其进行更新。这是因为我们每次运行程序时都会创建一个新的空map。让我们修复这个缺陷。

在TODO中添加新的功能

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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函数,它将返回一个Todo结构体或io:Error
  • 我们通过定义各种OpenOptions配置如何打开”db.txt”文件。最值得注意的是create(true)标记,如果文件不存在,它将创建该文件。
  • f.read_to_string(&mut content)?读取所有文件中的字节并放入content字符串中。
    注意:记得将use std::io::Read;添加到文件顶端,这样才能使用read_to_string方法。
  • 我们需要将文件的String类型转换为HashMap。为此,我们将map变量与此行绑定在一起:let map: HashMap<String, bool>。这是编译器无法做类型推断的情况之一,因此我们需要自己声明类型。
  • lines文档方法创建一个迭代器迭代每一行字符串,意思是现在我们将迭代文件中的每一个元素,因此我们将每个元素的末尾都加上/n.
  • map文档方法接收一个闭包,并且在迭代器迭代到每一个元素时调用此闭包。
  • line.splitn(2, '\t')文档此代码将根据tab制表符分割每行的字符。
  • collect::<Vec<&str>>()文档如文档中所述,它是标准库中功能最强大的方法之一:它将迭代器转换为相关的集合。
    在这里我们使用::Vec<&str>告诉map方法转换我们已分割的字符串的借用切片到一个Vector中。这是在告诉编译器我们在操作结束后希望获得怎样的集合。
  • 然后我们使用.map(|v| (v[0], v[1]))将其转换为元组以方便使用。
  • 然后我们使用以下方法将元组的两个元素转换为String和boolean.map(|(k, v)| (String::from(k), bool::from_str(v).unwrap()))
    注意:记得在文件头部添加use std::str::FromStr;,这样才能使用from_str方法。
  • 我们最终将它们收集到我们的HashMap中。这次我们不需要声明类型,因为Rust从绑定声明中推断出它。
  • 最后,如果我们未遇到任何错误,我们将使用Ok(Todo { map })把结构体返回给调用者。
    请注意,就像在JavaScript中一样,如果键和变量在结构内具有相同的名称,则可以使用较短的表示方法。

另一种方式

尽管通常认为map更加惯用,但以上内容也可以使用for循环来实现。随意使用最喜欢的一种。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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 })
}

上面的代码在功能上等效于以前使用的“功能更强”的方法。

如何使用new方法

在main内部,只需使用以下代码更新绑定todo变量的方式:

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

现在,如果我们回到终端并运行一堆”add”命令,我们应该看到我们的数据库正确更新:

1
2
3
4
5
6
7
$ cargo run -- add "make coffee"
todo saved
$ cargo run -- add "make pancakes"
todo saved
$ cat db.txt
make coffee true
make pancakes true

您可以在这找到所有现阶段的代码片段。

如何更新集合中的值

就像在所有TODO应用程序中一样,我们希望不仅能够添加待办项目,而且能够对其进行切换并将其标记为已完成。

如果添加完成的方法

为了做到这个功能,我们先向我们的结构体中添加一个”complete”方法。在方法中,我们获取一个键(key)的引用,更新它的值,或者在键不存在的时候返回None

1
2
3
4
5
6
7
8
9
10
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
  • self.map.get_mut文档会返回给我们一个key所对应的可变的值引用,当key没有储存的值则返回None
  • 我们使用*文档操作符解引用并设置成false。

如何使用complete方法

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

main中,我们使用else if语句检查作为参数传递的操作是否是”complete”:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 在main函数中

if action == "add" {
// 添加action的片断
} 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),
},
}
}

是时候分析一下我们在这里做什么了:

  • 我们使用todo.complete(&item)方法匹配了Option的返回值。
  • 如果匹配到None我们会向用户打印警告,以获得更好的体验。
    我们使用&item传递item的引用给”todo.complete”函数,因此item的值的仍然被当前方法所拥有。这代表我们可以在后面使用println!宏。
    如果我们不这么做,item的值将会被”complete”拥有,并且在”complete”方法结束时被丢弃。
  • 如果我们匹配到返回值为Some,我们调用todo.save持久化我们的数据至文件中。

尝试运行程序

现在该尝试在终端机中本地开发的应用程序了。让我们首先删除db文件以重新开始。

1
$ rm db.txt

然后添加和修改一些待办事项:

1
2
3
4
5
6
$ 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”。

假设我们要再次煮咖啡:

1
2
3
4
$ cargo run -- add "make coffee
$ cat db.txt
make coffee true
code rust true

奖励:如何使用Serde储存JSON数据

这是一个可运行的很小的程序。但是,让我们稍微改变一下。在JavaScript世界里我们更喜欢使用JSON文件而不是纯文本文件。

我们将借此机会来看看如何安装和使用Rust开源社区的软件包,来自crates.io

如果安装Serde

为了安装新的包到我们的项目中,打开cargo.toml文件。在文件底部你可以看到[dependencies]字段:简单的把如下信息添加到文件中:

1
2
[dependencies]
serde_json = "1.0.60"

这样就可以了,下一次编译时cargo会帮我们下载需要的包并编译代码。

如何升级Todo::new方法

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

升级在impl代码块中的new方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 在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绑定文件option对象,我们不需要在此之前人为申请储存内容的字符串内存。Serde会为我们处理好它。
  • 我们升级文件后缀为db.json
  • serde_json::from_reader文档将为我们反序列化文件。它会干扰map的返回类型,并将尝试将JSON转换为兼容的HashMap。如果一切顺利将会像之前一样返回Todo结构体。
  • Err(e) if e.is_eof()是一个匹配断言,它可以使我们完善Match语句的行为。
    如果Serde返回一个过早EOF(end of file)错误,这代表那个文件是完全的空白(例如在第一次运行时,或者如果我们删除了文件)。在那种情况下,我们从错误中恢复并返回一个空的HashMap。
  • 对于所有其他错误,立即退出程序。

如何升级Todo.save

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

1
2
3
4
5
6
7
8
9
10
11
// 在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>。这次我们返回Box包含一个Rust中通用错误实现。
    简而言之,盒子是指向内存中分配的指针。
    由于打开文件时可能会返回文件系统错误,而转换文件时可能会返回Serde错误,所以我们实际上并不知道函数会返回这两个错误中的哪一个。
    因此,我们返回一个指向可能错误的指针,而不是错误本身,以便调用者处理它们。
  • 我们当然已经将文件名更新为db.json以匹配其他方法。
  • 最后,我们让Serde承担繁重的工作,并将HashMap编写为JSON文件(标准格式)。
  • 记得删除文件开关的use std::io::Read;use std::str::FromStr;,我们不再需要它们了。

这样就完成了。

现在,您可以运行程序并检查保存到文件中的输出。如果一切顺利,现在您应该将待办事项另存为JSON。

您可以在gist中找到为此编写的完整代码。

结束语,技巧和其他资源

这是一段漫长的旅程,很荣幸您能与我同在。 我希望您能学到一些东西,并且对本入门书有所好奇。别忘了我们使用的是非常”低级”的语言,但是大多数人可能对代码的审查非常熟悉。

这就是我个人吸引Rust的原因–它使我能够编写既快速又具有内存效率的代码,而不必担心这种责任感:我知道编译器将在我身边,甚至让我有可能运行它之前停止我的动作。

在结束之前,我想与您分享一些其他技巧和资源,以帮助您在Rust的旅程中前进:

  • Rust fmt这是一个非常方便的工具,您可以按照一致的模式来设置代码格式。不再浪费时间配置您喜欢的linter插件。
  • cargo check文档会尝试不运行而编译代码:这在开发时非常有用,您只想在不实际运行的情况下检查代码的正确性。
  • Rust随附了集成的测试套件和生成文档的工具:cargo testcargo doc。这次我们没有涉及它们,因为本教程看起来很密集。也许在将来。

在我看来,要了解有关该语言的更多信息,最好的资源是:

  • 官方的Rust website,收集所有信息的地方。
  • 如果您喜欢通过聊天进行互动,Rust的Discord服务器有一个非常活跃和乐于助人的社区。
  • 如果您喜欢读书,”The Rust programming language“是您的正确选择。
  • 如果您更喜欢视频类型,Ryan Levick的Rust介绍视频系列是一个了不起的资源。

您可以在GitHub上的本文的源代码。

封面图开源于https://rustacean.net/

感谢您的阅读和快乐编码!

CATALOG
  1. 1. 我们将在这个实战教程中做什么
  2. 2. 项目将包含什么
  3. 3. 开始之前
  4. 4. Rust项目是如何开始的
  5. 5. 如何读取参数
  6. 6. 如何使用自定义的数据类型添加和保存数据
    1. 6.1. 如果定义我们的结构体
    2. 6.2. 如果在我们的结构体中添加方法
  7. 7. 简单概述一下Rust的所有权系统
    1. 7.1. 如何将map中的数据存到硬盘上
    2. 7.2. 如何在main函数中使用结构体
  8. 8. 如何读取文件
    1. 8.1. 在TODO中添加新的功能
    2. 8.2. 另一种方式
    3. 8.3. 如何使用new方法
  9. 9. 如何更新集合中的值
    1. 9.1. 如果添加完成的方法
    2. 9.2. 如何使用complete方法
  10. 10. 尝试运行程序
  11. 11. 奖励:如何使用Serde储存JSON数据
    1. 11.1. 如果安装Serde
    2. 11.2. 如何升级Todo::new方法
    3. 11.3. 如何升级Todo.save
  12. 12. 结束语,技巧和其他资源