基础语法以及文件操作

Rust 是一门 命令式编程 的语言,并且借鉴了一些 函数式编程 的概念。
Rust 围绕于 代码安全稳定性 的概念,这代表没有任何东西的类型可以是 NullRust 不存在 Null
这个概念。同时任何有机会出错的代码都会建议你进行错误处理,导致程序发生错误并且崩溃的机会非常低。
Rust内存安全 不使用 垃圾收集器 来实现,而是靠 所有权生存周期 实现的。并且在我看来,Rust
开发者在解决这个问题的同时,解决了所有我在意的问题。

本文仅作于我的学习笔记,不保证完全正确。

安装

要安装 Rust ,官方的 RustUp 就有下载链接以及linux你需要运行的命令。

要注意的是,如果你电脑没有 VisualStudio C++ 套件,你需要去安装一套,不然会编译失败。

创建项目

CargoRust的包管理器,并且用语创建项目。

命令指示符
1
cargo new <项目名>

这会创建一个 Executable 的项目模版,而你也可以创建 Liberary 模块的项目模版。
在当前目录你就能看到src文件夹里面有一个main.rs文件,并且带有默认的Hello World代码。

src/main.rs
1
2
3
fn main() {
println!("Hello, world!");
}

如果你来自其他编程语言,你可以很简单的理解println这个“函数”是把 Hello, world 输出。
但你要知道的是,println并不是一个function,而是一个macro,这些的名字都以!结尾,并且是会生成一段代码。之所以println
是一个macro而无法使用函数实现,这是因为rust函数不可以输入未知数量的参数,而println可以支持格式化字符串。

同时你还可以看到根目录有一个 Cargo.toml ,这里面是你项目的配置。

Cargo.toml
1
2
3
4
5
6
7
8
[package]
name = "kvstore"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]

最后是 target 目录,乍看一下是一堆不知道是什么东西的文件,但是重点在于 target/debug/
目录中有你代码的可执行文件 kvstore
如果你运行这个文件,就会输出 Hello, World!

你可以使用一下指令编译:

命令指示符
1
cargo build

但是事实上,你一般不会这样编译并且手动执行程序,有个缩短的指令:

命令指示符
1
cargo run

如果出现 [error: link.exe not found]{.label .danger} 错误,这代表你没安装 VSC++

注意这是在debug文件夹里面,而还有一个release文件夹,这是因为这是一个debug的编译。
debug的时候编译器不会去尝试最优化二进制文件,因此一般编译速度快,但执行速度比较慢。
release的时候编译器会尽可能的进行优化,编译时间长,但执行速度快。
有很多人接触rust的时候都因为没在编译的时候加上--release而觉得rust不够快。

代码

在这将构造一个使用key设置和获取value的命令行工具。

获得指令参数

我们可以使用std::env::args()获取指令行运行的参数。

::rust里面代表下一级的意思,也就是std里面env里面的args()

main.rs
1
let args = std::env::args();

在这里,args是一个iterable,而一般操作系统的第一个参数为可执行文件的路径,我们不需要这个,因此可以使用skip(1)跳过一个项目。

main.rs
1
let args = std::env::args().skip(1);

然后我们要获取第一个参数,我们可以使用next()获取iterable的下一个项目:

main.rs
1
let key = args.next();

但这时候我们就看到ide标出了args那行,看上去报错了。
这是因为我们需要让rust知道args是可变换的。与很多语言不一样,rust需要你明确说明这个是mutable
,而大部分主流语言都是相反的,让你去说明哪些元素是不可变换的,在rust所有元素默认为不可变换。
我们只需要加上mut即可:

main.rs
1
2
let mut args = std::env::args().skip(1);
let key = args.next();

在IDE可以看到,key的类型为Option<String>,这代表这个key是一个可以是不存在的的字符串,没有字符串就会指向Option<None>
如果我们要让程序在没有输入的时候 panic,我们可以使用unwrap()来让程序崩溃。

