Get Started with Rust: Generics

Generic programming allows programmers to write general algorithms that work with arbitrary types. It reduces code duplication and provides type safety, which enables us to write more concise and clean code. This approach is also known as parametric polymorphism or templates in other languages.

Rust supports two types of generic code:

  • Compile-time generics, similar to C++ templates.
  • Run-time generics, similar to virtual functions in C++ and generics in Java.

Serokell has a broad experience as a Rust development company. In this article, we will share practical knowledge about compile-time generics (which we just call generics) and answer the following questions:

  • Why do we use generics?
  • How to use generic types in Rust?
  • How to define custom types, functions, and methods with generics?
  • How to restrict generics with traits?

What are generics in Rust?

Let’s start with an example, creating and printing a vector of integers:

let mut int_vector = Vec::new();

int_vector.push(1);
int_vector.push(2);
int_vector.push(3);

for i in &int_vector {
    println!("{}", i);
}
// Prints:
// 1
// 2
// 3

We call the Vec::new function to create a new empty vector, add multiple elements with push, and print all the elements.

Rust is a statically typed language, meaning the compiler should know the types of all the variables at compile time. Once we push the first integer into the vector, the compiler recognizes int_vector as a vector of integers, and it can’t store any other type. If we try to push 4.0, the code doesn’t compile:

error[E0308]: mismatched types
   |
   |     int_vector.push(4.0);
   |                ---- ^^^ expected integer, found floating-point number
   |                |
   |                arguments to this function are incorrect

We can write a similar program using strings:

let mut str_vector = Vec::new();

str_vector.push("one");
str_vector.push("two");
str_vector.push("three");

for i in &str_vector {
    println!("{}", i);
}
// Prints:
// one
// two
// three

This time, if we try to push 4, the code doesn’t compile:

error[E0308]: mismatched types
   |
   |     str_vector.push(4);
   |                 ---- ^^^ expected `&str`, found integer
   |                 |
   |                 arguments to this function are incorrect

Aside from the name and elements, both code snippets look pretty similar. With Vec::new, we created and used two vectors: one that holds integers and another that holds strings. This is possible because vectors are implemented using generics. When we use new, we construct a new empty Vec<T>, which can hold any type.


💡 The <T> syntax

We use the <T> syntax when declaring generic types. T is known as the type parameter, and it’s written between the angle brackets. T represents any data type, which means there is no way to know what T is at runtime – we can’t pattern match on it, can’t do any type-specific operations, and can’t have different code paths for different T’s.

You can use any letter to signify a type parameter, but it’s common to use T.


The compiler can often infer the type we want to use based on the value and how we use it. When we push a value of a specific type into a vector, the compiler figures out what kind of elements we want to store and infers the type of the vector. For example, when we populated int_vector with i32 values, Rust inferred that its type is Vec<i32>.

If we don’t insert any elements into the vector and don’t give any hints about the elements of the vector, the code doesn’t compile:

let mut int_vector = Vec::new();

for i in &int_vector {
    println!("{}", i);
}
error[E0282]: type annotations needed for `Vec<T>`
   |
   |     let mut int_vector = Vec::new();
   |         ^^^^^^^^^^^^^^
   |
help: consider giving `int_vector` an explicit type
    , where the type for type parameter `T` is specified
   |
   |     let mut int_vector: Vec<T> = Vec::new();
   |                       ++++++++

Because we aren’t inserting any values into the vector anymore, Rust doesn’t know what kind of elements we intend to store. The error suggests that we can add an explicit type. With a type annotation, we can tell the compiler that the Vec<T> in int_vector will hold elements of the i32 type:

// We specify the type within angle brackets
let mut int_vector: Vec<i32> = Vec::new();

We can’t leave a vector without a specific type. We need to tell the compiler which concrete type we will use: explicitly (with type annotations) or implicitly (by using specific types).

Let’s look at another example, a HashMap collection, which has two generic types: key type and value type:

