17.2 Generic Structs and Enums
Alright, let’s get our hands dirty with generic structs and enums. You’ve seen how they work with functions, and honestly, this is where the concept really starts to sing. It’s the same core idea—writing code that operates abstractly over some type T—but applied to data structures. This is how we stop copying, pasting, and slightly altering the same struct definition six times for different data types. It’s the programmer’s version of “work smarter, not harder.”
The Point of It All
Let’s start with the canonical example, the “Hello, World!” of generic structs: a Point. Without generics, you’re stuck making a PointI32, a PointF64, and, heaven help you, a PointU8. Madness.
// The boring, repetitive way. Don't do this.
struct PointI32 {
x: i32,
y: i32,
}
struct PointF64 {
x: f64,
y: f64,
}
// The enlightened, generic way. Do this.
struct Point<T> {
x: T,
y: T,
}
See that <T>? It’s a promise. It says, “I, the Point struct, will hold two values of some type. I don’t care what it is right now. We’ll figure that out later when you actually use me.” T is just a placeholder, a stand-in for a real type that will be supplied later.
Now you can create points for any type you fancy:
let integer_point = Point { x: 5, y: 10 };
let float_point = Point { x: 1.0, y: 4.0 };
let string_point = Point { x: "hello", y: "world" }; // Why? I don't know. But you can!
The compiler is clever enough to infer the concrete type for T from the values you give it. For integer_point, T becomes i32. For float_point, it becomes f64.
Mixing It Up: Multiple Type Parameters
What if your x and y need to be different types? The designers didn’t lock us into a single-type tyranny. You can use as many type parameters as you need. Let’s say you’re building a graph and you need to store a node ID (probably an integer) and a weight (probably a float).
struct Node<Id, W> {
id: Id,
weight: W,
}
let node = Node { id: 42u32, weight: 3.14f64 };
Here, for node, the compiler monomorphizes this into a concrete Node<u32, f64>. Id becomes u32, W becomes f64. You can name these parameters anything (K and V for a map, T and E for a result), but conventional names help others understand your code.
Generics in Enums: The Ultimate Power
This is where generics become truly sublime. You’ve already been using a fantastically generic enum without even realizing it: Option<T> and Result<T, E>. Their definitions are beautifully simple and powerful because of generics.
enum Option<T> {
Some(T),
None,
}
enum Result<T, E> {
Ok(T),
Err(E),
}
A single, elegant definition gives you the ability to handle the concept of “maybe a value” or “either a success or an error” for any type in the entire language. This is a masterclass in API design. Instead of an OptionI32, OptionString, ResultF64String, etc., we have one clear, consistent, and composable abstraction.
The Monomorphization Machine
You might be thinking, “This is all great, but what’s the cost?” The beauty of Rust’s approach is that the cost is effectively zero. How? Monomorphization.
It’s a ten-dollar word for a simple concept: the compiler takes your generic code and stamps out a unique, concrete version for every specific type it’s used with. When you write:
let p1 = Point { x: 5, y: 10 }; // Point<i32>
let p2 = Point { x: 1.0, y: 4.0 }; // Point<f64>
The compiler doesn’t create a bloated binary with code that has to figure out what T is at runtime. Instead, it creates two separate, optimized struct definitions behind the scenes: one for Point<i32> and one for Point<f64>. It then compiles all the code using these points as if you had written those specific structs yourself.
This is why generic code in Rust is just as fast as hand-writing all the duplicate code. The abstraction is compile-time only. You get the safety and cleanliness of generics with the performance of writing everything out longhand. It’s genuinely the best of both worlds.
The Gotchas: Trait Bounds and Methods
Here’s the first speed bump you’ll hit. Try to write a method on our generic Point<T> that returns the distance from the origin.
impl<T> Point<T> {
fn distance_from_origin(&self) -> f64 {
(self.x.powi(2) + self.y.powi(2)).sqrt() // Error! Cannot do math on `T`
}
}
This explodes spectacularly. The compiler rightly says, “Hey, I have no idea what type T is! Can it be squared? Is it a number? Can it be square rooted? How should I know?!”
This is where trait bounds come in. You must tell the compiler exactly what capabilities T must have for this method to work. We need to know that T can be converted to a f64 for the calculation, or perhaps that it supports multiplication and addition. We’ll dive deep into this in the next section, but the fix involves telling the compiler about the traits T must implement.
This is the core trade-off: generics give you incredible flexibility, but that flexibility is constrained by the need to be explicit about what operations your generic type can actually perform. It forces you to think about the contracts your code requires, which is a feature, not a bug. It stops you from accidentally passing a Point<String> into a function that tries to do math on it. The compiler has your back, even when you’re being abstract.