Rust Generics Made Simple – DEV Community

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));
}
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));
}
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 {...}
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,
}
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 };
}
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),
}
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
}
}
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,
}
}
}
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 likeT: Trait_Name
to apply constraints. - Use the
where
keyword after the return type and before the function body, such aswhere T: Trait_Name
.
In short, there are two main reasons for constraining a generic:
- The function body needs functionality provided by a specific trait.
- 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);
}
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);
}
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 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!
Follow us on X: @LeapcellHQ