原文链接:Rust Programming Language Tutorial – How to Build a To-Do List App
从2015年首次开源发布至今,Rust语言获得了编程社区大量的关注,并且连续五年在StackOverflow开发者问卷中被评为最受喜爱的编程语言
Rust是由Mozilla基金会设计的一门面向系统编程的语言(类似C和C++),由于不依赖于内存垃圾回收机制,Rust拥有很好的性能。
Rust的学习曲线比较陡峭。我自己并不是一位资深的Rust程序员,但是希望这篇教程能够通过实际运用,帮助你更好地理解Rust的一些基本概念。
教程里我们将要实现什么应用?
我决定像JavaScript的传统那样(React/Vue),通过实现一个todo应用来作为入门的第一个项目。教程中除了一些基本的编程概念外,我们还会用到一些命令行工具,有必要熟悉一下它们。
这个todo应用将运行在命令行终端中,todo事项的状态将以一系列boolean值表示并存储:true代表待办,false代表已完成。
教程里涉及到的内容
- Rust错误处理
Options
和Null
类型Struct
和impl
- 终端I/O
- 文件处理
- Rust所有权与借用概念
- 模式匹配
- 迭代器Iterators和闭包closures
- 使用第三方
crates
开始之前
开始教程之前,作为一名JavaScript程序员,有一些建议给大家:
- Rust是一门强类型语言,我们需要重点关注变量的类型。
- 另一个和JavaScript相反的地方,Rust没有AFI机制,需要手动输入分号。只有函数的最后一行可以省略分号,这种情况下这行代码将被视为函数的返回值。
下面正式开始~
Rust起步
首先将Rust编译器下载到你的电脑上,参考官网的指引。指引中还能找到如何将这门语言集成到你最爱的编辑器上。
除了编译器本身,下载下来的还有一个名为Cargo
的Rust包管理器,类似于JavaScript中的npm
或yarn
。
下载安装完毕后,切到你的想要建立项目的目录位置,在终端中输入命令cargo new <project-name>
就可以创建项目了。这里我就用"todo-cli"来命名这个项目,命令如下:
$ cargo new todo-cli
然后切换到新创建的项目目录中,能看到如下所示的项目结构:
$ tree .
.
├── Cargo.toml
└── src
└── main.rs
我们的整个教程就在这个项目中展开。
和很多编程语言一样,Rust首先从一个main函数开始运行。fn
表示函数声明,println!
中的!
表示宏macro,没错这个默认的程序就是Rust版的"hello world!"。
要想让这个代码跑起来,还需要构建。运行命令cargo run
$ cargo run
Hello world!
如何读取参数
接下来,我们的目标是让应用读取命令行中用户输入的两个参数:第一个代表行为action,第二个代表事项todo。
用下面的代码替换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()
[文档]是Rust标准库env模块中的衣蛾方法,用来返回应用启动时传入的参数。它是一个可迭代的iterator
,因此我们可以通过nth()
函数来获取对应的参数值。0是程序本身,因此我们的nth()
需要从1开始,代表读取启动时第1个参数。expect()
[文档]是由Option
枚举类型定义的方法,它的作用是返回值或者值不存在的话,就立即结束程序运行(Rust术语中叫panic),并返回参数列表中提供的信息。
作为程序员,我们需要确保正确处理每一种场景。程序运行时可能没有输入任何参数,因此我们需要通过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
来实现,它能够避免直接在main函数中写一大堆代码。
定义struct
由于在后续步骤中将大量使用HashMap,我们可以直接将其引入以提高效率。在文件的第一行添加如下代码:
use std::collections::HashMap
这样,我们就可以在代码中直接使用HashMap
,而无需像std::env::args().nth(1)
每次都写出完整路径。
在main函数下面添加如下代码:
struct Todo {
// 使用 rust 内置的 HashMap 来存储键值对
map: HashMap<String, bool>,
}
这样,我们就创建了一个自定义的名为Todo的类型:一个仅含“map”字段的struct,这个字段的值是一个HashMap,你可以视为类似于JavaScript中的object,只不过Rust中要求声明其字段类型。
HashMap<String, bool>
表示键名为字符串类型,键值为boolean类型(即todo事项的活跃状态)
向struct中增加方法method
方法method类似于普通函数function,都是由fn
关键字声明,接收参数并返回一个值。
两者的区别在于,method在struct作用域中定义,并且它的第一个参数是self
(指向struct实例本身)。
通过impl
代码块:
impl Todo {
fn insert(&mut self, key: String) {
// 向map中插入一个新事项
// 值传true
self.map.insert(key, true);
}
}
这个方法非常直白简单:取结构体struct的引用以及一个key字符串,然后通过HashMap内置的insert
方法把它插入到map中。
有两个需要注意的点:
mut
[文档] 表示可变变量。在Rust中,变量默认是不可改变的。如果想要更新一个值,需要使用mut
关键字来声明。我们的方法会通过新增事项改变map值,因此需要声明为一个可变变量。&
[文档]表示引用reference。可以把它想象成一个指向值被存储内存地址的指针,而非值本身。
在Rust的术语中,上述过程被称为borrow
(借用),表示方法的这个参数并没有实际拥有这个值,仅仅是指向存储它的内存地址。
Rust所有权(ownership)概念简介
上述内容提到了借用的概念,接下来我们就顺便简单讲一下所有权。
所有权是Rust最独特的点,正是因为它,Rust程序员可以既不用手动操作内存(如C/C++),也不依赖于垃圾回收(如JavaScript/Python)这种需要不断检查程序来释放内存。
所有权机制有三条规则:
- 每一个值都有一个变量,即它的所有者;
- 同一时间每一个值只能有一个所有者;
- 当值对应的所有者超出作用域范围时,值会被丢弃。
Rust 会在编译时检查代码是否符合上述规则,因此我们必须掌握Rust中变量的内存释放机制。看看下面这个例子:
fn main() {
// 字符串“Hello”的所有者是x
let x = String::from("Hello");
// 将值传入了doSomething函数中,现在x值的所有者变成了doSomething
// Rust会释放掉x的相关内存
doSomething(x);
// 下面这行代码会让编译器抛出一个错误,因为它试图使用变量x
// 但x的值已经被移入函数doSomething中了,x不再是该值的所有者,x已经被丢弃
println!("{}", x);
}
大部分初学者初见Rust时,都比较难理解这个机制。
你可以从Rust的官方文档中阅读关于OwnerShip更深一些的介绍。
本教程将不再更深入介绍Rust的所有者机制了,只用先记住上面提到的三条规则。比如上面实现的insert
方法,我们在不接管map
所有权的情况下让它把数据存储下来,然后再释放掉它占用的内存。
将map
存储到硬盘
作为一个demo性质的应用,我们就选择最简单的持久化存储方案了:直接把map
存储到硬盘文件中。
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)
}
}
代码解读:
->
操作符后面的表示返回值类型。save
方法会返回一个Result
类型。- 遍历
map
并格式化每一个字符串:用一个tab符分隔一对键值对,然后另起一行写入下一对 - 将格式化后的字符串推入
content
字符串变量中 - 将
content
写入到db.txt
文件中
注意save
方法拿走了self
的所有权,那么编译器会防止我们在调用它之后还错误地尝试更新map
(因为此时self
对应的内存已经被释放掉了)。
我个人的建议强制将save
方法放在最后使用,这帮助我们利用Rust内存管理机制来写出更加严谨的代码,能够从开发阶段就防止代码缺陷。
在main中使用struct
现在我们有了insert
和save
两个方法,可以在main中使用了。如果应用接收到的第一个参数action为"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
实例化一个struct,值为可变类型- 通过
.
操作符调用ToDo insert
方法 - 对
save
方法的Result
类型返回值进行匹配match,对两种可能的结果在屏幕上输出相应信息
运行下试试!在命令行终端中输入:
$ cargo run -- add "code rust"
todo saved
然后检查下存储的内容是否符合预期:
$ cat db.txt
code rust true
没问题!
上述所有代码在这里:gist
如何读取文件
到这儿,我们的应用还存在一个缺陷:每当我们进行“add”操作时,都会重新创建一个空的map
写入新的事项,而不是在原来的基础上更新,下面来修改下。
在Todo中添加新的函数
我们将在Todo struct中实现一个新的函数,从文件中读取返回之前存储的所有内容。注意这是不是一个方法,因为它的第一个参数不是self
。按照Rust的传统,我们把这个函数名定义为new
(类似之前用到的 HashMap::new())。
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和其它一些语言一样具有丰富的编程范式,例如iterators迭代器,closure闭包以及lambda匿名函数等。
- 定义了一个名为
new
的函数,返回的Result
类型是Todo struct或者io:Error
; - 定义了通过
OpenOptions
来打开“db.txt“文件。其中create(true)
表示若文件不存在则创建一个新的。 f.read_to_string(&mut content)?
读取文件中的所有字节并将它们添加到content
字符串中。注意:使用该函数需要在文件头部添加use std::io::Read
;- 接下来,通过
let map: HashMap<String, bool>
定义HashMap类型的变量,用来存放转换后的值; lines
[文档]方法会创建一个遍历器来遍历字符串的每一行;map
[文档]方法接收一个闭包并在遍历每个entry时执行;line.splitn(2, '\t')
[文档]把每一行内容按制表符tab分割开;collect::<Vec<&str>>()
[文档]是标准库中一个功能强大的方法:将遍历结果转换为一个相关组合。::Vec<&str>
表示接收的参数是字符串引用。这一行代码将分割后的字符串转换为字符串引用类型的Vector;- 然后为了后续处理方便,通过
.map(|v| (v[0], v[1]))
继续转换为一个元组; - 接着通过
.map(|(k, v)| (String::from(k), bool::from_str(v).unwrap()))
将元组的两个成员转换为字符串与布尔值。注意,为了使用bool::from_str(v)
方法需要在文件头部添加use std::str::FromStr
; - 通过
collect()
方法将结果放入到HashMap中,此时不需要再声明类型了,因为我们在一开始绑定时就已经声明了类型; - 最后如果整个过程没有触发报错,
Ok(Todo {map})
将会返回我们需要的struct。这里和JavaScript里一样,如果struct内键名和变量名一致,可以简写。
另一种写法
虽然实际中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");
// 将键值对插入到map中
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” 指令后,可以看到我们的db更新也正常了:
$ 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列表中的值
作为一个todo应用,当然不能只支持添加事项,还要能够改变事项的状态。
添加complete方法
接下来在我们的Todo struct中添加一个“complete”方法,它将接收一个键的引用,然后更新它的值,如果这个键不存在的话则返回None
。
impl Todo {
// [其它方法代码]
fn complete(&mut self, key: &String) -> Option<()> {
match self.map.get_mut(key) {
Some(v) => Some(*v = false),
None => None,
}
}
}
代码解读:
- 定义方法返回类型:一个空的
Option
; - 返回的匹配表达式结果为空的
Some
类型或者None
类型; self.map.get_mut
[文档]返回键值的可变引用,如果没有的话就返回None
;- 使用操作符
*
[文档]重定义引用的值,将其设置为false。
使用complete方法
可以参考之前insert方法的使用方式。在main
函数中通过检查第一个参数是否为“complete”,搭配else if
关键字来使用它。
// 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),
},
}
}
- 对
todo.complete(&item)
方法返回的Option类型进行匹配; - 如果结果是
None
那我们输出错误提示,提升下用户体验;&item
表示我们传入complete方法的是item
的引用,这样item
值的所有权仍在当前函数作用域中,确保后续println!
宏指令可以正确使用到它;如果不这么做,item
值的所有权将被移交给complete
方法。 - 如果complete方法返回的是
Some
类型,我们就调用todo.save
将这次改变存储到文件中。
详细代码参考:gist
尝试运行应用
删掉db.txt文件,让我们在终端里从头完整地运行一遍。
$ rm db.txt
添加一些todo事项,再修改一些事项:
$ 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”)。
再添加一次事项:
$ cargo run -- add "make coffee
$ cat db.txt
make coffee true
code rust true
符合预期!
如何使用Serde
将todo存储为JSON
这个todo应用虽小,但是已经能够正常运行了,接下来让我们稍稍改变下它。出身JavaScript的我更喜欢用JSON文件来存储数据。借此机会正好可以教大家如何安装使用源自Rust开源社区crates.io
(类似JavaScript中的npm)的第三方包。
安装serde
这里我们用到的是一个可以进行JSON处理的包:Serde。打开项目根目录下的cargo.toml
文件(类似JavaScript中的package.json),在最下方可以看到有一个`[dependencies]字段,在它下面添加:
[dependencies]
serde_json = "1.0.60"
这样就可以了,在下次使用cargo编译时会自动下载Serde并包含到项目代码中。
升级Todo::new
第一个可以使用Serde的地方就是读取文件操作。现在让我们用JSON文件来替代.txt文件。
修改如下:
// 在 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
绑定,因为现在不需要我们手动处理文件内容到字符串,Serde会帮我们处理; - 文件的扩展名更新为
db.json
; serde_json::from_reader
[文档]将会把文件内容进行反序列化,并将其转变为HashMap。Err(e) if e.is_eof()
是一个匹配守卫,帮助我们优化匹配语句。如果Serde返回一个异常EOF(end of file),比如读取了一个不存在的文件,匹配守卫可以避免在这里提前结束,而是让程序从异常处恢复,继续运行后续代码,返回一个空的HashMap;- 至于其它类型的错误,就直接终中断程序处理。
升级 Todo.save
另一个需要使用到Serde的地方是存储事项时,需要将HashMap存储为JSON。使用如下代码:
// 在 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(())
}
- 现在save方法的返回类型修改为了
Box<dyn std:error::Error>
,它包含一个Rust中通用的错误类型实现;简单来说,box就是一个指向已分配内存的指针。我们可能在读取文件时返回一个文件系统错误,也可能在序列化转换时返回一个Serde错误,因为我们不确定具体会返回哪一个,那就直接返回这个错误所在的内存地址,方便调用者自行处理错误; - 打开文件时使用的文件名当然也要修改为
db.json
; - 最后我们让Serde来完成核心功能,将HashMap写入到JSON文件中;
- 记得移除已不再使用的文件顶部
use std::io::Read;
和use std::str::FromStr;
声明。
现在运行程序,检查下输出的文件,没问题的话,todo事项已经被存储为JSON了。完整代码参考:gist
结语,小提示以及学习资源
这教程挺长的,我非常感谢你能学习完并看到这里,希望这篇教程不仅能让你学到些东西,同时也能点燃你对Rust的兴趣。别忘了我们学习的是一门比较底层的语言,代码review的过程对于大多数人来说都不会太熟悉。
这也正是我喜欢Rust的原因,我可以毫无负担地编写出性能又高又安全的代码,因为我知道编译器会帮我把关,让那些错误代码根本没有运行的机会。
结束本文前,我还想分享几个小贴士和资源,帮助大家在Rust旅程中前行:
- Rust fmt,一个非常顺手的代码格式工具,不必再花费更多时间在你自己的linter插件上了;
cargo check
[文档]可以自动编译代码,这在开发阶段非常有帮助;- Rust自带一套集成测试套件cargo test以及一个文档生成工具cargo doc。我们的教程中没有涉及到它们,也许未来会吧。
想要学习更多Rust编程的话,我推荐这些资源:
- Rust官网,几乎涵盖了所有的内容;
- 如果你喜欢交流学习,Rust的 Discord 社区会很有帮助;
- 如果你喜欢通过阅读来学习,The Rust programming language会很适合你;
- 如果你更喜欢视频学习方式,Ryan Levick的introduction to Rust更适合你。
这片文章涵盖的所有代码可以在这里浏览:GitHub;
封面来自 https://rustacean.net/。
感谢阅读,祝大家编程快乐!
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。