30 Days of Rust - Day 22
johnnylarner
Posted on May 28, 2023
Good afternoon folks, I'm writing today's blog from a sunny corner of Friedrichshain, Berlin. The warm May sun is quite a contrast to today's topics: generics, traits and lifetimes š š
Yesterday's questions answered
No questions to answer
Today's open questions
No open questions
Keep things general
Many developers who've only worked with Python will not have worked much with generics. Unless your project is using static typing tools, it may not bring a lot of value to constantly define generics or applying really strict type annotation rules. For statically typed languages, you often have no option but to define and use generics.
What is a generic again?
Generic type annotations provide developers a way to reuse code for different data types. For example, finding the largest number in an array. An array of integers and an array of floats are of different types. Instead of writing two functions with different type annotations, you can declare a generic:
fn largest<T>(list: &[T]) -> &T {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
Generic implementations
Generics can also be used with structs and enums (Result and Option are both generics). This also means we can define specific implementations for concrete types of a struct or an enum. Consider a Point type that could be of integer or float type. We may want to run operations against a float typed Point only:
struct Point<T, U> {
x: T,
y: U,
}
impl Point<f32, f32> {
fn distance_from_origin(&self) -> f32 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
}
}
What do generics cost?
According to the Rust book, generics in Rust only cost resources at compile time. At this stage, the compiler will scan the code and collect all possible variants of generic and then render concrete implementations of those generics. This process has a fancy name: monomorphization.
Rust code can be treacherous
Where generics serve to share code across different parts of your program, traits are a tool to share behaviour across your code base. Traits are typically referred to as interfaces in other languages. I've condensed the main features of traits below:
- Traits can have default implementations which can be overridden
- Default implementations are implicitly adopted where all conditions are met, whereas overridden traits must be declared explicitly.
- Traits can replace type annotations in function and method signatures. These are known as parameters or trait bounds.
Bound to treason
Trait bounds can expressed in 3 ways:
// As trait parameters
pub fn notify(item1: &impl Summary, item2: &impl Summary)
// As trait bounds
pub fn notify<T: Summary>(item1: &T, item2: &T)
// As a where clause
pub fn notify<T>(item1: &T, item2: &T)
where T: Summary
Trait parameters are useful when arguments need to be of different types. Trait bounds ensure that arguments are of the same type. And where clauses are useful when multiple traits need to be included. You can use the + operator to express multiple stacked traits:
pub fn notify<T, U>(item1: &T, item2: &U)
where
T: Summary
U: Clone + Debug
Get a life!
We've already learned about Rust's strict scoping rules. Scoping becomes more complex when functions we use take and return references to rather than ownership of data.
If a function returns a reference to one of two values which were declared in different scopes, then the lifetime of our output will differ. This means the borrow checker cannot prove our code to be safe at runtime.
The lifetime of a reference is often inferred by the Rust compiler. This is true as well when writing functions, methods and structs. However, occasionally we need to indicate to the compiler what the lifetime of a certain reference is. This is similar to when you have to indicate to the compiler what a variable's type is.
The compiler has three rules that it applies to infer a reference's lifetime. If these cannot be fulfilled, you as the developer have to step in:
- The first rule is that the compiler assigns a lifetime parameter to each parameter thatās a reference.
- The second rule is that, if there is exactly one input lifetime parameter, that lifetime is assigned to all output lifetime parameters.
- The third rule is that, if there are multiple input lifetime parameters, but one of them isĀ
&self
Ā orĀ&mut self
Ā because this is a method, the lifetime ofĀself
Ā is assigned to all output lifetime parameters.
Rule three tells us that we will rarely encounter this problem when working with structs. So let's take a look at the example from the Rust book for a function requiring lifetime annotations:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
As you can see, the syntax for declaring a lifetime is a bit weird. And as with generics annotation, we use an arbitrary character, this time typically lowercase and start at a, b, c and so on.
Where lifetime annotations differ from generics is that they explain only the relationship between an output parameter (aka return value) and input parameters (aka arguments).
In the above example, the relationship between all lifetimes is the same. I understand this to equal:
When both argument x and y are alive, so is the output.
The Rust book explains this as the output being equal to the shortest lifetime of x and y. I think this is not obvious, hence my reformulated explanation. When compared to how generics are defined, we see substantial divergence: x, y and the output would all have the same lifetime.
Posted on May 28, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.