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:
- It's a security risk, however slight
- 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
- Install the package
- 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
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
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
- Project meta-data: it includes dependencies
-
.gitignore
configured for Rust - Code where the magic happens
To run the default project, use the cargo
command inside the project's folder:
cargo run
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!
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!");
}
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.
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>,
}
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:
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;
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;
The current structure looks like this:
/___
|___ src
|___ lib.rs
|___ main.rs
|___ model.rs
|___ solutions.rs
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())
}
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'sArrayList
- The
Option
type works likeOptional
in Java: it may contain a value,Some
or not,None
. - The
max_by
is implemented by comparing the size of themembers
value of each group - Rust implements
partial_cmp()
for each "primitive" type by default, includingu16
- if
group
is empty,iter()
directly returnsNone
so we can safely callunwrap()
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> {
| ^^^^^^^^
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())
}
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
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())
}
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())
}
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
- Because tests will need to access the
model
module, we need to set it public - Create the
tests
module. Tests don't need to be public.
mod a;
mod b;
/___
|___ src
|___ lib.rs
|___ main.rs
|___ model.rs
|___ solutions.rs
|___ tests.rs
|___ tests
|___ a.rs
|___ b.rs
For tests, we need to:
- Add the
#[cfg(test)]
annotation to the file - 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
}
- Run this code only with
cargo test
and not withcargo build
- Import paths. The Rust convention is to use structures' full path but to use functions' path last segment
- Mark this function as a test
- Call the method to test
- 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);
}
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)
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 {
// ...
}
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)
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
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());
}
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![],
};
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"),
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
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>,
}
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,
|
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>,
}
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())
}
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>>,
}
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],
};
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)
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(),
],
}
}
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());
}
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,
}
}
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
Top comments (0)