use std::collections::HashMap;

let mut score_map: HashMap<&str, i32> = HashMap::new();
score_map.insert("Jamie", 16);
score_map.insert("Ashley", 71);

let mut codes_map: HashMap<u32, &str> = HashMap::new();
codes_map.insert(282, "type annotation error");
codes_map.insert(308, "mismatched types error");

let mut flags_map: HashMap<&str, bool> = HashMap::new();
flags_map.insert("required", true);

We can create a hash map with any combination of keys and values. We don’t need to create a specific collection for all the possible types (e.g., StringIntMap, StringBoolMap, etc); we can use one collection and specify the types we want.


💡 We can define multiple generic parameters by separating them with commas:

fn take_three<T, U, V>(t: T, u: U, v: V) {}

It’s common to name generic types starting from the T and continuing alphabetically. Sometimes the names can be more meaningful, for example, the types of keys and values:

HashMap<K, V>

How do generics work?

In some sense, when we define a generic collection (e.g., Vec<T>), we define an infinite set of collections (such as Vec<i32>, Vec<i64>, and Vec<&str>).


💡 When Rust compiles generic code, it performs a process called monomorphization. We write generic code that works for some arbitrary type T, and Rust generates specific uses of the code by filling in the concrete types at compile time. For example, it will generate code for Vec<i32> and Vec<&str>, but the resulting code will be different due to how each type works underneath.


When we use generics, we can write code for multiple data types instead of rewriting the same code for each type, making the code more straightforward and less error-prone.

Rust can make use of generics in several places:

  • Function and method definitions.
  • Struct and enum definitions.
  • Impl blocks.
  • Trait definitions.
  • Type aliases.

Generic definitions

We will cover generics in various Rust definitions, starting with generic functions.

Generic functions

Let’s play around with some vectors. Imagine that we want to write a function that reverses elements of any vector, including int_vector and str_vector we’ve seen before. We can write specialized functions for different kinds of vectors, such as int_reverse, str_reverse, and so on:

fn int_reverse(vector: &mut Vec<int>) {...}

fn str_reverse(vector: &mut Vec<&str>) {...}

Because the actual type of the elements doesn’t matter when reversing a vector, these functions would have the same implementation and differ only in the types of their parameters. This is where generic functions come in – we can create one generic reverse function that is generic over the type of vector elements.

//     [1][2]                 [3]
fn reverse<T>(vector: &mut Vec<T>) {
    let mut new_vector = Vec::new();

    while let Some(last) = vector.pop() {
        new_vector.push(last);
    }

    *vector = new_vector;
}

// [1]: The generic definition follows the function name.
// [2]: The function is generic over the type `T`.
// [3]: The function takes a reference to a generic vector as an argument.

The reverse function iterates over the elements of a vector using the while loop. We get the elements in the reverse order because pop removes the last element from a vector and returns it.

We can use this generic function with both of our vectors, as well as any other vector:

let mut int_vector: Vec<i32> = vec![1, 2, 3];
reverse(&mut int_vector);
println!("After: {:?}", int_vector);
// Prints:
// After: [3, 2, 1]

let mut str_vector: Vec<&str> = vec!["one", "two", "three"];
reverse(&mut str_vector);
println!("After: {:?}", str_vector);
// Prints:
// After: ["three", "two", "one"]

let mut bool_vector: Vec<bool> = vec![true, false, false, false];
reverse(&mut bool_vector);
println!("After: {:?}", bool_vector);
// Prints:
// After: [false, false, false, true]

This time, we use the vec! macro to create a vector with initial values and pretty-print it with {:?}. The reverse works, but don’t try this in production – better use the built-in function vector.reverse.

To sum up, with generics we write one function and delegate the task of writing repetitive code for all the different types to the compiler.

Generic enums

We can use generics in other definitions as well. For instance, Option<T> is a generic enum frequently encountered in Rust:

//          [1][2]
pub enum Option<T> {
  None,
//    [3]
  Some(T),
}

