DEV Community

Cover image for 30 Days of Rust - Day 29
johnnylarner
johnnylarner

Posted on

30 Days of Rust - Day 29

Good evening folks,
We're nearly there. Two posts left. I've got a final kick of energy to push through. Today we'll be zooming over some of Rust's object oriented, pattern matching and unsafe features.

Yesterday's questions answered

No questions to answer

Today's open questions

No open questions

Is Rust object-oriented?

There are many ways to define whether a programming language is object-oriented or not. When it comes to Rust, a useful way to think about this is to ask yourself: what are OO languages designed to achieve and how do they do this?

Encapsulating logic

Objects allow you to separate and encapsulate different parts of your code. Rust's features definitely support encapsulation. Structs and impl blocks allow programmers to write distinct code that is separated from other bits of code ✅

Objects as data storage

Often object-oriented languages express human concepts through grouping data in objects. In Rust enums and structs are also a great way to group conceptually similar data in one place. ✅

Reusing and types through inheritance

The most obvious missing aspect so far for new Rust developers is the absence of inheritance. In many programming languages, objects expressed as subclasses are able to inherit from parent classes. Inheritance can:

  1. Reduce code duplication by defining common code at the parent level
  2. Increase the breadth of composability as subclasses share their parent's type.

We know there are no classes in Rust, and that structs cannot inherit from each other. But this doesn't mean Rust doesn't have mechanisms to tackle the issues noted above.

trait objects provide a way of declaring default behaviour. If a trait implements a method and a struct implements that trait , the struct will take on the default implementation.

What's more traits also grant programmers access to typed interfaces across different structs. If a function or struct field has a trait object as its type definition, it will accept any struct that implements that trait. trait objects incur a small runtime penalty. As we don't know at compile time what all the types could be (imagine you're writing a library that would allow users to make their own components), trait objects are stored on the heap. This means we need to access them via a pointer at runtime to find the method we want to call. This is known as dynamic dispatch.

Match, match, match

When learning Rust, match statements are one of the first novel features you come across. What's particularly cool about match is that the compiler will often force you to exhaustively match all possible outcomes. This catches bugs before they even have a chance to compile.

match arms require so-called refutable patterns, patterns that can match or not match. Irrefutable patterns are most commonly seen in variable assignment:

let x = 5;
Enter fullscreen mode Exit fullscreen mode

Nested matches

If you're working with nested enums or structs, you can use match patterns beyond the root level of the data structure:

enum MarriageStatus {
    Single,
    MarriedTo(String),
    Divorced,
}

struct TaxProfile {
    name: String,
    age: i32,
    marriage_status: MarriageStatus,
}

fn main() {
    let profile = TaxProfile {
    name: String::from("Max Mustermann"),
    age: 30,
    marriage_status: MarriageStatus::MarriedTo(String::from("Jane Doe")),
    };

match profile {
    TaxProfile {
        marriage_status: MarriageStatus::MarriedTo(spouse),
        ..
    } => {
        println!("Congrats, you and {spouse} get a tax break.");
    }
    _ => println!("Only married people are worthy of tax breaks!"),
    }
}
Enter fullscreen mode Exit fullscreen mode

Here we want to print a different message based on whether a given tax profile gets a tax break or not. This code demonstrates several more advanced features of the match system:

  1. struct matching requires you to temporarily instantiate a struct and compare the relevant fields
  2. Irrelevant fields can be ignored using the range operator ... This is useful when we have more than one other field we want to ignore.
  3. We can also match based on the inner field's value. This can also be used in the result of the match arm.

Match guards and bindings

In the above example the first arm of the match statement matched to an enum. If we wanted to only match if your spouse has a specific name, we'd also have to introduce a match guard:

match profile {
    TaxProfile {
        marriage_status: MarriageStatus::MarriedTo(spouse),
        ..
    } if spouse == "Angela Merkel" => {
        println!("Congrats, you and Angie get a tax break.");
    }
    _ => println!("Only married people are worthy of tax breaks!"),
    }
}
Enter fullscreen mode Exit fullscreen mode

In this case, only the spouse of Angela Merkel would get a tax break. What if we wanted to give tax breaks to old people too? We could specify a range for the match arm and use a binding to extract the result. We can print this to the screen to give the user a personalised response:

match profile {
    TaxProfile {
        marriage_status: MarriageStatus::MarriedTo(spouse),
        ..
    } if spouse == "Angela Merkel" => {
        println!("Congrats, you and Angie get a tax break.");
    }
    TaxProfile {
        age: boomer_age @ 60..=79,
        ..
    } => println!("Even at age {boomer_age} you matter to society, have a tax break."),
    _ => println!("Sorry, no tax break for you."),
}
Enter fullscreen mode Exit fullscreen mode

What is unsafe code?

Until now in these blog posts we've only talked about safe Rust code. Safe Rust code is effectively any code that meets the Rust compiler's ownership, borrowing and type rules. We call the code safe because we know that this code will not suffer from invalid pointers, null values, unintended side effects or security issues posed by incorrect pointer addresses.

Unsafe code is the inverse: we no longer get any guarantees from Rust that our program upon submission to the compiler won't have any of these issues at runtime. The trade off is that we no longer have to adhere to all of the compiler's rule. One example might be having more than one mutable reference in scope at any given time:

fn split_as_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = values.len();
    let ptr = values.as_mut_ptr();

    assert!(mid <= len);

    unsafe {
        (
            slice::from_raw_parts_mut(ptr, mid),
            slice::from_raw_parts_mut(ptr.add(mid), len - mid),
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

You can see we use the unsafe keyword to declare a block in which we can execute unsafe code. What makes this code unsafe is calling the associated function from_raw_parts_mut as this function is declared as unsafe. To make a function unsafe you can prepend the function declaration with the unsafe keyword.

Beyond the function being unsafe, we can tell from our function's return type that both calls from_raw_parts_mut a mutable reference for our array into scope. This is not permitted by the Rust compiler in a safe context. But was the code is unsafe, we don't have any issues when submitting our code.

The split_as_mut function is considered as an acceptable example of using unsafe Rust as:

  1. The unsafe part of the code is isolated and wrapped in a safe API. This makes calling the function safe regardless of where you call it.
  2. We manually assert that the pointers we create will point to a valid location in memory for the data structure we're trying to access
  3. Both pointers access unique subsets of the array.

Multilingual Rust

We can also declare Rust APIs for other language using the extern keyword. Private extern functions call functions from other languages, while public functions can be called in other languages:

extern "C" {
    fn abs(input: i32) -> i32;
}

#[no_mangle]
pub extern "C" fn call_from_c() {
    println!("Just called a Rust function from C!");
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)