An introduction to Rust

Background

Rust has been gaining popularity among developers, being the most loved language in stackoverflow survey for at least the past five years; but other indexes, such as Tiobe, rank it below the 20 most used languages. It is also difficult to find developers currently working with this language, or companies using it for active development.

What does all this mean? While Rust is recognized as a valuable tool by the developer community, its application is taking time due to its specialization and particularities. Why then invest in such a language? How does the future of Rust look? To answer these questions we will have a look at the language, its syntax, its basic features and how Rust code can be written for web applications. 

Characteristics

Rust originated within the Mozilla team in 2010 as a side project. Since then it has evolved into a community-driven language, with the foundation.rust-lang.org as its main governance institution.

It has some characteristics that, altogether, make it different from many other languages:

  • Is a compiled language.
  • Is strongly typed with both nominative and structural typing.
  • Lacks a garbage collection system, using a «borrow checker» instead.
  • Provides pointers/references for stack and advanced pointers for heap memory management.
  • Allows both object oriented and functional programming.
  • It is conceived specially for concurrent code development.
  • Is agnostic to the asynchronous runtime.
  • It has a test suite integrated in the language.

Let's go through them to find out what they offer:

Being a compiled language, Rust will provide a lot of information about the memory usage of our program before runtime. Making use of strongly typed structures Rust guarantees that, once the program compiles, it is memory safe. Of course we as developers can make mistakes in many other ways, especially regarding business logic; but the compiler ensures that structurally our program will be as performant and coherent as possible. It is important to mention that it uses nominative typing for structs and structural for tuples: that means that two instances of different structs will have different types to the compiler even when these structs have indentical shapes. With tuples happens the opposite: two tuples with similar shapes have the same type when they have similar shape.

This happens mainly because of the lack of any garbage collection system. Languages such as Java, Python, or Javascript rely on a garbage collector to handle the memory usage and clean up unused variables. In other languages, as in C or C++, it is required to manually handle memory resources; but in Rust we have a «borrow checker» system at compilation time that ensures that, when a variable goes out of the scope of its usage, it is cleaned.

This is done automatically for values with known sizes, which are stored in the stack. For these values we can make use of C-like pointers (*&) to reference and dereference them, although within some constraints imposed by Rust to avoid unsafe memory handling.

For those values whose size is not known beforehand by the compiler and have to be stored in the heap, such an array —«Vector»—, Rust provides specific structures to wrap them (Box, Rc, Arc, etc.), ensuring that they will be cleaned as soon as possible.

One of the main features of Rust is a comeback to a simplified version of C structs instead of C++/Java-like classes. There are three main keywords related to structs in Rust: struct, impl and trait. We can define a new struct using the struct keyword with the values that it is holding; after defining it, we can add methods with the impl keyword. In case we want to describe a common behaviour for several implementations, we can do it with the trait keyword, similar to what in other languages is called an interface:

// We can define a struct for Person
⁠// with a name that will be a String
⁠struct Person {
name: String,
⁠}

⁠// We can also define a similar struct, but for a Cat
⁠// also with a name that will be a string
⁠struct Cat {
name: String,
⁠}

⁠// Each living being can perform certain actions
⁠trait LivingBeingActions {
fn introduce(&self);
⁠}

⁠// The person can introduce himself verbally
⁠impl LivingBeingActions for Person {
fn introduce(&self) {
println!("- {}: Hi! It's a pleasure :)", &self.name);
}
⁠}

⁠// The can can also introduce himself, except… in cat's style
⁠impl LivingBeingActions for Cat {
fn introduce(&self) {
println!("- {}: Mrrrrr… Miaaou", &self.name);
}
⁠}

⁠// We can instantiate the Person and the Cat,
⁠// and then they can introduce themselves
⁠fn main() {
let john_doe = Person {
name: String::from("John Doe"),
};
let grumpy = Cat {
name: String::from("Grumpy"),
};
john_doe.introduce(); // - John Doe: Hi!, a pleasure :)
grumpy.introduce(); // - Grumpy: Mrrrrr... Miaaou
⁠}

