Traits in Rust: gemeinsames Verhalten definieren

1. Übersicht

In Rust ist ein trait eine Möglichkeit, ein Verhalten zu beschreiben, das viele Datentypen gemeinsam haben. Es ähnelt einem Interface in Java, bietet aber zusätzliche Funktionen: Standard-Implementierungen (default implementations), Trait Bounds für Generics und die Wahl zwischen Static Dispatch und Dynamic Dispatch.

Nach diesem Artikel wirst du wissen, wie du ein Trait definierst, es für ein Struct implementierst, Traits als Parameter und Rückgabewerte verwendest und eine Liste von Objekten unterschiedlicher Typen mit dyn Trait erstellst. Du wirst auch verstehen, warum der Compiler dein Code manchmal ablehnt.

Alle Beispiele funktionieren mit Rust stable (Edition 2021 und neuer) und benötigen keine externen Bibliotheken.

2. Ein Trait definieren und implementieren

Angenommen, ein E-Commerce-System hat zwei Typen: Order und Customer. Du möchtest, dass beide sich selbst „zusammenfassen” können als eine kurze Zeichenkette.

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct Order {
    pub id: u32,
    pub total: u64, // Einheit: Dong
}

pub struct Customer {
    pub name: String,
    pub email: String,
}

impl Summary for Order {
    fn summarize(&self) -> String {
        format!("Bestellung #{} - {} Dong", self.id, self.total)
    }
}

impl Summary for Customer {
    fn summarize(&self) -> String {
        format!("Kunde {} <{}>", self.name, self.email)
    }
}

fn main() {
    let order = Order { id: 42, total: 350_000 };
    let customer = Customer {
        name: "An".to_string(),
        email: "an@example.com".to_string(),
    };

    println!("{}", order.summarize());
    println!("{}", customer.summarize());
}

Erklärung:

  • pub trait Summary deklariert ein Trait mit einer Methode summarize. Hier nur die Signatur, kein Rumpf.
  • impl Summary for Order bietet die spezifische Implementierung für Order. Jeder Typ muss alle Methoden ohne Rumpf im Trait implementieren.
  • &self bedeutet, dass die Methode das Objekt ausleiht, keine Ownership nimmt.

Ergebnis:

Bestellung #42 - 350000 Dong
Kunde An <an@example.com>

Hinweis: Wenn du order.summarize() aus einem anderen Modul aufrufen möchtest, muss das Trait Summary im Scope sein (use crate::Summary;). Das ist ein sehr häufiger Fehler. Der Compiler meldet „no method named summarize found”, obwohl du es richtig implementiert hast.

3. Standard-Implementierung

Ein Trait kann einen vorgefertigten Funktionscode bereitstellen. Ein Typ, der das Trait implementiert, wird die Standardversion verwenden oder bei Bedarf überschreiben.

pub trait Summary {
    fn title(&self) -> String;

    fn summarize(&self) -> String {
        format!("[Zusammenfassung] {}", self.title())
    }
}

impl Summary for Order {
    fn title(&self) -> String {
        format!("Bestellung #{}", self.id)
    }

    // Überschreiben der Standardimplementierung
    fn summarize(&self) -> String {
        format!("{} - {} Dong", self.title(), self.total)
    }
}

impl Summary for Customer {
    fn title(&self) -> String {
        format!("Kunde {}", self.name)
    }
    // Nicht überschrieben summarize -> Standardversion verwenden
}

Wenn du main aus Abschnitt 2 erneut ausführst, ist das Ergebnis:

Bestellung #42 - 350000 Dong
[Zusammenfassung] Kunde An

Bemerkenswert ist, dass die Standard-Methode summarize title() aufrufen kann, obwohl title() noch keinen Rumpf im Trait hat. Das ist ein nützliches Muster. Du brauchst nur, dass der konkrete Typ das Minimum bereitstellt (title), während das Trait den Rest darauf aufbaut.

Von nun an verwendet dieser Artikel diese Version des Trait für weitere Beispiele.

4. Trait als Parameter

4.1. Syntax impl Trait

Der einfachste Weg, eine Funktion zu schreiben, die „alles mit Summary” akzeptiert:

fn print_summary(item: &impl Summary) {
    println!("Benachrichtigung: {}", item.summarize());
}

fn main() {
    let order = Order { id: 7, total: 120_000 };
    print_summary(&order);
}
Benachrichtigung: Bestellung #7 - 120000 Dong

4.2. Trait Bound

&impl Summary ist eigentlich eine Kurzschreibweise für ein Generic mit Trait Bound:

fn print_summary<T: Summary>(item: &T) {
    println!("Benachrichtigung: {}", item.summarize());
}

Die beiden Schreibweisen unterscheiden sich bei mehreren Parametern:

// a und b können zwei verschiedene Typen sein
fn compare_loose(a: &impl Summary, b: &impl Summary) { /* ... */ }

