• Tutorial on How to Build a To-do List App
  • Claudio Restifo/ @Hylerrix
  • Release Time/Translation Time: 20210104/20210129
  • Note: this article follows the freeCodeCamp translation specification and will be included in Rust, The Art of Deno Research.

Since the first open source version was released in 2015, Rust has received a lot of attention from the community. Rust was also the most popular programming language for developers every year in 2016, according to StackOverflow’s developer survey.

Designed by Mozilla, Rust is defined as a system-level programming language (like C and C++). Rust has no garbage processor and therefore performs extremely well. And some of these designs often make Rust look sophisticated.

Rust’s learning curve is widely regarded as one of the more difficult. I am not an in-depth user of the Rust language, but in this tutorial I will try to provide some practical approaches to concepts that will help you understand them more deeply.

What will we build in this hands-on tutorial?

I decided to make a To-do app our first Rust project by following the long tradition of JavaScript applications. We will focus on the command line, so you must know something about it. You also need to understand the basics of programming concepts.

This program will run on a terminal basis. We will store a collection of elements, each with a Boolean value representing its active state.

What concepts are we going to be talking about?

  • Error handling in Rust.
  • Options and Null.
  • User-defined Structs and impl.
  • Terminal I/O.
  • File system processing.
  • Ownership and borrow in Rust.
  • Match patterns.
  • Iterators and closures.
  • Use external package crates.

Before we get started

For developers from a JavaScript background, here are a few tips before we dive in:

  • Rust is a strongly typed language. This means that we need to keep an eye on variable types when the compiler can’t infer them for us.
  • Also unlike JavaScript, Rust does notAFI. This means that we must actively type the semicolon (“;”) after the statement. ) — unless it is the last statement of a function (in which case you can omit the semicolon);Think of it as a return.

Note: AFI, Automatic semicolon insertion JavaScript does not need to write semicolons, but certain statements must use semicolons to ensure that they are executed correctly.

Without delay, let’s get started!

How did Rust start from scratch

Step one: Download Rust to your computer. To download it, follow the instructions in the Getting Started section of the Official Rust documentation.

, through the curl, proto ‘= HTTPS’ – tlsv1.2 – sSf https://sh.rustup.rs | sh installation.

In the documentation above, you’ll also find instructions on how to integrate Rust with your familiar editors for a better development experience.

In addition to the Rust compiler itself, Rust comes with a tool, Cargo. Cargo is Rust’s package-management tool, much like NPM and YARN are used by JavaScript developers.

To start a new project, go to the terminal where you want to create the project, then simply run Cargo New to start. In my case, I decided to name my project “todo-cli”, so I had the following command:

$ cargo new todo-cli
Copy the code

Now go to the newly created project directory and print out a list of its files. You should see these two files in there:

$tree...├ ── fret. ├─ SRC └Copy the code

For the rest of the tutorial, we’ll focus on the SRC /main.rs file, so just open it.

Like many other programming languages, Rust has a main function as an entry point to everything. Fn to declare a function, println! In the! The symbol is a macro. You might immediately recognize that this is a “Hello World” program in the Rust language.

To compile and run the program, you can go directly to cargo Run.

$ cargo run
Hello world!
Copy the code

How do I read command line arguments

Our goal is to have our CLI tool accept two parameters: the first parameter represents the type of operation to be performed, and the second parameter represents the object to be operated on.

We’ll start by reading and printing the parameters entered by the user.

Replace the contents of the main function with the following:

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);
Copy the code

Digest the important information in the code:

  • let [documents]Bind a value to a variable
  • std::env::args() [documents]A function imported from the env module of the standard library that returns arguments passed to the launcher. Since it is an iterator, we can use itnth()Function to access values stored at each location. Position 0 leads to the program itself, which is why we start reading from the first element instead of the 0th.
  • expect() [documents]Is aOptionEnumeration defines a method that returns a value that needs to be given, and if the given value is not present, the program is immediately stopped with the specified error message printed.

Since the program can run without arguments, Rust asks us to check that it does provide that value by giving us the Option type.

As developers, it is our responsibility to ensure that appropriate measures are taken in every case.

In our current program, if no parameter is provided, the program will exit immediately.

