Ein Unit-Struct ist ein Struct ohne Felder. Auf den ersten Blick scheint er nutzlos — keine Daten, keine Zustände, im Speicher 0 Bytes groß. Genau deshalb ist er ein mächtiges Werkzeug: ein Typ ohne Laufzeit-Repräsentation, der trotzdem im Typ-System existiert. Damit eignen sich Unit-Structs perfekt als Marker (etwas, das einen Trait-Impl erlaubt, ohne Daten mitzubringen), als State-Tags in typsicheren State-Machines und in Verbindung mit PhantomData als Carrier für Type-Parameter. Dieser Artikel zeigt die Syntax und durchläuft alle wichtigen Anwendungs-Patterns.

Deklaration

Rust Unit-Struct
struct Marker;
struct ProduktionMode;
struct AdminBerechtigung;

Syntax:

  • struct Name; — keine Felder, kein Block, abgeschlossen mit ;.
  • Name in UpperCamelCase wie bei anderen Structs.

Konstruktion ist trivial:

Rust Instanz
struct Marker;

fn main() {
    let m = Marker;
    // size_of::<Marker>() == 0
}

Marker (ohne Klammern) ist gleichzeitig der Typ und der einzige Wert dieses Typs. Es gibt keine andere Möglichkeit, eine Unit-Struct-Instanz zu erzeugen.

Größe: 0 Bytes

Unit-Structs belegen keinen Speicher:

Rust Zero-Sized
use std::mem::size_of;

struct A;
struct B { _x: u8 }
struct C(u8);

fn main() {
    println!("{}", size_of::<A>());      // 0
    println!("{}", size_of::<B>());      // 1
    println!("{}", size_of::<C>());      // 1
}

Das macht sie zu Zero-Sized Types (ZSTs). Im Code-Generierung optimiert der Compiler sie weg: Funktionsaufrufe mit ZST-Parametern haben keine Argument-Übergabe, Strukturen mit ZST-Feldern haben kein Padding für sie. Komplett kostenlos.

Anwendung 1: Marker-Typen

Ein Marker-Typ ist ein Unit-Struct, der die Existenz einer Eigenschaft im Typ-System ausdrückt:

Rust Berechtigungs-Marker
struct AdminBerechtigung;

fn loesche_user(_admin: &AdminBerechtigung, user_id: u64) {
    println!("User {user_id} gelöscht (mit Admin-Recht)");
}

fn main() {
    // Ohne Admin-Marker: Compile-Fehler beim Aufruf
    // loesche_user(&??, 42);

    // Mit Admin-Marker:
    let admin = AdminBerechtigung;
    loesche_user(&admin, 42);
}

Wer keinen AdminBerechtigung-Wert zur Hand hat, kann loesche_user nicht aufrufen. Der Marker existiert nur im Typ-System — kostenlos zur Laufzeit, aber zur Compile-Zeit prüfbar.

Anwendung 2: Trait-Impl ohne Daten

Manche Traits beschreiben Verhalten, das keine Instanz-Daten braucht:

Rust Trait-Provider
trait Zeitprovider {
    fn jetzt(&self) -> u64;
}

struct SystemZeit;

impl Zeitprovider for SystemZeit {
    fn jetzt(&self) -> u64 {
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or(0)
    }
}

struct FixeZeit(u64);

impl Zeitprovider for FixeZeit {
    fn jetzt(&self) -> u64 { self.0 }
}

SystemZeit ist ein Unit-Struct, weil seine jetzt-Methode keine Instanz-Daten braucht. FixeZeit ist ein Tuple-Struct mit einem Wert für Tests. Beide implementieren den gleichen Trait.

Anwendung 3: State-Machine-States

Typsichere State-Machines nutzen Unit-Structs als State-Tags:

Rust State-Machine
use std::marker::PhantomData;

// State-Tags
struct Inaktiv;
struct Verbunden;
struct Geschlossen;

// Verbindung, parametrisiert über State:
struct Connection<State> {
    _state: PhantomData<State>,
}

impl Connection<Inaktiv> {
    fn neu() -> Self {
        Connection { _state: PhantomData }
    }

    fn verbinden(self) -> Connection<Verbunden> {
        println!("verbinde...");
        Connection { _state: PhantomData }
    }
}

impl Connection<Verbunden> {
    fn senden(&self, msg: &str) {
        println!("sende: {msg}");
    }