// a und b müssen vom gleichen Typ T sein
fn compare_strict<T: Summary>(a: &T, b: &T) { /* ... */ }

Der Aufruf compare_strict(&order, &customer) wird ein Compilerfehler sein, da Order und Customer nicht vom gleichen Typ sind.

4.3. Mehrere Bounds und where-Klausel

Wenn du mehrere Traits gleichzeitig brauchst, verbinde sie mit +. Wenn die Liste lang wird, gehe zu where für bessere Lesbarkeit:

use std::fmt::{Debug, Display};

fn log_item<T>(item: &T)
where
    T: Summary + Display + Debug,
{
    println!("{item} | {item:?} | {}", item.summarize());
}

Diese Funktion akzeptiert nur Typen, die alle drei Traits implementieren. Fehlt ein Trait, wird der Compiler einen Fehler direkt an der Aufrufstelle melden, nicht erst zur Laufzeit.

Generics in Rust verwenden Monomorphisierung. Der Compiler erzeugt eine separate Version von log_item für jeden konkreten Typ. Dadurch ist der Funktionsaufruf zur Laufzeit kostenfrei (Static Dispatch). Im Gegenzug kann die Binärdatei größer werden.

5. impl Trait zurückgeben

Du kannst auch „einen Typ, der Summary implementiert” zurückgeben, ohne den Typen zu nennen:

fn latest_order() -> impl Summary {
    Order { id: 100, total: 999_000 }
}

fn main() {
    println!("{}", latest_order().summarize());
}
Bestellung #100 - 999000 Dong

Dieses Muster ist besonders nützlich bei Closures und Iteratoren. Der Typ ist oft sehr lang oder lässt sich gar nicht aufschreiben.

Hinweis: impl Trait in der Rückgabeposition ist immer noch ein einziger konkreter Typ. Der folgende Code wird nicht kompiliert:

fn pick(vip: bool) -> impl Summary {
    if vip {
        Customer { name: "An".into(), email: "an@example.com".into() }
    } else {
        Order { id: 1, total: 0 } // Fehler: `if` and `else` have incompatible types
    }
}

Wenn du mehrere verschiedene Typen zurückgeben musst, brauchst du Box<dyn Summary> (Abschnitt 6).

6. Trait-Objekte mit dyn Trait

Generics erfordern, dass der Typ zum Zeitpunkt der Kompilation bekannt ist. Manchmal brauchst du aber eine Liste, die sowohl Order als auch Customer enthält. Dann verwende ein Trait-Objekt:

fn main() {
    let items: Vec<Box<dyn Summary>> = vec![
        Box::new(Order { id: 1, total: 50_000 }),
        Box::new(Customer {
            name: "Bình".to_string(),
            email: "binh@example.com".to_string(),
        }),
    ];

    for item in &items {
        println!("{}", item.summarize());
    }
}
Bestellung #1 - 50000 Dong
[Zusammenfassung] Kunde Bình

Erklärung:

  • dyn Summary ist ein Typ, dessen Größe zur Kompilation unbekannt ist. Deshalb muss er hinter einem Zeiger wie Box, & oder Rc stecken.
  • Jedes Trait-Objekt hat zwei Teile: einen Zeiger auf die Daten und einen Zeiger auf die Vtable. Der Aufruf summarize() wird zur Laufzeit in der Vtable nachgeschlagen (Dynamic Dispatch).
  • Die Funktion pick aus Abschnitt 5 lässt sich korrigieren, indem du den Rückgabetyp zu Box<dyn Summary> änderst und jeden Branch in Box::new(...) verpackst.

Schneller Vergleich:

Kriterium Generics / impl Trait dyn Trait
Dispatch Statisch, kann inline sein Dynamisch über Vtable
Liste mehrerer Typen Nein Ja
Binärgröße Größer Kleiner

Hinweis: Nicht jedes Trait ist mit dyn verwendbar. Ein Trait muss dyn-kompatibel sein (früher object safe genannt). Ein Trait ist nicht dyn-kompatibel, wenn er generische Methoden hat, z. B. fn send<T>(&self, t: T). Ein Trait verliert diese Eigenschaft auch, wenn eine Methode Self zurückgibt, ohne where Self: Sized zu haben. Der Grund: Die Vtable kann nicht unendlich viele Versionen einer generischen Methode enthalten, und der Compiler weiß nicht, welcher echte Typ Self ist.

7. Derive und Standard-Traits

Die Standard-Bibliothek hat viele bekannte Traits. Einige werden automatisch mit #[derive] erzeugt:

use std::fmt;

#[derive(Debug, Clone, PartialEq)]
pub struct Order {
    pub id: u32,
    pub total: u64,
}

impl fmt::Display for Order {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "Order#{}", self.id)
    }
}

fn main() {
    let a = Order { id: 5, total: 10_000 };
    let b = a.clone();

    println!("{a}");          // Display
    println!("{a:?}");        // Debug
    println!("{}", a == b);   // PartialEq
    println!("{}", a.to_string().len());
}
Order#5
Order { id: 5, total: 10000 }
true
7