Some Rust syntax: struct, trait, and impl: try me at the Rust Playground 

This use of structs instead of classes will prevent nested structures, favouring composition instead. Rust will allow us to write code in an object-oriented manner as well as in a functional style, but within a limited frame in both cases. It is perfectly possible to use object-oriented patterns as dependency injection or features as polymorphism, as well as write functional stateless piped code with closures. Both approaches fit naturally, although with some constraints that force the developer to write atomic and readable structures. The resulting code has a somewhat complex syntax, but flat and simple structures which, at the end, make the code easier to read and understand.

Rust comes with a standard library that provides several functionalities. Among them are the threading tools, which will allow us to write concurrent programs. This, with the help of the compiler, static typing, borrow checker and the advanced pointers will ensure that our multithreaded program is memory safe even in multi-threaded programs.

An interesting feature of Rust is that it is not coupled with an asynchronous runtime. For this, we will need to bring an external library: a very common option is the Tokio library, that can be found in the Rust official packages repository, although there are other options available.

The tests are written with the embedded tests suite. It is possible to write both unit tests for each module as well as integration tests for the whole application. Its features are limited but clear following the philosophy of the language.

Working with Rust coming from Java-like languages is somewhat confusing due to the considerable paradigm shift; of course, once this difficulty is overcome the language writes naturally. It has similarities to common used languages such as C++ and C, but with a cleaner footprint. Given the tools it provides for memory management and generics, it could easily be described as «declarative C».

A basic Rust example

To demonstrate how a program can be structured in Rust we can create a naive and simple application, just to visualize the main language features.

Following our previous example, we can develop a program that will ask for a users list from, lets say, a PostgreSQL database. The catch is that we want the list of users in reverse order. 

So we have some different concerns here: 

  • The User itself, which will be a plain model. 
  • The business logic, which is reversing the order of the retrieved list: this will be a use case. This use case will make use of any retrieval system that complies with a trait.
  • Finally we will need the system to retrieve data from the PostgreSQL database, which will be a struct. To avoid coupling the use case with the PostgreSQL repository we can create a trait that all repositories have to comply with. The use case will be able to receive any repository instance that complies with this trait.

Structure of our basic application

Now that we have an idea of our application, let's implement it. We will omit a few lines of code needed for the program to compile, but the whole example can be seen in the Rust Playground.

First we can create our entity, an User with just an id field:

pub struct User {
pub id: i8,
⁠}

The User model

Now that we have our main model, the next question is what we want to do with it. In other words: the use case to retrieve a reverted list of users. We know that it will need to hold the repository instance, but we don’t know the type: this is a task for a generic type.

struct GetUsersRevertedUseCase<T> {
repository: T,
⁠}

Struct for our use case

Our use case is gathering the type of the repository instance when it is constructed. Let's create the new method to construct it, for which we can use the impl keyword:

impl<T: Repository> GetUsersRevertedUseCase<T> {
fn new(repository: T) -> GetUsersRevertedUseCase<T> {
GetUsersRevertedUseCase { repository }
}
⁠}

Implementing new for our use case

⁠One thing to note here is the <T: Repository> syntax. We are telling the compiler that T will be any struct implementing the Repository trait, returning error if any other type of instance is passed. Also our new method returns an instance of itself.

We can think now about the different repositories that this use case may depend on. We know that it will be an asynchronous action, and that it will return a list of users. Lets implement this in a trait:

trait Repository {
async fn get_users(&self) -> Result<Vec<User>, String>;
⁠}

Trait for our repository

We have new things here. First, we have the async keyword, which makes our function return a Result type. This Result type is one of the particularities of Rust: it represents the result of an action that can be successful —Ok(T)— or error —Err(K)—. In our case, if the method is successful, it will return Ok<Vec<User>>, which is an array —a vector in Rust language— of User items; otherwise, if it errors, it will return Err(String). For the sake of brevity we won’t return any error, but the Result type forces us to take that possibility into account, as we are writing asynchronous code.