Let’s run the program with the following command while passing two arguments, remember to append the arguments to –.

$ cargo run -- hello world!
    Finished dev [unoptimized + debuginfo] target(s) in0.01 s Running target/debug/todo_cli hello'world'The \!' '
"hello"."world!"
Copy the code

How do I insert and save data using a custom type

Let’s consider what we want to achieve in this program: be able to read the parameters entered by the user on the command line, update our todo list, and then store it somewhere to provide records.

To achieve this goal, we will implement custom types in order to satisfy our business.

We’ll use structs in Rust, which enable developers to design code with a better structure than having to write all the code in the main function.

How do we define our structure

Since we will be using a lot of HashMaps in our project, we can consider including them in a custom structure.

Add the following line at the top of the file:

use std::collections::HashMap
Copy the code

This will allow us to use HashMap directly without having to type in the full package path each time we use it.

Below the main function, let’s add the following code:

struct Todo {
  // Use Rust's built-in HashMap to hold key-value pairs.
  map: HashMap<String.bool>,}Copy the code

This will define the type of Todo we need: a structure with and only map fields.

This field is of type HashMap. You can think of it as a JavaScript object that requires us to declare key and value types in Rust.

  • HashMap<String, bool>Indicates that we have a string key whose value is a Boolean: in the application to represent the active state of the current element.

How do WE add methods to our structure

Methods are just like regular functions — they are all declared by the fn keyword, they all take arguments, and they can all return values.

However, they differ from regular functions in that they are defined in the struct context, and their first argument is always self.

We will define an IMPL (implementation) block below the new structure above.

impl Todo {
  fn insert(&mut self, key: String) {
    // Add a new element to our map.
    // We set its status value to true by default
    self.map.insert(key, true); }}Copy the code

This function is straightforward: it inserts the incoming key into the Map using the HashMap’s built-in insert method.

Two of the most important lessons are:

  • mut  [doc]Set a mutable variable
    • In Rust, each variable is immutable by default. If you want to change a value, you need to usemutKeyword to add variability to related variables. Since our function needs to modify the map to add a new value, we need to set it to a mutable value.
  • &  [doc]Identifies a reference.
    • You can think of this variable as a pointer to a specific place in memory where the value is stored, not directly.
    • In Rust, this is considered a borrow, meaning that the function does not own the variable but points to its storage location.

A brief overview of the Rust ownership system

With the previous knowledge of borrow and reference in mind, now is a good time to briefly discuss ownership in Rust.

Ownership is the most unique feature in Rust, allowing Rust programmers to write programs without manually allocating memory (for example, in C/C++) while still running without a garbage collector (such as JavaScript or Python), Rust constantly looks at your program’s memory to free up unused resources.

The ownership system has three rules:

  • Every value in Rust has a variable: its owner.
  • Each value can have only one owner at a time.
  • This value is removed when the owner is out of range.

Rust checks these rules at compile time, which means that if and when to free values in memory needs to be specified by the developer.

Consider the following example:

fn main() {
  // The owner of String is x
  let x = String::from("Hello");

  // We move the value into this function
  // doSomething is now the owner of X
  // Once out of doSomething's scope
  // Rust frees the memory associated with X.
  doSomething(x);

  // The compiler raises an error because we tried to use the value x
  // Because we have moved it to doSomething
  // We cannot use it at this time because there is no ownership at this point
  // And the value may have been deleted
  println!("{}", x);
}
Copy the code

This concept is widely considered the most difficult to master when learning Rust because it is new to many programmers.

You can read a more in-depth explanation of ownership in Rust’s official documentation.

We’re not going to go into the ins and outs of the ownership system. Now, remember the rules I mentioned above. Try at each step to consider whether you need to “own” the value and then delete it, or whether you need to keep referring to it so you can keep it.

For example, in the insert method above, we don’t want to have a map because we still need it to store its data somewhere. Only then can we finally free the allocated memory.

How do I save the map to the hard disk

Since this is a demo program, we’ll go with the simplest long-term storage solution: write the map to a file to disk.

Let’s create a new method in the IMPL block.

impl Todo {
  // [rest of 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)
  }
}
Copy the code
  • ->Represents the type returned by the function. What we’re returning here is oneResultType.
  • We iterate over the map and format a string containing both key and value, separated by TAB characters, and terminated with a new newline character.
  • We put the formatted string into the content variable.
  • We will becontentWrite capacity namedb.txt“.

