Tuple-Structs sind die zweite Form von Structs: anstelle benannter Felder haben sie positionale, wie bei einem Tupel. Sie sind eine schmale, aber wichtige Variante mit zwei Hauptanwendungen — als Newtype-Wrapper (Struct mit genau einem Feld, um Typsicherheit zu erzeugen) und als kleine geometrische Typen wie Koordinaten oder RGB-Farben. Dieser Artikel zeigt, wann ein Tuple-Struct dem klassischen Field-Struct vorzuziehen ist, wie er sich vom anonymen Tupel unterscheidet, und welche Patterns aus ihm einen unverzichtbaren Werkzeug der Sprache machen.
Deklaration
struct Punkt(f64, f64);
struct RGB(u8, u8, u8);
struct Tag(u32);Syntax:
structals Schlüsselwort.- Name in
UpperCamelCase. - Felder in runden Klammern, nur Typen — keine Namen.
Jeder Tuple-Struct ist ein eigener Typ. Punkt(1.0, 2.0) ist nicht das gleiche wie das anonyme Tupel (1.0, 2.0) — auch wenn sie strukturell identisch aussehen.
Konstruktion und Zugriff
struct Punkt(f64, f64);
fn main() {
let p = Punkt(3.0, 4.0);
println!("x={}, y={}", p.0, p.1);
// Mutation (Bindung muss mut sein):
let mut q = Punkt(0.0, 0.0);
q.0 = 5.0;
}Zugriff via Index-Notation: p.0, p.1 — die Indices sind literale Konstanten (nicht Variablen). p.i mit i als Variable funktioniert nicht.
Pattern-Destrukturierung
# struct Punkt(f64, f64);
let p = Punkt(3.0, 4.0);
let Punkt(x, y) = p;
println!("{x} {y}");
// Selektiv mit _:
let Punkt(x, _) = Punkt(7.0, 8.0);
// In match-Armen:
# let p2 = Punkt(0.0, 0.0);
match p2 {
Punkt(0.0, 0.0) => println!("Ursprung"),
Punkt(x, 0.0) => println!("Auf x-Achse bei {x}"),
Punkt(0.0, y) => println!("Auf y-Achse bei {y}"),
Punkt(_, _) => println!("woanders"),
}Pattern-Matching auf Tuple-Structs ist sehr ausdrucksstark — wie bei anonymen Tupeln, aber mit dem zusätzlichen Typ-Match.
Tuple-Struct vs. anonymes Tupel
Strukturell identisch, semantisch unterschiedlich:
// Anonymes Tupel:
let anonym: (f64, f64) = (3.0, 4.0);
// Tuple-Struct:
struct Punkt(f64, f64);
let punkt: Punkt = Punkt(3.0, 4.0);
// Beide haben identisches Memory-Layout, aber:
// - Punkt ist ein eigener Typ
// - Anonyme Tupel sind strukturell typisiert ((f64, f64))
fn distanz_zu_null(p: Punkt) -> f64 {
(p.0 * p.0 + p.1 * p.1).sqrt()
}
// distanz_zu_null(anonym); // Fehler — (f64, f64) ≠ PunktTuple-Structs sind nominal: zwei verschiedene Tuple-Structs mit identischen Feldern sind verschiedene Typen. Das ist genau der Vorteil — du kannst Typsicherheit ohne Field-Namen erzeugen.
| Aspekt | Anonymes Tupel | Tuple-Struct |
|---|---|---|
| Eigener Typ? | Nein, strukturell | Ja, nominal |
| Methoden möglich? | Nein direkt (nur Trait-Impls) | Ja (via impl) |
derive? | Nicht direkt | Ja |
| Wann nutzen | Kurzlebige, lokale Bündel | Domain-Typen, Newtype |
Der Newtype-Spezialfall
Ein Tuple-Struct mit genau einem Feld heißt Newtype:
struct Meter(f64);
struct Sekunden(u64);
struct UserId(u64);Auf den ersten Blick: ein Wrapper, der scheinbar nichts hinzufügt. In Wirklichkeit: ein eigener Typ, der Typsicherheit garantiert. Die folgenden Beispiele sind alle Compile-Fehler:
# struct Meter(f64);
# struct Sekunden(u64);
# struct UserId(u64);
let strecke = Meter(100.0);
let dauer = Sekunden(60);
let user = UserId(42);
// strecke + dauer; // Fehler — keine Add-Impl
// strecke == dauer; // Fehler — verschiedene Typen
// user + 1; // Fehler — UserId + i32Newtype-Pattern ist eines der wichtigsten in idiomatischem Rust. Ein eigenes Kapitel dazu folgt in diesem Abschnitt.
Methoden auf Tuple-Structs
struct Punkt(f64, f64);
impl Punkt {
fn neu(x: f64, y: f64) -> Self {
Punkt(x, y)
}
fn x(&self) -> f64 { self.0 }
fn y(&self) -> f64 { self.1 }
fn distanz_zu_null(&self) -> f64 {
(self.0 * self.0 + self.1 * self.1).sqrt()
}
}
fn main() {
let p = Punkt::neu(3.0, 4.0);
assert_eq!(p.distanz_zu_null(), 5.0);
}Wie bei Field-Structs: impl Tuple-Struct { ... } fügt Methoden hinzu. Die Methoden können named Accessoren bereitstellen (fn x(&self) -> f64), wenn die Index-Notation .0/.1 unleserlich wird.
Mehrere Felder
Tuple-Structs mit mehreren Feldern sind selten — meist ist ein Field-Struct lesbarer:
// Geht, aber bei mehr als 2-3 Feldern verwirrend:
struct PersonTuple(String, u32, bool);
// Bei semantisch unterschiedlichen Feldern besser:
struct PersonFields {
name: String,
alter: u32,
aktiv: bool,
}
fn main() {
let p = PersonTuple("Anna".into(), 28, true);
// p.0 — was war das nochmal? Der Name?
println!("{}", p.0);
let q = PersonFields { name: "Bert".into(), alter: 30, aktiv: false };
// q.name — selbsterklärend
println!("{}", q.name);
}Faustregel: Tuple-Structs mit 1 Feld (Newtype) — sehr häufig. Mit 2-3 Feldern wie Koordinaten oder RGB — gelegentlich. Mit 4+ Feldern — fast immer schlechter als ein Field-Struct.
Sichtbarkeit der Felder
Wie bei Field-Structs: Felder können einzeln pub markiert werden — hier auf positionaler Basis:
pub struct Punkt(pub f64, pub f64); // beide öffentlich
pub struct Geheim(pub i32, String); // erstes pub, zweites privatBei rein privaten Feldern brauchst du einen Konstruktor — Code außerhalb des Moduls kann den Struct sonst nicht erstellen.
Praxis: Tuple-Structs im echten Code
RGB-Farbe
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Rgb(pub u8, pub u8, pub u8);
impl Rgb {
pub const SCHWARZ: Rgb = Rgb(0, 0, 0);
pub const WEISS: Rgb = Rgb(255, 255, 255);
pub fn r(&self) -> u8 { self.0 }
pub fn g(&self) -> u8 { self.1 }
pub fn b(&self) -> u8 { self.2 }
pub fn hell(&self) -> bool {
(self.0 as u32 + self.1 as u32 + self.2 as u32) > 384
}
pub fn als_hex(&self) -> String {
format!("#{:02x}{:02x}{:02x}", self.0, self.1, self.2)
}
}
fn main() {
let rot = Rgb(255, 0, 0);
assert_eq!(rot.als_hex(), "#ff0000");
assert!(!rot.hell());
}Drei u8-Werte als RGB. Tuple-Struct passt — die Reihenfolge (R, G, B) ist Konvention und intuitiv.
IPv4-Adresse
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Ipv4(pub u8, pub u8, pub u8, pub u8);
impl Ipv4 {
pub const LOCALHOST: Ipv4 = Ipv4(127, 0, 0, 1);
pub fn ist_privat(&self) -> bool {
matches!(self,
Ipv4(10, _, _, _)
| Ipv4(172, 16..=31, _, _)
| Ipv4(192, 168, _, _)
)
}
pub fn als_string(&self) -> String {
format!("{}.{}.{}.{}", self.0, self.1, self.2, self.3)
}
}
fn main() {
let lokal = Ipv4(192, 168, 1, 1);
assert!(lokal.ist_privat());
assert_eq!(lokal.als_string(), "192.168.1.1");
}Ipv4(a, b, c, d) ist semantisch klarer als [u8; 4], ohne derive(...) zu verlieren.
Newtype für API-Token
pub struct ApiToken(String);
impl ApiToken {
pub fn neu(roh: String) -> Result<Self, &'static str> {
if roh.len() < 32 { return Err("zu kurz"); }
Ok(ApiToken(roh))
}
pub fn als_str(&self) -> &str { &self.0 }
}
fn main() {
let t = ApiToken::neu(String::from("a".repeat(40))).unwrap();
println!("{}", t.als_str());
// t.0 — direkter Zugriff nicht möglich (privat)
}Tuple-Struct mit privatem Feld plus Konstruktor mit Validierung — kein anderer Code-Pfad kann einen unvaliden ApiToken erzeugen.
Domain-Typ für UserId
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct UserId(pub u64);
pub fn finde_user(id: UserId) -> Option<String> {
// Niemand kann versehentlich eine OrderId oder ProductId übergeben
None
}
fn main() {
let id = UserId(42);
finde_user(id);
// finde_user(42); // Fehler — kein u64
}Klassische Newtype-Anwendung für stark-typisierte IDs. UserId(42), OrderId(42), ProductId(42) sind drei verschiedene Typen — Verwechslungs-sicher.
Geld-Wert in Cents
#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
pub struct EurCent(pub i64);
impl EurCent {
pub const NULL: EurCent = EurCent(0);
pub fn plus(self, other: EurCent) -> EurCent {
EurCent(self.0 + other.0)
}
pub fn als_euro_string(&self) -> String {
let euro = self.0 / 100;
let cent = (self.0 % 100).abs();
format!("{},{:02} €", euro, cent)
}
}
fn main() {
let preis = EurCent(1990);
let mwst = EurCent(380);
let brutto = preis.plus(mwst);
assert_eq!(brutto.als_euro_string(), "23,70 €");
}Geld als i64-Cent — keine Float-Rundungsfehler. Newtype-Pattern macht's typsicher.
Sekunden vs. Millisekunden
#[derive(Clone, Copy, Debug)]
pub struct Sekunden(pub u64);
#[derive(Clone, Copy, Debug)]
pub struct Millisekunden(pub u64);
impl From<Sekunden> for Millisekunden {
fn from(s: Sekunden) -> Self {
Millisekunden(s.0 * 1000)
}
}
fn warte(dauer: Millisekunden) {
std::thread::sleep(std::time::Duration::from_millis(dauer.0));
}
fn main() {
warte(Millisekunden(500));
warte(Sekunden(2).into()); // Implizite Konvertierung über From
}From-Impl macht Konvertierung explizit aber bequem. Verwechslung von Sekunden mit Millisekunden ist Compile-Fehler.
Punkt im 2D-Raum
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Punkt2D(pub f64, pub f64);
impl Punkt2D {
pub fn neu(x: f64, y: f64) -> Self { Punkt2D(x, y) }
pub fn distanz_zu(self, other: Punkt2D) -> f64 {
let dx = self.0 - other.0;
let dy = self.1 - other.1;
(dx * dx + dy * dy).sqrt()
}
}
fn main() {
let a = Punkt2D::neu(0.0, 0.0);
let b = Punkt2D::neu(3.0, 4.0);
assert_eq!(a.distanz_zu(b), 5.0);
}Tuple-Struct für mathematische Punkte. (x, y) ist Konvention — Field-Struct mit { x, y } wäre eine Alternative.
Wrapper über Stdlib-Typ
use std::collections::HashSet;
// Newtype über HashSet, der eigene Trait-Implementierungen erlaubt:
pub struct EindeutigeIds(HashSet<u64>);
impl EindeutigeIds {
pub fn neu() -> Self { EindeutigeIds(HashSet::new()) }
pub fn hinzufuegen(&mut self, id: u64) -> bool {
self.0.insert(id)
}
pub fn anzahl(&self) -> usize { self.0.len() }
}
fn main() {
let mut set = EindeutigeIds::neu();
assert!(set.hinzufuegen(42));
assert!(!set.hinzufuegen(42)); // schon drin
assert_eq!(set.anzahl(), 1);
}Newtype über HashSet<u64> — verbirgt die interne Repräsentation und erlaubt eigene Methoden mit Domain-Semantik.
Besonderheiten
Tuple-Structs sind nominal, anonyme Tupel strukturell.
Punkt(1.0, 2.0) und (1.0, 2.0) haben identisches Memory-Layout, aber unterschiedliche Typen. Funktionen, die Punkt erwarten, lehnen (f64, f64) ab. Das ist die Stärke der Tuple-Structs — eigener Typ mit Typsicherheit.
Newtype mit einem Feld ist ein Standard-Rust-Pattern.
struct Meter(f64), struct UserId(u64), struct ApiToken(String) — überall in idiomatischem Rust. Macht aus einem primitiven Typ einen domain-spezifischen Typ mit Typsicherheit.
Bei 4+ Feldern ist ein Field-Struct meist besser.
PersonTuple(name, alter, aktiv, email, ...) wird schnell unleserlich — was war .3 nochmal? Field-Struct mit benannten Feldern ist klarer. Tuple-Structs leuchten bei 1-3 positionalen Feldern mit klarer Konvention (RGB, IPv4, x/y).
Field-Access via .0/.1, nicht via Variable.
let i = 0; let v = p.i; funktioniert NICHT — Indices müssen literale Konstanten sein. Wer dynamischen Index braucht: nicht Tuple-Struct, sondern Array oder Vec.
Tuple-Struct als Konstruktor-Funktion verwendbar.
Punkt ist auch eine Funktion: Punkt(1.0, 2.0) ruft die implizite Konstruktor-Funktion. Du kannst sie sogar als Closure übergeben: (0..5).map(|i| Punkt(i as f64, 0.0)).
derive funktioniert auf Tuple-Structs wie auf Field-Structs.
#[derive(Debug, Clone, PartialEq)] auf Punkt(f64, f64) macht die Felder über Debug/Clone/etc. zugänglich. Voraussetzung: alle Felder selbst implementieren die jeweiligen Traits.
Newtype über Trait-Crates: nutze sie für Custom-Impls.
Wer einen eigenen Trait-Impl für Vec<T> braucht, hat ein Problem: die Orphan-Rule verhindert das (sowohl Vec als auch der externe Trait sind nicht „deiner"). Lösung: Newtype-Wrapper struct MyVec(Vec<T>) — auf dem darfst du jeden Trait implementieren.
repr(transparent) für Zero-Cost-Newtype.
#[repr(transparent)] struct Meter(f64); garantiert, dass Meter im Speicher identisch zu f64 ist. Wichtig bei FFI oder wenn die Newtype-Hülle zur Laufzeit keine Kosten verursachen darf.
Weiterführende Ressourcen
Externe Quellen
- The Rust Book – Tuple Structs
- Rust Reference – Struct Types
- Rust by Example – Structures
- Rust API Guidelines – Newtype Pattern