← back

Rust: Not Just Zoom Zoom Fast

When it comes to Rust, the first thing that usually comes to mind is its impressive performance. And while Rust certainly delivers on this front, there’s so much more to the language than just raw speed. From its well-designed syntax and powerful abstractions, to its robust package manager and vibrant ecosystem, Rust is a language that truly has it all. In this post, we’ll take a closer look at some of the key features that make Rust such a versatile and compelling language.

While the language features discussed in this post may not be exclusive to Rust, it is the way in which they are carefully designed and integrated that sets Rust apart. Rust is the only language where these features converge seamlessly to create a coherent system, which is why it is such a captivating language.

Practical Immutability

In Rust, variables can be declared as either immutable or mutable using the mut keyword. Rust embraces immutability as a default by making variables immutable by default. This means that we must explicitly declare variables as mutable if we need to change their value later. This approach makes it easier to reason about the behavior of programs and helps prevent accidental mutations.

let x = 5; // immutable
let mut y = 5; // mutable

y = 6; // ok
x = 6; // error: cannot assign twice to immutable variable `x`

In addition to mutable variables, Rust supports passing mutable references to functions. Functions must declare whether they intend to mutate their arguments or not, which further helps prevent accidental mutations. This allows us to use mutable variables in a controlled, explicit way.

fn increment(num: &mut i32) {
    *num += 1; // dereference the pointer to mutate the value
}

fn main() {
    let mut x = 10; // x is mutable
    increment(&mut x); // pass a mutable reference to x

    println!("x: {}", x); // prints "x: 11"
}

By embracing immutability as a default and using mutable variables only when necessary, Rust code becomes more robust and predictable.

Algrebraic Data Types (ADTs) and Pattern Matching

Algebraic Data Types (ADTs) are a fundamental concept in functional programming that allow for the creation of complex data types by combining simpler types. ADTs can be of two types: Sum types and Product types. Sum types combine multiple types into a single type that can hold one of the constituent types at any given time. Rust provides a powerful implementation of Sum types in the form of enums or enumerated types. Structs, on the other hand, are used to represent Product types. You’ve probably already used Product types in other languages (interfaces in TypeScript, classes in Java, etc.).

For example, consider a program that represents the types of shapes. We can use an enum to represent the different types of shapes:

enum Shape {
    Circle(f64),
    Rectangle(f64, f64),
    Triangle(f64, f64, f64),
}

Here, we have defined an enum Shape that has three variants: Circle that takes a single f64 argument representing the radius, Rectangle that takes two f64 arguments representing the length and width, and Triangle that takes three f64 arguments representing the lengths of its three sides. This allows us to represent any possible shape in a single data type.

Now we can use pattern matching to easily and safely parse the data of a shape. If you don’t know what pattern matching is, think of it as a switch statement on steroids. It allows us to match a value against a pattern and execute code based on the pattern that matches. I have a whole blog post on pattern matching btw.

fn area(shape: Shape) -> f64 {
    match shape {
        // pi * radius^2
        Shape::Circle(radius) => std::f64::consts::PI * radius * radius,

        // length * width
        Shape::Rectangle(length, width) => length * width,

        // Heron's formula: sqrt(s * (s - a) * (s - b) * (s - c)) where s = (a + b + c) / 2
        Shape::Triangle(side1, side2, side3) => {
            let s = (side1 + side2 + side3) / 2.0;
            (s * (s - side1) * (s - side2) * (s - side3)).sqrt()
        }
    }
}

Rust’s compiler also ensures that our pattern matching is exhaustive, meaning that we must handle all possible cases. This prevents us from accidentally forgetting to handle a case and causing a runtime error.

Exhaustive pattern matching

ADTs along with pattern matching make it trivial to create and handle complex data types in a safe and concise way.

Built-In Abstractions

Rust provides powerful built-in abstractions that help in writing correct and safe code easily. Two of the most important abstractions in Rust are Option and Result. These types are essential for working with Rust’s null-safety and error-handling systems, which help prevent bugs and improve the reliability of Rust programs. In this section, we’ll explore Option and Result in detail, and see how they can be used to write more reliable and error-free Rust code.

No Null, No Problem (Option)

