rust 官服 时间_Rust 语言入门教程:从实战 To-Do App 开始

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 即可开始。就我而言,我决定将我的项目命名为“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 函数里的内容:

letaction=std::env::args().nth(1).expect("Please specify an action");letitem=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,因此我们可以考虑将其纳入自定义结构体中。

在文件顶部添加如下行:

usestd::collections::HashMap

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

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

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

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

这个字段是 HashMap 类型。你可以将其考虑为一种 JavaScript 对象,在 Rust 中要求我们声明键和值的类型。HashMap 表示我们具有一个字符串组成的键,其值是一个布尔值:在应用中来代表当前元素的活动状态。

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

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

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

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

implTodo{fn insert(&mutself,key: String){// 在我们的 map 中新增一个新的元素。// 我们默认将其状态值设置为 trueself.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 的所有者是 xletx=String::from("Hello");// 我们将值移动到此函数中// 现在 doSomething 是 x 的所有者// 一旦超出 doSomething 的范围// Rust 将释放与 x 关联的内存。doSomething(x);// 由于我们尝试使用值 x,因此编译器将引发错误// 因为我们已将其移至 doSomething 内// 我们此时无法使用它,因为此时已经没有所有权// 并且该值可能已经被删除了println!("{}",x);}

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

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

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

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

如何将 map 保存到硬盘上

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

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

implTodo{// [其余代码]fn save(self)-> Result{letmutcontent=String::new();for(k,v)inself.map{letrecord=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(){// ...[参数绑定代码]letmuttodo=Todo{map: HashMap::new(),};ifaction=="add"{todo.insert(item);matchtodo.save(){Ok(_)=>println!("todo saved"),Err(why)=>println!("An error occurred: {}",why),}}}

让我们看看我们都做了什么:let mut todo = Todo 让我们实例化一个结构体,绑定它到一个可变变量上。

我们通过 . 符号来调用 TODO insert 方法。

我们将匹配 save 功能返回的结果,并在不同情况下载屏幕上显示一条消息。

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

$cargorun--add"code rust"todosaved

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

$ cat db.txt

code rust true

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

如何读取文件

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

在 TODO 中新增 new 方法

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

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

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

implTodo{fn new()-> Result{letmutf=std::fs::OpenOptions::new().write(true).create(true).read(true).open("db.txt")?;letmutcontent=String::new();f.read_to_string(&mutcontent)?;letmap: HashMap=content.lines().map(|line|line.splitn(2,'\t').collect::>()).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。

这是编译器在为我们推断类型时遇到麻烦的情况之一,因此我们需要自行声明。

lines [文档] 在字符串的每一行上创建一个 Iterator 迭代器,来在文件的每个条目中进行迭代。因为我们已在每个条目的末尾使用了 /n 格式化。

map [文档] 接受一个闭包,并在迭代器的每个元素上调用它。

line.splitn(2, '\t') [文档] 将我们的每一行通过 tab 制表符切割。

collect::>() [文档] 是标准库中最强大的方法之一:它将迭代器转换为相关的集合。

在这里,我们告诉 map 函数通过将 ::Vec 附加到方法中来将我们的 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!

另一种等价方式

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

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

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

如何使用这个新方法

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

letmuttodo=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。

implTodo{// [其余的 TODO 方法]fn complete(&mutself,key: &String)-> Option{matchself.map.get_mut(key){Some(v)=>Some(*v=false),None=>None,}}}

让我们看看上面代码发生了什么:我们声明了方法的返回类型:一个空的 Option。

整个方法返回 Match 表达式的结果,该结果将为空 Some()或 None。

我们使用 * [文档] 运算符来取消引用该值,并将其设置为 false。

如何使用 complete 方法

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

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

// 在 main 函数中ifaction=="add"{// add 操作的代码}elseifaction=="complete"{matchtodo.complete(&item){None=>println!("'{}' is not present in the list",item),Some(_)=>matchtodo.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{// 打开 db.jsonletf=std::fs::OpenOptions::new().write(true).create(true).read(true).open("db.json")?;// 序列化 json 为 HashMapmatchserde_json::from_reader(f){Ok(map)=>Ok(Todo{map}),Err(e)ife.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>{// 打开 db.jsonletf=std::fs::OpenOptions::new().write(true).create(true).open("db.json")?;// 通过 Serde 写入文件serde_json::to_writer_pretty(f,&self.map)?;Ok(())}

和以前一样,让我们看看这里所做的更改:Box。这次我们返回一个包含 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 test 和 cargo doc。这次我们没有涉及它们,因为本教程内容量已经足够多了,或许未来会有所涉及。

想要了解有关 Rust 的更多内容,我认为这些资源真的很棒:官方 Rust 网站,所有重要信息的聚集地。

如果你喜欢通过聊天来互动交流,Rust 的 Discord 服务器是个很活跃和有用的社区。

如果你想要通过读书来学习,“Rust 程序设计语言”一书是个很好的选择。

如果你更喜欢视频类型的资料,Ryan Levick 的“Rust 介绍”视频系列是个很棒的资源。

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

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

译者结语

由于《Deno 钻研之术》的铺垫,Rust 语言的探索之旅也正式开启。在本文阅读的过程中,如果 cargo 安装包下载速度太慢的话,可以将 cargo 源设置为 https://mirrors.ustc.edu.cn/。

最后,随着这篇文章的结束,一月结束。二月份会除了 Deno 期刊外停更一段时间文章,这段时间会以编码和高效学习为主,重点包括但不限于:《ECMAScript+ 面试宝典》:打造 2021 年的面试宝典。

《Deno LeetCode 算法之旅》 - 暂译名:在 Deno 下使用 TypeScript 刷 LeetCode 算法。

...实现我的更多开源灵感。

欢迎持续关注!Github:https://github.com/hylerrix,公众号(@ningowood)。

你可能感兴趣的:(rust,官服,时间)