// [1]: The generic definition follows the enum name.
// [2]: The enum is generic over the type `T`.
// [3]: The `Some` takes an argument of type `T`.

Because it’s generic, we can use Option to wrap any type we like:

let o1: Option<i32> = Some(1);
let o2: Option<&str> = Some("two");
let o3: Option<bool> = Some(true);
let o4: Option<f64> = Some(4.0);
...

For example, we can use it to wrap integers (Option<i32>) and implement a safe division function:

fn safe_division(a: i32, b: i32) -> Option<i32> {
    match b {
        0 => None,
        _ => Some(a / b),
    }
}

println!("{:?}", safe_division(4, 2));
// Prints:
// Some(2)

Or we can use Option to safely access the elements of the str_vector:

let str_vector = vec!["one", "two", "three"];

let head = str_vector.get(0);
println!("{:?}", head);
// Prints:
// Some("one")

Note: The get method is a safe way to access an element of the vector at a given position: it either returns a reference to the element wrapped in Some or None if the position is out of bounds.

Generic structs

In the same way, we can create generic structs. Let’s imagine we need to create a struct that holds user scores. If our program supports multiple kinds of scores, we don’t want to repeat ourselves and write code for all the different score types; we can keep the score generic:

//       [1][2]
struct Score<T> {
    user: String,
//             [3]
    team_score: T,
//             [4]
    scores: Vec<T>,
}

// [1]: The generic definition follows the struct name.
// [2]: The struct is generic over the type `T`.
// [3]: The `team_score` field is of type `T`.
// [4]: The `scores` field is of type `Vec<T>`.

At the point of usage, when we assign values to the struct’s parameters, we commit to the specific type for T. We can use any type for the score, for example, integer:

let int_score: Score<i32> = Score {
    user: "Lesley".to_string(),
    team_score: 1_641_213,
    scores: vec![5213, 1232, 1512],
};

println!(
    "Int score: user = {}, scores = {:?}",
    int_score.user, int_score.scores
);
// Prints:
// Int score: user = Lesley, scores = [5213, 1232, 1512]

Or float:

let flt_score = Score {
    user: "Simone".to_string(),
    team_score: 54.2526,
    scores: vec![4.2526],
};

println!(
    "Float score: user = {}, scores = {:?}",
    flt_score.user, flt_score.scores
);
// Prints:
// Float scores: user = Simone, scores = [4.2526]

Note that we didn’t specify the type of flt_score; the compiler can automatically infer the struct type from the used values.

The compiler ensures that the type of team_score is the same as the type of scores elements because they are defined as T. For example, using an integer for one and a float for another doesn’t work:

let broken_score = Score {
    user: "Simone".to_string(),
    team_score: 100,
    scores: vec![4.2526],
};
error[E0308]: mismatched types
    |
    |         scores: vec![4.2526],
    |                      ^^^^^^ expected integer, found floating-point number

Generic methods

Furthermore, we can implement a generic method on generic structs. For example, we can implement a method to get the last score (assuming that scores preserves the order):

//  [1]      [2]
impl<T> Score<T> {
//                                    [3]
    fn last_score(&mut self) -> Option<&T> {
        self.scores.last()
    }
}

// [1]: The generic definition follows the `impl` keyword.
// [2]: The struct is generic over the type `T`.
// [3]: The function's return value is generic over `T`
//      (It returns an optional reference to a value of type `T`).

💡 Why are there multiple <T>?

First, we declare the type parameter (T) after the impl keyword (impl<T>), and then we use other Ts to refer to that first T: to specify the struct type we are implementing (Score<T>) or specify the return type (Option<&T>).

The distinction between these becomes more clear when we have more complex types; for example:

impl<T> Pair<T, T> { ... }
impl<T> MyStruct<Vec<T>> { ... }

We can use the last_score method with any type of score:

let int_score: Score<i32> = Score {
    user: "Lesley".to_string(),
    team_score: 1_641_213,
    scores: vec![5213, 1232, 1512],
};

let last_int_score = int_score.last_score();
println!("Last score: {:?}", last_int_score);
// Prints:
// Last score: Some(1512)

let flt_score = Score {
    user: "Simone".to_string(),
    team_score: 54.2526,
    scores: vec![4.2526],
};

let last_flt_score = flt_score.last_score();
println!("Last score: {:?}", last_flt_score);
// Prints:
// Last score: Some(4.2526)

How to constrain a generic type

We’ve covered a few generics that take any possible type: Vec<T>, Option<T>, etc. This flexibility doesn’t come for free. Not knowing anything about the type limits the kind of things we can do with the values of this type: we can’t print them, compare them, etc.

At least, in the previous examples, we knew something about the type of container, which allowed us to implement some functionality. What if we only have the type T? Imagine a simple generic function that accepts a value of one type and returns a value of the same type:

fn quick_adventure<T>(t: T) -> T

We don’t know anything about T. The only thing we can do is return the parameter’s value. Quite boring and limiting:

fn quick_adventure<T>(t: T) -> T {
    t
}

println!("{}", quick_adventure(17));
// Prints:
// 17

println!("{}", quick_adventure("out"));
// Prints:
// out

💡 When we write a polymorphic function that works for different types, we limit the possibilities in implementation. For example, we can easily add or multiply if we write a function that works for a pair of integers. But if we write a function that works both for a pair of integers and a pair of strings, we can’t because strings don’t support these operations. However, we can use shared operations, like comparing two elements that work for integer and string values.

Generics improve code readability. A “very” generic function can be, in great measure, described by the types themselves, which additionally serve as documentation.


Let’s look at a more practical example. We implemented a generic reverse function that reverses a vector in place. What if we change it and try to print the values instead of updating the vector?

fn reverse_print<T>(vector: &mut Vec<T>) {
    while let Some(last) = vector.pop() {
        println!("{}", last);
    }
}

It doesn’t compile!

error[E0277]: `T` doesn't implement `std::fmt::Display`
    |
    |    println!("{}", last);
    |                   ^^^^ `T` cannot be formatted with the default formatter
    |