main.rs
1
let key = args.next().unwrap();

这时候如果你不提供参数,就会崩溃并且看到

called Option::unwrap() on a None value

同样的我们也可以获取到第二个参数value,并且把它们输出看看是否正确。

main.rs
1
2
3
4
5
6
fn main() {
let mut args = std::env::args().skip(1);
let key = args.next().unwrap();
let value = args.next().unwrap();
println!("The key is {}, the value is {}", key, value)
}

如果你想要一个更清楚的panic信息,你可以使用Option::expect("信息")而不是Option::unwrap()

文件读写

那么这时候我们应该怎么写入文件内容呢?
这时候我们就可以去rust官方文档里面看看,官方文档有个很好记的快捷域名 docs.rs/std
搜索之后可以看到 std::fs::File 似乎很合适。
然后再让我们看看 std::fs
是做什么的,看上去有一些很方便的函数
看上去我们能用 std::fs::write() 来写入字符串到一个文件路径上。

我将会以 <key>\t<value>\n 的方式存储这些对。
那么我们可以通过 std::format!() 获取该字符串。 (这里有个!因此是macro)

main.rs
1
let contents = std::format!("{}\t{}\n", key, value);

然后通过 std::fs::write() 来将字符串写入到 kv.db 中。

main.rs
1
std::fs::write("kv.db", contents);

那么现在我们可以试试看:

命令指示符
1
cargo run -- hello world

这时候你就会发现有一个警告

this Result may be an Err variant, which should be handled

write()会返回一个Result,而这个Result有可能是错误的,因此应该被处理。

想一下,在写入文件的时候一定能成功吗?这是不一定的,你可能没有权限写入,或者写入失败。
在其他语言里面,你或许可以throw一个错误,并且在另一个地方catch到那个错误。
但在 Rust 里面,并没有 throw 错误并且 catch 的概念,如果程序 panic 了,那程序就会崩溃,就是这么简单。
那么我们看看这个 Result 到底是什么东西。

main.rs
1
let result = std::fs::write("kv.db", contents);

从IDE可以看到,result 的类型为 Result<(), Error>,这代表如果是成功的,就会是 () ,错误了当然就是错误 Error

() 是一个 unit ,是一个空的元组,它的意思类似于其他语言的 void ,也就是不返回任何东西,只是代表成功了。

那么我们要怎么处理这些错误呢?我们可以使用pattern matching模式匹配。
Pattern match 像是其他语言里面的 switch ,但更加完善且强大。

main.rs
1
2
3
4
5
6
7
8
match std::fs::write("kv.db", contents) {
Ok(()) => {
// 成功的代码
}
Err(e) => {
// 错误的代码
}
}

在这里的意思是,如果为成功 Ok(()) 就运行成功的代码块。
而如果失败,就把错误放到 e 里面,运行错误的代码块。
这像是 try catch ,但是 match 不止能用于错误处理,任何地方都可以使用。

但是与其优雅的处理错误,我们也可以像之前参数一样让错误发生时就panic程序,让程序崩溃。
ResultOption 一样都有 unwrap() , 而功能也是一样,当错误发生时 panic

main.rs
1
std::fs::write("kv.db", contents).unwarp();

Structs

我们不希望整个程序都在 main() 里面,而是应该创造更多的抽象概念。做到这一点的第一步就是构造一个 struct 结构体。
Rust 不是一个面向对象的语言,没有 class 的概念,你的类型通常就是一个 struct
struct 本质上就是一堆键值,类型,数值的集合,你可以定义不同键值为不同的类型,并在创建的时候提供数值。
implstruct 的实现,先构造 struct ,然后使用 impl 添加函数。

让我们创建一个创建 Databaseimpl

main.rs
1
2
3
4
5
6
7
struct Database {}

impl Database {
fn new() -> Database {
Database {}
}
}

然后我们可以在 main() 里面调用 Database::new() 来获取实例。

