DEV Community

loading...
Cover image for My first cup of Rust

My first cup of Rust

Nicolas Frankel
Dev Advocate | Former developer | Former architect | Former teacher | Still learning and blogging.
Originally published at blog.frankel.ch ・12 min read

It all started with an informal chat with my friend Anthony. We were talking about languages, and I said that I preferred compiled ones. He then went on to mention Rust. We admitted that we were too afraid to learn it because of its perceived complexity.

After the chat, I thought about it, and I wondered why I didn't check by myself. I did. And I became interested.

There are tons of resources on the Web on how to learn Rust. This post is a memo of the steps I followed. It's probably less structured than my other posts but I hope you might benefit from those notes anyway.

Rust entry-point

The main entry-point for learning Rust is the official Rust book.

In the book, the installation method is with curl. IMHO, this approach has two downsides:

  1. It's a security risk, however slight
  2. More importantly, no package manager can automatically update the installation.

Fortunately, a Homebrew package exists. It's a two-step process:

brew install rustup-init   // 1
rustup-init                // 2
Enter fullscreen mode Exit fullscreen mode
  1. Install the package
  2. Launch the proper intialization process

It outputs the following:

Welcome to Rust!

This will download and install the official compiler for the Rust
programming language, and its package manager, Cargo.

*** Logs, logs and more logs ***

Rust is installed now. Great!

To get started you need Cargo's bin directory ($HOME/.cargo/bin) in your PATH
environment variable. Next time you log in this will be done
automatically.

To configure your current shell, run:
source $HOME/.cargo/env
Enter fullscreen mode Exit fullscreen mode

Create a new project

As a "modern" language, Rust provides a lot of different features beyond the syntax. Among them is the ability to create projects via the cargo command based on a template.

cargo new start_rust
Enter fullscreen mode Exit fullscreen mode

Alternatively, your favorite IDE probably has a Rust plugin. For example, JetBrains provides a one for IntelliJ IDEA.

Whatever the chosen approach, the project is under source control. It displays the following structure:

/___
|___ Cargo.toml     // 1
|___ .gitignore     // 2
|  |___ src
|     |___ main.rs  // 3
Enter fullscreen mode Exit fullscreen mode
  1. Project meta-data: it includes dependencies
  2. .gitignore configured for Rust
  3. Code where the magic happens

To run the default project, use the cargo command inside the project's folder:

cargo run
Enter fullscreen mode Exit fullscreen mode

It starts compilation and then executes the application:

   Compiling start_rust v0.1.0 (/Users/nico/projects/private/start_rust)
    Finished dev [unoptimized + debuginfo] target(s) in 1.05s
     Running `target/debug/start_rust`
Hello, world!
Enter fullscreen mode Exit fullscreen mode

If you follow along, you might notice that a new Cargo.lock file has appeared after the first run. It plays the same role as Ruby's Gemfile.lock and npm's package-lock.json.

Now is the right time to inspect the source file. I believe that even if it's the first time you see Rust code, you can infer what it does:

fn main() {
    println!("Hello, world!");
}
Enter fullscreen mode Exit fullscreen mode

Let's set a goal

My hands-on learning approach requires a goal. It must not be too simple to learn something but not too hard, lest I become discouraged.

At first, I wanted to create a simple GUI application, the same I used to explore JVM desktop frameworks. But after reading about the state of GUI frameworks in Rust, I decided against it. Instead, I chose to lower the bar and implement one of the Java exams I had given to my students.

The exam defines a couple of model classes that serve as a foundation for the work. I did split the work into "exercise" classes. Each class contains a single method with either an empty body for void returning methods or a dummy return value for others. I wrote the instruction in each class: it's up to the student to write the implementation that returns the expected result.

Let's start small and define only part of the model.

Starting the design of the model

Here's how it translates naively into Rust:

pub struct Super {
    pub super_name: String,
    pub real_name: String,
    pub power: u16,
}

