Generic, Traits, and Lifetimes
Defining Generic Types
Similar to how duplicated code is a great indicator for where to create a function, some duplicated code can be reformatted to use generics.
You can define a generic type parameter to a function, e.g.,
fn func_name<T>(list: &[T]) -> T {...}
fn func_name<T>(list: &[T]) -> T {...}
This function (func_name
) is generic over the type T
and has a single parameter named list
which is a slice of values of type T
, and returns a single value of same type. In this case, T
may be an i32
, String
, etc.
You can also define a generic type parameter for a struct, and methods implemented on structs, e.g.,
struct Point<T> {
x: T,
y: T,
}
impl<T> Point<T> {
fn<T> new(x: T, y, T) -> Self { x, y }
}
struct Point<T> {
x: T,
y: T,
}
impl<T> Point<T> {
fn<T> new(x: T, y, T) -> Self { x, y }
}
Example
Functions with generic types won't compile if the logic isn't supported for all types that
T
could take on. In this case you have to specify the trait of the generic type as a parameter. For example, the below function defines thatT
must implement thestd::ops::Add
trait, this will compile and support adding integers, floats, or anything that already has theAdd
trait implemented on it. See Defining Traits for more details.rustfn add_two<T: std::ops::Add<Output = T>>(x: T, y: T) -> T { x + y }
fn add_two<T: std::ops::Add<Output = T>>(x: T, y: T) -> T { x + y }
If you want to have different members of the struct
to have different types, this can be achieved by specifying two generic types, e.g., struct Point<T, U> {...}
.
Enums can also have generic types. The Option<T>
and Result<T, E>
are two examples that are familiar.
enum Option<T> {
Some(T),
None
}
enum Result<T, E> {
Ok(T),
Err(E),
}
enum Option<T> {
Some(T),
None
}
enum Result<T, E> {
Ok(T),
Err(E),
}
Defining Traits
Traits allow us to define shared behaviour for our data types, and serve a similar purpose that interfaces do in other programming languages, with a few key differences.
Default Implementations
If a trait has a method that is given some default behaviour then this default can be imposed on a struct
by implementing the trait. If the trait has a default implementation, then simply applying the trait in an impl
block will give the struct
its default methods.
trait Activity {
fn activity(&self) -> String {
String::from("going for a run") // Default implementation
}
}
pub struct Athlete {
pub name: String,
}
impl Activity for Athelete {}
let athelete = Athelete { name: "Jacob".to_string() };
println!("The athelete named {} is {}", athelete.name, athelete.run());
trait Activity {
fn activity(&self) -> String {
String::from("going for a run") // Default implementation
}
}
pub struct Athlete {
pub name: String,
}
impl Activity for Athelete {}
let athelete = Athelete { name: "Jacob".to_string() };
println!("The athelete named {} is {}", athelete.name, athelete.run());
Output:
The athelete named Jacob is going for a run
The athelete named Jacob is going for a run
Note: In the above example this is similar in essence to the role that a base implementation of a parent class in other languages such as C++.
Traits as Parameters
You can define a function that takes a parameter that implements a specific trait. This allows the function to call methods on it's parameter, allowing potentially different types to be passed to the function. For example,
pub fn notify(item: &impl Summary) {
println!("Breaking news! {}", item.summarize());
}
pub fn notify(item: &impl Summary) {
println!("Breaking news! {}", item.summarize());
}
In this example a type for item
isn't specified, rather a trait is specified such that only arguments that implement the Summary
trait are accepted. Another more complete way to identify traits as parameters can be used with the where
keyword (extra traits for demonstration).
fn add_two2<T, U>(x: T, y: U) -> T
where
T: std::ops::Add<U, Output = T> + Send + Sync + Copy,
{
x + y
}
fn add_two2<T, U>(x: T, y: U) -> T
where
T: std::ops::Add<U, Output = T> + Send + Sync + Copy,
{
x + y
}
Validating References with Lifetimes
Every reference in Rust has a lifetime. Lifetimes are specified as generics. Lifetimes are meant to restrict access to dangling references
Rust employs a borrow checker to validate the lifetimes of borrows to ensure they're used in the correct scope.
If you create a function that conditionally returns a reference (e.g. a string slice governed by and if/else) then the borrow checker doesn't know the lifetime of the returned value. In this case a lifetime specifier must be used.
The lifetime annotation syntax follows as such:
&i32 // a reference
&'a i32 // a reference with an explicit lifetime
&'a must i32 // a mutable reference with an explicit lifetime
&i32 // a reference
&'a i32 // a reference with an explicit lifetime
&'a must i32 // a mutable reference with an explicit lifetime
Lifetime annotations tell Rust how generic lifetime parameters of multiple references relate to each other, so on their own they don't mean much without having another reference to compare to. Since lifetime parameters are generics, they are specified inside angle brackets in function signatures. For example,
fn purchase_longest<'a>(item1: &'a str, item2: &'a str) -> &'a str {
if item1.len() > item2.len()
item1
else
item2
}
fn purchase_longest<'a>(item1: &'a str, item2: &'a str) -> &'a str {
if item1.len() > item2.len()
item1
else
item2
}
The above lifetime parameters declare that the returned value will live at least as long as the shortest living function parameter also annotated with the lifetime specifier 'a
. It it important to understand that lifetime specifiers don't change the lifetime of the references, but place constraints on what the borrow checker uses to determine the useable scope of those references. When returning a reference from a function, the lifetime parameter for the return type needs to match the lifetime parameter for one of the parameters.
Structs can also had attributes that are references by specifying a lifetime parameter.
struct Novel<'a> {
paragraph1: &'a str,
paragraph2: &'a str,
}
struct Novel<'a> {
paragraph1: &'a str,
paragraph2: &'a str,
}
In this case, the struct Novel
can't outlive the reference it holds.
A thought on references: For any concrete (instantiated) data type, there exists a location in memory that holds the value of that type. When we create a new variable with reference to that instance, we create a semantic abstraction where two variables access the same peice of memory. This is different than a pointer because a pointer holds the memory address of the associated instance. A reference doesn't only point to that memory address, but creates a duplicate variable that borrows the value held at the memory address. (In Rust) If the original instance goes out of scope then the memory location is freed automatically. If any references still exist to that memory address then a dangling reference would occur. Rust doesn't allow these to occur in the first place, so they have to be managed by the programmer pro-actively, instead of reactively as is the case in other programming languages.
The 'static
lifetime specifier indicates that the reference will exist for the entire life of the program.
Stopped at Lifetime Elision...