Rust 101

Tagged: rust

I’ve recently had the chance to try Rust for a project and it has been a surprising pleasant experience. Rust is a modern language and I felt quire familiar with it in a few days. It has a simple and effective design and thanks to that, it gets frequently mentioned as a successor to old low level languages C like. Rust is fast and, in a lot of benchmarks, the compiled code performance is comparable to C and C++. I don’t have experience yet with other similar low-level languages as Nim or Zig but I believe that there’s a reason why Rust got extremely popular in the last few years.

In this article I’ll write down a quick overview of the most important concept that I found make Rust a different and more evolved language compared to it’s predecessor. The Rust documentation is really well written with a lot of example and it will allow you to use the language in a very short time.

Memory Safety

Rust is a memory safe language which means that it’s design prevent the programmer to do operation that could provoke memory faults and crashes of the program. If you ever wrote C or C++ you know what kind of bugs I’m talking about. This kind of problems are hard to spot and debug and in complex programs, it gets easy to use an already free memory or to forget to free one.

There have been two main ways to manage program memory. One is letting the memory management in the hands of the programmer (as C, C++), and the other is using a Garbage Collector that will clean the unused allocated memory portions for you (as Lisp, Java, Python, JS). Manually freeing the memory can get complicated with large and complex programs, and automatic garbage collection can slow down your execution at random, and it’s not that easy to control.

Rust bring a different approach; writing programs that are actually memory safe by design. It does that by not exposing the memory management functions, without using an external tool to do the job. It introduces the concept of ownership, a set of rules that check your code at compile time to prevent you to do operations that break the ownership of a variable.

Ownership

This are the ownership rules:

  1. Each value in Rust has an owner.
  2. There can only be one owner at a time.
  3. When the owner goes out of scope, the value will be dropped.
let s1 = String::from("hello"); // s1 is the owner of the String
let s2 = s1; // s2 becomes the new owner, and s1 is invalidated
println!("{}, world!", s1); // This will cause a compile-time error because s1 is no longer valid

The previous code will not compile. The compiler would even give you suggestion to what caused the error: error[E0382]: borrow of moved value: s1. One may think that the string s1 should still be there, but in reality Rust already called drop on the string structure, deallocating the memory segment and making it no longer valid.

Rust is getting in the way of the programmer for a good reasons; following this rules will prevent problems with memory deallocation, like forgetting to deallocate something, accessing a resource already deallocated, or double freeing a variable. In exchange, Rust is enforcing some patterns and providing some data structure to solve all ownership problems. The following is a useful schema I found on reddit, that quickly points you to which data structure is better to use for each scenario (T is the generic data type).

rust ownership diagram
source: https://www.reddit.com/r/rust/comments/mgh9n9/ownership_concept_diagram/

Borrowing

Borrowing is a mechanism that allows you to create references to data owned by another variable, enabling multiple parts of your code to access and potentially modify the same data without transferring ownership.

fn main() {
    let mut s = String::from("hello");

    change(&mut s);
}

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

In the code above, I’ve just created an mutable reference to a String object, and passed it to a function that modify the string. This behaviour it’s called borrowing, since we borrow the value, and then give it back to the caller when finished using it.

In Rust, we cannot borrow as mutable variable more than once. This restriction of preventing multiple mutable references to the same data, helps preventing data races at compile time.

Structs and objects

Rust allows the creation of custom data types using struct and enum. A struct is between a C struct and a C++ class. It can be used as object and have access to self, but it’s missing a full support for inheritance.

struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn new(w: u32, h: u32) -> Rectangle {
        Rectangle {
            width: w,
            height: h,
        }
    }

    fn area(&self) -> f64 {
        (self.width * self.height) as f64
    }
}

fn main() {
    let mut rect = Rectangle::new(3, 4);
    println!("Area: {}", rect.area());
}

A new struct Rectangle has been defined, with a simple area method which access the property using the reference to itself.

When a new struct is defined inside a file, it can be encapsulated simply by avoiding the use of pub on the methods. This way, they will not be visible from outside but still accessible from inside.

Traits

Polymorphism can be pefrormed by defining a Trait which adds the possibility to have shared behaviour between structures.

trait Area {
    fn area(&self) -> f64;
}

impl Area for Circle {
    fn area(&self) -> f64 {
        std::f64::consts::PI * self.radius * self.radius
    }
}

Here I’ve defined a trait Area which every shape struct can implement in it’s own way. The trait it’s only the function signature, with parameters and expected return type.

Enum

Enum are just a way of define a set of values that a variable of that type can take. It’s similar to struct in the sense that accept the use of impl with custom methods and traits.

enum Animal {
    Bird,
    Insect,
    Fish,
    Mammal,
}

Pattern matching

Pattern matching is a construct that is essential in the Rust design. It allows to automatically match the value of a variable using it’s possible states, similar to the statement case in other languages. I use the operator match that receives the variable and give the possibility to execute code when a value is matching.