    fn schliessen(self) -> Connection<Geschlossen> {
        println!("schließe");
        Connection { _state: PhantomData }
    }
}

fn main() {
    let c = Connection::<Inaktiv>::neu();
    let c = c.verbinden();
    c.senden("Hi");
    let _c = c.schliessen();
    // c.senden(...);    // Compile-Fehler — Connection<Geschlossen> hat keine senden-Methode
}

Die State-Markers existieren nur im Typ-System. Methoden sind nur für bestimmte States verfügbar — der Compiler verhindert ungültige Übergänge. Klassisches Typestate-Pattern.

PhantomData<State> markiert dem Compiler, dass der Type-Parameter State semantisch zum Struct gehört, obwohl er kein Feld hat. Mehr im PhantomData-Abschnitt.

PhantomData — der Begleiter

std::marker::PhantomData<T> ist ein Zero-Sized-Type, der dem Compiler sagt: „dieser Struct gehört semantisch zu T, auch wenn T nicht direkt im Layout steht".

Rust PhantomData
use std::marker::PhantomData;

// Generischer Container, der intern nichts mit T zu tun hat,
// aber typsicher mit T arbeiten will:
struct TypsichererSchluessel<T> {
    wert: u64,
    _marker: PhantomData<T>,
}

impl<T> TypsichererSchluessel<T> {
    fn neu(wert: u64) -> Self {
        TypsichererSchluessel { wert, _marker: PhantomData }
    }
}

struct User;
struct Order;

fn main() {
    let user_id: TypsichererSchluessel<User> = TypsichererSchluessel::neu(42);
    let order_id: TypsichererSchluessel<Order> = TypsichererSchluessel::neu(42);
    // user_id und order_id sind verschiedene Typen — Verwechslung verhindert.
}

Ohne PhantomData würde der Compiler protestieren, dass T ungenutzt ist. PhantomData löst das ohne Laufzeit-Kosten.

Anwendung 4: Tag-Typen für Generics

Manchmal will man einen generischen Typ mit verschiedenen Implementierungen:

Rust Algorithm-Selector
struct Fast;
struct Accurate;

struct Berechner<Algo> {
    _algo: std::marker::PhantomData<Algo>,
}

trait Strategie {
    fn berechne(x: f64) -> f64;
}

impl Strategie for Fast {
    fn berechne(x: f64) -> f64 { x * 1.5 }     // grobe Schätzung
}

impl Strategie for Accurate {
    fn berechne(x: f64) -> f64 { x * 1.4142135623 }   // exakter
}

impl<Algo: Strategie> Berechner<Algo> {
    fn rechne(x: f64) -> f64 {
        Algo::berechne(x)
    }
}

fn main() {
    let r1 = Berechner::<Fast>::rechne(10.0);
    let r2 = Berechner::<Accurate>::rechne(10.0);
    println!("{r1} {r2}");
}

Fast und Accurate sind Unit-Structs, die als Tags für die Algorithmus-Wahl dienen. Pure Type-Level-Selektion — keine Runtime-Conditionals.

Praxis: Unit-Structs im echten Code

Test-Marker

Rust Test-Mode
pub struct TestMode;
pub struct ProductionMode;

pub struct Datenbank<Env> {
    url: String,
    _env: std::marker::PhantomData<Env>,
}

impl Datenbank<TestMode> {
    pub fn test() -> Self {
        Datenbank { url: "sqlite::memory:".into(), _env: std::marker::PhantomData }
    }

    pub fn alle_zuruecksetzen(&mut self) {
        println!("Test-DB zurückgesetzt");
    }
    // alle_zuruecksetzen nur im Test-Mode möglich.
}

impl Datenbank<ProductionMode> {
    pub fn produktion(url: String) -> Self {
        Datenbank { url, _env: std::marker::PhantomData }
    }
    // Kein alle_zuruecksetzen — verhindert Datenverlust.
}

Methoden, die nur im Test-Kontext sinnvoll sind, gibt es nur am Test-Mode-Typ. Production-Code ruft sie nicht aus Versehen.

Default-Implementation für Container

Rust Default-Strategie
pub struct StandardKomparator;
pub struct InverseKomparator;

pub trait Sortiert<T> {
    fn vergleiche(a: &T, b: &T) -> std::cmp::Ordering;
}

