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:

Rust let-Form
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 auch let (a, b) = ..., let Punkt { x, y } = ... oder let _ = ... 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 der let-Form ein Statement.

Ein konkretes Beispiel:

Rust Einfache Bindung
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 5 vom Typ i32.
  • 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.

Rust Bindung vs. Mutation
fn main() {
    let x = 5;
    // x = 6;  // Fehler: cannot assign twice to immutable variable `x`
}

Der Compiler-Output dazu:

Rust Fehlermeldung
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.

Rust Inferenz reicht
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:

Rust Inferenz braucht Hilfe
let werte: Vec<i32> = Vec::new();          // ohne Annotation: type annotations needed
let zahl: u8 = "200".parse().unwrap();      // parse() ist generisch über den Target-Typ

Vec::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.

Rust Turbofish
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.

Rust Ohne Initialisierung — Fehler
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.

Rust Deferred init
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:

Rust Idiomatisch — Block als Expression
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

Rust Tupel
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

Rust Structs
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):

Rust Wegwerf-Pattern
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

Rust 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

Rust 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)

Rust let else
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:

Rust Funktioniert nicht
// let x = let y = 5;        // Fehler: let-Form ist kein Wert
// foo(let y = 5);            // Fehler

Was du stattdessen verwendest: einen Block als Expression, in dem let legal vorkommt:

Rust Funktioniert
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

/ Weiter

Zurück zu Variablen & Bindungen

Zur Übersicht