new() 这个名字事实上只是一个惯例,这可以叫任何名字,只是官方就是用的 new()

然后想一下有什么数据结构适合我们需要的键值组合,那就是 Hash map
而很方便的, Rust 自带了 Hash Map 的实现。

main.rs mark:2
1
2
3
struct Database {
map: std::collections::HashMap<String, String>
}

在这里的 <String, String> 代表这个 Hash map 的键值都是字符串。

我知道这个程序将会需要多次的 std::collections::HashMap ,而我也不想每次都要写这么长一串,因为我们可以使用 use 导入。

main.rs mark:1,4,10
1
2
3
4
5
6
7
8
9
10
11
12
13
use std::collections::HashMap;

struct Database {
map: HashMap<String, String>,
}

impl Database {
fn new() -> Database {
Database {
map: HashMap::new(),
}
}
}

那么现在有了个Database,但这个 Map 是空的,我们应该在创建数据库的时候,解析数据库文件的数据,然后把那些数据放进 map
因此现在我们需要的步骤如下:

  1. 读取 kv.db
  2. 解析该字符串
  3. 导入 map 里面

读取数据

在上面 std::fs 中我们还可以看到一个函数, std::fs::read
但看一下这个函数的返回类型为 Result<Vec<u8>> ,是一个 unsined 8 bit integer 的向量,这明显不是我们要的字符串。
接下来看看 std::fs::read_to_string ,这回返回的类型就是一个字符串了。

我们可以使用之前的 match ,来进行错误处理

main.rs mark:1
1
2
3
4
let result = match std::fs::read_to_string("kv.db") {
Ok(contents) => {}
Err(e) => {}
}

那么我们要怎么达成一个逻辑,如果成功取出content,失败就把错误返回让上层处理呢?
你可以看到第一行中有一个 let ,这是因为 match 事实上是可以有返回值到上层的。
基本上 Rust 所有东西都是 expression 而不是 statement
也就是说我们可以这样做:

main.rs mark:1
1
2
3
4
let result = match std::fs::read_to_string("kv.db") {
Ok(contents) => contents,
Err(e) => {}
}

然后我们需要返回错误,给上层处理。 这时候我们就需要用 Result::Err()
把错误包装起来,并且把返回类型改为 Result<Database, Error>

main.rs mark:2,6
1
2
3
4
5
6
7
8
9
10
11
12
13
impl Database {
fn new() -> Result<Database, Error> {
let result = match std::fs::read_to_string("kv.db") {
Ok(contents) => contents,
Err(e) => {
return Err(e);
}
};
Database {
map: HashMap::new(),
}
}
}

事实上,因为这个操作非常的常见, Rust 给了一个简化版的处理方案,你只需要加上 ? 就能达到一样的逻辑。

main.rs mark:3
1
2
3
4
5
6
7
8
impl Database {
fn new() -> Result<Database, Error> {
let result = std::fs::read_to_string("kv.db")?;
Database {
map: HashMap::new(),
}
}
}

其实, ? 不止对 Result 有用,以后有遇到再说。
并且因为之前把返回类型改变为了 Result ,我们需要把 Database 也用 Ok() 包装一下。
同时在上面创建数据库的时候,加上 expect() 来让程序数据库出错的时候 panic

main.js mark:11
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
27
use std::collections::HashMap;
use std::io::Error;

fn main() {
let mut args = std::env::args().skip(1);
let key = args.next().unwrap();
let value = args.next().unwrap();
println!("The key is {}, the value is {}", key, value);
let contents = std::format!("{}\t{}\n", key, value);
std::fs::write("kv.db", contents).unwrap();
let database = Database::new().expect("Database::new() crashed");
}

struct Database {
map: HashMap<String, String>,
}

impl Database {
fn new() -> Result<Database, Error> {
let result = std::fs::read_to_string("kv.db")?;
Ok(
Database {
map: HashMap::new(),
}
)
}
}

解析数据

