Welcome back! In our previous lesson, we dove into how Rust uses enums and match to model complex states, guided by the principle of "making invalid states unrepresentable." This gave us a powerful, type-safe way to define the what of our data structures.
In this lesson, we'll build on that by exploring the how—defining abstract behaviors and writing code that can operate on a variety of types. The learning outcome is to implement traits and use generics to write reusable, type-safe code. These two features are the pillars of abstraction in Rust. For someone with your background in building large, maintainable systems, you'll recognize this as the key to avoiding code duplication and creating flexible, yet robust, APIs. In the embedded world, this is precisely how we write a single driver that can work for multiple different GPIO pins or sensor models while letting the compiler catch any configuration mistakes.
Writing Code for Many Types: Generics
In your work with C++, you've encountered templates as a way to write code that isn't tied to a single, concrete type. Rust's generics serve a similar purpose but with a stronger emphasis on compile-time verification.
Let's imagine a common task: writing a function to swap the elements of a two-element tuple. Without generics, you'd need a separate function for each combination of types.
// A function specific to i32 and a string slice
fn swap_specific(pair: (i32, &str)) -> (&str, i32) {
(pair.1, pair.0)
}
// What if we want to swap a float and a bool? We'd need another function.
This leads to significant code duplication. Generics solve this by allowing us to use placeholder types.
Intro to Embedded Rust Part 6: Generics and Traits | DigiKey
This video from DigiKey's "Intro to Embedded Rust" series provides a clear, practical introduction to generics.
Please watch the first part of the video, focusing on these segments: The introduction, which frames generics and traits as tools to avoid boilerplate. The demonstration of creating a generic swap function. Pay close attention to the syntax <T, U> for declaring type parameters and how they are used in the function signature.
The key syntax is declaring generic type parameters in angle brackets after the function name: fn swap<T, U>(pair: (T, U)) -> (U, T). Here, T and U are placeholders that the compiler will fill in with concrete types when the function is called.
The "Zero-Cost" Abstraction: Monomorphization
A critical question for any systems developer is: what's the performance cost of this abstraction? Like C++ templates, Rust generics are a zero-cost abstraction at runtime. The compiler performs a process called monomorphization, where it generates a specialized version of the generic function for each concrete type combination it's used with.
Intro to Embedded Rust Part 6: Generics and Traits | DigiKey
The same DigiKey video has a concise explanation of monomorphization.
Watch the segment from monomorphization. It explains how the compiler generates concrete functions from the generic template, and importantly, it mentions the potential trade-off of increased binary size—a key consideration for memory-constrained embedded devices.
This means that a call to swap::<i32, &str> and swap::<f64, bool> will result in two distinct functions in the compiled binary, each as efficient as if it had been written by hand. There is no runtime overhead for dynamic lookups.
Generic Data Structures
Generics aren't limited to functions. You can also define generic structs and enums. In fact, you've already been using them! The Option<T> and Result<T, E> types we've seen are just standard library enums defined with generic type parameters.
Intro to Embedded Rust Part 6: Generics and Traits | DigiKey
Let's continue with the DigiKey video to see how generics are applied to data structures.
Please watch the following segments: Generic structs: This section shows how to create a generic Pair<T, U> struct and implement methods for it. This is a very common pattern. Generic enums: Here, the video demonstrates how the familiar Option<T> enum could be implemented using a generic enum, solidifying the concept.
Defining Shared Behavior: Traits
Generics are powerful, but they have a limitation. The compiler checks the generic code before it's monomorphized. What if our generic function needs to perform an operation that isn't available on all types, like addition or comparison?
This is where traits come in. A trait is a collection of methods that defines a shared behavior. It's similar to an interface in Java or Dart, or a pure abstract base class in C++. A trait is a contract: any type that implements a trait guarantees that it provides the methods defined in that trait.
Rust for Embedded C Programmers
This document from the OpenTitan project is aimed at developers like you with a C background. It provides a concise, technical explanation of traits.
Please read the section on Traits. It introduces the concept, shows how to implement a trait like Clone, and explains the use of #[derive()] for common traits.
Constraining Generics with Trait Bounds
Traits and generics work together through trait bounds. A trait bound constrains a generic type to only those types that implement a specific trait. This tells the compiler that it's safe to use the trait's methods within the generic function.
Let's try to write a generic add function.
fn add<T>(a: T, b: T) -> T {
a + b // COMPILE ERROR!
}
The compiler will reject this because it doesn't know if an arbitrary type T can be added. We need to tell it that T must implement the Add trait from the standard library.
Intro to Embedded Rust Part 6: Generics and Traits | DigiKey
The DigiKey video explains this exact problem and solution.
Please watch the segment on trait bounds. It shows how the where clause is used to add the Add trait bound, solving the compilation error and making the generic function robust.
The corrected signature looks like this, using a where clause for clarity:
use std::ops::Add;
fn add<T>(a: T, b: T) -> T
where
T: Add<Output = T>, // T must implement the Add trait
{
a + b
}
This now compiles, because we've guaranteed to the compiler that any T used with this function will have the + operator defined. This combination of generics and trait bounds is the heart of writing reusable and type-safe code in Rust.
Static vs. Dynamic Dispatch
So far, all the generic code we've written is resolved at compile time via monomorphization. This is called static dispatch. The compiler knows the exact type and can directly call the correct method, or even inline it. It's extremely fast.
However, sometimes you need more flexibility. Imagine you want a collection of different objects that all share a certain behavior, like various shapes that can all be drawn.
trait Drawable {
fn draw(&self);
}
struct Button { /* ... */ }
impl Drawable for Button { /* ... */ }
struct Image { /* ... */ }
impl Drawable for Image { /* ... */ }
// This won't compile! Vec<T> must hold elements of a single, concrete type.
// let ui_elements: Vec<Drawable> = vec![Button{...}, Image{...}];
To solve this, Rust provides dynamic dispatch via trait objects. A trait object is a "fat pointer" that holds both a pointer to the data and a pointer to a virtual method table (vtable). This is the same mechanism C++ uses to implement virtual functions, a concept you are well-acquainted with. The syntax for a trait object is dyn Trait.
You can create a Vec of trait objects like this: Vec<Box<dyn Drawable>>. Box<T> is a smart pointer that allocates memory on the heap, which is necessary here because the different objects (Button, Image) can have different sizes.