Now we can think about how we will trigger our use case, so let's implement an execute method for it.

impl<T: Repository> GetUsersRevertedUseCase<T> {
[…]
async fn execute(&self) -> Result<Vec<User>, String> {
let users = self.repository.get_users().await.unwrap();
let reverted_users: Vec<User> = users.into_iter().rev().collect();
Ok(reverted_users)
}
⁠}

Implementing execute for our use case

We see the async syntax, this time awaiting the function of the repo we are calling. We also see the Result type again. Everything is familiar. 

As the repository is returning a ResultOk<T> or Err<K>— type, we need to extract the value —the array of users— to operate with it. We do this with the unwrap() function, extracting the value if the operation is successful, or panicking with the error if it's not.

We also have some methods operating over the users vector to reverse it: into_iter, which turns a vector into an iterable list of items; rev, which reverses this iterable list, and collect, which returns back the iterable list into a vector. These utility methods belong to Vec struct, and are embedded into the Rust to manipulate vectors in a functional manner.

Ok, now we have a use case and an interface for any repository we may pass to it, so we can proceed to create an actual repository for PostgreSQL:

impl Repository for PostgreSQLRepository {
async fn get_users(&self) -> Result<Vec<User>, String> {
⁠ let duration_secs = Duration::from_secs(2);
⁠ sleep(duration_secs);
⁠ let user_1 = User { id: 1 };
⁠ let user_2 = User { id: 2 };
⁠ Ok(vec![user_1, user_2])
⁠ }
⁠}

 Struct for our PostgreSQL repository

We are going to mock the asynchronous behaviour, so we are using two methods of the standard library: sleep and Duration. Duration will return a special time type that can be used by other methods, such as sleep; and sleep will simply halt execution for the given time. We are just halting our function for two seconds, then instantiating two users, and finally returning a Vector holding them.

We now have all needed components for our program: an User entity, the GetUsersRevertedUseCase, the interface Repository and the actual PostgreSQLRepository that implements it. Lets create an entry point and make use of all these elements:

#[tokio::main]
⁠async fn main() {
⁠ let repository = PostgreSQLRepository;
⁠ let use_case = GetUsersRevertedUseCase::new(repository);
⁠ let users = use_case.execute().await.unwrap();
⁠ println!("{:#?}", users);
⁠}

Entry point for our program

As we are running asynchronous code we will need an asynchronous runtime: thus, we can call the macro #[tokio:main]. Then we just create an instance of the repository, we construct our use case injecting the repository, and execute it awaiting. The result will be a vector of users in reverse order:

[
⁠ User {
⁠ id: 2,
⁠ },
⁠ User {
⁠ id: 1,
⁠ },
⁠]

Result: a reverted list of users

As mentioned above this code is available in the Rust Playground, where we can compile and run it.

Conclusion

Rust is not a straightforward language. Study and some patience is required to understand both the basic syntax and the underlying features that it provides; it is also young, and its ecosystem has to evolve. But it is promising.

It is in performance demanding tasks where the language fits perfectly. For general web development, where rapid prototyping with agile workflows is a requirement, interpreted languages such as Python or TypeScript may be more suitable; but in cases where memory leaks may cause spikes and performance issues in server usage, Rust is a much better option.

The current acceptance of Rust within the industry is very limited, but the Rust foundation is supported by Google, Microsoft, AWS, Huawei and Mozilla: there is a need for such a performant language, and we will probably see how it is introduced in the following years. There are difficulties though: the funding companies will try to influence Rust to make it evolve fitting their particular needs, and there are strong disagreements among the internal Rust development teams. But the language fits so well into the aforementioned tasks that it will be worthwhile to follow its evolution and integration into the web industry.

In conclusion: it is possible and desirable to use Rust in specific contexts where performance and assurance is required.