现在我们获取到了文件的内容,接下来就是把它转换为 HashMap
首先第一件事是把行分开,一行就是一对的键值。我们可以使用 result.lines() 获取一个行的 iterator ,并且通过一个 for
循环获取行字符串。

这里的 result.lines() 事实上是懒的 iterator,在调用的时候并没有已经预先计算好有的元素,而是需要的时候再计算,并不是一个向量或者列表。

然后使用 line.split_once('\t'')\t 分割字符串。
接下来使用 map.insert() 把 kv 加入到 map 里。

main.rs mark:5,6,7
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
impl Database {
fn new() -> Result<Database, Error> {
let result = std::fs::read_to_string("kv.db")?;
let mut map = HashMap::new();
for line in result.lines() {
let (key, value) = line.split_once('\t').expect("Corrupted Data");
map.insert(key, value);
}
Ok(
Database {
map: map,
}
)
}
}

但如果你现在运行,你会发现是不能够编译的。会出现

expected struct String, found &str

的错误,那么它这个 &str 到底是什么类型呢,为什么 key, value 的类型是它。

因为没有垃圾收集, Rust 需要一个概念,所有权借所有权
CC++ 里面,我们一般是在用完之后调用 free 来从 Heap 释放。
而在 Rust 里面,一般都不会用到 free 这种释放内存的方法,而是每一个内存中的信息都有一个所有者。

main.rs
1
let value = args.next().unwrap();

例如在这里,这个String的所有者就是value。当那个所有者跑出作用域以外的时候,它拥有内存就会被销毁。
在编译的时候,编译器知道什么时候这个 binding 会跑出作用域,并在跑出之后销毁。

main.rs mark:11,12,31
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
27
28
29
30
31
32
use std::collections::HashMap;
use std::io::Error;

fn main() {
let mut args = std::env::args().skip(1);
let key = args.next().unwrap();
let value = args.next().unwrap();
println!("The key is {}, the value is {}", key, value);
let contents = std::format!("{}\t{}\n", key, value);
std::fs::write("kv.db", contents).unwrap();
let database = Database::new().expect("Database::new() crashed");
}

struct Database {
map: HashMap<String, String>,
}

impl Database {
fn new() -> Result<Database, Error> {
let result = std::fs::read_to_string("kv.db")?;
let mut map = HashMap::new();
for line in result.lines() {
let (key, value) = line.split_once('\t').expect("Corrupted Data");
map.insert(key, value);
}
Ok(
Database {
map: map,
}
)
}
}

那么理所当然的, map 拥有的那个 HashMap,理应在第31行的时候跑出作用域并且被销毁,但事实却不是这样。
因为Rust还有转移所有权的概念,这个 HashMap 被转移进 Database ,这 Database 然后被转移出作用域到第11行,最后在12行的时候跑出作用域。
然后这个 HashMap 就被释放了, HashMap 内的字符串也同样的被释放。
因此在你程序里面,你应该了解这个实例,现在的拥有权在谁手上。

main.rs
1
2
3
4
for line in result.lines() {
let (key, value) = line.split_once('\t').expect("Corrupted Data");
map.insert(key, value);
}

那么回到 &str 的问题,result 的类型为 String 的原因是因为那是一个有所有者的字符串,但你在 for line in lines()
中的 line 并不是 String 的原因是那些是 resultview 映射。
因此当 line 跑出作用域之后,什么事都不会发生,因为 line 并不是行字符串的所有者,而只是一个指向那一行的指针而已。
因此在31行result释放了之后, HasmMap 就不能够再使用 line 了。这也是为什么类型为 &str

再看看这个错误,现在这个错误的意思就是,

expected struct String, found &str

我期望的是已经有所有的字符串,而不是字符串切片的 view
HashMap 需要这个 String 的所有权。

解决这个问题的方法是调用 &strto_owned() ,这会把 view 变成一个实际有所有者的 String,然后就能成功 insert 了。