pub struct Group {
    pub name: String,
    pub members: Vec<Super>,
}
Enter fullscreen mode Exit fullscreen mode

Organizing one's code

In Java or Kotlin, we organize our code in packages. Rust has packages, but it has different semantics. It also offers crates and modules. Rust's modules are similar to Java's packages.

Here's the overview of a sample package:

Organizing one's code

I must admit the official documentation confused me. This thread is a pretty good explanation, IMHO. The above diagram represents my understanding of it; feel free to correct me if necessary.

Let's move the model-related structures to a dedicated model module in the library crate to understand better how it works. To achieve that, we need to create a lib.rs file - the common name - that defines the model module:

mod model;
Enter fullscreen mode Exit fullscreen mode

Our project now consists of a binary and a library. The library crate contains one module that includes the model.

We will also create the functions to implement in a dedicated solutions module. Because solutions need to access the model, we need to set its visibility to public. And since we want to test functions in solutions, let's make it also public.

pub mod model;
pub mod solutions;
Enter fullscreen mode Exit fullscreen mode

The current structure looks like this:

/___
   |___ src
      |___ lib.rs
      |___ main.rs
      |___ model.rs
      |___ solutions.rs
Enter fullscreen mode Exit fullscreen mode

Implementing our first function

The exercise A class requires that given a collection of Group, we should return the Group that has the most members, i.e., the largest group. Note that in the initial exam, A and I are similar. However, students should solve A by using "standard" Java for loop and I by using only Java streams.

Rust makes it easier to follow Functional Programming principles.

pub fn find_largest_group(groups: Vec<Group>) -> Option<&Group> {
    groups
        .iter()
        .max_by(|&g1, &g2| g1.members.len().partial_cmp(&g2.members.len()).unwrap())
}
Enter fullscreen mode Exit fullscreen mode

This single implementation warrants many comments:

  • The syntax of the closure is inspired by Ruby's
  • By convention, the last expression of a function is the returned value
  • Rust considers that the function body is an expression because it doesn't end with a semicolon. If it did, it would be a statement and wouldn't qualify as a return value.
  • The & character indicates a reference instead of a value. It's akin to C and Go in that regard.
  • Vec is a resizable collection with order and random access, similar to Java's ArrayList
  • The Option type works like Optional in Java: it may contain a value, Some or not, None.
  • The max_by is implemented by comparing the size of the members value of each group
  • Rust implements partial_cmp() for each "primitive" type by default, including u16
  • if group is empty, iter() directly returns None so we can safely call unwrap() in the closure

The code looks fine until we try to compile it:

error[E0106]: missing lifetime specifier
 --> src/solutions.rs:3:57
  |
3 | pub fn find_largest_group(groups: Vec<Group>) -> Option<&Group> {
  |                                                         ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value with an elided lifetime, but the lifetime cannot be derived from the arguments
help: consider using the `'static` lifetime
  |
3 | pub fn find_largest_group(groups: Vec<Group>) -> Option<&'static Group> {
  |                                                         ^^^^^^^^
Enter fullscreen mode Exit fullscreen mode

That's the start of Rust's fun.

I don't want to paraphrase a whole section of the Rust book. Suffice to say that references are valid in a specific scope, and when the latter cannot be inferred, we need to be explicit about it.

The static lifetime mentioned in the hint means the reference will be valid until the end of the program. In most cases, that's not necessary. Instead, we should bind the lifetime of the returned value to the lifetime of the parameter. Lifetime hints look a lot like generics with the additional '.

Let's change the signature accordingly:

pub fn find_largest_group<'a>(groups: Vec<Group>) -> Option<&'a Group> {
    groups
        .iter()
        .max_by(|&g1, &g2| g1.members.len().partial_cmp(&g2.members.len()).unwrap())
}
Enter fullscreen mode Exit fullscreen mode

Yes, the update code still doesn't compile:

error[E0515]: cannot return value referencing function parameter `groups`
  --> src/foo.rs:13:5
   |
13 |       groups
   |       ^``-
   |       |
   |  _____`groups` is borrowed here
   | |
14 | |         .iter()
15 | |         .max_by(|&g1, &g2| g1.members.len().partial_cmp(&g2.members.len()).unwrap())
   | |____________________________________________________________________________________^ returns a value referencing data owned by the current function
Enter fullscreen mode Exit fullscreen mode

That's Rust's fun continued.

Because the function uses the groups parameter, it borrows it and becomes its owner. In our context, the function doesn't modify groups; hence borrowing is not necessary. To avoid it, we should use a reference instead and set the lifetime accordingly:

pub fn find_largest_group<'a>(groups: &'a Vec<Group>) -> Option<&'a Group> {
    groups
        .iter()
        .max_by(|&g1, &g2| g1.members.len().partial_cmp(&g2.members.len()).unwrap())
}
Enter fullscreen mode Exit fullscreen mode

