Rust ist eine statisch typisierte Sprache mit überraschend wenig Typ-Annotationen im täglichen Code. Der Grund ist ein leistungsfähiger Type Inferenz-Algorithmus, der in den meisten Fällen den passenden Typ aus dem Kontext erschließt. Anders als bei Haskell oder ML, wo die Inferenz oft auch über Funktionsgrenzen hinweg läuft, ist sie in Rust lokal pro Funktion — was eine bewusste Designentscheidung ist: Funktions-Signaturen bleiben dadurch lesbare API-Verträge. Dieser Artikel erklärt, wie die Inferenz arbeitet, wo sie hilft, wo sie an Grenzen stößt und wie der Turbofish-Operator (::<T>) gezielt eingreift.
Was Type Inference in Rust tut
Inferenz heißt: der Compiler füllt Typ-Informationen aus, die du nicht explizit hingeschrieben hast.
fn main() {
let a = 42; // Compiler folgert: i32
let b = 3.14; // f64
let c = "Hallo"; // &'static str
let d = (1, 2.0, "drei"); // (i32, f64, &str)
let e = vec![1, 2, 3]; // Vec<i32>
let f = a + 1; // i32 (aus a's Typ)
}Der Algorithmus arbeitet bidirektional: Informationen fließen sowohl von Werten zu Bindungen als auch von Verwendungen zurück zu Werten.
fn main() {
let leerer_vec = Vec::new(); // Typ noch unklar
let mut v: Vec<u8> = leerer_vec; // Annotation klärt es
v.push(255);
// — v ist Vec<u8>, leerer_vec war also auch Vec<u8>
}Der Compiler sieht in Zeile 2 die Annotation Vec<u8> und folgert rückwirkend: Vec::new() in Zeile 1 muss Vec<u8> gewesen sein.
Wo Inferenz greift, wo nicht
Faustregel: Inferenz funktioniert innerhalb einer Funktion, Funktions-Signaturen bleiben explizit.
| Stelle | Inferenz? |
|---|---|
let-Bindungen | ja |
| Closure-Parameter und -Rückgabe | ja |
| Generische Argumente bei Aufrufen | ja |
Element-Typ in Container (Vec, HashMap, …) | ja |
| Funktions-Parameter | nein, explizit |
| Funktions-Rückgabetyp | nein, explizit |
const und static | nein, explizit |
| Struct- und Enum-Felder | nein, explizit |
Das ist eine bewusste Entscheidung. Eine Funktions-Signatur ist Teil deines öffentlichen API; sie soll lesbar und stabil sein, unabhängig vom Funktions-Body. Hätte Rust globale Inferenz wie Haskell, würde eine harmlose Body-Änderung die abgeleitete Signatur ändern und Aufrufer überall brechen.
Default-Typen: {integer} und {float}
Wenn der Compiler nach allen Constraints noch immer einen mehrdeutigen numerischen Typ hat, fällt er auf Defaults zurück:
{integer}→i32{float}→f64
fn main() {
let x = 42; // i32, weil keine andere Info da ist
let y = 0.1; // f64
let z: u8 = 200; // u8, weil Annotation überstimmt Default
let q = 5u64; // u64 durch Suffix
}Wenn schon irgendwo im Code ein konkreter Typ erzwungen wird, übernimmt die Inferenz den. Erst wenn nichts den Typ einschränkt, schlagen die Defaults zu.
Suffixe auf Literalen
Du kannst den Typ direkt an Literalen festschreiben:
let a = 42_u8;
let b = 1_000_000_i64;
let c = 3.14_f32;Praktisch in Tests und bei Bit-Operationen, wo man auf bestimmte Bitbreite angewiesen ist.
Der Turbofish — gezielter Eingriff
Wenn eine generische Funktion mehrdeutig ist und keine Annotation auf der linken Seite hilft, kommt der Turbofish zum Einsatz: ::<T>.
fn main() {
// parse() ist generisch über den Ziel-Typ
let n = "42".parse::<i32>().unwrap();
let f = "3.14".parse::<f64>().unwrap();
// collect() ist generisch über den Container
let zahlen = (1..=5).collect::<Vec<i32>>();
let als_set = (1..=5).collect::<std::collections::HashSet<i32>>();
// Vec::new() ohne Element-Hinweis braucht Hilfe
let mut v = Vec::<u8>::new();
v.push(0);
}Der Name „Turbofish" stammt vom Fisch-Symbol — ::<> sieht aus wie ein stilisierter Fisch von der Seite.
Alternative: Annotation auf der Bindung
Statt Turbofish kannst du auch links annotieren:
// Mit Turbofish
let n = "42".parse::<i32>().unwrap();
// Mit Bindungs-Annotation
let n: i32 = "42".parse().unwrap();Beide sind valide. Wann was?
- Turbofish ist näher an der Aufruf-Stelle und kürzer in Method-Chains. Liest sich oft natürlicher.
- Annotation ist sinnvoll, wenn die Bindung Teil eines Strukts oder Funktions-Rückgabewerts ist, die ohnehin schon einen Typ deklarieren.
Turbofish in Method-Chains
Besonders nützlich bei längeren Chains, in denen ein einzelner Adapter den Typ präzisieren muss:
let summe: u32 = (1..=10).sum();
// oder
let summe = (1..=10).sum::<u32>();Iterator::sum ist generisch über den akkumulierten Typ — irgendwo muss der Compiler eine Festlegung sehen.
Inferenz bei Closures
Bei Closures ist die Inferenz besonders aktiv — sowohl die Parameter- als auch die Rückgabe-Typen werden meist erkannt:
fn main() {
// Aus dem Verwendungskontext (Iterator über Vec<i32>) klar:
let doppelt = (1..=5).map(|x| x * 2).collect::<Vec<i32>>();
println!("{:?}", doppelt);
}Der Compiler weiß: (1..=5) ist ein Iterator<Item=i32>, map nimmt eine Closure Fn(i32) -> ?, die Rückgabe der Closure ist i32 * 2 = i32. Komplette Inferenz, ohne Annotation.
Manchmal hilft eine Annotation am Closure-Parameter:
let komparator = |a: &i32, b: &i32| a.cmp(b);Sinnvoll, wenn die Closure später woandershin gereicht wird und dort der Typ aus dem Kontext noch nicht greifbar ist (z. B. wenn du sie in einer eigenen Variable speicherst, bevor sie aufgerufen wird).
Fehler erkennen und beheben
Der häufigste Inferenz-Fehler:
fn main() {
let v = Vec::new();
// — Fehler:
// error[E0282]: type annotations needed
// --> src/main.rs:2:9
// |
// 2 | let v = Vec::new();
// | ^ consider giving `v` an explicit type
}Drei mögliche Fixes:
- Annotation auf der Bindung:
let v: Vec<i32> = Vec::new();. - Turbofish auf der Funktion:
let v = Vec::<i32>::new();. - Spätere Verwendung verrät den Typ:
fn main() {
let mut v = Vec::new();
v.push(42_i32);
println!("{:?}", v);
}Der push(42_i32) legt den Element-Typ fest; rückwirkend wird Vec::new() als Vec<i32> aufgelöst. Inferenz schaut bis zu mehrere Statements voraus, solange sie im gleichen Scope sind.
Mehrdeutigkeit zwischen mehreren Typen
fn main() {
let n = "1".parse().unwrap();
// Fehler: type annotations needed for `Result<_, _>`
println!("{n}");
}parse ist generisch über alles, was FromStr implementiert. "1" kann zu u8, i32, f64, … geparst werden. Lösung: einer der zwei üblichen Wege:
let n: i32 = "1".parse().unwrap();
let n = "1".parse::<i32>().unwrap();Inferenz und Generics in Funktions-Aufrufen
Bei einem Aufruf einer generischen Funktion versucht der Compiler, die Type-Parameter aus den Argumenten zu schließen.
fn paar<T>(a: T, b: T) -> (T, T) {
(a, b)
}
fn main() {
let p = paar(1, 2); // T = i32 (aus Argumenten)
let q = paar("a", "b"); // T = &str
// let r = paar(1, "a"); // Fehler: T kann nicht sowohl i32 als auch &str sein
let r = paar::<i32>(1, 2); // explizit
}Wenn die Argumente eindeutig den Type-Parameter festlegen, brauchst du keinen Turbofish. Erst wenn die Inferenz mehrdeutig ist (z. B. weil ein Wert irgendwo verloren geht oder es mehrere Möglichkeiten gibt), wirst du explizit.
Inferenz für höher-rangige Type-Parameter
Bei generischen Funktionen mit Rückgabe-Type-Parametern, die nicht in den Argumenten erscheinen, muss der Type-Parameter immer aus dem Verwendungs-Kontext kommen:
fn standardwert<T: Default>() -> T {
T::default()
}
fn main() {
let n: i32 = standardwert(); // T = i32
let s = standardwert::<String>(); // T = String
// let x = standardwert(); // Fehler: type annotations needed
}Was Inferenz nicht sieht
Drei häufige Stolperfallen:
Across function boundaries
Inferenz endet an Funktions-Grenzen.
fn nimm_vec(v: Vec<i32>) {}
fn main() {
// Compiler erkennt aus nimm_vec(...) NICHT, dass v: Vec<i32> sein muss,
// wenn er die Annotation entfernt — die Signatur ist explizit,
// also gilt sie als Constraint.
let v = Vec::new();
nimm_vec(v); // ok — Inferenz nutzt die Signatur
}Das funktioniert hier durchaus — nimm_vec(v) constraint v auf Vec<i32>, und Inferenz greift bidirektional. Die Grenze betrifft Signatur-Inferenz, nicht Argument-Inferenz.
Default-Trait-Implementations
Bei Aufrufen von Trait-Methoden, die ihre Receiver-Typen polymorph haben, hilft der Compiler manchmal nicht weiter:
use std::str::FromStr;
let _ = i32::from_str("42").unwrap(); // ok, explizit i32
let _ = <i32 as FromStr>::from_str("42").unwrap(); // ausführliche FormDie ausführliche Form (<T as Trait>::methode(...)) hilft, wenn mehrere Traits den gleichen Methodennamen haben oder die Inferenz nicht weiter kommt.
Interessantes
Rust-Inferenz ist nicht Hindley-Milner pur.
Klassisches Hindley-Milner (wie in Haskell) inferiert auch Funktions-Signaturen. Rust beschränkt sich bewusst auf lokale Inferenz pro Funktion, weil Signaturen Teil der API-Stabilität sind. Es ist eher eine bidirektionale lokale Typ-Constraint-Lösung, dem Hindley-Milner-Vorbild ähnlich aber bewusst eingeschränkt.
_ ist auch in Typ-Positionen erlaubt.
let v: Vec<_> = ...; sagt: „der Container ist ein Vec, den Element-Typ folgert der Compiler selbst". Praktisch, wenn der Container-Typ wichtig ist (z. B. zur Annotation in Funktions-Rückgaben), der Element-Typ aber klar ist.
Der Turbofish heißt so, weil ::<> aussieht wie ein Fisch.
Schräg gelesen: die zwei Doppelpunkte sind Augen, die spitzen Klammern bilden die Schwanzflosse. Diese Mnemonic hilft beim Erinnern — und macht aus einer leicht ungewöhnlichen Syntax eine memorierbare Konvention.
Inferenz für i32 und f64 als Defaults ist eine bewusste Entscheidung.
i32 ist auf den meisten Plattformen so groß wie ein Maschinenwort und schnell. f64 ist die übliche Präzision für „normales" Rechnen. Die Defaults bilden ab, was die meisten Programmierer in der Mehrheit der Fälle wollen. Wer kleinere oder größere Typen braucht, annotiert explizit.
rust-analyzer kann inferred Typen einblenden.
In VS Code und anderen Editoren mit rust-analyzer kannst du Inlay-Hints aktivieren — der inferred Typ erscheint dann als ausgegrauter Hint hinter der Bindung. Sehr lehrreich am Anfang: man sieht, was die Inferenz für dich entschieden hat.
Type Inference und Generics sind eng verzahnt.
Sobald du generische Funktionen schreibst oder benutzt, ist Inferenz das, was den Code lesbar hält. Ohne sie müsstest du vec.iter().map::<i32, _>(|x| x.clone()).filter::<_, _>(|x| x > &0).collect::<Vec<i32>>() schreiben — mit Inferenz reicht vec.iter().map(|x| x.clone()).filter(|x| x > &0).collect::<Vec<_>>() und in den meisten Fällen sogar weniger.
Inferenz greift nicht vorausschauend über mehrere Anweisungen hinaus — sie schaut zurück.
Der Algorithmus erlaubt zwar, dass ein späteres push(42_i32) die Element-Inferenz eines früheren Vec::new() festlegt. Aber spätere move-Operationen oder Aufrufe können den Type nicht ändern, sondern nur ihn (falls offen) bestimmen. Bei Mehrdeutigkeit über mehrere Statements gewinnt nicht das letzte Statement, sondern derjenige Constraint, der den Typ am genauesten festlegt.
Funktions-Rückgaben mit impl Trait haben „opaque“ Typen.
Wenn eine Funktion -> impl Iterator<Item=i32> zurückgibt, kennt der Aufrufer den konkreten Typ nicht (z. B. Map<Filter<Range<i32>, …>>). Inferenz behandelt ihn als opaken Typ — das funktioniert, lässt sich aber nicht in eine konkrete Typ-Annotation überführen. Mehr im Traits- und Iteratoren-Kapitel.
Weiterführende Ressourcen
Externe Quellen
- Rust Reference – Type Inference
- The Rust Book – Data Types
- The Rustonomicon – Type Inference
- Inlay Hints in rust-analyzer
- rustc Error E0282