impl Animal {
    fn can_fly(&self) -> bool {
        match self {
            Animal::Bird => true,
            Animal::Insect => true,
            _ => false, // Covers Fish and Mammal
        }
    }
}

For example, here it’s matching all the possible states that a variable of type Animal can have. The option _ is a catch-all pattern, representing all the other possible states that the variable can take.

Result and Option

Two enumerated types that are part of the Rust standard library are Result and Option. They’re values used to deal with errors that need to be managed and potentially absent values. To access the value, the pattern matching method shown above is required.

Result is used in case a function may fail and there’s a need to grab the exception and handle it. Rust allows functions to throw errors and then catch them from the caller by performing a match on the function’s returned value.

use std::fs::File;
use std::io;

fn read_file(path: &str) -> Result<String, io::Error> {
    let file = File::open(path)?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    Ok(contents)
}

fn main() {
    match read_file("example.txt") {
        Ok(contents) => println!("File contents: {}", contents),
        Err(error) => println!("Error reading file: {}", error),
    }
}

? is a special operator in Rust that allows to return the error directly, saving you the boilerplate of writing another match.

Option instead is used to return an optional value, since Rust doesn’t have the concept of null (and no Null Pointer Exception).

fn find_first_even(numbers: &[i32]) -> Option<i32> {
    for num in numbers {
        if num % 2 == 0 {
            return Some(*num);
        }
    }
    None
}

fn main() {
    let numbers = vec![1, 3, 5, 7, 8];
    match find_first_even(&numbers) {
        Some(even) => println!("First even number: {}", even),
        None => println!("No even numbers found"),
    }
}

Functional

Rust took closures and iterators from functional programming. It’s easy to write inline functions and iterate over array with the set of functions available for functional programming.

let numbers = vec![1, 2, 3, 4, 5];
let doubled: Vec<_> = numbers.iter().map(|x| { x * 2 }).collect();

In the code above I’m using map over a list of numbers, and running the lambda function |x| x * 2 on it, in fact creating a closure. If you need to add the possibility to add iteraction support to a struct, you would need to implement the Iterator trait, as shown in the docs

Analyzer

rust-analyzer is what is going to help you understanding Rust and the patterns that the language is guiding you to use. When integrated inside your favourite editor (I’m using Zed right now), you will see warning and errors generated directly when saving a file.

Let’s try to analyse some broken code

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
    println!("{}", s1);
}

Why is this code broken?

error[E0382]: borrow of moved value: `s1`
  --> src/main.rs:4:20
   |
65 |     let s1 = String::from("hello");
   |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
66 |     let s2 = s1;
   |              -- value moved here
67 |     println!("{}", s1);
   |                    ^^ value borrowed here after move
   |
   = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
   |
66 |     let s2 = s1.clone();
   |                ++++++++

Rust is telling that when the code does s2 = s1, it moves the value s1 so it cannot be used anymore in the println call. A solution is to clone s1 into s2 so it will then have two independent instances of the string. Pretty handy to find a quick solution, especially for beginners. The Rust team did a great job for creating high quality error messages to guide the user to find a solution.

Macros

Rust supports different types of macros: declarative and procedural. Macro helps extending the language by removing boilerplate code and even extending programs with external code (e.g. plugins).

Declarative macros are called like functions, they just append ! to their name.

#[macro_export]
macro_rules! vec {
    ( $( $x:expr ),* ) => {
        {
            let mut temp_vec = Vec::new();
            $(
                temp_vec.push($x);
            )*
            temp_vec
        }
    };
}

let v = vec![1, 2, 3, 4]; // Creates a new vec inline

My suggestion is to avoid using macros and always prefer functions where possible. Macros are hard to understand, maintain and document when they get complex.

Cargo and packages

cargo is the official package manager for Rust. It downloads the dependencies and keep track of the Rust version the project is supporting. It can be used for all the task related to the project, like running, building it, generating the documentations, etc. The package registry is crates.io which counts more than 140K packages as today.

Tests

Rust provides a test framework directly in the language which supports both unit and integration tests. cargo test is how to run all the tests for a project.

This is a unit test example, included in the same file of the function implementation.

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        let result = 2 + 2;
        assert_eq!(result, 4);
    }
}

When to use Rust

Rust at the moment is getting really popular, but I believe it is not a solution for all types of problems. When performance are a requirement, instead of using C or C++, Rust can provide a significant improvement over them. Rust is nearly as fast as C, but it is definitely safer, and the choice will pay off…

Rust new UI frameworks are emerging every year, and I believe is in a great spot, despite not being object oriented, to become the go-to language for creating the next generation of desktop apps. Checkout Tauri which is a faster replacement than Electron, or the new GPUI which uses native libraries and looks very promising.

rust nation uk
source: https://www.youtube.com/watch?v=6mZRWFQRvmw&t=27012s

© 2024 Elia Scotto ⋅ RSS feed