It is worth noting that Save has _ ownership _ from self. At this point, if we accidentally try to update the map after the save call, the compiler will block us (because self’s memory will be freed).

This is a perfect example of how Rust’s memory management can be used to create more stringent code that will not compile (to prevent human error during development).

How do I use structs in main

Now that we have these two methods, we can start using them. Now we’ll continue writing functionality inside the main function we wrote earlier: If the provided operation is add, we’ll insert the element and store it in a file for future use.

Add the following code below the two parameter bindings you wrote earlier:

fn main() {
  / /... [Parameter binding 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),
    }
  }
}
Copy the code

Let’s see what we’ve done:

  • let mut todo = TodoLet’s instantiate a structure and bind it to a mutable variable.
  • We’re through.Symbol to callTODO insertMethods.
  • We will match the result returned by the save function and display a message on the download screen in different cases.

Let’s test run it. Open terminal and type:

$ cargo run -- add "code rust"
todo saved
Copy the code

Let’s check to see if the element is really saved:

$ cat db.txt
code rust true
Copy the code

You can find the full code snippet in this GIST.

How to read a file

There is a fundamental flaw in our program right now: every time “add” is added, we rewrite the entire map instead of updating it. This is because we create a new empty map object every time the program runs, and now let’s fix it.

Add a new method to TODO

We will implement a new function for the Todo structure. When called, it reads the contents of the file and returns the stored value to our Todo. Note that this is not a method because it does not take self as the first argument.

We’ll call it new, which is just a Rust convention (see HashMap::new() used earlier).

Let’s add the following code to the IMPL block:

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(&mutcontent)? ;let map: HashMap<String.bool> = content
      .lines()
      .map(|line| line.splitn(2.'\t').collect::<VecThe < &str>>())
      .map(|v| (v[0], v[1]))
      .map(|(k, v)| (String::from(k), bool::from_str(v).unwrap()))
      .collect();
    Ok(Todo { map })
  }

/ /... Residual method
}
Copy the code

If the code above gives you a headache, don’t worry. We use a more functional programming style here, primarily to show examples of Rust’s support for many other languages, such as iterators, closures, and lambda functions.

Let’s see what happens in the code above:

  • We defined onenewFunction, which returns a Result type, orTodoThe structure is eitherio:Error.
  • We do this by defining variousOpenOptionsTo configure how to open db.txt. The most remarkable thing is thatcreate(true)Flag, which creates the file if it does not already exist.
  • f.read_to_string(&mut content)?Read all the bytes in the file and append them tocontentString.
    • Pay attention to: Remember to add usestd:io::ReadAt the top of the file and in other use statementsread_to_stringMethods.
  • We need to convert the String type in the file to HashMap. To do this we bind the map variable to this line:let map: HashMap<String, bool>.
    • This is one of those cases where the compiler has trouble inferring types for us, so we need to declare them ourselves.
  • lines [documents]Creates an Iterator on each line of the string to iterate over each entry in the file. Because we’ve used it at the end of each entry/nFormatting.
  • The map [document] takes a closure and calls it on each element of the iterator.
  • line.splitn(2, '\t') [documents]Cut each of our rows with a TAB character.
  • collect::<Vec<&str>>() [documents]Is one of the most powerful methods in the library: it converts iterators into related collections.
    • In this case, we tell the map function to use the::Vec<&str>The Venctor attached to the method to convert our Split string to a borrowed string slice tells the compiler which collection is needed at the end of the operation.
  • And then for convenience, we use.map(|v| (v[0], v[1]))Convert it to a meta-ancestor type.
  • Then use the.map(|(k, v)| (String::from(k), bool::from_str(v).unwrap()))Convert the two elements of the meta-ancestor to String and Boolean.
    • Note: Remember to adduse std::str::FromStr;At the top of the file and other use statements so that the from_str method can be used.
  • We eventually collected them into our HashMap. We don’t need to declare the type this time because Rust inferred it from the binding declaration.
  • Finally, if we never encounter any errors, useOk(Todo { map })Returns the result to the caller.
    • Note that, as in JavaScript, you can use a shorter notation if the key and variable have the same name within the structure.