Now, the returned value has the same lifetime as the parameter. At this point, the compiler can correctly infer the lifetime, and we can remove all hints.

pub fn find_largest_group(groups: &Vec<Group>) -> Option<&Group> {
    groups
        .iter()
        .max_by(|&g1, &g2| g1.members.len().partial_cmp(&g2.members.len()).unwrap())
}
Enter fullscreen mode Exit fullscreen mode

Automated tests

A well-designed language should make testing easy. We can create a new dedicated module aptly named tests with each test case in a dedicated module.

pub mod model;     // 1
pub mod solutions;
mod tests;         // 2
Enter fullscreen mode Exit fullscreen mode
  1. Because tests will need to access the model module, we need to set it public
  2. Create the tests module. Tests don't need to be public.
mod a;
mod b;
Enter fullscreen mode Exit fullscreen mode
/___
   |___ src
      |___ lib.rs
      |___ main.rs
      |___ model.rs
      |___ solutions.rs
      |___ tests.rs
      |___ tests
          |___ a.rs
          |___ b.rs
Enter fullscreen mode Exit fullscreen mode

For tests, we need to:

  1. Add the #[cfg(test)] annotation to the file
  2. And annotate each test function with #[test]
#[cfg(test)]                                      // 1

use crate::model::Group;                          // 2
use crate::solutions::a;                          // 2

#[test]                                           // 3
fn should_return_none_if_groups_is_empty() {
    let groups = Vec::new();
    let result = a::find_largest_group(&groups);  // 4
    assert!(result.is_none());                    // 5
}
Enter fullscreen mode Exit fullscreen mode
  1. Run this code only with cargo test and not with cargo build
  2. Import paths. The Rust convention is to use structures' full path but to use functions' path last segment
  3. Mark this function as a test
  4. Call the method to test
  5. Assert the result. The ! hints at a macro.

Traits for the win

So far, so good.
Now, let's add a second more meaningful test method:

#[test]
fn should_return_group_if_groups_has_only_one() {
    let group = Group {
        name: "The Misfits of Science",
        members: Vec::new(),
    };
    let groups = vec![group];
    let result = a::find_largest_group(&groups);
    assert!(result.is_some());
    assert_eq!(result.unwrap(), &group);
}
Enter fullscreen mode Exit fullscreen mode

It fails miserably with two errors:

error[E0369]: binary operation `==` cannot be applied to type `&Group`
  --> src/tests/a.rs:19:5
   |
19 |     assert_eq!(result.unwrap(), &group);
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |     |
   |     &Group
   |     &Group
   |
   = note: an implementation of `std::cmp::PartialEq` might be missing for `&Group`
   = note: this error originates in a macro (in Nightly builds, run with -Z macro-backtrace for more info)

error[E0277]: `Group` doesn't implement `Debug`
  --> src/tests/a.rs:19:5
   |
