原文链接: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错误处理
  • OptionsNull类型
  • Structimpl
  • 终端I/O
  • 文件处理
  • Rust所有权与借用概念
  • 模式匹配
  • 迭代器Iterators和闭包closures
  • 使用第三方crates

开始之前

开始教程之前,作为一名JavaScript程序员,有一些建议给大家:

  • Rust是一门强类型语言,我们需要重点关注变量的类型。
  • 另一个和JavaScript相反的地方,Rust没有AFI机制,需要手动输入分号。只有函数的最后一行可以省略分号,这种情况下这行代码将被视为函数的返回值。

下面正式开始~

Rust起步

首先将Rust编译器下载到你的电脑上,参考官网的指引。指引中还能找到如何将这门语言集成到你最爱的编辑器上。

除了编译器本身,下载下来的还有一个名为Cargo的Rust包管理器,类似于JavaScript中的npmyarn

下载安装完毕后,切到你的想要建立项目的目录位置,在终端中输入命令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

现在我们有了insertsave两个方法,可以在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编程的话,我推荐这些资源:

这片文章涵盖的所有代码可以在这里浏览:GitHub;

封面来自 https://rustacean.net/

感谢阅读,祝大家编程快乐!


明酱
62 声望6 粉丝