phew!

You did a great job! Image from rustacean.net/.

Another equivalent

Although map is generally considered more useful, the above can also be used through the basic for loop. You can choose the way you like.

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 contents into a new string
  let mut content = String::new();
  f.read_to_string(&mutcontent)? ;// Assign a new empty HashMap
  let mut map = HashMap::new();
  
  // Iterate over each line in 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 it into the HashMap
    map.insert(String::from(key), bool::from_str(val).unwrap());
  }
  / / return Ok
  Ok(Todo { map })
}
Copy the code

The above code is functionally equivalent to the previous functional code.

How to use this new method

In main, you just initialize the todo variable with the following code block:

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

Now if we go back to the terminal and execute a few “add” commands as follows, we should see that our database has been properly updated.

$ cargo run -- add "make coffee"
todo saved
$ cargo run -- add "make pancakes"
todo saved
$ cat db.txt
make coffee     true
make pancakes   true
Copy the code

You can find all the complete code for this stage in this GIST.

How do I update a value in a collection

As with all Todo apps, we want to be able not only to add items, but also to align them for a state switch and mark them as completed.

How do I add a complete method

We need to add a complete method to the Todo structure. Here, we get the reference value for the key and update its value. In the absence of key, None is returned.

impl Todo {
  // [other TODO methods]

  fn complete(&mut self, key: &String) - >Option< > () {match self.map.get_mut(key) {
      Some(v) => Some(*v = false),
      None= >None,}}}Copy the code

Let’s see what happens to the code above:

  • We declare the return type of the method: an emptyOption.
  • The whole method returnsMatchThe result of the expression, which will be nullSome() orNone.
  • We use the* [documents]Operator to dereference the value and set it to false.

How do I use the complete method

We can use the “complete” method just as we did with insert before.

In the main function, we use the else if statement to check if the action passed from the command line is “complete.”

// In main

if action == "add" {
  // Add the code for the operation
} 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),
    },
  }
}
Copy the code

It’s time to analyze what we did in the code above:

  • If we detect that Some is returned, we call todo.save to permanently store the changes to our file.
  • We matched bytodo.complete(&item)The Option returned by the.
  • If the return result isNone, we will print warnings to the user to provide a good interactive experience.
    • We’re through&itemPass item as a reference to the “todo.plete” method so that the main function still has the value. That means we can take the nextprintln!Macros continue to use this variable.
    • If we don’t, the value will be used by “complete” and will be accidentally discarded.
  • If we detect that it returnsSomeValue is calledtodo.saveStore this change permanently to our file.

As before, you can find all the code for this stage in this GIST.

Run the program

Now it’s time to run the program in its entirety on the terminal. Let’s start the program from scratch by first deleting the previous db.txt:

$ rm db.txt
Copy the code

Then add and modify operations in Todos:

$ cargo run -- add "make coffee"
$ cargo run -- add "code rust"
$ cargo run -- complete "make coffee"
$ cat db.txt
make coffee     false
code rust       true
Copy the code

This means that after these commands are executed, we will have a completed element (” make coffee “) and an unfinished element (” code rust “).

Suppose we add another “make coffee” element:

$ cargo run -- add "make coffee"
$ cat db.txt
make coffee     true
code rust       true
Copy the code

Aside: How do I store it as JSON using Serde

The program worked, even though it was small. Also, we can change the logic a little bit. Coming from the JavaScript world, I decided to store the values as JSON files instead of plain text files.

We’ll take the opportunity to learn how to install and use a software package called Nostalgy.io from the Rust open source community.

How do I install Serde

To install the new package into our project, open the cargo. Toml file. At the bottom, you should see a [Dependencies] field: Just add the following to the file:

[dependencies]
serde_json = "1.0.60"
Copy the code

That’s enough. The next time we run the program, Cargo will compile our program and download and import the new package into our project.

How to change Todo::New

The first place we’ll use Serde is when reading db files. Now we will read a JSON file instead of a “.txt “file.

In the IMPL code block, we look more like the new method:

// In the Todo IMPL code block