19 |     assert_eq!(result.unwrap(), &group);
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `Group` cannot be formatted using `{:?}`
   |
   = help: the trait `Debug` is not implemented for `Group`
   = note: add `#[derive(Debug)]` or manually implement `Debug`
   = note: required because of the requirements on the impl of `Debug` for `&Group`
   = note: 1 redundant requirements hidden
   = note: required because of the requirements on the impl of `Debug` for `&&Group`
   = note: required by `std::fmt::Debug::fmt`
   = note: this error originates in a macro (in Nightly builds, run with -Z macro-backtrace for more info)
Enter fullscreen mode Exit fullscreen mode

The compiler detects two errors. Let's handle the second one first. Something bad happens, and the compiler wants to print the structure in detail to help us understand. It needs the Group structure to implement the Debug trait. Rust's traits are similar to Scala's and Java's interfaces (with default implementations).

You can add a trait to a struct with the derive macro. It's a good idea to make both our model classes "debuggable":

#[derive(Debug)]
pub struct Super {
    // ...
}

#[derive(Debug)]
pub struct Group {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

The first error above tells that Group cannot be compared using == because the PartialEq trait is missing. It's tempting to add this trait to Group, but now the compiler throws a new error:

error[E0369]: binary operation `==` cannot be applied to type `Vec<Super>`
  --> src/model.rs:12:5
   |
12 |     pub members: Vec<Super>,
   |     ^^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: this error originates in a derive macro (in Nightly builds, run with -Z macro-backtrace for more info)
Enter fullscreen mode Exit fullscreen mode

To understand it, we need to look at the documentation of PartialEq:

When derived on structs, two instances are equal if all fields are equal, and not equal if any fields are not equal.

A solution is to "override" the default trait implementation and decide what it means for two Group to be equal. A more accessible alternative is to add the trait to Super as all attributes of Super can then be compared using PartialEq.

More ownership you'd ever want to know about

At this point, we would hope that the code compiles, but the compiler still complains:

error[E0382]: borrow of moved value: `group`
  --> src/tests/a.rs:19:33
   |
15 |     let group = Group { name: String::from("The Misfits of Science"), members: Vec::new() };
   |         ```

- move occurs because `group` has type `Group`, which does not implement the `Copy` trait
16 |     let groups = vec![group];
   |

                       ```- value moved here
...
19 |     assert_eq!(result.unwrap(), &group);
   |                                 ^^^^^^ value borrowed here after move
Enter fullscreen mode Exit fullscreen mode

We talked briefly about ownership above: the error is related. In line 16, groups gets ownership of the group variable. As a consequence, we are not allowed to use group after that line.

Like all ownership-related compiler errors, the reason is pretty straightforward: groups could have been cleared so that group wouldn't exist anymore. We could try to copy/clone group, but that would be neither efficient nor idiomatic. Instead, we need to change our mindset, completely forget about group and use groups. The new version becomes and finally compiles:

#[test]
fn should_return_group_if_groups_has_only_one_element() {
    let group = Group {
        name: String::from("The Misfits of Science"),
        members: Vec::new(),
    };
    let groups = vec![group];
    let result = a::find_largest_group(&groups);
    assert!(result.is_some());
    assert_eq!(result, groups.first());
}
Enter fullscreen mode Exit fullscreen mode

Designing samples

The original Java project provides test samples so that students can quickly check their solutions. The idea is to create immutable instances that you can reuse across tests. For that, Rust provides the const keyword. Let's create a dedicated file for samples:

const JUSTICE_LEAGUE: Group = Group {
    name: String::from("Justice League"),
    members: vec![],
};
Enter fullscreen mode Exit fullscreen mode

It fails with an error:

error[E0015]: calls in constants are limited to constant functions, tuple structs and tuple variants
 --> src/tests/samples.rs:4:11
  |
4 |     name: String::from("Justice League"),
  |           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Enter fullscreen mode Exit fullscreen mode

You might have wondered why the String::from() function call. By default, double quotes define a string slice and not a proper String.

Since constants need to be initialized by a simple assignment, we cannot convert the type. We have to change our model class:

#[derive(Debug, PartialEq)]
pub struct Group {
    pub name: &str,
    pub members: Vec<Super>,
}
Enter fullscreen mode Exit fullscreen mode

Compilation now fails with a now-familiar error:

error[E0106]: missing lifetime specifier
  --> src/model.rs:10:15
   |
10 |     pub name: &str,
   |               ^ expected named lifetime parameter
   |
help: consider introducing a named lifetime parameter
   |
9  | pub struct Group<'a> {
10 |     pub name: &'a str,
   |
Enter fullscreen mode Exit fullscreen mode

We need to bind the lifetime of the attribute to the lifetime of its parent structure.

#[derive(Debug, PartialEq)]
pub struct Group<'a> {
    pub name: &'a str,
    pub members: Vec<Super>,
}
Enter fullscreen mode Exit fullscreen mode

