August 25, 20234 min read

Generics in Rust with Monomorphization

Generics in Rust is not like in any other mainstream programming language except for C++ and uses something called Monomorphization. This approach is fantastic since it is basically zero cost.

Generics in Rust work differently than in most mainstream programming languages (with the exception of C++), relying on a concept called Monomorphization for compile-time generics. This approach is fantastic since it is zero cost, but if we are not careful, it can lead to code bloat. We discussed Rust's runtime polymorphism in a different article.

So, what are generics? In simple terms, they allow us to write code that is parameterized by types. Let's consider a standard generic function that swaps elements in a slice:

// A generic function to swap elements in a slice
fn swap_elements<T>(slice: &mut [T], index1: usize, index2: usize) {
    if index1 < slice.len() && index2 < slice.len() {
        slice.swap(index1, index2);
    }
}

This function can work with a slice of integers, strings, or any custom struct. Since Rust is known for zero-cost abstractions, one might ask: what is the runtime performance hit for this flexibility? The answer is none. Rust achieves compile-time generic dispatch through a process called Monomorphization.

Monomorphization

Monomorphization is the process where the compiler generates separate concrete implementations of generic functions or types for each set of type parameters used at compile time. This eliminates the need for runtime type checks or dynamic dispatch.

For our example, when the compiler encounters swap_elements, it doesn't generate a single generic function that handles arbitrary types at runtime. Instead, it inspects your codebase. If you invoke swap_elements on an i32 slice and a &str slice, it generates two specialized versions:

// Monomorphized version for i32
fn swap_elements_i32(slice: &mut [i32], index1: usize, index2: usize) {
    if index1 < slice.len() && index2 < slice.len() {
        slice.swap(index1, index2);
    }
}

// Monomorphized version for &str
fn swap_elements_str(slice: &mut [&str], index1: usize, index2: usize) {
    if index1 < slice.len() && index2 < slice.len() {
        slice.swap(index1, index2);
    }
}

The resulting assembly matches handcoded, specialized functions perfectly.

Generic structs and enums

Rust applies this same monomorphization process to structs and enums (like the standard library's Option<T> or Result<T, E>):

struct Point<T> {
    x: T,
    y: T,
}

If you instantiate Point<i32> and Point<f64>, the compiler generates two distinct struct layouts:

struct Point_i32 {
    x: i32,
    y: i32,
}

struct Point_f64 {
    x: f64,
    y: f64,
}

This guarantees that the data is laid out contiguously in memory without boxing, pointer chasing, or type header overhead.

Takeaway

Generics in Rust are quite powerful. We just scratched the surface here. In some other articles, we will explore more advanced Generics features of Rust. The best part about it is that you don't need to worry about its runtime performance. However, having too many generics can lead to slow compile times and bloating of the binary. Trait objects offer the flexibility of faster compile times at the cost of runtime performance. But it's not a bad thing; in many other managed languages, generic types incur runtime overhead. In the end, it depends on your specific implementation.

AuthorRafi Hasan