Das let-Keyword ist die Eintrittstür in fast jede Funktion in Rust. Auf den ersten Blick scheint es eine Variablen-Deklaration zu sein, wie var in JavaScript oder int x = 5; in C. Bei genauerem Hinsehen ist es mehr: let bindet einen Namen an einen Wert, prüft optional einen Typ, akzeptiert links ein Pattern statt nur eines Bezeichners und ist die syntaktische Wurzel für if let, while let und let else. Dieser Artikel zerlegt let in seine Bestandteile, zeigt jedes davon mit Beispiel und ordnet den Begriff „Bindung" gegenüber dem klassischen „Variable" ein.
Die Anatomie einer let-Anweisung
Das Grundgerüst:
let <pattern>: <type-annotation> = <expression>;Die einzelnen Teile:
let— das Keyword. Reserviert seit Rust 1.0.<pattern>— links steht ein Pattern, nicht einfach ein Identifier. Ein einzelner Bezeichner ist der Spezialfall (let x = ...). Es geht aber auchlet (a, b) = ...,let Punkt { x, y } = ...oderlet _ = ...zum Wegwerfen.: <type-annotation>— optional. Wenn weggelassen, leitet der Compiler den Typ aus der rechten Seite ab.= <expression>— der Wert, an den gebunden wird. In den meisten Fällen Pflicht, mit einer wichtigen Ausnahme (siehe weiter unten).;— Semikolon. Macht aus derlet-Form ein Statement.
Ein konkretes Beispiel:
fn main() {
let alter: u8 = 32;
let name = "Lina";
println!("{name} ist {alter} Jahre alt");
}alter hat eine explizite Type-Annotation. name wird per Type Inference als &'static str erkannt — der String steht im Programm-Code, lebt für die gesamte Laufzeit und ist eine Referenz darauf.
Warum „Bindung", nicht „Variable"?
In Sprachen wie Python oder Java ist eine Variable ein veränderbarer Slot, in dem ein Wert liegt. Du kannst neue Werte hineinschreiben, der Slot bleibt derselbe.
In Rust ist let x = 5; etwas anderes:
- Es gibt einen Wert
5vom Typi32. - Es gibt einen Namen
x, der auf diesen Wert zeigt. - Diese Verknüpfung — Name → Wert — ist die Bindung.
Ohne mut ist sie unveränderbar: weder kann x einem anderen Wert zugewiesen werden, noch kann der Wert hinter x mutiert werden. Das ist der grundlegend andere Standardfall im Vergleich zu fast allen Mainstream-Sprachen.
Der Begriff stammt aus der funktionalen Programmierung (Haskell, ML, OCaml). Rust übernimmt ihn bewusst: die Sprache will dich an Werte denken lassen, nicht an Slots.
fn main() {
let x = 5;
// x = 6; // Fehler: cannot assign twice to immutable variable `x`
}Der Compiler-Output dazu:
error[E0384]: cannot assign twice to immutable variable `x`
--> src/main.rs:3:5
|
2 | let x = 5;
| - first assignment to `x`
3 | x = 6;
| ^^^^^ cannot assign twice to immutable variable
|
help: consider making this binding mutable
|
2 | let mut x = 5;
| +++Beachte: der Compiler nennt es selbst „immutable variable". Auf Sprach-Ebene ist „Bindung" das präzisere Wort; in den Diagnose-Texten taucht „variable" der besseren Lesbarkeit halber auf. Beide Begriffe meinen dieselbe Sache.
Type-Annotation: optional, aber manchmal nötig
Type-Annotation ist in 90 % aller Fälle optional. Der Compiler errät den Typ aus dem Initialisierer.
let a = 42; // a: i32 (Default-Integer-Typ)
let b = 3.14; // b: f64 (Default-Float-Typ)
let c = "Hallo"; // c: &'static str
let d = vec![1, 2, 3]; // d: Vec<i32>
let e = (1, "x", 2.0); // e: (i32, &str, f64)Manchmal kommt die Inferenz aber nicht weiter — etwa wenn nichts auf der rechten Seite den konkreten Typ verrät. Dann brauchst du eine Annotation:
let werte: Vec<i32> = Vec::new(); // ohne Annotation: type annotations needed
let zahl: u8 = "200".parse().unwrap(); // parse() ist generisch über den Target-TypVec::new() allein gibt der Inferenz keine Information über den Element-Typ; "200".parse() ist generisch und braucht den Ziel-Typ. Alternative zur Annotation auf der Bindungs-Seite: der Turbofish auf der Expression-Seite.
let zahl = "200".parse::<u8>().unwrap();
let werte = Vec::<i32>::new();Welche Variante besser ist, ist Geschmackssache — der eigene Artikel zu Type Inference geht tiefer darauf ein.
Initialisierung ist Pflicht (mit einer Ausnahme)
Eine Bindung muss vor ihrer ersten Verwendung einen Wert bekommen. Das ist in Rust streng — strenger als in C oder Java.
fn main() {
let x: i32;
println!("{x}"); // Fehler: borrow of possibly-uninitialized variable
}Das erlaubt ist aber die deferred Initialisierung — du darfst eine Bindung anlegen und sie später (in allen möglichen Branches) initialisieren, solange sie vor der ersten Lese-Operation in jedem Pfad einen Wert hat.
fn klassifiziere(score: i32) -> &'static str {
let kategorie: &str;
if score >= 90 {
kategorie = "A";
} else if score >= 80 {
kategorie = "B";
} else {
kategorie = "C";
}
kategorie
}Der Borrow Checker prüft, dass jeder Pfad vor dem return einen Wert in kategorie schreibt. Vergessen wir einen Branch, schlägt es fehl — kein Default-Wert, keine implizite null.
Idiomatischer wäre allerdings, den ganzen if-Block als Expression zu binden:
fn klassifiziere(score: i32) -> &'static str {
let kategorie = if score >= 90 {
"A"
} else if score >= 80 {
"B"
} else {
"C"
};
kategorie
}Dazu mehr im Artikel zu Scoping und Blocks.
Pattern-Binding
Die linke Seite von let ist ein Pattern. Ein Pattern kann einen Wert in Teile zerlegen.
Tupel destrukturieren
fn main() {
let (x, y, z) = (1, 2.5, "drei");
println!("{x} {y} {z}");
let punkt = (4.0, 5.0);
let (px, py) = punkt;
}Structs destrukturieren
struct Punkt { x: f64, y: f64 }
fn main() {
let p = Punkt { x: 1.0, y: 2.0 };
// Felder direkt extrahieren
let Punkt { x, y } = p;
println!("{x} {y}");
// Mit Umbenennung
let Punkt { x: links, y: oben } = Punkt { x: 0.0, y: 0.0 };
// Mit Wegwerfen einzelner Felder
let Punkt { x, .. } = Punkt { x: 5.0, y: 7.0 }; // y ignorieren
}Pattern mit Wegwerfen
_ wirft den ganzen Wert weg — nützlich, wenn du eine Expression ausführen willst, deren Wert dich nicht interessiert (z. B. bei Result mit must_use):
let _ = std::fs::write("log.txt", "Datei-Inhalt");_ ist nicht das gleiche wie eine reguläre Bindung an einen Namen, der mit _ beginnt. let _ = x; droppt den Wert sofort. let _wert = x; bindet den Wert für die Lebenszeit des Scopes — nur die Warnung über ungenutzte Variablen verschwindet.
let in der Expression-Form
let taucht auch in drei kombinierten Formen auf, die hier nur kurz angerissen werden — sie bekommen eigene Artikel in späteren Kapiteln.
if let
let maybe = Some(42);
if let Some(x) = maybe {
println!("Wert: {x}");
}Bindet x nur, wenn maybe der Some-Variante entspricht. Eine kompakte Form von match für genau einen relevanten Fall.
while let
let mut stack = vec![1, 2, 3];
while let Some(top) = stack.pop() {
println!("{top}");
}Schleift, solange das Pattern matcht. Sehr idiomatisch für Iteration über Option-zurückgebende Methoden.
let else (seit Rust 1.65)
fn lese_konfig(input: &str) -> Result<u32, &'static str> {
let Ok(zahl) = input.parse::<u32>() else {
return Err("kein Integer");
};
Ok(zahl * 2)
}let else bindet bei Pattern-Match und nimmt im Fehler-Fall einen divergierenden Branch. Ideal für frühen Ausstieg ohne tiefe Schachtelung. Details im Kapitel Enums & Pattern Matching.
let an unerwarteten Stellen
Eine letzte Eigenheit: let ist eine Anweisung, kein Ausdruck. Das hat eine wichtige Konsequenz — du kannst eine let-Form nicht als Wert irgendwo verwenden:
// let x = let y = 5; // Fehler: let-Form ist kein Wert
// foo(let y = 5); // FehlerWas du stattdessen verwendest: einen Block als Expression, in dem let legal vorkommt:
let x = {
let y = 5;
y * 2
};Der Block wird zur Expression y * 2 ausgewertet (letzte Zeile ohne Semikolon) und an x gebunden.
Interessantes
Pattern in let sind nicht refutable.
Während match-Arme und if let mit Patterns arbeiten, die nicht alle Werte abdecken (refutable), muss ein Pattern in let infallibel sein — es muss garantiert matchen. Deshalb funktionieren let (a, b) = tupel; und let Punkt { x, y } = p;, aber let Some(x) = maybe; ist ein Fehler. Genau dafür gibt es let else und if let.
Die Konvention für ungenutzte Bindungen ist der Unterstrich.
Wenn du eine Bindung anlegen musst, sie aber nicht benutzt — etwa weil du nur das Drop-Verhalten brauchst (let _guard = mutex.lock();) oder ein Tupel destrukturierst und nur Teile interessieren — verwende _name oder _. Der Compiler unterdrückt dann die unused-Warnung.
let _ = ... droppt sofort.
Genau hingucken: let _ = lock.lock(); ruft lock() auf und droppt den Guard unmittelbar. Der Lock ist nach diesem Statement schon wieder frei. Wer einen Guard halten will, muss ihm einen echten Namen geben — und sei er nur _lock. Das Unterstrich-Pattern (_) und ein Unterstrich-Präfix (_lock) sind nicht dasselbe.
Die Bindung bleibt, der Wert kann zwischenzeitlich gemoved sein.
Eine let-Bindung lebt bis zum Ende ihres Scopes. Wird der gebundene Wert vorher in eine Funktion gemoved oder per Pattern-Match destrukturiert, ist die Bindung zwar noch lexikalisch da, ihr Inhalt aber „leer". Eine erneute Verwendung führt zu E0382 („use of moved value"). Die Bindung kannst du aber jederzeit durch Shadowing oder Re-Assignment (bei mut) neu mit Inhalt füllen.
Type-Annotation funktioniert auch im Pattern.
let (a, b): (i32, u8) = (1, 2); annotiert beide Teile des Tupels. Sehr selten nötig, aber gut zu wissen, wenn die Inferenz für komplexere Patterns mal nicht greift.
Mehrere let-Bindungen mit gleichem Namen in einem Scope sind erlaubt.
Das ist Shadowing — ein eigenes Konzept. Sieht auf den ersten Blick aus wie „zweimal das gleiche Wort", ist aber etwas anderes: bei jedem let x = ... entsteht eine neue Bindung, die die alte überlagert. Mehr dazu im Shadowing-Artikel.
Weiterführende Ressourcen
Externe Quellen
- The Rust Book – Variables and Mutability
- Rust Reference – Let Statements
- Rust Reference – Patterns
- Rust by Example – Variable Bindings
- rustc Error E0384