Du hast ToString nie implementiert, kannst aber trotzdem a.to_string() aufrufen. Das ist möglich wegen einer Blanket Implementation in der Standard-Bibliothek:

impl<T: Display + ?Sized> ToString for T { /* ... */ }

Das bedeutet, jeder Typ mit Display hat automatisch ToString. Du kannst auch Blanket Impls für dein eigenes Trait schreiben, z. B. impl<T: Display> Summary for T. Achte aber auf Konflikte mit anderen Impls.

8. Orphan Rule und Newtype Pattern

Rust hat die Orphan Rule. Du darfst nur impl Trait for Type schreiben, wenn das Trait oder der Typ in deinem Crate definiert wurde. Diese Regel verhindert, dass zwei Crates das gleiche Trait für den gleichen Typ auf zwei verschiedene Arten implementieren.

Dieser Code wird abgelehnt, da sowohl Display als auch Vec aus der Standard-Bibliothek kommen:

// Fehler E0117: only traits defined in the current crate can be implemented for types defined outside of the crate
impl std::fmt::Display for Vec<Order> { /* ... */ }

Die Lösung ist das Newtype Pattern. Du wickelst den fremden Typ in ein Struct deines Crates:

pub struct Cart(pub Vec<Order>);

impl fmt::Display for Cart {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let total: u64 = self.0.iter().map(|o| o.total).sum();
        write!(f, "Warenkorb: {} Bestellungen, gesamt {} Dong", self.0.len(), total)
    }
}

fn main() {
    let cart = Cart(vec![
        Order { id: 1, total: 100_000 },
        Order { id: 2, total: 250_000 },
    ]);
    println!("{cart}");
}
Warenkorb: 2 Bestellungen, gesamt 350000 Dong

Cart ist ein lokaler Typ, daher darfst du Display implementieren. Newtype kostet nichts zur Laufzeit: Cart hat das gleiche Layout wie Vec<Order>.

9. Tests für Traits schreiben

Tests in Rust stehen in der gleichen Datei in einem #[cfg(test)] Modul. Wir testen die Überschreibung und die Standard-Implementierung, sowie den Fall mit Trait-Objekten:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn order_overrides_default_summary() {
        let order = Order { id: 42, total: 350_000 };
        assert_eq!(order.summarize(), "Bestellung #42 - 350000 Dong");
    }

    #[test]
    fn customer_uses_default_summary() {
        let customer = Customer {
            name: "An".to_string(),
            email: "an@example.com".to_string(),
        };
        assert_eq!(customer.summarize(), "[Zusammenfassung] Kunde An");
    }

    #[test]
    fn trait_objects_dispatch_to_correct_impl() {
        let items: Vec<Box<dyn Summary>> = vec![
            Box::new(Order { id: 1, total: 0 }),
            Box::new(Customer { name: "Bình".into(), email: "b@x.vn".into() }),
        ];
        let result: Vec<String> = items.iter().map(|i| i.summarize()).collect();
        assert_eq!(result, vec!["Bestellung #1 - 0 Dong", "[Zusammenfassung] Kunde Bình"]);
    }
}

Tests ausführen:

cargo test
running 3 tests
test tests::customer_uses_default_summary ... ok
test tests::order_overrides_default_summary ... ok
test tests::trait_objects_dispatch_to_correct_impl ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

Der dritte Test ist wichtig. Er bestätigt, dass beim Durchgang durch die Vtable jedes Element die richtige Implementierung seines echten Typs aufruft.

10. Fazit

Traits sind die Grundlage für die Weitergabe von Verhalten in Rust. Die Hauptpunkte:

  • Verwende trait um Verhalten zu deklarieren und impl Trait for Type, um es zu implementieren. Standard-Methoden helfen, wiederholten Code zu vermeiden.
  • Nutze impl Trait oder Trait Bounds (T: Trait, where), wenn der Typ zum Zeitpunkt der Kompilation bekannt ist. Das sorgt für Static Dispatch ohne Laufzeit-Overhead.
  • Verwende Box<dyn Trait>, wenn du mehrere verschiedene Typen speichern oder zurückgeben musst. Vergiss nicht die Anforderung an dyn-Kompatibilität.
  • Denk daran, das Trait mit use in den Scope zu bringen, bevor du seine Methoden aufrufen kannst.
  • Wenn die Orphan Rule auftritt, wende das Newtype Pattern an.

Wann solltest du kein neues Trait erstellen? Wenn ein Verhalten nur eine einzige Implementierung hat und bei Tests nicht ersetzt werden soll, reicht eine normale Methode auf dem Struct. Traits sollten nur dann hinzugefügt werden, wenn mehrere Typen wirklich das gleiche Verhalten teilen, oder wenn du Teile des Systems abstrahieren musst, um sie zu entkoppeln.

rusttraitgenericspolymorphism