- 原文:Rust Programming Language Tutorial – How to Build a To-Do List App
- 做者/译者:Claudio Restifo/@hylerrix
- 发布时间/翻译时间:20210104/20210129
- 备注:本文遵循 freeCodeCamp 翻译规范,同时本文会收录在《Deno 钻研之术》的 Rust 篇中。
Rust 语言从 2015 年发布的首个开源版本开始,便得到了社区大量的关注。从 StackOverflow 上的开发者调查来看,Rust 也是 2016 年每一年都最受开发者喜欢的编程语言。html
Rust 由 Mozilla 设计,被定义为一个系统级编程语言(就像 C 和 C++)。Rust 没有垃圾处理器,所以性能极为优良。且其中的一些设计也常让 Rust 看起来很高级。git
Rust 的学习曲线被广泛认为是较为艰难的。我并非 Rust 语言的深刻了解者,但在这篇教程中,我将尝试提供一些概念的实用方法,来帮助你更深刻的理解。程序员
咱们将在这篇实战教程中构建什么?github
我决定经过遵循 JavaScript 应用的悠久传统,来将一个 to-do app 当作咱们的第一个 Rust 项目。咱们将重点使用命令行,因此有关命令行的知识必须有所了解。同时,你还须要了解一些有关编程概念的基础知识。数据库
这个程序将基于终端运行。咱们将存储一些元素的集合,并在其中分别存储一个表示其活动状态的布尔值。express
对于来自 JavaScript 背景的开发者来讲,这里有几个咱们开始深刻前的建议:npm
;
来将其当作一条 return)。译者注:AFI,Automatic semicolon insertion,自动分号插入。JavaScript 能够不用写分号,但某些语句也必须使用分号来保证正确地被执行。编程
事不宜迟,让咱们开始吧!json
开始的第一步:下载 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] 设置一个可变变量
mut
关键字来给相关变量加入可变性。因为咱们的函数须要经过修改 map 来添加新的值,所以咱们须要将其设置为可变值。& [doc] 标识一个引用。
有了前面关于借用(borrow)和引用(reference)的知识铺垫,如今是个很好的时机来简要地讨论 Rust 里的全部权(ownership)。
全部权是 Rust 中最独特的功能,它使 Rust 程序员无需手动分配内存(例如在 C/C++ 中)就能够编写程序,同时仍能够在无需垃圾收集器(如 JavaScript 或 Python)的状况下运行,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 写入文件到磁盘。
让咱们在 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
类型。content
容写入名为 db.txt
的文件中。值得注意的是,save
拥有自 self 的全部权。此时,若是咱们在调用 save 以后意外尝试更新 map,编译器将会阻止咱们(由于 self 的内存将被释放)。
这是一个完美的例子,展现了如何使用 Rust 的内存管理来建立更为严格的代码,这些代码将没法编译(以防止开发过程当中的人为错误)。
如今咱们有了这两种方法,就能够开始使用了。如今咱们将继续在以前编写的 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
方法。让咱们测试运行吧。打开终端并输入:
$ cargo run -- add "code rust"
todo saved
复制代码
让咱们来检查元素是否真的保存了:
$ cat db.txt
code rust true
复制代码
你能够在这个 gist 中找到完整的代码片断。
如今咱们的程序有个根本性的缺陷:每次“add”添加时,咱们都会重写整个 map 而不是对其进行更新。这是由于咱们在程序运行的每一次都创造一个全新的空 map 对象,如今一块儿来修复它。
咱们将为 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>>()
[文档] 是标准库中最强大的方法之一:它将迭代器转换为相关的集合。
::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 })
将结果返回给调用方。
phew!
你作的很棒!图片来源于 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 同样,咱们但愿不只可以添加项目,并且可以对齐进行状态切换并将其标记为已完成。
咱们须要在 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。咱们能够像以前使用 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!
宏中继续使用到这个变量。若是咱们检测到返回了 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
复制代码
该程序即便很小,但也能正常运行了。此外,咱们能够稍微改变一些逻辑。对于来自 JavaScript 世界的我,决定将值存储为 JSON 文件而不是纯文本文件。
咱们将借此机会了解如何安装和使用来自 Rust 开源社区的名为 creates.io 的软件包。
要将新的软件包安装到咱们的项目中,请打开 cargo.toml
文件。在底部,你应该会看到一个 [dependencies]
字段:只须要将如下内容添加到文件中:
[dependencies]
serde_json = "1.0.60"
复制代码
这就够了。下次咱们运行程序的时候,cargo 将会编译咱们的程序并下载和导入这个新的包到咱们的项目之中。
咱们要使用 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 的另外一个地方是将 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。
咱们固然已经将文件名更新为 db.json
以匹配文件名。
最后,咱们让 Serde 承担繁重的工做:将 HashMap 编写为 JSON 文件。
请记得从文件顶部删除 use std::io::Read;
和 use std::str::FromStr;
,由于咱们再也不须要它们了。
这就搞定了。
如今你能够运行你的程序并检查输出是否保存到文件中。若是一切都很顺利,你会看到你的 todos 都保持为 JSON 了。
你能够在这个 gist 中阅读当前阶段下完整的代码。
这是一段漫长的旅程,很荣幸你能阅读到这里。
我但愿你能在这个教程中学到一些东西,并产生了更多的好奇心。别忘了咱们在这里介绍的是一门很是“底层”的语言。
这是 Rust 吸引个人重要缘由——Rust 使我可以编既快速又具备内存效率的代码,而没必要畏惧承担过多的编码责任:我知道编译器会帮我优化更多,在运行前可能会出现错误的状况下提早中断运行。
在结束前,我想向你分享一些其余技巧和资源,以帮助你在 Rust 的旅途中继续前行:
cargo check
[文档] 将尝试在不运行的状况下编译代码:这在你只想在不实际运行时检查代码正确性的状况下,会变得颇有用。想要了解有关 Rust 的更多内容,我认为这些资源真的很棒:
你能够在 Github 中找到本文的相关源码。
文中的插图来自于 rustacean.net/。
感谢阅读,祝你编码愉快!