When a method is called on a trait object, the program uses the vtable to look up the correct function at runtime. This has a small performance cost compared to static dispatch, but it provides flexibility and can lead to smaller binary sizes, as the generic function doesn't need to be monomorphized for every type.

Application in Embedded Rust
This might all seem a bit abstract, but it's the fundamental pattern used to build Hardware Abstraction Layers (HALs) in embedded Rust. We can create generic drivers that are not tied to specific hardware pins.
Let's design a generic Led struct.
Intro to Embedded Rust Part 6: Generics and Traits | DigiKey
The final section of the DigiKey video provides a perfect, hands-on example of applying these concepts in an embedded context.
Please watch the final segment starting from the embedded example. This demonstrates how to create a generic Led<P> struct, where P is a generic type representing a GPIO pin. The crucial part is the trait bound where P: OutputPin, which ensures at compile time that you can only create an Led with a pin that has been configured as a push-pull output.
The final code from the video looks something like this:
// In an embedded HAL, you'd find a trait like this:
// trait OutputPin {
// fn set_high(&mut self) -> Result<(), Self::Error>;
// fn set_low(&mut self) -> Result<(), Self::Error>;
// }
struct Led<P> {
pin: P,
active_high: bool,
}
// The implementation is generic over P, but constrained.
impl<P> Led<P>
where
P: embedded_hal::digital::v2::OutputPin, // The pin MUST be an output pin!
{
fn new(pin: P, active_high: bool) -> Self {
Led { pin, active_high }
}
fn on(&mut self) {
if self.active_high {
self.pin.set_high().unwrap();
} else {
self.pin.set_low().unwrap();
}
}
fn off(&mut self) {
if self.active_high {
self.pin.set_low().unwrap();
} else {
self.pin.set_high().unwrap();
}
}
}
This pattern is incredibly powerful. It allows the HAL authors to provide a generic Led driver that works with any pin (PA5, PB12, etc.), but the trait bound guarantees that you cannot misuse it. If you try to pass in a pin configured as an input, your code will fail to compile. This is a perfect example of leveraging the type system to build safe and reusable embedded firmware.
Conclusion
In this lesson, we've explored the powerful duo of generics and traits, which form the basis of abstraction in Rust. You now have the tools not just to define concrete data structures, but to define abstract behaviors and write code that operates on them safely and efficiently.
Here are the key takeaways:
- Generics allow you to write functions and data structures with placeholder types (
<T>), avoiding code duplication. - Rust uses monomorphization to create specialized, efficient code for each concrete type used with a generic, resulting in static dispatch with no runtime cost.
- Traits define shared behaviors (like interfaces) that types can implement.
- Trait bounds (
where T: Trait) constrain generics, ensuring at compile time that a type supports the necessary operations. This is the key to Rust's type-safe generic programming. - Trait objects (
dyn Trait) enable dynamic dispatch using vtables, similar to C++ virtual functions, allowing for runtime polymorphism at the cost of a small performance overhead.
The concepts from today and the previous lesson are deeply intertwined. The Option<T> and Result<T, E> types you've seen are generic enums that are central to Rust's programming model. In our next lesson, we will focus specifically on them, learning how to handle errors using Result and Option types with combinators and the ? operator. This will complete our initial tour of Rust's core features for building robust and reliable software.