Maker.io main logo

Intro to Embedded Rust Part 6: Generics and Traits

63

2026-02-26 | By ShawnHymel

Raspberry Pi MCU

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:

Copy Code
// 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:

Copy Code
// 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:

Copy Code
// 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);
}

The commented-out version of add() tries to add two generic values together, but the compiler rejects this because not all types can be added, as the + operator isn't universally defined.

The solution is to add a trait bound using the where clause: T: std::ops::Add<Output = T>. This tells the compiler that T must implement the Add trait, and specifically that adding two T values produces another T as output. Now the function only accepts types that know how to add themselves together.

In main(), we see this in action: add(-3, 10) works because i32 implements Add, and add(12.345, 2.86) works because f64 implements Add. However, add(true, false) would fail because booleans don't implement Add. You can't add booleans together in a meaningful way. Similarly, add("Hello, ", "world!") fails because string slices (&str) don't implement Add (though owned String types do support concatenation through a different mechanism).

Trait bounds are essential in embedded Rust. You'll see them everywhere in embedded-hal traits, ensuring that generic device drivers only accept types with the required functionality, like OutputPin or I2C capabilities.

Example 4: Generic Structs

Generic structs allow you to create data structures that can hold different types while maintaining type safety. We’ll look at this example:

Copy Code
struct Pair<T, U> {
    first: T,
    second: U,
}

impl<T, U> Pair<T, U> {

    // Acts as a manual constructor
    fn new(first: T, second: U) -> Self {
        Pair { first, second }
    }

    // Swap first and second
    fn swap(self) -> Pair<U, T> {
        Pair {
            first: self.second,
            second: self.first,
        }
    }
}

fn main() {
    let collection = Pair::new(42, "hello");
    let swapped = collection.swap();

    println!("{}, {}", swapped.first, swapped.second);
}

The Pair<T, U> struct is defined with two type parameters, meaning it can store two values of any type (first of type T and second of type U). When implementing methods for a generic struct, you need to declare the type parameters again with impl<T, U> Pair<T, U>, telling the compiler that the methods work with any combination of types.

The new() function acts as a constructor (Rust doesn't have built-in constructors like other languages), accepting two parameters and returning Self, which is shorthand for Pair<T, U>. 

The swap() method demonstrates consuming ownership. It takes self by value (not &self), consumes the original Pair, and returns a new Pair<U, T> with the types reversed.

In main(), when we call Pair::new(42, "hello"), the compiler infers T = i32 and U = &str, creating a Pair<i32, &str>. Calling swap() on it returns a Pair<&str, i32> with the values and their types switched. This pattern is common in embedded Rust: you'll see generic structs in hardware abstraction layers where the same peripheral driver struct works with different pin types, all checked at compile time for safety.

Example 5: Generic Enums

Generic enums are particularly powerful in Rust for representing values that can be in different states, each potentially holding different types of data. Let’s look at an example:

Copy Code
// Similar to Option<T> { Some(T), None }
enum Maybe<T> {
    Something(T),
    Nothing,
}

impl<T> Maybe<T> {

    // Panic if nothing, otherwise return the value in Something
    fn unwrap(self) -> T {
        match self {
            Maybe::Something(value) => value,
            Maybe::Nothing => panic!("Called unwrap on Nothing"),
        }
    }
}

fn main() {
    let no_value: Maybe<String> = Maybe::Nothing;
    let some_number: Maybe<f64> = Maybe::Something(1.2345);

    // Check manually
    match no_value {
        Maybe::Something(value) => println!("Value: {}", value),
        Maybe::Nothing => println!("No value found"),
    }

    // If we know that it's not nothing, we can use unwrap
    println!("Unwrapped value: {}", some_number.unwrap());
}

The Maybe<T> enum is a simplified version of Rust's built-in Option<T> type, demonstrating how to create enums with type parameters. It has two variants: Something(T), which holds a value of type T, and Nothing, which holds no value.

The impl<T> Maybe<T> block provides an unwrap() method that consumes the enum (takes self by value) and uses pattern matching to either extract the value from Something or panic if it encounters Nothing.

In main(), we create two Maybe instances with different concrete types: Maybe<String> for no_value and Maybe<f64> for some_number. When we manually match on no_value, we handle both cases explicitly: a safe approach that forces you to consider what happens when there's no value.

The unwrap() call on some_number is convenient when you're certain a value exists, but it will crash the program if you're wrong. This pattern of using generic enums to represent optional values or results is fundamental to Rust's approach to error handling. You'll see Option<T> and Result<T, E> constantly in embedded code for handling operations that might fail, hardware that might not be present, or sensor readings that might be invalid.

Conclusion

Generics and traits are foundational to writing flexible, reusable code in Rust while maintaining type safety and zero-cost abstractions. You'll encounter these concepts constantly in embedded Rust development. For example, traits allow device drivers to work across different microcontrollers, and generic structs enable hardware abstraction layers to encode pin configurations and peripheral states directly in the type system.

In the next tutorial, we will put these concepts into practice and wrap up the I2C TMP102 code into a standalone library (crate).

Find the full Intro to Embedded Rust series here.

Have questions or comments? Continue the conversation on TechForum, DigiKey's online community and technical resource.