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);Die Syntax sieht aus wie eine Mischung aus Struct und Funktion-Signatur. Das struct-Schlüsselwort und der Name in UpperCamelCase folgen den üblichen Konventionen, aber die Felder stehen in runden Klammern, nicht in geschweiften, und sie haben nur Typen — keine Namen. Das macht die Deklaration extrem kompakt: struct Tag(u32); ist eine vollwertige Typ-Definition in einer Zeile.
Trotz der visuellen Ähnlichkeit zu einem Tupel ist jeder Tuple-Struct ein eigener, nominaler Typ. Punkt(1.0, 2.0) ist nicht dasselbe wie das anonyme Tupel (1.0, 2.0) — auch wenn sie im Speicher identisch aussehen. Wer eine Funktion mit Parameter-Typ Punkt aufruft, kann ihr kein (f64, f64)-Tupel übergeben, und umgekehrt. Diese strikte Trennung ist das, was Tuple-Structs nützlich macht: sie sind die kleinste Form, die ein eigenes „Typ-Tag" trägt.
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;
}Der Zugriff auf die Felder erfolgt mit der Index-Notation: p.0 für das erste Feld, p.1 für das zweite, und so weiter. Wichtige Einschränkung: die Indices müssen literale Konstanten sein, keine Variablen. let i = 0; p.i funktioniert nicht — der Compiler braucht die Index-Position zur Compile-Zeit, um den Typ zu bestimmen.
Wer wirklich dynamische Indizierung braucht, sollte nicht zu einem Tuple-Struct greifen, sondern zu einem Array ([T; N]) oder Vec (Vec<T>). Tuple-Structs sind für strukturierte Daten mit fixer Bedeutung pro Position gedacht (RGB, Koordinaten, IPv4-Oktett), nicht für homogene Sequenzen.
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) ≠ PunktAnonyme Tupel und Tuple-Structs haben identisches Memory-Layout — wenn du dir die Bytes anschaust, kannst du sie nicht unterscheiden. Auf semantischer Ebene sind sie aber strikt getrennt: Tuple-Structs sind nominal (zwei verschieden benannte Typen sind unterschiedlich, auch bei gleicher Struktur), anonyme Tupel sind strukturell (zwei (f64, f64)-Werte sind gleich, egal woher sie kommen).
Genau diese Trennung ist der Vorteil. Du kannst Typsicherheit ohne Field-Namen erzeugen: zwei Tuple-Structs struct Meter(f64) und struct Sekunden(f64) sind völlig unterschiedliche Typen, obwohl beide einen f64 halten — der Compiler verhindert Verwechslungen, ohne dass du Wrapper-Methoden schreiben musst.
| 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 + i32Auf den ersten Blick scheint ein Newtype nichts hinzuzufügen — es ist nur ein dünner Wrapper um einen einzelnen Wert. In Wirklichkeit ist er das Werkzeug Nr. 1 für Typsicherheit in der Domäne: aus einem nackten u64-Feld wird eine UserId, und das System verhindert, dass du sie versehentlich mit einer OrderId verwechselst oder mit einer i32-Konstante addierst.
Die im Beispiel gezeigten „Fehler" sind alle Compile-Errors, keine Runtime-Probleme. Der Newtype-Wrapper macht aus einer ganzen Bug-Klasse („falscher Zahlentyp verwendet") eine Compile-Zeit-Garantie. Das Newtype-Pattern ist eines der wichtigsten in idiomatischem Rust — ein eigenes Kapitel dazu folgt weiter unten.
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());
}Eine RGB-Farbe ist ein typisches Beispiel, bei dem Tuple-Struct ideal passt. Die Reihenfolge (R, G, B) ist eine etablierte Konvention — niemand muss raten, was .0 bedeutet (es ist Rot). Trotzdem bekommt der Typ einen eigenen Namen, sodass eine Rgb(255, 0, 0) nicht mit einem zufälligen (u8, u8, u8) verwechselt werden kann.
Die Implementation zeigt auch zwei wichtige Patterns: Associated Constants (SCHWARZ, WEISS) für häufige Default-Werte direkt am Typ, und benannte Accessor-Methoden (r(), g(), b()) für die Stellen, wo die Index-Notation unleserlich wäre. Damit kann der Aufrufer wählen — schnell mit .0/.1/.2 oder explizit mit .r()/.g()/.b().
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, String> {
if roh.len() < 32 { return Err(String::from("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)
}Ein Tuple-Struct mit privatem Feld plus validierendem Konstruktor ist eines der mächtigsten Sicherheits-Patterns in Rust. Das innere Feld roh: String ist privat — niemand außerhalb des Moduls kann es direkt setzen. Der einzige Weg, einen ApiToken zu konstruieren, führt über neu, das die Länge prüft und bei Misserfolg einen Fehler liefert.
Damit hast du eine Typ-Garantie: jedes ApiToken-Objekt, das in deinem System existiert, ist garantiert mindestens 32 Zeichen lang. Andere Funktionen können sich darauf verlassen, ohne selbst nachzuprüfen. Das ist ein Beispiel für „Make Invalid States Unrepresentable" — eine fundamentale Designphilosophie in starker typisierten Sprachen.
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
}Stark-typisierte IDs sind die häufigste Newtype-Anwendung in Geschäftsanwendungen. In einer typischen E-Commerce-Anwendung gibt es User-IDs, Order-IDs, Product-IDs, alle als u64 repräsentiert. Ohne Newtype-Wrapper kann jede Funktion, die u64 erwartet, jede dieser IDs nehmen — und ein Bug, bei dem versehentlich eine User-ID an finde_order übergeben wird, kompiliert anstandslos.
Mit UserId(u64), OrderId(u64), ProductId(u64) sind das drei verschiedene Typen. Verwechslungen werden zur Compile-Zeit gefangen. Der #[derive(...)]-Block macht die ID gleichzeitig kopierbar (für einfache Weitergabe), hashbar (für HashMap-Keys), gleich-vergleichbar (für ==-Tests).
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