Rust replaces the concept of null with the Option type, providing a safer alternative that eliminates the risks associated with null values. Option is an enum that can be either Some with a value or None to represent absence of a value. This type-safe approach allows us to handle absence of a value without resorting to null. Here’s how the Option type is defined in Rust:

enum Option<T> {
    Some(T),
    None,
}

By using Option, we can ensure that our code is free from null-related bugs and errors, making it easier to reason about program behavior. You may be familiar with this pattern from other languages like Haskell’s Maybe monad or OCaml’s option type.

Let’s look at Option in practice. Consider a function that takes a vector of integers and returns the largest integer in the vector. If the vector is empty, we want to return None. Otherwise, we want to return Some with the largest integer. Here’s how we can implement this function in Rust:

fn largest(numbers: Vec<i32>) -> Option<i32> {
    if numbers.is_empty() {
        return None;
    }

    let mut largest = numbers[0];

    for num in numbers {
        if num > largest {
            largest = num;
        }
    }

    Some(largest)
}

Now we can use this function and use pattern matching to handle the both cases:

fn main() {
    let numbers = vec![1, 2, 3];

    match largest(numbers) {
        Some(num) => println!("Largest number: {}", num),
        None => println!("No largest number"),
    }
}

And this works!

largest function works

But we can do better. Rust provides an extensive standard library that includes a number of useful functions. Here we can create an iterator from our Option and use the max function to get the largest number:

fn largest(numbers: Vec<i32>) -> Option<i32> {
    numbers.into_iter().max()
}

This also automatically handles the case where the vector is empty, returning None for us. Rust’s standard library is full of useful functions like this.

Want to return the double of the largest of the even numbers but only if it’s less than 100? No problem!

fn largest_even_less_than_100(numbers: Vec<i32>) -> Option<i32> {
    numbers
        .into_iter() // create an iterator from the vector
        .filter(|num| num % 2 == 0) // filter out only even numbers
        .max() // get the largest number - returns an Option<i32>
        .map(|num| num * 2) // double the Some value inside the Option, leaves None unchanged
        .filter(|num| num < &100) // only return Some if the value is less than 100
}

I think you get the point.

When Things Don’t Go As Planned (Result)

Rust’s Result type is another built-in abstraction that is often used to handle errors in Rust programs. It represents the success or failure of an operation. Result is an enum with two possible variants - Ok and Err. Ok represents the successful result of an operation, while Err represents an error that occurred during the operation. Here’s how the Result type is defined in Rust:

enum Result<T, E> {
    Ok(T),
    Err(E),
}

You might be familiar with this pattern from other languages like Haskell’s Either monad or OCaml’s result type.

Let’s look at an example of how Result can be used to handle errors. Let’s say we have a function that takes two integers as arguments and returns the result of dividing the first integer by the second. However, division by zero is not allowed and will result in an error. We can use the Result type to handle the possible error case:

fn divide(x: i32, y: i32) -> Result<i32, &'static str> {
    if y == 0 {
        return Err("Cannot divide by zero");
    }
    Ok(x / y)
}

Now we can use this function and handle the success and error cases:

fn main() {
    match divide(10, 2) {
        Ok(result) => println!("Result: {}", result),
        Err(error) => println!("Error: {}", error),
    }
}

Pretty simple, right? Let’s look at some functions Rust provides to make working with Result a breeze.

Say we wanted to add 10 to the result of a chained division operation. We could use nested match statements but that’s a bit ugly and verbose. Rust has us covered with the and_then and map functions:

fn main() {
    let result = divide(10, 2)
        .and_then(|x| divide(x, 2))
        .map(|x| x + 10);

    match result {
        Ok(result) => println!("Result: {}", result),
        Err(error) => println!("Error: {}", error),
    }
}

This is a lot more concise than using nested match statements. Rust’s standard library provides a lot more functions for working with Result and Option, so be sure to check them out.

Vibrant Community and Ecosystem

Rust is more than just a language; it has a thriving ecosystem and community. This community is supported by a robust ecosystem of libraries, tools, and resources that make it easy to build, test, and deploy Rust applications.

Here are some key aspects of Rust’s ecosystem and community:

These are some of my favourite features of Rust. If I go on, this article will be too long, so I’m linking some resources for Rust coolness below. I highly recommend checking them out.

Some Other Cool Resources