Intro to Embedded Rust Part 6: Generics and Traits
2026-02-26 | By ShawnHymel
Generics and traits are two of Rust's most powerful features for writing flexible, reusable code without sacrificing type safety or performance. Generics allow you to write functions, structs, and enums that work with multiple types without duplicating code. They are similar to templates in C++ or generics in Java. Traits, on the other hand, define shared behavior across different types—they're similar to interfaces in languages like Java or Go. We’ll examine both generics and traits in this tutorial.
Note that all code for this series can be found in this GitHub repository.Example 1: Generic Functions
Let’s take a look at a generic function:
// Explicit, statically typed function
// fn swap(pair: (i32, &str)) -> (&str, i32) {
// (pair.1, pair.0)
// }
// Generic function
fn swap<T, U>(pair: (T, U)) -> (U, T) {
(pair.1, pair.0)
}
fn main() {
let original = (42, "hello");
let swapped = swap(original);
println!("{:?}", swapped);
}
The swap() function demonstrates the basics of generic functions in Rust. The commented-out version shows an explicit, statically-typed function that only works with a specific tuple type: (i32, &str). If you wanted to swap a tuple of different types, you'd need to write an entirely new function. The generic version solves this by introducing type parameters <T,U> after the function name, which act as placeholders for any concrete types.
The function signature fn swap<T, U>(pair: (T, U)) -> (U, T) tells the compiler: "This function accepts a tuple of any two types T and U, and returns a tuple with those types reversed." In main(), we call swap() with a tuple containing an i32 and a &str, and the compiler automatically infers that T = i32 and U = &str, generating specialized code for those specific types at compile time.
This is known as “monomorphization.” You write the function once, and the compiler creates optimized, type-specific versions for each usage. The result is code that's both flexible and has zero runtime overhead compared to writing separate functions for each type combination. Keep in mind that monomorphization can cause some bloat: for each type used, the compiler creates a new function that takes up flash space.
Example 2: Traits
A trait specifies a set of methods that types must implement, creating a contract that guarantees certain functionality. Let’s see an example:
// Define the trait
trait Hello {
fn say_hello(&self);
}
// Define a type
struct Person {
name: String,
}
// Implement the trait for the type
impl Hello for Person {
fn say_hello(&self) {
println!("Hello, {}", self.name);
}
}
// Use the struct
fn main() {
let me = Person {
name: String::from("Shawn"),
};
me.say_hello();
}
Traits define shared behavior that multiple types can implement, similar to interfaces in other languages. The Hello trait declares a contract: any type that implements this trait must provide a say_hello() method that takes &self (a reference to the instance) and returns nothing.
We then define a Person struct with a name field of type String. The impl Hello for the Person block is where we fulfill the trait contract by providing the actual implementation of say_hello() for the Person type. In this case, printing a greeting that includes the person's name.
In main(), we create a Person instance and can call say_hello() on it because Person implements the Hello trait. The power of traits becomes apparent when you realize that any number of different types (like Robot, Animal, or AI) could also implement the Hello trait with their own specific greeting behavior, and you could write generic functions that accept any type implementing Hello. This is the foundation of polymorphism in Rust, as it defines common behavior across different types without inheritance.
Example 3: Trait Bounds
Trait bounds combine generics with traits to constrain which types can be used with generic functions. For example:
// Error: cannot add `T` to `T`
// fn add<T>(a: T, b: T) -> T
// {
// a + b
// }
// Fix: add trait bound
fn add<T>(a: T, b: T) -> T
where
T: std::ops::Add<Output = T>,
{
a + b
}
fn main() {
// This works: T = i32
let result_1 = add(-3, 10);
println!("{}", result_1);
// This works: T = f64
let result_2 = add(12.345, 2.86);
println!("{}", result_2);
// Error: cannot add `bool` to `bool`
// let result_3 = add(true, false);
// println!("{:?}", result_3);
// Error: cannot add `&str` to `&str` (Add<&str> not implemented for &str)
// let result_4 = add("Hello, ", "world!");
// println!("{:?}", result_4);
}