impl<T: Ord> Sortiert<T> for StandardKomparator {
    fn vergleiche(a: &T, b: &T) -> std::cmp::Ordering { a.cmp(b) }
}

impl<T: Ord> Sortiert<T> for InverseKomparator {
    fn vergleiche(a: &T, b: &T) -> std::cmp::Ordering { b.cmp(a) }
}

pub fn sortiere_mit<T, K: Sortiert<T>>(v: &mut [T]) {
    v.sort_by(|a, b| K::vergleiche(a, b));
}

fn main() {
    let mut v = vec![3, 1, 4, 1, 5];
    sortiere_mit::<_, StandardKomparator>(&mut v);
    assert_eq!(v, vec![1, 1, 3, 4, 5]);
    sortiere_mit::<_, InverseKomparator>(&mut v);
    assert_eq!(v, vec![5, 4, 3, 1, 1]);
}

Berechtigungs-Capability

Rust Capability
pub struct ReadCapability;
pub struct WriteCapability;

pub struct File {
    pfad: String,
}

impl File {
    pub fn oeffnen_zum_lesen(pfad: &str) -> (File, ReadCapability) {
        (File { pfad: pfad.to_string() }, ReadCapability)
    }

    pub fn oeffnen_zum_schreiben(pfad: &str) -> (File, WriteCapability) {
        (File { pfad: pfad.to_string() }, WriteCapability)
    }

    pub fn lesen(&self, _: &ReadCapability) -> String {
        format!("Inhalt von {}", self.pfad)
    }

    pub fn schreiben(&mut self, _: &WriteCapability, daten: &str) {
        println!("Schreibe '{daten}' nach {}", self.pfad);
    }
}

fn main() {
    let (datei, cap) = File::oeffnen_zum_lesen("/tmp/foo");
    let inhalt = datei.lesen(&cap);
    println!("{inhalt}");
    // datei.schreiben(&cap, ...);    // Fehler — falsche Capability
}

Capability-basierte Sicherheit: nur wer den passenden Capability-Token hat, kann bestimmte Operationen ausführen. Unit-Structs sind perfekt dafür.

Bit-Flag-Implementierung

Rust Flags
pub struct Lesbar;
pub struct Schreibbar;
pub struct Ausfuehrbar;

pub struct DateiPermissions<R, W, X> {
    _r: std::marker::PhantomData<R>,
    _w: std::marker::PhantomData<W>,
    _x: std::marker::PhantomData<X>,
}

impl DateiPermissions<Lesbar, Schreibbar, ()> {
    pub fn lese_schreibe() -> Self {
        DateiPermissions { _r: std::marker::PhantomData, _w: std::marker::PhantomData, _x: std::marker::PhantomData }
    }
}

(Vereinfachtes Beispiel — vollständige typestate-permission-Systeme sind komplexer, aber funktionieren genau so.)

Logger-Strategie

Rust Log-Strategie
pub trait Logger {
    fn log(msg: &str);
}

pub struct StdoutLogger;
pub struct StderrLogger;
pub struct NullLogger;

impl Logger for StdoutLogger {
    fn log(msg: &str) { println!("{msg}"); }
}

impl Logger for StderrLogger {
    fn log(msg: &str) { eprintln!("{msg}"); }
}

impl Logger for NullLogger {
    fn log(_: &str) { /* nichts */ }
}

pub fn arbeite<L: Logger>() {
    L::log("Arbeite...");
}

fn main() {
    arbeite::<StdoutLogger>();
    arbeite::<NullLogger>();    // wird im Release-Build oft komplett wegoptimiert
}

Statische Logger-Auswahl per Type-Parameter. Im Release-Build wird NullLogger komplett eliminiert — Zero-Cost-Abstraktion.

Type-Tags für Konstanten

Rust Konstanten-Selektor
pub struct Metric;
pub struct Imperial;

pub trait Einheit {
    const NAME: &'static str;
    const FAKTOR_ZU_METER: f64;
}

impl Einheit for Metric {
    const NAME: &'static str = "Meter";
    const FAKTOR_ZU_METER: f64 = 1.0;
}

impl Einheit for Imperial {
    const NAME: &'static str = "Feet";
    const FAKTOR_ZU_METER: f64 = 0.3048;
}