fn new() - >Result<Todo, std::io::Error> {
  / / open the db. Json
  let f = std::fs::OpenOptions::new()
    .write(true)
    .create(true)
    .read(true)
    .open("db.json")? ;// Serialize JSON to 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),
  }
}
Copy the code

Notable changes are:

  • The file option is no longer requiredmut fBecause we don’t need to manually assign content to a String as we did before. Serde handles the logic.
  • We update the file extension name todb.json.
  • serde_json::from_reader [documents]Will deserialize the file for us. It interferes with the map’s return type and attempts to convert JSON to a compatible HashMap. If all goes well, we will return to the Todo structure as before.
  • Err(e) if e.is_eof()Is aMatch the guards, which lets us optimize the behavior of the Match statement.
    • If Serde returns a premature EOF (end of file) as an error, it means that the file is completely empty (for example, on the first run, or if we delete the file). In that case, we recover from the error and return an empty HashMap.
  • For all other errors, the program is immediately interrupted and exits.

How to change todo.save

Another place we’ll use Serde is to save the map as JSON. To do this, update the save method in the IMPL block to:

// In the Todo IMPL code block
fn save(self) - >Result< (),Box<dyn std::error::Error>> {
  / / open the db. Json
  let f = std::fs::OpenOptions::new()
    .write(true)
    .create(true)
    .open("db.json")? ;// Write files through Serde
  serde_json::to_writer_pretty(f, &self.map)? ;Ok(())}Copy the code

As before, let’s look at the changes made here:

  • Box<dyn std::error::Error>. This time we return a generic error implementation of RustBox.
    • In short, a Box is a pointer to an allocation in memory.
    • We don’t actually know which of the two errors will be returned by the function, since opening the file might return a Serde error.
    • So we need to return a pointer to possible errors, not the errors themselves, so that the caller can process them.
  • We have of course updated the file name todb.jsonTo match the file name.
  • Finally, we let Serde do the heavy lifting: write the HashMap as a JSON file.
  • Please remember to delete from the top of the fileuse std::io::Read; 和 use std::str::FromStr;Because we don’t need them anymore.

That’s done.

Now you can run your program and check that the output is saved to a file. If all goes well, you’ll see that your Todos remain JSON.

You can read the complete code for the current phase in this GIST.

Epilogue, tips and more resources

It’s been a long journey, and I’m honored that you’re reading this far.

I hope you’ve learned something from this tutorial and become more curious. Remember that we are introducing a very “low-level” language here.

This is a big part of Rust’s appeal to me — it allows me to write code that is both fast and memory-efficient without the fear of taking on too much coding responsibility: I know that the compiler will optimize more for me, interrupting the run before it might go wrong.

Before I close, I’d like to share with you some other tips and resources to help you move forward on Rust’s journey:

  • Rust FMT is a very convenient tool that you can run in a consistent pattern to format your code. Don’t waste time configuring your favorite Linter plugin.
  • cargo check [documents]Will try to compile code without running it: this can be useful in cases where you just want to check that the code is correct when you don’t actually run it.
  • Rust comes with an integrated test suite and documentation tools: Cargo Test and Cargo Doc. We won’t cover them this time, because there’s enough of them in this tutorial and we may cover them in the future.

To learn more about Rust, I think these resources are really great:

  • The official Rust site, the gathering place for all important information.
  • Rust’s Discord server is an active and useful community if you enjoy interacting through chat.
  • If you want to learn by reading a book, the Rust Programming Language is a great choice.
  • If you prefer video material, Ryan Levick’s Rust Introduction video series is a great resource.

You can find the source code for this article on Github.

The illustrations are from rustacean.net/.

Thanks for reading and have fun coding!

Conclusion the translator

With the introduction of The Art of Deno, the exploration of the Rust language began. During the course of this article, if the Cargo installation package is slow to download, you can set the Cargo source to mirrors.ustc.edu.cn/.

Finally, with the end of this article, January ends. In February, there will be a break from writing in addition to Deno journals. This period will focus on coding and efficient learning, including but not limited to:

  • ECMAScript interview Guide: Create a 2021 Interview Guide.
  • Deno LeetCode: A Journey to TypeScript LeetCode in Deno.
  • . Realize more of my open source inspiration.

Welcome to continue to pay attention! Github: github.com/hylerrix (@ningowood).