[译]如何用 Rust 构建待办事项应用程序——Rust 编程语言的实践介绍

399 阅读17分钟

原文链接:www.freecodecamp.org/news/how-to… 作者:Claudio Restifo

自2015年首次开源发布以来,Rust编程语言已经获得了社区的大量关注。自2016年以来,它也被评为Stack Overflow开发者调查中最受欢迎的编程语言。

Rust是由Mozilla设计的,被认为是一种系统编程语言(如C或C++)。它没有垃圾收集器,这使得它的性能真的很好。但它的设计往往让它看起来和感觉都非常“高级”。

Rust的学习曲线被认为有些陡峭。我自己不是这门语言的大师,但是通过本教程,我将尝试给你一个实用的方法来帮助你更深入地理解一些概念。

我们将在这个实践教程中构建什么

我决定遵循JavaScript应用程序的悠久传统,制作一个待办事项应用程序作为我们的第一个项目。我们将使用命令行,所以熟悉它是必要的。您还需要一些一般编程概念的知识。

此应用程序将在终端中运行。我们将值存储为项目的集合和表示其活动状态的布尔值。

我们将在这里涵盖的

  • Rust中的错误处理。

  • 选项和Null类型。

  • 结构和impl.

  • 终端I/O.

  • 文件系统处理。

  • Rust中的所有权和借款。

  • 匹配模式。

  • 迭代器和闭包。

  • 使用外部板条箱。

在我们开始之前

在我们开始之前,来自JavaScript背景的人给了我们一些建议:

  • Rust是一种强类型语言。这意味着,当编译器无法为我们推断类型时,我们将不得不处理变量类型。

  • 与JavaScript相反,这里没有AFI。这意味着我们必须自己键入分号(";"),除非它是函数的最后一个语句。在这种情况下,你可以省略;把它作为回报。

废话不多说,我们开始吧。

如何开始使用 Rust

开始,请将Rust下载到您的计算机上。要做到这一点,请按照您在Rust官方网站的入门页面上找到的说明进行操作。

在那里,您还可以找到将语言与您最喜欢的编辑器集成的说明,以获得更好的体验。

除了Rust编译器本身,Rust还附带了一个名为Cargo的工具。Cargo是Rust包管理器,对JavaScript开发人员来说,它就像npm或纱线一样。

要启动新项目,请导航到您希望创建项目的位置,然后只需运行货运新。在我的情况下,我决定将我的项目命名为to do-cli,这样我就可以运行:

$ cargo new todo-cli

现在导航到新创建的目录并列出其内容。你应该在里面看到两个文件:

$ tree .

.

├── Cargo.toml

└── src

 └── main.rs

在本教程的其余部分,我们将处理src/main.rs文件,所以继续打开它。

像许多其他语言一样,Rust有一个将首先运行的主要函数。在println!是一个。正如你可能猜到的,这个程序是“你好世界!”的铁锈版本。

要构建和运行它,只需执行货物运行。

$ cargo run

Hello world!

如何阅读论点

我们的目标是让我们的CLI接受两个参数:第一个是动作,第二个是项目。

我们将从阅读用户输入的参数并将其打印出来开始。

将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[doc]将值绑定到变量。

  • std:: env:: args()[doc]是从标准库的env模块中引入的函数,它返回程序启动时的参数。由于它是一个迭代器,我们可以使用n th()函数访问存储在每个位置的值。位置0的参数是程序本身,这就是为什么我们从第一个元素开始阅读。

  • except()[doc]是为Option枚举定义的方法,它将返回值,或者如果不存在,将立即终止程序(Rust 术语中的恐慌),返回提供的消息。

因为程序可以在没有参数的情况下运行,Rust要求我们通过给我们一个Option类型来检查是否实际提供了一个值:值是否存在。

作为程序员,我们有责任确保在每种情况下采取适当的行动。

目前,如果没有提供参数,我们将立即退出程序。

让我们运行程序并传递两个参数。为此,将它们附加在--。例如:

$ cargo run -- hello world!

    Finished dev [unoptimized + debuginfo] target(s) in 0.01s

     Running `target/debug/todo_cli hello 'world'\!''`

"hello", "world!"

如何使用自定义类型插入和保存数据

让我们思考一下我们的项目目标。我们希望阅读用户给出的参数,更新我们的待办事项列表,并将其存储起来以备使用。

为此,我们将实现我们自己的类型,在那里我们可以定义我们的方法来满足业务需求。

我们将使用Rust的结构,它让我们以一种干净的方式完成这两个任务。它避免了必须在main函数中编写所有代码。

如何定义我们的结构

由于我们将在以下步骤中大量键入HashMap,我们可以将其纳入范围,并为自己节省一些键入。

在我们文件的顶部添加这一行:

use std::collections::HashMap

这将让我们直接使用HashMap,而不需要每次都输入完整的路径。

在主函数下面,让我们添加以下代码:

struct Todo {

    // use rust built in HashMap to store key - val pairs

    map: HashMap<String, bool>,

}

这将定义我们的自定义To do类型:一个带有一个名为map的字段的结构。

这个字段是一个HashMap。您可以将其视为一种JavaScript对象,其中Rust要求我们声明键和值类型。

  • HashMap<String, bool>意味着我们有由Strings组成的键,还有一个布尔值:活动状态。

如何向我们的结构添加方法

方法就像常规函数——它们用fn关键字删除,它们接受参数,并且它们有返回值。

然而,它们不同于常规函数,因为它们是在结构的上下文中定义的,它们的第一个参数总是自定义的。

我们将在新添加的结构下面定义一个impl(实现)块。

impl Todo {

    fn insert(&mut self, key: String) {

        // insert a new item into our map.

        // we pass true as value

        self.map.insert(key, true);

    }

}

这个函数非常简单:它只是对结构和键进行引用,并使用HashMap的内置插入方法将其安装到我们的映射中。

两条非常重要的信息:

  • mut[doc]使变量可变。

  • 在Rust中,默认情况下每个变量都是不可变的。如果要更新值,需要使用mut关键字选择加入可变性。由于我们的函数通过添加新值来有效地更改映射,因此我们需要将其声明为可变的。

  • &[doc]表示引用*。*

  • 您可以将var aible想象成指向存储值的内存位置的指针,而不是“值”本身。

  • 在Rust术语中,这被称为借用,这意味着函数实际上并不拥有这个值,但它仅仅指向存储它的位置。

Rust所有权制度概述

有了前面关于借用和引用的提示,现在是简要谈论所有权的好时机。

所有权是Rust最独特的特征。它使Rust程序员能够编写程序,而不需要手动分配内存(如C/C++),同时仍然能够在没有垃圾收集器(如JavaScript或Python)的情况下运行,垃圾收集器会不断查看程序的内存以释放未使用的资源。

所有制有三条规则:

  • Rust中的每个值都有一个变量:它的所有者。

  • 每个值一次只能有一个所有者。

  • 当所有者超出范围时,该值将被删除。

Rust在编译时检查这些规则,这意味着如果希望在内存中释放值,您必须明确。

想想这个例子:

fn main() {

 // the owner of the String is x

 let x = String::from("Hello");



 // we move the value inside this function.

 // now doSomething is the owner of x.

 // Rust will free the memory associated with x 

 // as soon as it goes out of "doSomething" scope.

 doSomething(x);



 // The compiler will throw an error since we tried to use the value x

 // but since we moved it inside "doSomething"

 // we cannot use it as we don't have ownership

 // and the value may have been dropped.

 println!("{}", x);

}

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

您可以从Rust的官方文档中阅读关于所有权的更深入的解释。

对于所有制的来龙去脉,我们就不深究了。现在只要记住我上面提到的规则。试着思考,在每一步中,我们是否需要“拥有”这些值,然后删除它们,或者我们是否需要一个引用,这样它才能被保留。

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

如何将地图保存到磁盘

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

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

impl Todo {

