原文链接:www.freecodecamp.org/news/how-to…
文章日期:2021.1.4
文章首发于知乎[zhuanlan.zhihu.com/p/524550812] 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
$ cargo new todo-cli
$ tree .
.
├── Cargo.toml
└── src
└── main.rs
就像很多其他的软件,Rust 也有一个 main 函数,运行程序时,main 函数是入口。
下面我们来看,目前自动生成的 main 函数
fn main() {
println!("Hello, world!");
}
fn 相当于 js 里的 function。 println! 不是函数,而是宏。这个程序就是 rust 版本的 “hello world”
执行这个程序的命令是 cargo run
$ cargo run
Hello, world!
fn 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 看起来像 js 的 let,实际更像 js 的 const,因为 let 定义了一个不变量。
std::env::args() 是标准库的函数,提供了处理命令行输入的能力。args() 是一个 iterator,在 rust 里 iterator 可以通过 nth() 来获得第几个变量的值。 位置 0 是 程序本身,第一个变量从 1 开始。
expect() 是枚举 Option 的方法,如果 Option 不存在,则终止当前程序,并且打印 expect 里的内容。
$ cargo run
thread 'main' panicked at 'Please specify an action', crates/todo-cli/src/main.rs:2:42
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
$ cargo run aa
thread 'main' panicked at 'Please specify an item', crates/todo-cli/src/main.rs:3:40
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
$ cargo run aa bb
"aa", "bb"
注意,如果测试的参数没有 - 等命令行常用的符号,可以直接用上面的命令来进行调试。但是,一般 cli 的常用选项都会加 - ,这时 - 与 cargo 自身的命令会起冲突,所以要加 — 进行开发调试。下面这个例子是一个调试命令行工具更常用的写法:
$ cargo run -- aa bb
"aa", "bb"
下面我们把输入的内容存到一个数据结构里,在 Rust 里,使用 struct 来定义数据结构。类似与 js 的 object。
use std::collections::HashMap;
struct Todo {
// 使用 rust 内置的 HashMap 来存储 Key Value 对
map: HashMap<String, bool>,
}
现在就有了自定义的 Todo 类型:一个 struct,有一个字段,这个字段的名字叫 map,类型是 HashMap<String, bool>。这个 HashMap 的 key 是 String 类型的,value 是 boolean 类型的。
现在我们来向struct Todo 增加方法,Rust 增加方法的写法和 Golang 有相似的地方。
impl Todo {
fn insert(&mut self, key: String) {
// 往 map 里插入新数据
// 把 true 作为值
self.map.insert(key, true);
}
}
impl 是 implementaion 的简写,相当于给 Todo 实现方法的地方。对于给 Todo 增加的每一个方法和定义普通函数类似,但是第一个参数,总是 self。
上述的方法,给 map 增加了一个 key-value 对。insert 是 map 的内置方法。
出现了新的关键字:
- mut:这个关键字让变量编程了可变变量。在 Rust 中,所有的变量都是默认不可变的。如果你想要更新一个变量的值,你需要加上 mut 关键字。因为 insert 方法要改变 map 的值,相当于改了 self 的值,所以要在声明 self 时,加上 mut 关键字。
- & :表明这是一个引用。可以想象 self 是一个内存地址的指针,而不是值本身。
在 rust 中,如果你获得了一个 &,表示你 borrow 了这个变量,这表明,这个函数并不拥有 self 的值,而是借用了 self 的值。
Rust 所有权系统简介
有了上面关于 borrow 和 reference 的代码,现在可以聊聊所有权了。
所有权是 Rust 最独特的功能。这个功能让 Rust 可以不用手动处理内存(就好像 C 和 C++),同时还不需要 GC(就好像 JavaScript 和 Python)。
所有权系统有三条规则:
- 每一个 Rust 的变量都有自己的所有者
- 每一个变量在一个时刻只能有一个所有者
- 当所有者离开作用域,变量就会被丢弃
Rust 会在编译时对这些规则进行检查,这代表着,开发者必须显示标注你用的值什么时候要被释放。
fn main() {
// String 的所有者是 x
let x = String::from("Hello");
// 我们把 x 移入到了函数中
// 现在 doSomething 是 x 的所有者
// 当离开 doSomething 时,x 的内存就会被释放
doSomething(x);
// 编译器会抛出异常
// 因为我们把 x 的所有权交给 doSomething 以后,我们已经没有 x 了。
// x 可能也被 drop 了
println!("{}", x);
}
这个概念被认为是学习 Rust 最难的一个事情之一,因为这个概念,在其他语言里没有。
你可以在官方文档里读到更多关于所有权的信息。
在这个简单的程序里,不会涉及太多关于所有权的问题。在每一步,如果需要获得一个变量的所有权,并且释放它,或者需要一个变量的引用,代表着还要保留变量。
在这个 insert 的例子里,我们不想去拥有 map,我们还需要它在某个地方保留这些数据。只有最后我们才能清空内存。
如何把 map 存入到磁盘上
因为这是一个示例程序,所以我们使用最简单的方案,把 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)
}
}
- → 代表函数的返回值。这里返回了一个 Result
- 在这个方法里,我们遍历了map 的所有值,把 key 和 value 用 tab 进行分隔
- 然后把所有的内容装入 content 变量中
- 最后把 content 的内容写入到 db.txt 文件中
这里要注意,save 函数获得了 self 的所有权。这是故意这么做的,这样如果我们执行了 save,之后就不能在去更新 map 了。
这么设计,save 函数只能在最后执行,否则就会报错。也是一个使用 rust 的特性进行内存管理策略的例子。
如何在 main 里使用 struct
现在我们在 main 函数里实例化写好的 Todo 结构体。
// ...[参数绑定的代码]
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 这行代码实例化了一个结构体,并且把这个变量声明为可变变量
- 调用结构体的方法使用 . 符号
- 对于 save 的返回的 Result 的结果,我们使用了 Rust 的模式匹配机制,把成功和失败的两种情况进行了处理。
$ cargo run -- add "code rust"
"add", "code rust"
todo saved
$ cat db.txt
code rust true
如何从一个文件读数据
目前的程序有一个问题,每一次增加,都是把之前的内容进行了替换,而不是更新。因为每一次我们的 map 都是一个新 map。
在 TODO 里增加一个新函数
我们创建一个新的函数来把之前写入到 db.txt 里的内容读出来。
我们把这个函数称之为 new,new 有点像 js 里的 constructor,但是 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 })
}
// ...其余的方法
}
- new 函数,的返回是一个 Result,如果成功则返回 Todo,如果失败,则返回 std::io::Error
- 打开 db.txt 时,使用了 OpenOptions。打开的这个文件,可读,可写,create(true) 说明,如果这个文件不存在,则创建。
- ? 是 rust 对于 Result 展开的语法糖,如果遇到 Error,则会立即抛出 Error,如果一切顺利,则获得 Result 中的类型。
f.read_to_string(&mut content)?
读取了文件所有的内容,并且把文件的内容放入了 content 中。这里需要增加 use std::io::Read;否则 read_to_string 会报错。- 读取的内容是一个文本,我们需要把文本转换成一个 HashMap。
let map: HashMap<String, bool>
声明做了这个 HashMap。这里编译器并不能帮我们推断类型,所以需要显式声明这个 map 的类型。 - lines() 对一个字符串的每一行创建了一个迭代器。
- map 会调用一个闭包,然后作用闭包到迭代器的每一个元素中。
- line.splitn(2,’\t’) 会把每一行字符串按照 tab 进行分隔成 2 个。
collect::<Vec<&str>>()
是标准库非常强大的一个方法,这个方法把一个迭代器转化为一个集合类型。这里把分隔好的字符串,转化成了Vec<&str>
。- .map(|v| (v[0], v[1])) 继续转化内容为一对 tuple
- 然后.map(|(k, v)| (String::from(k), bool::from_str(v).unwrap())) 把这个 tuple 转化为 String 和 boolean。注意这里要增加 use std::str::FromStr;
- 最后调用 collect(),获得最终的 HashMap。因为声明的 map 有类型,所以 collect 不再需要类型。
- 如果没有遇到任何错误,最后返回 *
Ok*(Todo { map })
,和 JavaScript 类似,如果 struct 的元素的名字和变量名字一样,可以简写。
另一个实现方式
使用 for 循环,而不是迭代器的方法:
fn new() -> Result<Todo, std::io::Error> {
// open the db file
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.read(true)
.open("db.txt")?;
// read its content into a new string
let mut content = String::new();
f.read_to_string(&mut content)?;
// allocate an empty HashMap
let mut map = HashMap::new();
// loop over each lines of the file
for entries in content.lines() {
// split and bind values
let mut values = entries.split('\t');
let key = values.next().expect("No Key");
let val = values.next().expect("No Value");
// insert them into HashMap
map.insert(String::from(key), bool::from_str(val).unwrap());
}
// Return Ok
Ok(Todo { map })
}
上面这个实现与更“函数式”的实现结果是等价的。
如何使用新的函数
现在需要更新初始化 Todo 的代码
let mut todo = Todo::new().expect("Initialisation of db failed");
现在每次运行的结果,都会保存到 db.txt 中
$ cargo run -- add "from js to rust"
todo saved
$ cargo run -- add "from js to rust 2"
todo saved
$ cat db.txt
from js to rust 2 true
from js to rust true
如何更新集合中的数据
就像大多数 TODO 应用,不仅要增加条目,在完成时,还要标识完成。
增加 complete 方法
impl Todo {
// [其余的 TODO 方法]
fn complete(&mut self, key: &String) -> Option<()> {
match self.map.get_mut(key) {
Some(v) => Some(*v = false),
None => None,
}
}
}
- complete 方法的返回值是一个空的 Option
- 方法体根据匹配结果要么是一个空的 Some,要么是一个 None
- self.map.get_mut 会给我们一个 key 的可变引用,如果没有找到这个 key,则返回 None
-
- 把变量进行去引用,然后把值改为 false
如何使用 complete 方法
我们可以扩展之前 insert 在的代码。
// 在 main 函数中
if action == "add" {
// 增加 complete 方法
} 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) 的返回结果进行匹配
- 如果为 None,则提示友好的信息告诉用户没有这个行为。我们给 complete 传入的是&item,所以所有权,仍然在当前代码。所以我们可以在 println! 中使用 item。如果不这么做,item 的值会被 complete 获得,接下来就不能用了。
- 如果我们检测到 Some,说明对数据进行了更改,这时调用 save 方法,保存当前的内容。
运行代码
$ rm db.txt
$ cargo run -- add "make tea"
$ cargo run -- add "code rust"
$ cargo run -- complete "make tea"
$ cat db.txt
make tea false
code rust true
赠品:如何用 JSON 进行存储
这个程序,虽然小巧,但是可以运行。因为我们来自 JavaScript 的世界,所以我们把最后的输出改为 JSON。
这里需要使用第三方的库,所以我们去 Rust 寻找第三方库的网站 crates.io。
如何安装 serde
按照第三方库在项目中,打开 cargo.toml,在 [dependencies]
[dependencies]
serde_json = "1.0.60"
保存以后,在编译时,cargo 会去下载 serde 的 crate
更新代码
首先更新 new 方法,这里不再打开一个 txt 文件,而是 JSON 文件
// 在 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 都会帮我们做这些事。
- 文件扩展名改为 json
- serde_json::from_reader 会把文件反序列化给我们。并且会进行自动转化,如果一切顺利,则获得和之前一样的 Todo
- Err(e) if e.is_eof() 是一个 Match guard,可以定一个一个 Match 语句的行为。如果Serde 返回的错误是 EOF (end of file),这说明这个文件是空文件(例如第一次运行,或者我们删除了文件)。如果是一个空文件,则新建一个空 HashMap。
- 所有其他的错误,则直接 panic
如何更新 save
修改 save 代码
// inside Todo impl block
fn save(self) -> Result<(), Box<dyn std::error::Error>> {
// open db.json
let f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.open("db.json")?;
// write to file with serde
serde_json::to_writer_pretty(f, &self.map)?;
Ok(())
}
- Box,这里返回了一个 Box 包含 Rust 的泛型错误。box 是一个指向内存的指针。因为,这里即可能是一个文件系统的错误,也可能是 serde 的错误,所以我们并不知道返回的错误是什么。所以使用指针来保存错误,而不是返回错误本身。
- 把存储的文件内容改为 db.json
- 最后,serde 帮我们把文件内容存储为 JSON(pretty printed 格式)
- 这时就不再需要use std::io::Read; 和 use std::str::FromStr;
现在再重新运行你的程序,存储的格式就变为了 JSON。