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
struct Marker;
struct ProduktionMode;
struct AdminBerechtigung;Syntax:
struct Name;— keine Felder, kein Block, abgeschlossen mit;.- Name in
UpperCamelCasewie bei anderen Structs.
Konstruktion ist trivial:
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:
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:
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:
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:
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".
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:
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
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
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
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
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
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
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
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
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 Typen — Marker 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
- The Rust Book – Unit-Like Structs
- Rust Reference – Unit Struct
- std::marker::PhantomData
- Rust Reference – Zero Sized Types