    // [rest of the code]

    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)

    }

}
  • ->从函数中注释返回的类型。我们返回一个结果。

  • 我们在地图上迭代,格式化每个字符串,用制表符分隔键和值,用新行分隔每行。

  • 我们将格式化的字符串推送到内容变量中。

  • 我们在一个名为db.txt.的文件中写入内容

重要的是要注意,储蓄是自我的所有权

这是一个任意的决定,所以如果我们在调用保存后意外地试图更新地图,编译器会阻止我们(因为自己的内存会被释放)。

这是个人决定强制保存为最后使用的方法。这是一个完美的例子来展示如何使用Rust的内存管理来创建更严格的不能编译的代码(这有助于防止开发过程中的人为错误)。

如何在main中使用结构

现在我们有了这两种方法,我们可以使用它们。我们从阅读提供的参数开始就停止了main。现在,如果提供的操作是添加,我们将把该项目插入到文件中,并将其存储以备以后使用。

在两个参数绑定下面添加这些行:

fn main() {

    // ...[arguments bindig code]



    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 to do=To do让我们实例化一个结构,将其绑定为可变的。

  • 我们使用.表示法调用TO DO插入方法。

  • 我们匹配从保存函数返回的结果,并为这两种情况在屏幕上打印一条消息。

让我们测试一下。导航到您的终端并键入:

$ cargo run -- add "code rust"

todo saved

让我们检查保存的项目:

$ cat db.txt

code rust true

到目前为止,您可以在本要点中找到完整的代码片段。

如何从文件中读取

现在我们的程序有一个根本性的缺陷:每次我们“添加”时,我们都会覆盖地图,而不是更新它。这是因为我们每次运行程序时都会创建一个新的空映射。让我们解决这个问题。

在TO DO中添加一个新函数

我们将为我们的To do结构实现一个新函数。调用后,它将读取我们文件的内容,并返回使用先前存储的值填充的To do。请注意,这不是一个方法,因为它不把自己作为第一个参数。

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

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

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 })

    }



// ...rest of the methods

}

不用担心,如果这感觉有点压倒性。我们使用了一种更加函数化的编程风格,主要是为了展示和介绍Rust支持其他语言中的许多范例,如迭代器、闭包和lambda函数。

让我们看看这里发生了什么:

  • 我们正在定义一个新函数,它将返回一个To do结构或io: Error的Result。

  • 我们通过定义各种Open Options来配置如何打开db.txt文件。最值得注意的是创建(true)标志,如果文件还不存在,它将创建该文件。

  • f.read_to_string(&mut含量)?读取文件中的所有字节,并将它们附加到内容字符串中。

  • 注意:记得添加use std*:: io:*: Read;在文件的顶部,以及其他use语句,以使用read_to_string方法。

  • 我们需要将文件的String类型转换为HashMap。我们通过将map变量绑定到这一行来实现: let map: HashMap<String, bool>。

  • 这是编译器无法为我们定义类型的情况之一,所以我们自己声明它。

  • line[doc]为字符串的每一行创建一个迭代器,这意味着现在我们将迭代文件的每一个条目,因为我们在每个条目的末尾用/n格式化了它。

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

  • line.splitn(2,'\t')[doc]将在制表符上拆分我们的行。

  • 在文档中描述的[doc]是标准库中最强大的方法之一:它将迭代器转换为相关的集合。

  • 在这里,我们告诉map函数通过在方法中添加:: Vec<&str>来将我们的Split字符串转换为借用字符串切片的Vector。这告诉编译器在操作结束时我们想要哪个集合。

  • 然后我们使用. map(|v|(v[0], v[1]))将其转换为一个元组。

  • 然后我们使用. map(|(k, v)|(String:: from(k),bool::from_str(v).unwrap()))将元组的两个元素转换为String和布尔值。

  • 注意:记得添加use std*::* str:: From Str;在文件的顶部以及其他use语句,以便能够使用from_str方法。

  • 我们最终将它们收集到我们的HashMap中。这一次我们不需要声明类型,因为Rust从绑定声明中推断出它。

  • 最后,如果我们从未遇到任何错误,我们将结构返回给调用方,并使用Ok(To do{map})。

  • 请注意,与JavaScript非常相似,如果键和变量在结构中具有相同的名称,我们可以使用更短的表示法。