pub fn umrechnen<E: Einheit>(wert: f64) -> f64 {
    wert * E::FAKTOR_ZU_METER
}

fn main() {
    assert_eq!(umrechnen::<Metric>(5.0), 5.0);
    assert!((umrechnen::<Imperial>(10.0) - 3.048).abs() < 1e-9);
}

Compile-Zeit-Konstanten via Type-Parameter — sehr typisch in numerischen Bibliotheken.

Singleton-Lookup

Rust Service-Provider
pub trait Service {
    fn handle(req: &str) -> String;
}

pub struct EchoService;
pub struct UppercaseService;

impl Service for EchoService {
    fn handle(req: &str) -> String { req.to_string() }
}

impl Service for UppercaseService {
    fn handle(req: &str) -> String { req.to_uppercase() }
}

pub fn dispatch<S: Service>(req: &str) -> String {
    S::handle(req)
}

fn main() {
    assert_eq!(dispatch::<EchoService>("hi"), "hi");
    assert_eq!(dispatch::<UppercaseService>("hi"), "HI");
}

Service-Auswahl statisch per Type-Parameter — Zero-Cost-Dispatch.

Trait-Vermittlungs-Layer

Rust Adapter-Pattern
pub struct JsonFormat;
pub struct XmlFormat;

pub trait Format {
    fn rendere<T: std::fmt::Debug>(value: &T) -> String;
}

impl Format for JsonFormat {
    fn rendere<T: std::fmt::Debug>(value: &T) -> String {
        format!("{{\"value\": {:?}}}", value)
    }
}

impl Format for XmlFormat {
    fn rendere<T: std::fmt::Debug>(value: &T) -> String {
        format!("<value>{:?}</value>", value)
    }
}

FAQ

Wozu brauche ich einen Struct ohne Felder?

Als Typ-Marker — er existiert nur im Typ-System, nicht zur Laufzeit. Damit lassen sich Berechtigungen, States, Algorithmus-Auswahl und mehr typsicher modellieren. Wer „nur einen Wert" will, nimmt () (Unit-Type). Unit-Struct ist ein eigener Typ.

Unit-Struct vs. Unit-Type ()?

() ist der „leere Tupel"-Typ, hat genau einen Wert (auch ()), und ist überall in Rust gebräuchlich (Funktionen ohne Rückgabe). Unit-Structs sind eigene TypenMarker und OtherMarker sind verschiedene Typen, beide haben size_of von 0. () ist überall der gleiche Typ.

Sind Unit-Structs wirklich kostenlos?

Ja. Sie belegen 0 Bytes Speicher. Funktionen, die einen Unit-Struct als Parameter haben, übergeben nichts auf Maschinencode-Ebene — der Compiler eliminiert das. Marker und State-Tags sind also vollständig zero-cost.

Brauche ich Klammern bei der Konstruktion?

Nein. struct Marker; (mit ;) deklariert einen Unit-Struct. Konstruktion: let m = Marker; — kein (). Bei struct Marker(); (mit ()) wäre es ein Tuple-Struct mit 0 Feldern, der mit Marker() konstruiert wird. Beide funktionieren, Unit-Form (struct Name;) ist idiomatischer.

Wann PhantomData?

Wenn dein Struct einen Type-Parameter <T> hat, aber kein Feld vom Typ T enthält. PhantomData<T> sagt dem Compiler „T gehört semantisch zu diesem Struct". Ohne sie würde der Compiler T als ungenutzt anmaulen.

Kann ich impl auf Unit-Structs nutzen?

Ja, wie auf jedem anderen Struct. impl Marker { fn foo() {...} } ist normales Rust. Da Unit-Structs keine Felder haben, sind Methoden meist Associated Functions (Self::foo()) — sie operieren ohne Instanz-Daten.

Typestate-Pattern und Unit-Structs gehören zusammen.

Typestate ist die Technik, States als verschiedene Typen zu modellieren. Methoden sind nur am passenden State-Typ verfügbar. Unit-Structs als State-Marker plus PhantomData<State> als Carrier sind das Standard-Setup.

derive(Default) funktioniert auf Unit-Structs.

#[derive(Default)] struct Marker; macht Marker::default() zu einer trivialen Konstruktion. Nützlich, wenn dein Code generisch T::default() aufruft und du einen Marker als T einsetzen willst.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Structs & Methoden

Zur Übersicht