Skip to content

Rust Traits, Implementations and Associated Types

A Rust trait describes behaviour that a type agrees to provide; it is not a class containing shared fields. This lesson defines a reading source, gives integer and text fixtures different result types, and composes a named report without inheritance or runtime dispatch.

Prerequisites and outcome

Complete generics, bounds and const generics. You will implement required methods, reuse a default method, constrain an associated type, and recognise why two implementations cannot conflict. The example uses fixture data and stable Rust; hardware I/O and trait objects are separate topics.

What the Rust trait contract includes

Our Source trait declares an associated type Value, an associated constant UNIT, a required read method and a default summary method. Self refers to the implementing type, so Self::Value names that implementation's chosen value type. The read receiver is shared: callers borrow the source rather than consume it.

The trait reference explains required and default associated items. A default method can call required methods, but does not allocate fields or provide a parent object's storage. Implementations must supply the required items; the trait declaration's signatures are not optional conventions.

Build the complete fixture-source program

cargo new pi_traits
cd pi_traits

Keep edition = "2024" in Cargo.toml and replace src/main.rs with:

mod sources {
    use std::fmt::Display;

    pub trait Source {
        type Value: Display;
        const UNIT: &'static str;

        fn read(&self) -> Option<Self::Value>;

        fn summary(&self) -> String {
            match self.read() {
                Some(value) => format!("{value} {}", Self::UNIT),
                None => format!("missing ({})", Self::UNIT),
            }
        }
    }

    #[derive(Debug, Clone, PartialEq, Eq)]
    pub struct Temperature {
        pub millidegrees: Option<i32>,
    }

    impl Source for Temperature {
        type Value = i32;
        const UNIT: &'static str = "mC";

        fn read(&self) -> Option<Self::Value> {
            self.millidegrees
        }
    }

    pub struct Status {
        pub online: bool,
    }

    impl Source for Status {
        type Value = String;
        const UNIT: &'static str = "status";

        fn read(&self) -> Option<Self::Value> {
            Some(String::from(if self.online { "online" } else { "offline" }))
        }
    }
}

use sources::{Source, Status, Temperature};

trait Named {
    fn name(&self) -> &str;
}

struct Device<S> {
    name: String,
    source: S,
}

impl<S> Named for Device<S> {
    fn name(&self) -> &str {
        &self.name
    }
}

// Delegate to the contained source while retaining its chosen value type.
impl<S: Source> Source for Device<S> {
    type Value = S::Value;
    const UNIT: &'static str = S::UNIT;

    fn read(&self) -> Option<Self::Value> {
        self.source.read()
    }
}

trait Report: Source + Named {
    fn report_line(&self) -> String {
        format!("{}: {}", self.name(), self.summary())
    }
}

// One implementation covers every type satisfying both requirements.
impl<T: Source + Named> Report for T {}

fn read_integer<S: Source<Value = i32>>(source: &S) -> Option<i32> {
    source.read()
}

fn main() {
    let device = Device {
        name: String::from("Pi 4B"),
        source: Temperature {
            millidegrees: Some(46_700),
        },
    };
    let status = Device {
        name: String::from("Pi 4B"),
        source: Status { online: true },
    };
    let missing = Temperature { millidegrees: None };

    println!("{}", device.report_line());
    println!("{}", status.report_line());
    println!("{}", missing.summary());
    println!("integer={:?}", read_integer(&device));
    println!("unit={}", <Temperature as Source>::UNIT);
}

#[cfg(test)]
mod tests {
    use super::{Device, Named, Report, Source, Status, Temperature, read_integer};

    #[test]
    fn required_method_preserves_zero() {
        let source = Temperature {
            millidegrees: Some(0),
        };
        assert_eq!(source.read(), Some(0));
        assert_eq!(source.summary(), "0 mC");
    }

    #[test]
    fn default_method_handles_absence() {
        let source = Temperature { millidegrees: None };
        assert_eq!(source.read(), None);
        assert_eq!(source.summary(), "missing (mC)");
    }

    #[test]
    fn associated_value_can_be_owned_text() {
        let source = Status { online: false };
        assert_eq!(source.read(), Some(String::from("offline")));
        assert_eq!(source.summary(), "offline status");
    }

    #[test]
    fn generic_device_delegates_integer_source() {
        let device = Device {
            name: String::from("fixture"),
            source: Temperature {
                millidegrees: Some(-500),
            },
        };
        assert_eq!(device.name(), "fixture");
        assert_eq!(read_integer(&device), Some(-500));
        assert_eq!(device.report_line(), "fixture: -500 mC");
    }

    #[test]
    fn blanket_report_also_works_for_text_source() {
        let device = Device {
            name: String::from("network"),
            source: Status { online: true },
        };
        assert_eq!(device.report_line(), "network: online status");
    }

    #[test]
    fn derived_clone_and_equality_preserve_fixture() {
        let source = Temperature {
            millidegrees: Some(46_700),
        };
        let cloned = source.clone();
        assert_eq!(cloned, source);
        assert_eq!(
            format!("{source:?}"),
            "Temperature { millidegrees: Some(46700) }"
        );
    }

    #[test]
    fn associated_constant_is_implementation_specific() {
        assert_eq!(<Temperature as Source>::UNIT, "mC");
        assert_eq!(<Status as Source>::UNIT, "status");
        assert_eq!(<Device<Temperature> as Source>::UNIT, "mC");
    }
}
1
2
3
4
cargo check
cargo test
cargo fmt --check
cargo run --quiet

Expected output:

1
2
3
4
5
Pi 4B: 46700 mC
Pi 4B: online status
missing (mC)
integer=Some(46700)
unit=mC

There are seven tests. Status constructs an owned String on each read; traits do not make that allocation disappear. Temperature copies an Option. These are implementation choices behind the same contract.

Associated types versus generic trait parameters

Temperature chooses Value = i32; Status chooses Value = String. The function bound Source<Value = i32> accepts only a source whose associated type is exactly i32. Merely having a read method with a similar name does not implement Source.

For this non-generic Source trait, a particular implementing type has one chosen Value, not a caller-selected result type for every call. A trait declared with a parameter, such as Convert, can instead have distinct implementations for different T where coherence permits. That is useful for a conversion with several targets; our source has one natural reading type. The Book's advanced traits contrasts these choices.

<Temperature as Source>::UNIT is a fully qualified path. It states both the implementing type and the trait, useful when names would otherwise be ambiguous. UNIT belongs to the implementation, not to an individual instance. The 'static string reference here points to a string literal; it does not require each source object to live forever.

Defaults, supertraits and delegation

Neither Temperature nor Status overrides summary(), so each gets its default body. An implementation can replace that method while keeping its signature. Callers should rely on the documented trait contract rather than assume every implementation uses identical formatting or cost.

Report requires both Source and Named as supertraits. It can use their methods, but does not inherit their fields. Device satisfies them through explicit implementations, and its source field uses composition. Named does not need a bound on S; Source does, because it calls S's read method. See the supertrait rules.

Keep use sources::Source in scope for ordinary trait-method calls on Temperature or Status. The trait is declared in a different module, unlike their inherent methods. Fully qualified calls such as Source::summary(&missing) can select the trait explicitly. Adding pub to methods inside a trait impl is not how to export them; make the trait accessible and use its API.

Blanket implementations and coherence

The impl<T: Source + Named> Report for T {} line is a blanket implementation. Its empty body is valid because report_line has a default. Device qualifies, as does Device; no separate Report impl is needed for either.

Adding an explicit Report implementation for Device would overlap with that blanket implementation and fail with E0119. A more specific-looking implementation does not automatically override a generic one on stable Rust. Decide where customization belongs before committing to a broad blanket impl.

There is also an orphan check: implementing a foreign trait directly for a foreign type is rejected. Defining our own trait allows implementations on foreign types, and defining a local wrapper can allow foreign-trait implementations. Generic cases also have uncovered-parameter restrictions; “one local type anywhere” is not a complete rule. The implementation reference gives the precise check. A type alias for a foreign type does not create a local wrapper.

Derive is generated implementation code

Temperature derives Debug, Clone, PartialEq and Eq. Those generated implementations use its fields, so the fields must satisfy the relevant requirements. Eq is suitable for Option; changing that field to Option would fail its Eq requirement. Deriving Clone does not imply Copy.

The derive reference describes generated implementations and built-in traits. Display is not a built-in derive: user-facing text needs a chosen format, while Debug supports diagnostics. See the Display documentation. Derive macros are revisited in the macro lesson; there is no need to write a procedural macro here.

Deliberately failing: foreign trait on a foreign type

In a separate scratch project, replace src/main.rs with this complete program:

1
2
3
4
5
6
7
8
9
use std::fmt;

impl fmt::Display for Vec<i32> {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(formatter, "{:?}", self)
    }
}

fn main() {}

cargo check reports E0117: Display and Vec are both foreign to this crate. Matching a method signature does not remove the orphan rule. A local newtype such as struct Readings(Vec<i32>); gives you a distinct type whose Display implementation can deliberately format its contained vector.

Exercises and troubleshooting

  1. Remove type Value = i32 from Temperature's implementation: expect E0046 for the missing required item, rather than an inferred associated type.
  2. Call read_integer on the Status-based device: expect E0271 because its Value is String, not i32. The general report still accepts it.
  3. Add impl Report for Device<Temperature> {}: expect E0119 because the blanket implementation already applies.
  4. Fully qualify Source in other bounds and type paths, then remove it from the root use list: expect E0599 at missing.summary(), even though the implementation remains present. Restore the import or use sources::Source::summary(&missing); import Source directly from sources in the child tests if you remove the root name.
  5. Repair the foreign-trait example with Readings, implement Display for that wrapper, and format self.0. Print Readings containing [46700, 60000]; expect [46700, 60000]. Changing Vec's name through a type alias is not a repair.
  6. Change only Temperature's field from Option to Option: expect compile failure, including the derived Eq requirement. A wholesale float conversion needs an explicit equality policy and updated associated types, fixtures and tests.

If a required item is missing, check the trait declaration. If a method cannot be found, distinguish a missing implementation, an unsatisfied bound and a missing import. If impls conflict, inspect blanket coverage; hiding them in different modules does not avoid coherence.

Verification and next step

On October 10, 2026, the lesson was verified on a Raspberry Pi 4B with 64-bit user space, kernel 6.18.50+rpt-rpi-v8, Rust and Cargo 1.99.0, and edition 2024. Cargo check, all seven tests, formatting and debug/release output comparisons passed. Fully qualified calls, the import repair and the local Display wrapper also passed. The orphan, missing associated type, mismatched value type, overlapping implementation, missing import and float Eq examples failed as expected, including E0117, E0046, E0271, E0119, E0599 and E0277 respectively. No live sensor or performance measurement is involved.

Next: lifetimes and borrowed APIs, explaining the validity of returned references. Source here also has an associated constant, which affects whether it can become a trait object; the dispatch lesson will explain that separately.

Previous: generics and bounds · Course overview

Donate