Rust Generics Made Simple – DEV Community

Rust Generics Made Simple – DEV Community


Cover

A common requirement in programming is using the same function to handle data of different types. In programming languages that don’t support generics, you usually need to write a separate function for each data type. The existence of generics offers convenience to developers, reduces code redundancy, and greatly enriches the language’s expressive capabilities. It allows a single function to replace many functions that perform the same task but work on different types of data.

For example, when generics are not used, defining a double function whose parameter can be of type u8, i8, u16, i16, u32, i32, etc., looks like this:

fn double_u8(i: u8) -> u8 { i + i }
fn double_i8(i: i8) -> i8 { i + i }
fn double_u16(i: u16) -> u16 { i + i }
fn double_i16(i: i16) -> i16 { i + i }
fn double_u32(i: u32) -> u32 { i + i }
fn double_i32(i: i32) -> i32 { i + i }
fn double_u64(i: u64) -> u64 { i + i }
fn double_i64(i: i64) -> i64 { i + i }

fn main(){
  println!("{}", double_u8(3_u8));
  println!("{}", double_i16(3_i16));
}
Enter fullscreen mode

Exit fullscreen mode

The double functions above have identical logic; the only difference lies in the data types.

Generics can be used to solve this problem of code duplication due to type differences. When using generics:

use std::ops::Add;
fn double<T>(i: T) -> T
  where T: Add<Output=T> + Clone + Copy {
  i + i
}

fn main(){
  println!("{}", double(3_i16));
  println!("{}", double(3_i32));
}
Enter fullscreen mode

Exit fullscreen mode

The letter T above is the generic (similar to the meaning of a variable like x), used to represent various possible data types.

Using Generics in Function Definitions

When defining a function with generics, instead of specifying the parameter and return types explicitly in the function signature, generics are used to make the code more adaptable. This provides more flexibility for function callers and avoids code duplication.

In Rust, generic parameter names can be arbitrary, but by convention, T (the first letter of “type”) is preferred.

Before using a generic parameter, it must be declared:

fn largest<T>(list: &[T]) -> T {...}
Enter fullscreen mode

Exit fullscreen mode

In the generic version of the function, the type parameter declaration appears between the function name and the parameter list, as in largest. This declares the generic parameter T, which is then used in the parameter list: &[T] and the return type T.

In the parameter part, list: &[T] means that the parameter list is a slice of elements of type T.

In the return part, -> T indicates that the return value of the function is also of type T.

Therefore, this function definition can be understood as: the function has a generic parameter T, its argument is a slice of elements of type T, and it returns a value of type T.

In summary, for a generic function: the after the function name means a generic T is defined in the function’s scope. This generic can only be used within the function’s signature and body, just like a variable defined within a scope is only usable in that scope. A generic simply represents a variable for a data type.

So, the meaning expressed by this part of the function signature is: a parameter of some data type is passed in, and a value of the same type is returned — and this type can be anything.



Using Generics in Structs

The field types in a struct can also be defined using generics, for example:

struct Point<T> {
    x: T,
    y: T,
}
Enter fullscreen mode

Exit fullscreen mode

Note: You must declare the generic parameter first with Point before using T as a type in the struct fields. Also, in this case, x and y are of the same type.

If you want x and y to have different types, you can use different generic parameters:

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

fn main() {
  let p = Point { x: 1, y: 1.1 };
}
Enter fullscreen mode

Exit fullscreen mode



Using Generics in Enums

Generics can also be used in enums. The most common generic enum types in Rust are Option and Result:

enum Option<T> {
    Some(T),
    None,
}

enum Result<T, E> {
    Ok(T),
    Err(E),
}
Enter fullscreen mode

Exit fullscreen mode

Option and Result are often used as return types for functions. Option is used to indicate the presence or absence of a value, whereas Result focuses on whether the value is valid or an error occurred.

If a function runs normally, Result returns an Ok(T), where T is the actual return type. If the function fails, it returns an Err(E), where E is the error type.



Using Generics in Methods

Generics can also be used in methods. Before using generic parameters in method definitions, you must declare them in advance using impl. Only after such a declaration can Point be used inside, so that Rust knows the type in the angle brackets is a generic, not a concrete type.

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

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}
Enter fullscreen mode

Exit fullscreen mode

Note: Point in the method declaration is not a generic declaration; it is the full type of the struct because the struct was defined as Point, not just Point.

Besides using the struct’s generic parameters, you can also define additional generic parameters within the methods themselves, just like in generic functions:

struct Point<T, U> { // Struct generics
    x: T,
    y: U,
}

impl<T, U> Point<T, U> {
    // Function generics
    fn mixup<V, W>(self, other: Point<V, W>) -> Point<T, W> {
        Point {
            x: self.x,
            y: other.y,
        }
    }
}
Enter fullscreen mode

