Rust

Jan 31, 2025
#cs


I recently started learning the Rust programming language. Here are some of my favorite Rust features. All this and more can be found in the Rust Book.

Shadowing

In Rust, it's idiomatic to shadow variables. This is useful when you want to change the type of a variable. For example, if your program accepts user input as a string, and you want to convert it into an integer, you can do:

let input: String = "123";
let input: i32 = input.parse().unwrap();

In C++, you are not allowed to shadow a variable in the same scope, so you have to define a new variable with a different name to store the parsed value, which is uglier:

const std::string input = "123";
const int parsed_input = std::stoi(input);

Expressions

An expression is anything that evaluates to a value. More things are expressions in Rust than in C++. For example, in Rust, any code block inside curly braces is an expression. This makes it very convenient to define constant values that require many lines to initialize:

let result: i32 = {
    let a: i32 = 5;
    let b: i32 = 10;
    a + b // If the last statement does not end with a semicolon, the value is implicitly returned
};

In C++, you have to use the Immediately-Invoked Closure pattern to accomplish the same thing, which is uglier:

const int result = [&]() {
    const int a = 5;
    const int b = 10;
    return a + b;
}();

In Rust, "if" is also an expression:

let result: i32 = if true {42} else {0};

C++ has special syntax for this purpose, the ternary expression, which is not as elegant as reusing the if-statement syntax:

const int result = true ? 42 : 0;

Enums

In Rust, enums can contain data. Rust also has a powerful pattern matching feature that makes working with enums very easy and fun:

enum Shape {
    Triangle { base: i32, height: i32}, // named members
    Square(i32), // unnamed member
}

fn area(shape: Shape) -> i32 {
    match shape {
        Triangle { base, height } => 0.5 * base * height,
        Square(side) => side * side,
    }
}

Memory Management

In Rust, every value has a unique owner, and the value is dropped when the owner goes out of scope.

{
    // Allocate a string value on the heap
    let mut s = String::from("hello");
    s.push_str(" world");
    println!("{s}");
    // `s`, which owns the string value, goes out of scope, so the string will be deallocated
}

This ownership rule means that the Rust compiler can prevent a lot of memory errors that are common in other languages. For example, in C++ this would be a memory leak:

{
    // Allocate an object on the heap
    Foo* foo = new Foo();
    // If you forget to call `delete foo`, then the object won't be deallocated
}

Of course, you can use smart pointers in C++ to achieve the same behavior that the Rust language enforces:

{
    std::unique_ptr<Foo> foo = std::make_unique<Foo>();
    // When the smart pointer goes out of scope, the object will be deallocated
}

Default Move

In Rust, if you assign a value to another variable, then the value is moved by default, and the compiler will prevent you from using the old variable:

let s1 = String::new("hello");
let s2 = s1; // s1 is no longer valid
println!("{s1}"); // this is a compiler error

In C++, values are copied by default, which can incur hidden runtime costs. Also, if you move a value, the compiler doesn't stop you from using the old variable, which can lead to unexpected behavior:

std::string s1 = "hello";
std::string s2 = s1; // this copies the string, which can be expensive
std::string s3 = std::move(s1);
std::cout << s1 << std::endl; // this is a NOT compiler error

In Rust, if you want to copy a value, you have to do so explicitly via clone():

let s1 = String::new("hello");
let s2 = s1.clone(); // this creates a deep copy
println!("{s1}"); // `s1` is still valid

Note that trivially copyable types, like integers, are still copied by default instead of moved.

References

In Rust, if you want to keep ownership of a value but allow other code to use the value, you can give a reference to the value.

fn calculate_length(s: String) -> usize {
    s.len()
}

fn main() {
    let s = String::from("hello");
    let l = calculate_length(s); // This moves `s`
    println!("{s} has length {l}"); // This is a compiler error because `s` is invalid
}

In the above example, the function calculate_length takes in the argument s by value. This means that when we call the function, the string we pass in gets moved, so we can no longer use it after the function returns. To fix this, we can modify the function to take in the argument by reference:

fn calculate_length(s: &String) -> usize {
    s.len()
}

fn main() {
    let s = String::from("hello");
    let l = calculate_length(&s); // pass a reference to `s`
    println!("{s} has length {l}"); // `s` is still valid
}

Note that when we call the function, we have to explicitly specify that we are passing the argument by reference. This is unlike C++, where the only way to tell if a function takes an argument by reference or value is by looking at the function signature:

size_t calculate_length(const std::string& s) {
    return s.length();
}

int main() {
    std::string s = "hello";
    // No way to tell just by looking at the function call if `s` is passed by reference or value
    size_t l = calculate_length(s);
    std::cout << s << "has length" << l << std::endl;
}

The act of creating a reference from a value is called borrowing. In order to prevent data races, the compiler enforces that if you borrow a mutable reference, then you cannot have any other references. In addition, the compiler enforces that references do not outlive the value they were borrowed from, to prevent dangling references.

fn dangle() -> &String {
    let s = String::from("hello");
    &s // This is a compiler error because the reference will outlive `s`
}

fn main() {
    let mut s = String::from("hello");
    let s1 = &s;
    // This would be a compiler error because we can't take a mutable reference if we already have an immutable reference `s1`
    // let s2 = &mut s;
    println!("{s1}");
    // This is NOT a compiler error because `s1` is out of scope
    let s2 = &mut s;
    s2.push_str(" world");
    // This print takes a reference to `s`, but that's okay because `s2` is out of scope
    println!("{s}"); 
}

Note that a reference goes out of scope right after its last usage.

Generics and Traits

Generics and Traits in Rust are like Templates and Concepts in C++.

First, let's start with Traits. A Trait specifies a set of methods that instances of the Trait must implement. For example, this trait specifies that instances must define a distance metric:

trait Measurable {
    fn distance(&self, other: &Self) -> i32;
}

And this struct implements the trait:

struct Point {
    x: i32,
    y: i32,
}

impl Measurable for Point {
    fn distance(&self, other: &Point) -> i32 {
        (self.x - other.x).abs() + (self.y - other.y).abs()
    }
}

To use this trait, we can write a generic function:

fn diameter<T: Measurable>(v: &[T]) -> i32 {
    let mut max_distance = 0;
    for a in v {
        for b in v {
            max_distance = max_distance.max(a.distance(b));
        }
    }
    max_distance
}

fn main() {
    let points = [
        Point { x: 1, y: 2 },
        Point { x: 2, y: 3 },
        Point { x: 5, y: 10 },
    ];
    let d = diameter(&points);
    println!("Diameter = {d}");
}

In Rust, generics must specify the traits that the template parameters should satisfy. This is unlike C++, where concepts are optional and compilation will succeed as long as every instantiation of the generic type is valid.

Lifetimes

Lifetimes are a type of generic that help the compiler ensure that references are alive for as long as they need to be. They do not affect the actual lifetime of any references, they are just used by the borrow checker. Lifetimes can be used to indicate that a reference returned by a function lives as long as the inputs:

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

Lifetimes are also required when storing references inside of structs:

struct Foo<'a> {
    x: &'a mut i32,
}

fn main () {
    let mut a = 5;
    let foo = Foo { x: &mut a };
    *foo.x = 10;
    println!("{a}");
}

This tells the compiler that a outlives foo, so it's valid for foo to store a reference to a.





Comment