30 Days of Rust - Day 29
johnnylarner
Posted on July 4, 2023
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:
- Reduce code duplication by defining common code at the parent level
- 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;
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!"),
}
}
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:
-
struct
matching requires you to temporarily instantiate astruct
and compare the relevant fields - Irrelevant fields can be ignored using the range operator
..
. This is useful when we have more than one other field we want to ignore. - 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!"),
}
}
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."),
}
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),
)
}
}
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:
- The
unsafe
part of the code is isolated and wrapped in asafe
API. This makes calling the functionsafe
regardless of where you call it. - 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
- 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!");
}
Posted on July 4, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.