Exit fullscreen mode

In this example, T and U are the generic parameters defined on the struct Point, while V and W are generic parameters defined on the method. There’s no conflict — think of them as struct generics vs. function generics.

You can also add constraints to generics to define methods only for certain types. For example, for the Point type, you can define methods not just based on T but specifically for certain types. This means a method is defined for that specific type, and other instances of Point with different T types won’t have that method. This enables defining specialized methods for specific generic types, while leaving other types without that method.



Adding Constraints to Generics

Constraining generics is also known as trait bounds. There are two main syntaxes for this:

  • When defining the generic type T, use syntax like T: Trait_Name to apply constraints.
  • Use the where keyword after the return type and before the function body, such as where T: Trait_Name.

In short, there are two main reasons for constraining a generic:

  1. The function body needs functionality provided by a specific trait.
  2. The data type represented by the generic T must be precise enough. (If no constraints are applied, a generic can represent any data type.)



Const Generics

So far, generics can be summarized as: generics implemented for types — all generics have been used to abstract over different types.

Arrays of the same type but different lengths are also considered different types in Rust. For example, [i32; 2] and [i32; 3] are distinct types. You can use slices (references) and generics to handle arrays of any type, for example:

fn display_array<T: std::fmt::Debug>(arr: &[T]) {
    println!("{:?}", arr);
}
Enter fullscreen mode

Exit fullscreen mode

However, the method above doesn’t work well (or at all) in scenarios where references are unsuitable. In such cases, const generics — which allow abstraction over values — can be used to handle differences in array length:

fn display_array<T: std::fmt::Debug, const N: usize>(arr: [T; N]) {
    println!("{:?}", arr);
}
Enter fullscreen mode

Exit fullscreen mode

This code defines an array of type [T; N], where T is a type-based generic parameter, and N is a value-based generic parameter — in this case, it represents the array’s length.

N is a const generic. It is declared using const N: usize, which means that N is a const generic parameter based on a usize value. Before const generics, Rust was not well suited for complex matrix computations. With const generics, that is changing.

Note: Suppose you need some code to run on a memory-constrained platform and want to limit how much memory a function’s parameters consume — in such cases, you can use const generics to express these constraints.



Performance of Generics

Rust’s generics are zero-cost abstractions, meaning you don’t have to worry about performance overhead when using them. On the other hand, the trade-off is longer compile times and potentially larger final binary sizes, because Rust generates separate code for each specific type used with a generic during compilation.

Rust ensures efficiency by monomorphizing generic code at compile time. Monomorphization is the process of converting generic code into specific code by filling in the concrete types used in the program. What the compiler does is the reverse of what we do when we write generic functions — the compiler looks at every place where the generic code is used and generates concrete implementations for those specific types. This is why there’s no runtime cost for using generics in Rust. Monomorphization is the reason why Rust’s generics are so efficient at runtime.

When rustc compiles code, it replaces all generics with the actual concrete data types they represent — just as variable names are replaced with memory addresses at compile time. Because the compiler replaces generic types with concrete ones, this can lead to code bloat, where a single function is expanded into multiple specialized versions for different data types. Sometimes this bloat can significantly increase the size of the compiled binary. However, in most cases, this is not a major issue.

On the upside, since generics have already been resolved to concrete types at compile time, calling a function that was generic involves no additional runtime computation to determine types. Therefore, Rust generics have zero runtime overhead.



Summary

In Rust, you can use generics to create definitions for items like function signatures or structs so that they can be used with multiple concrete data types. Generics can be used in functions, structs, enums, and methods, making your code more flexible and reducing repetition. Generic type parameters are specified using angle brackets with capital letters in CamelCase, such as . Rust ensures the efficiency of generics through monomorphization during compilation — converting generic code into specialized concrete code by filling in actual types. This results in some code bloat but guarantees high runtime performance.




We are Leapcell, your top choice for hosting Rust projects.

Leapcell

Leapcell is the Next-Gen Serverless Platform for Web Hosting, Async Tasks, and Redis:

Multi-Language Support

  • Develop with Node.js, Python, Go, or Rust.

Deploy unlimited projects for free

  • pay only for usage — no requests, no charges.

Unbeatable Cost Efficiency

  • Pay-as-you-go with no idle charges.
  • Example: $25 supports 6.94M requests at a 60ms average response time.

Streamlined Developer Experience

  • Intuitive UI for effortless setup.
  • Fully automated CI/CD pipelines and GitOps integration.
  • Real-time metrics and logging for actionable insights.

Effortless Scalability and High Performance

  • Auto-scaling to handle high concurrency with ease.
  • Zero operational overhead — just focus on building.

Explore more in the Documentation!

Try Leapcell

Follow us on X: @LeapcellHQ


Read on our blog



Source link

Leave a Reply

Your email address will not be published. Required fields are marked *