help: consider restricting type parameter `T`
    |
    |     fn reverse_print<T: std::fmt::Display>(vector: &mut Vec<T>) {
    |                       +++++++++++++++++++

The compiler doesn’t know anything about the type. How is it supposed to print it? The error message suggests we should consider restricting the type parameter. Spoiler alert, we can do this with trait bounds.

Note: If traits are fresh on your mind or you’ve recently read our article Traits in Rust, you can skip the following recap.

Recap on traits

Traits allow us to define interfaces or shared behaviors on types. To implement a trait for a type, we must implement methods of that trait.

All types implementing a given trait must support the functionality defined by this trait. For example, if we want to compare values of some type, it has to implement PartialEq and PartialOrd:

#[derive(PartialEq, PartialOrd)]
struct LevelScore {
    main_score: i32,
    bonus_score: i32,
}

let my_score = LevelScore {
    main_score: 10,
    bonus_score: 5,
};

let your_score = LevelScore {
    main_score: 5,
    bonus_score: 2,
};

if my_score > your_score {
    println!("I have a bigger score!")
} else {
    println!("You have a bigger score!")
}
// Prints:
// I have a bigger score!

Otherwise, we would get an error:

// #[derive(PartialEq, PartialOrd)]
struct LevelScore {
    main_score: i32,
    bonus_score: i32,
}
error[E0369]: binary operation `>` cannot be applied to type `LevelScore`
    |
    |     if my_score > your_score {
    |        -------- ^ ---------- LevelScore
    |        |
    |        LevelScore
    |
note: an implementation of `PartialOrd<_>` might be missing for `LevelScore`
    |
    |     struct LevelScore {
    |     ^^^^^^^^^^^^^^^^^ must implement `PartialOrd<_>`
help: consider annotating `LevelScore` with `#[derive(PartialEq, PartialOrd)]`
    |
    |     #[derive(PartialEq, PartialOrd)]
    |

A trait is an interface that defines behaviors with a contract that other code can rely on. We can implement functions that accept types implementing our trait. These functions don’t need to know anything else about these types.

Trait bounds on generic functions

Traits allow us to make certain promises about the type of behavior. We can use traits and generics to constrain a generic type, so it accepts only the types with a particular behavior and not just any type. As an example, we can restrict the generic type in reverse_print using a trait bound:

use std::fmt::Display;

//              [1] [2]
fn reverse_print<T: Display>(vector: &mut Vec<T>) {
    while let Some(last) = vector.pop() {
//                     [3]
        println!("{}", last);
    }
}

// The function is
// [1]: generic over `T` that is
// [2]: bound by the `Display` trait, which guarantees
// [3]: that values of type `T` can be printed.

<T: Display> is the syntax to declare a generic type parameter with a trait bound. In this case, it means that T is any type that implements the Display trait, which also means that we can print it.

Because string slices and integers implement the Display trait, we can use reverse_print with both str_vector and int_vector:

let mut str_vector: Vec<&str> = vec!["one", "two", "three"];
reverse_print(&mut str_vector);
// Prints:
// three
// two
// one

let mut int_vector: Vec<i32> = vec![1, 2, 3];
reverse_print(&mut int_vector);
// Prints:
// 3
// 2
// 1

But it doesn’t work with types that don’t implement Display:

struct LevelScore(i32, i32);

let mut score_vector = vec![LevelScore(10, 5), LevelScore(5, 20)];

reverse_print(&mut score_vector);
error[E0277]: `LevelScore` doesn't implement `std::fmt::Display`
    |
    |     reverse_print(&mut score_vector);
    |     ------------- ^^^^^^^^^^^^^^^^^ `LevelScore` cannot be formatted with the default formatter
    |     |
    |     required by a bound introduced by this call
    |
    = help: the trait `std::fmt::Display` is not implemented for `LevelScore`
note: required by a bound in `reverse_print`

Trait bounds on type parameters let us tell the compiler that we want the generic type to have particular behavior.

Multiple trait bounds

We can specify more than one trait bound. Let’s write another generic function that works with vectors: print_max should find and print the maximum element, which we can find by using the corresponding iterator and its method max:

use std::fmt::Display;

//                 [1]
fn print_max<T: Ord + Display>(elements: &[T]) {
    match elements.iter().max() {
        Some(max_value) => println!("Max value: {}", max_value),
        None => println!("Vector is empty"),
    }
}

// [1]: We use `+` to specify multiple traits.

This time, because we need to compare and print elements, we tell the compiler that T can only be the types that implement both Ord and Display.


💡 Because we don’t change the number of items, it’s recommended to use a slice (&[T]) instead of a vector &Vec<T>. If you want to refresh your knowledge, check out The Slice Type in the Rust book.


There is an alternative syntax that helps make the function signature cleaner. Instead of writing the trait bound with the type parameter declaration, we can list traits after the function parameters:

use std::fmt::Display;

fn print_max<T>(elements: &[T]) // [1]
where //                           [2]
    T: Ord + Display, //           [3]
{
    match elements.iter().max() {
        Some(max_value) => println!("Max value: {}", max_value),
        None => println!("Vector is empty"),
    }
}

// [1]: The function signature is followed by
// [2]: `where` clause, where we 
// [3]: specify the trait bounds.

With either syntax, the print_max function works with any vector of elements, as long as their type implements both Ord and Display:

let mut int_vector = vec![10, 200, 3];
print_max(&int_vector);
// Prints:
// Max value: 200

let mut str_vector = vec!["Go", "Scrabble", "Battleship", "Monopoly"];
print_max(&str_vector);
// Prints:
// Max value: Scrabble

Trait bounds on other generics

Trait bounds aren’t limited to generic functions. We can restrict any type parameter using traits.

For example, we can use trait bounds on generic structs:

//              [1]
struct GameScore<T: Ord> {
    name: String,
    scores: Vec<T>,
}

impl<T: Ord> GameScore<T> {
    fn high_score(&self) -> Option<&T> {
//                         [2]
        self.scores.iter().max()
    }
}

// [1]: We bound `T` with the `Ord` trait.
// [2]: So we can use `max` to return the maximum score.
let taylor_score = GameScore {
    name: "taylor".to_string(),
    scores: vec![10, 200, 3],
};

println!("{:?}", taylor_score.high_score());
// Prints:
// Some(200)

We don’t have to restrict the whole struct. We can limit implementations; in other words, we can conditionally implement methods for types that implement specific traits. For example, we can create two GameScore methods: high_score that can be used when vector elements implement Ord and reverse_print_scores – when they implement Display.

use std::fmt::Display;

struct GameScore<T> {
    name: String,
    scores: Vec<T>,
}

impl<T: Ord> GameScore<T> {
    fn high_score(&self) -> Option<&T> {
        self.scores.iter().max()
    }
}

impl<T: Display> GameScore<T> {
    fn reverse_print_scores(&mut self) {
        while let Some(last) = self.scores.pop() {
            println!("{}", last);
        }
    }
}

In the case of integer vector, we can use both:

let mut taylor_score = GameScore {
    name: "taylor".to_string(),
    scores: vec![10, 200, 3],
};

println!("{:?}", taylor_score.high_score());
// Prints:
// Some(200)

taylor_score.reverse_print_scores();
// Prints:
// 3
// 200
// 10

But if we have a custom type that only implements Ord, we can only use one of the methods:

#[derive(PartialEq, PartialOrd, Eq, Ord, Debug)]
struct LevelScore(i32, i32);

let mut dale_score = GameScore {
    name: "Dale".to_string(),
    scores: vec![LevelScore(10, 5), LevelScore(5, 20)],
};

println!("{:?}", dale_score.high_score());
// Prints:
// Some(LevelScore(10, 5))

Calling the second method doesn’t compile because LevelScore doesn’t implement Display:

dale_score.reverse_print_scores();
error[E0599]: the method `reverse_print_scores` exists for struct `GameScore<LevelScore>`, but its trait bounds were not satisfied
    |
    |     struct GameScore<T> {
    |     ------------------- method `reverse_print_scores` not found for this
...
    |     struct LevelScore(i32, i32);
    |     ---------------------------- doesn't satisfy `LevelScore: std::fmt::Display`
...
    |     dale_score.reverse_print_scores();
    |                ^^^^^^^^^^^^^^^^^^^^ method cannot be called on `GameScore<LevelScore>` due to unsatisfied trait bounds
    |

Conclusion

Generics allows us to write both more flexible and more restrictive code. When we use generics, we can write generic code for multiple types and not worry about the implementation for concrete types.

We learned how to use generics to write less code, create generic functions, structs, enums, methods, or traits, and restrict generics with trait bounds.

This blog post covered how to achieve compile-time polymorphism (generic code) using generics. We can use trait objects to get a run-time polymorphism (similar to virtual functions in other languages). In the Rust Book, you can read about using trait objects to allow functions to perform dynamic dispatch and return different types at run-time.

Lifetimes are another kind of generics that give the compiler information about how references relate to each other. We can ensure that references are valid as long as we want them. A good starting point is the Validating References with Lifetimes chapter of the Rust Book.

If you would like to read more beginner-friendly articles about Rust, be sure to follow us on Twitter or subscribe to our newsletter via the form below.

Banner that links to Serokell Shop. You can buy awesome FP T-shirts there!
More from Serokell
Nix development tool thumbnailNix development tool thumbnail
rust in production astropad thumbnailrust in production astropad thumbnail
rust traits thumbnailrust traits thumbnail