Rust Generics Basics
Introduction
Generics are one of Rust's most powerful features, allowing you to write flexible, reusable code without sacrificing performance. Unlike other languages where generics might incur runtime costs, Rust implements generics through monomorphization - creating specific versions of generic code at compile time.
In this tutorial, we'll explore the basics of Rust generics, learning how to create functions, structs, enums, and methods that work with multiple data types while maintaining Rust's safety guarantees.
What Are Generics?
Generics allow you to define code that works with multiple types rather than a single concrete type. Think of generics as a way to write a template for your code, which the compiler then fills in with specific types when used.
Without generics, you might write multiple versions of the same function for different types:
fn largest_i32(list: &[i32]) -> i32 {
let mut largest = list[0];
for &item in list {
if item > largest {
largest = item;
}
}
largest
}
fn largest_char(list: &[char]) -> char {
let mut largest = list[0];
for &item in list {
if item > largest {
largest = item;
}
}
largest
}
This approach leads to code duplication. With generics, you can write a single function that handles multiple types.
Generic Functions
Let's rewrite our largest function using generics:
fn largest<T: PartialOrd + Copy>(list: &[T]) -> T {
let mut largest = list[0];
for &item in list {
if item > largest {
largest = item;
}
}
largest
}
fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let result = largest(&number_list);
println!("The largest number is {}", result);
let char_list = vec!['y', 'm', 'a', 'q'];
let result = largest(&char_list);
println!("The largest char is {}", result);
}
Output:
The largest number is 100
The largest char is y
Breaking down the function definition:
fn largest<T: PartialOrd + Copy>(list: &[T]) -> T
<T: PartialOrd + Copy>
- This is where we declare the generic type parameterT
PartialOrd + Copy
- These are trait bounds (more on this later)(list: &[T]) -> T
- Our function takes a slice of typeT
and returns a value of typeT
Generic Structs
Structs can also be generic, allowing them to hold different types of data:
struct Point<T> {
x: T,
y: T,
}
fn main() {
let integer_point = Point { x: 5, y: 10 };
let float_point = Point { x: 1.0, y: 4.0 };
// This would not compile because x and y must be the same type
// let mixed_point = Point { x: 5, y: 4.0 };
}
You can use multiple generic type parameters when needed:
struct Point<T, U> {
x: T,
y: U,
}
fn main() {
let both_integer = Point { x: 5, y: 10 };
let both_float = Point { x: 1.0, y: 4.0 };
let integer_and_float = Point { x: 5, y: 4.0 };
}
Generic Enums
Enums can also use generic types. In fact, two of Rust's most used enums are generic:
enum Option<T> {
Some(T),
None,
}
enum Result<T, E> {
Ok(T),
Err(E),
}
Let's create our own generic enum:
enum Either<T, U> {
Left(T),
Right(U),
}
fn main() {
let left: Either<i32, &str> = Either::Left(5);
let right: Either<i32, &str> = Either::Right("hello");
// Process based on variant
match left {
Either::Left(value) => println!("Got left value: {}", value),
Either::Right(value) => println!("Got right value: {}", value),
}
}
Output:
Got left value: 5
Generic Methods
You can implement methods on generic structs and enums:
struct Point<T> {
x: T,
y: T,
}
impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}
// We can also implement methods for specific types
impl Point<f32> {
fn distance_from_origin(&self) -> f32 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
}
fn main() {
let p = Point { x: 5, y: 10 };
println!("p.x = {}", p.x());
let p_float = Point { x: 3.0_f32, y: 4.0_f32 };
println!("Distance from origin: {}", p_float.distance_from_origin());
}
Output:
p.x = 5
Distance from origin: 5.0
Methods can also define their own generic parameters:
struct Point<T, U> {
x: T,
y: U,
}
impl<T, U> Point<T, U> {
fn mixup<V, W>(self, other: Point<V, W>) -> Point<T, W> {
Point {
x: self.x,
y: other.y,
}
}
}
fn main() {
let p1 = Point { x: 5, y: 10.4 };
let p2 = Point { x: "Hello", y: 'c' };
let p3 = p1.mixup(p2);
println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
}
Output:
p3.x = 5, p3.y = c
Generic Type Constraints with Trait Bounds
Not all operations make sense for all types. For example, the >
comparison operator doesn't work on all types. To ensure our generic code only works with appropriate types, we use trait bounds:
fn largest<T: PartialOrd>(list: &[T]) -> &T {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
Here, T: PartialOrd
means "T can be any type that implements the PartialOrd trait," which guarantees the >
operator will work.
You can specify multiple trait bounds with the +
syntax:
fn display_and_calculate<T: Display + PartialOrd + Sum>(value: T) {
// Implementation
}
Or using the where
clause for cleaner code:
fn some_function<T, U>(t: T, u: U) -> i32
where T: Display + Clone,
U: Clone + Debug
{
// Implementation
}
Performance of Generics in Rust
Unlike some other languages, Rust implements generics using monomorphization. This means the compiler generates specific implementations of generic code for each concrete type used, eliminating any runtime cost.
When you compile:
let integer = Some(5);
let float = Some(5.0);
The Rust compiler creates code equivalent to:
enum Option_i32 {
Some(i32),
None,
}
enum Option_f64 {
Some(f64),
None,
}
let integer = Option_i32::Some(5);
let float = Option_f64::Some(5.0);
This approach means generic code in Rust runs just as fast as if you'd written type-specific code by hand!
Practical Example: A Generic Cache
Let's build a simple generic cache that can store and retrieve values of any type:
struct Cache<T> {
data: Option<T>,
}
impl<T> Cache<T> {
fn new() -> Self {
Cache { data: None }
}
fn store(&mut self, value: T) {
self.data = Some(value);
}
fn get(&self) -> Option<&T> {
self.data.as_ref()
}
fn clear(&mut self) {
self.data = None;
}
}
fn main() {
// Integer cache
let mut int_cache = Cache::new();
int_cache.store(42);
if let Some(value) = int_cache.get() {
println!("Retrieved value: {}", value);
}
// String cache
let mut string_cache = Cache::new();
string_cache.store(String::from("Hello, generics!"));
if let Some(value) = string_cache.get() {
println!("Retrieved string: {}", value);
}
}
Output:
Retrieved value: 42
Retrieved string: Hello, generics!
Visual Representation of Generics
Here's a diagram showing how generics work in Rust:
Summary
Generics in Rust allow you to write flexible, reusable code without sacrificing performance:
- Generic functions can work with multiple types using
<T>
syntax - Generic structs and enums can hold different types of data
- Trait bounds ensure generic code only works with appropriate types
- Methods can be implemented for generic types
- Rust's monomorphization approach means generic code has no runtime performance penalty
By using generics effectively, you can write cleaner, more maintainable code that works with a variety of types while maintaining Rust's performance and safety guarantees.
Exercises
- Create a generic
min
function that finds the smallest value in a slice of any type that can be compared. - Implement a generic
Pair<T>
struct with methods for creating a new pair and getting the larger of the two values. - Create a generic
LinkedList<T>
struct with methods to add and remove elements. - Write a generic function that takes two arguments of different types and returns a tuple with both values.
- Implement a generic
Result
-like enum that can be used to return either success or a custom error message.
Additional Resources
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)