Skip to content

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.,

rust
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.,

rust
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 that T must implement the std::ops::Add trait, this will compile and support adding integers, floats, or anything that already has the Add trait implemented on it. See Defining Traits for more details.

rust
fn 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.

rust
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.

rust
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,

rust
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).

rust
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:

rust
&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,

rust
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.

rust
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...