唷!

img

你做得很好!图像学分:rustacean.net/

替代办法

虽然map通常被认为更地道,但上面的也可以用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 })

}

上面的代码在功能上等同于之前使用的更“功能性”的方法。

如何使用新功能

在main中,只需用以下方式更新我们的to do变量:

let mut todo = Todo::new().expect("Initialisation of db failed");

现在,如果我们回到终端并运行一堆“添加”命令,我们应该会看到我们的数据库正在正确更新:

$ cargo run -- add "make coffee"

todo saved

$ cargo run -- add "make pancakes"

todo saved

$ cat db.txt

make coffee     true

make pancakes   true

您可以在本要点中找到到目前为止编写的完整代码。

如何更新集合中的值

和所有TO DO应用一样,我们希望不仅能够添加项目,还能够切换项目并将其标记为已完成。

如何添加完整的方法

为此,让我们向我们的结构添加一个名为“完成”的新方法。在其中,我们获取对键的引用,并更新值,或者如果键不存在,则返回“无”。

impl Todo {

// [Rest of the TODO methods]



  fn complete(&mut self, key: &String) -> Option<()> {

      match self.map.get_mut(key) {

          Some(v) => Some(*v = false),

          None => None,

      }

  }

}

让我们看看这里发生了什么:

  • 我们声明我们的函数返回类型:空选项。

  • 整个方法返回Match表达式的结果,该结果将是空的some()或无。

  • self.map.get_mut[doc]将为我们提供对key值的可变引用,或者如果集合中不存在该值,则提供无。

  • 我们使用*[doc]运算符取消引用该值并将其设置为false。

如何使用完整的方法

我们可以以类似于之前使用的插入的方式使用“完成”方法。

在main中,让我们检查作为参数传递的操作是否“完成”,方法是使用其他if语句:

// in the main function



if action == "add" {

    // add action snippet

} 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. full(&project)方法返回的Option。

  • 如果情况为无,我们会向用户打印警告,以获得更好的体验。

  • 我们将项目作为引用与&项目传递给"to do. full"方法,以便该值仍然由该函数拥有。这意味着我们可以用它来印刷!宏在下面的行。

  • 如果我们不这样做,这个值就会被“完成”所拥有,并下降到那里。

  • 如果我们检测到一些值已返回,我们调用todo.save将更改永久存储到我们的文件中。

和前面一样,您可以在本要点中找到到目前为止编写的代码的快照。

尝试运行程序

是时候在我们的终端上试用我们在本地开发的应用了。让我们从删除db文件开始重新开始。

$ rm db.txt

然后添加和修改一些待办事项:

$ cargo run -- add "make coffee"

$ cargo run -- add "code rust"

$ cargo run -- complete "make coffee"

$ cat db.txt

make coffee     false

code rust       true

这意味着在这些命令的末尾,我们有一个完成的操作(煮咖啡)和一个待定的操作:code Rust。

假设我们想再煮一次咖啡:

$ cargo run -- add "make coffee

$ cat db.txt

make coffee     true

code rust       true

奖金:如何将其存储为带Serde的JSON

程序,即使是最小的,正在运行。但是让我们稍微扭转一下。来自JavaScript世界的我决定将我的值存储为JSON文件,而不是纯文本文件。

我们将借此机会看看如何安装和使用一个来自Rust开源社区的包,名为crates.io.

如何安装serde

要将新包安装到我们的项目中,请打开cargo.toml文件。在底部,您应该会看到一个[依赖]字段:只需将以下内容添加到文件中:

[dependencies]

serde_json = "1.0.60"

就这样。下一次,货物将编译我们的程序,还将下载并包含新包以及我们的代码。