In turn, we also need to change the signature of the solution:

pub fn find_largest_group<'a>(groups: &'a Vec<Group<'a>>) -> Option<&'a Group<'a>> {
    groups
        .iter()
        .max_by(|&g1, &g2| g1.members.len().partial_cmp(&g2.members.len()).unwrap())
}
Enter fullscreen mode Exit fullscreen mode

Likewise, we change the attributes' type for Super and again Group:

#[derive(Debug, PartialEq)]
pub struct Super<'a> {
    pub super_name: &'a str,
    pub real_name: &'a str,
    pub power: u16,
}

#[derive(Debug, PartialEq)]
pub struct Group<'a> {
    pub name: &'a str,
    pub members: Vec<Super<'a>>,
}
Enter fullscreen mode Exit fullscreen mode

At this point, we can add Super constants and use them in Group constants:

pub const BATMAN: Super = Super {
    super_name: "Batman",
    real_name: "Bruce Wayne",
    power: 50,
};

pub const JUSTICE_LEAGUE: Group = Group {
    name: "Justice League",
    members: vec![SUPERMAN],
};
Enter fullscreen mode Exit fullscreen mode

Compilation fails again... what a surprise, but this time for another reason:

error[E0010]: allocations are not allowed in constants
  --> src/tests/samples.rs:17:14
   |
17 |     members: vec![SUPERMAN, BATMAN],
   |              ^^^^^^^^^^^^^^^^^^^^^^ allocation not allowed in constants
   |
   = note: this error originates in a macro (in Nightly builds, run with -Z macro-backtrace for more info)
Enter fullscreen mode Exit fullscreen mode

It seems that constants are not a good fit for samples. Instead, we should implement functions to generate them. This way, ownership won't get in our way.

pub fn batman<'a>() -> Super<'a> {
    Super {
        super_name: "Batman",
        real_name: "Bruce Wayne",
        power: 50,
    }
}

pub fn justice_league<'a>() -> Group<'a> {
    Group {
        name: "Justice League",
        members: vec![
            superman(),
        ],
    }
}
Enter fullscreen mode Exit fullscreen mode

Finally, we can implement the last test case:

#[test]
fn should_return_largest_group() {
    let groups = vec![samples::sinister_six(), samples::justice_league()];
    let result = a::find_largest_group(&groups);
    assert!(result.is_some());
    assert_eq!(result, groups.last());
}
Enter fullscreen mode Exit fullscreen mode

The final step is to restrict the visibility of the test sample's functions. So far, we have used the pub modifier to make functions accessible from anywhere. Yet, Rust allows us to restrict the visibility of a function to a specific module:

pub(in crate::tests) fn batman<'a>() -> Super<'a> {
    Super {
        super_name: "Batman",
        real_name: "Bruce Wayne",
        power: 50,
    }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Among all benefits of Rust, I did enjoy the most the user-friendliness of the compiler error messages. With just my passing understanding, I was able to fix most of the errors quickly.

I might not have gotten everything right at this time. If you're an already practicing Rustacean, I'll be happy to read your comments.

The complete source code for this post can be found on GitHub:

To go further:

Originally published at A Java Geek on May 30th, 2021

Discussion (0)