如何更新To do:: New

我们要使用Serde的第一个地方是当我们读取db文件时。现在,我们希望读取JSON文件,而不是读取. txt。

在impl块中,让我们更新新函数:

// inside Todo impl block



fn new() -> Result<Todo, std::io::Error> {

    // open db.json

    let f = std::fs::OpenOptions::new()

        .write(true)

        .create(true)

        .read(true)

        .open("db.json")?;

    // serialize json as 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绑定,因为我们不需要像以前那样手动将内容分配到字符串中。塞尔德会为我们处理的。

  • 我们更新了我们的文件扩展名为db.json.

  • serde_json::from_reader[doc]将为我们反序列化该文件。它干扰映射的返回类型,并将尝试将我们的JSON转换为兼容的HashMap。如果一切顺利,我们会像以前一样返回To do结构。

  • Err(e)如果e.is_eof()是一个Match守卫,它允许我们细化Match语句的行为。

  • 如果Serde返回一个错误一个过早的EOF(文件结束),这意味着该文件是完全空的(例如在第一次运行,或者如果我们删除了文件)。在这种情况下,我们从错误中恢复并返回一个空的HashMap。

  • 对于所有其他错误,请立即退出程序。

如何更新Todo.save

我们想使用Serde的另一个地方是当我们将地图保存为JSON时。为此,更新impl块中的保存方法为:

// 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<dyn std:: error:: Error>.这次我们返回一个包含Rust泛型错误实现的Box。

  • 简单地说,框是指向内存分配的指针。

  • 因为我们可能会在打开文件时返回一个文件系统误差,或者在转换文件时返回一个Serde错误,所以我们真的不知道我们的函数可能会返回这两个错误中的哪一个。

  • 因此,我们返回一个指向可能错误的指针,而不是错误本身,以便调用方处理它们。

  • 我们当然已经更新了文件名以db.json匹配。

  • 最后,我们让Serde完成繁重的工作,并将我们的HashMap写成JSON文件(打印精美)。

  • 记得删除两个use std:: io:: Read;和use std:: str:: From Str;从文件的顶部,因为我们不再需要他们。

就这样。

现在,您可以运行程序并检查保存到文件中的输出。如果一切顺利,您现在应该看到您的待办事项保存为JSON。

您可以在本要点中找到这样编写的完整代码。

结束语、提示和其他资源

这是一段相当长的旅程,我很荣幸你能和我一起走。

我希望你学到了一些东西,并通过这次介绍激发了你的好奇心。别忘了,我们使用的是一种非常“低级”的语言,然而审查代码可能对大多数人来说都很熟悉。

这就是Rust吸引我的地方——事实上,它让我能够编写速度极快、内存高效的代码,而不用担心随之而来的责任感:我知道编译器会在我身边,在我的代码运行之前就停止它。

在结束之前,我想与您分享一些额外的技巧和资源,以帮助您在Rust之旅中前进:

  • Rust fmt是一个非常方便的工具,您可以运行它来按照一致的模式格式化您的代码。不再浪费时间配置您最喜爱的linter插件。

  • 货物检查[doc]将尝试在不运行的情况下编译代码:这在开发时是一个非常有用的命令,在开发时,您只想检查代码的正确性,而不实际运行它。

  • Rust附带了一个集成的测试套件和一个生成文档的工具:货物测试和货物文档。这次我们没有提到它们,因为教程看起来相当密集。也许在未来。

要了解更多关于这门语言的信息,我认为最好的资源是:

  • Rust官方网站,收集所有信息。

  • 如果您喜欢通过聊天进行互动,Rust's Disode服务器有一个非常活跃和有用的社区。

  • 如果你喜欢通过阅读来学习,《锈编程语言》这本书是你的正确选择。

  • 如果你更喜欢视频类型,瑞安·莱维克对铁锈视频系列的介绍是一个惊人的资源。

您可以在GitHub上找到本文的源代码。

封面图片来自rustacean.net/.

感谢阅读和快乐编码!