Der ?-Operator ist eines der am häufigsten verwendeten Sprach-Features in Rust. Was vor seiner Einführung mehrere Zeilen mit match-Blöcken brauchte, ist heute ein einziges Zeichen am Ende eines Ausdrucks. Der Operator macht aus einem Result<T, E> ein T (im Erfolgsfall) oder propagiert den Err als Funktions-Rückgabe (im Fehlerfall). Dazu kommt automatische From-Konvertierung des Error-Typs — du kannst verschiedene Stdlib-Fehler in deinen eigenen Error-Typ überführen, ohne dafür explizit Code schreiben zu müssen. Dieser Artikel zeigt die Mechanik im Detail, die Voraussetzungen, die Sonderformen für Option und ControlFlow, sowie die typischen Stolperfallen.
Was der ?-Operator macht
Die Mechanik ist konzeptuell einfach. Auf einem Result<T, E>:
use std::fs;
// Mit ?
fn lese_und_zaehle(pfad: &str) -> Result<usize, std::io::Error> {
let inhalt = fs::read_to_string(pfad)?;
Ok(inhalt.lines().count())
}
// Ohne ? — explizit
fn lese_und_zaehle_explizit(pfad: &str) -> Result<usize, std::io::Error> {
let inhalt = match fs::read_to_string(pfad) {
Ok(s) => s,
Err(e) => return Err(e),
};
Ok(inhalt.lines().count())
}Beide Funktionen tun semantisch das gleiche. Die ?-Version ist eine Zeile, die explizite ist vier. Der Compiler übersetzt den ? intern in genau diesen match-Block.
Konkret macht expr? bei einem Result: wenn expr Ok(wert) ist, wird wert extrahiert und als Wert des ?-Ausdrucks verwendet — der Code läuft normal weiter. Wenn expr Err(e) ist, wird return Err(From::from(e)) ausgeführt — die Funktion bricht ab, der Aufrufer bekommt den Fehler.
Bei mehreren ?-Operationen in einer Funktion summiert sich der Effekt: aus zwanzig Zeilen Match-Boilerplate werden fünf Zeilen Logik. Das ist nicht nur kürzer, sondern auch lesbarer — der Code-Fluss konzentriert sich auf den Happy-Path, und die Error-Behandlung verschwindet hinter dem einen Zeichen.
Voraussetzungen am Rückgabe-Typ
Der ?-Operator funktioniert nicht überall — die umgebende Funktion muss einen passenden Rückgabe-Typ haben.
// OK: Funktion gibt Result zurück, ? auf Result
fn ok1() -> Result<i32, &'static str> {
let r: Result<i32, &'static str> = Ok(42);
let n = r?;
Ok(n)
}
// Fehler: Funktion gibt nichts zurück
// fn nicht_ok() {
// let r: Result<i32, &'static str> = Ok(42);
// let n = r?; // Compile-Fehler
// }Der Compiler verlangt: wenn ? auf einem Result<T, E> angewendet wird, muss die umgebende Funktion entweder Result<U, F> (mit kompatiblem F) oder einen anderen Try-Typ zurückgeben. Sonst gibt es einen Compile-Fehler.
Diese Beschränkung ist semantisch nötig: ? macht ein Early-Return mit dem Error. Wenn die Funktion keinen Result-Typ hat, kann sie den Error nicht zurückgeben. Bei einer Funktion mit () (kein Rückgabe-Wert) gäbe es keinen Weg, den Fehler zu kommunizieren.
In fn main() kannst du ? ebenfalls nicht direkt verwenden — außer du gibst der main-Funktion einen Result-Rückgabe-Typ:
use std::fs;
fn main() -> Result<(), std::io::Error> {
let inhalt = fs::read_to_string("/etc/hostname")?;
println!("Hostname: {}", inhalt.trim());
Ok(())
}fn main() -> Result<(), Box<dyn std::error::Error>> ist eine sehr typische Form für CLI-Programme: ? funktioniert überall, Fehler werden mit Debug-Output ausgegeben, der Exit-Code ist 1 bei Fehler und 0 bei Erfolg. Mehr im anyhow-Artikel und im Strategie-Artikel.
Automatische From-Konvertierung
Die wichtigste Eigenschaft des ?-Operators jenseits der reinen Propagation: er führt automatisch eine From-Konvertierung des Error-Typs durch.
use std::fs;
use std::num::ParseIntError;
#[derive(Debug)]
enum AppError {
Io(std::io::Error),
Parse(ParseIntError),
}
impl From<std::io::Error> for AppError {
fn from(e: std::io::Error) -> Self { AppError::Io(e) }
}
impl From<ParseIntError> for AppError {
fn from(e: ParseIntError) -> Self { AppError::Parse(e) }
}
fn lese_zahl(pfad: &str) -> Result<i32, AppError> {
let inhalt = fs::read_to_string(pfad)?; // io::Error → AppError::Io
let zahl: i32 = inhalt.trim().parse()?; // ParseIntError → AppError::Parse
Ok(zahl)
}In dieser Funktion gibt es zwei verschiedene Quell-Fehler-Typen: std::io::Error von fs::read_to_string, ParseIntError von parse(). Die Funktion gibt aber Result<i32, AppError> zurück. Wie funktioniert das?
Der ?-Operator schaut nach einer From<QuellError> for ZielError-Implementation. Bei fs::read_to_string(pfad)? findet er From<std::io::Error> for AppError und ruft From::from(io_error) auf, was eine AppError::Io(io_error) produziert. Diese wird dann als Funktions-Return propagiert.
Damit kannst du verschiedene Stdlib-Fehler automatisch in deinen eigenen Error-Typ konvertieren, sobald du die From-Impls einmal definiert hast. Das macht Multi-Quell-Error-Handling extrem kompakt.
Was passiert ohne From-Impl?
use std::fs;
// Ohne passende From-Impl: Compile-Fehler
// fn lese_einfach() -> Result<usize, String> {
// let inhalt = fs::read_to_string("foo.txt")?;
// // Fehler: `?` kann io::Error nicht zu String konvertieren
// Ok(inhalt.len())
// }
// Mit map_err: explizite Konvertierung
fn lese_mit_map() -> Result<usize, String> {
let inhalt = fs::read_to_string("foo.txt")
.map_err(|e| format!("Lese-Fehler: {e}"))?;
Ok(inhalt.len())
}Wenn keine From-Implementation existiert, lehnt der Compiler den ?-Operator ab — mit einer Fehlermeldung, die explizit auf den fehlenden From-Trait hinweist. Du kannst dann entweder eine From-Impl schreiben (für wiederkehrende Konvertierungen) oder mit map_err eine inline-Konvertierung machen (für Einmal-Fälle).
?-Operator auf Option
Der ?-Operator funktioniert auch auf Option<T> — mit analoger Semantik:
fn ersten_buchstaben(s: &str) -> Option<char> {
let erstes_wort = s.split_whitespace().next()?;
erstes_wort.chars().next()
}
fn main() {
assert_eq!(ersten_buchstaben("Hallo Welt"), Some('H'));
assert_eq!(ersten_buchstaben(""), None);
}Bei Option<T> macht ?: bei Some(x) wird x extrahiert; bei None wird return None ausgeführt. Voraussetzung: die Funktion gibt Option<U> zurück.
Damit kannst du Pipelines aus optional-fallible Operationen bauen, ohne in jedem Schritt Pattern-Match zu schreiben. Sehr typisch in Parsern, Lookups, Path-Traversal.
Mischen Result und Option im selben ?
fn parse_aus_map(map: &std::collections::HashMap<String, String>, key: &str) -> Result<i32, String> {
let s = map.get(key).ok_or_else(|| format!("Key '{key}' fehlt"))?;
let n: i32 = s.parse().map_err(|e| format!("kein i32: {e}"))?;
Ok(n)
}? funktioniert in einer Funktion immer mit einem Typ — entweder Result oder Option. Wenn du beide mischen willst (etwa Option-Lookup in einer Result-zurückgebenden Funktion), brauchst du eine Konvertierung. Option::ok_or(err) macht aus Option<T> ein Result<T, E>, danach funktioniert ? ganz normal.
Im Beispiel: map.get(key) liefert Option<&String>, .ok_or_else(...) macht es zu Result<&String, String>, ? propagiert das Err. Klassisches Pattern, wenn HashMap-Lookups in error-zurückgebenden Funktionen vorkommen.
Was passiert hinter den Kulissen
Der ?-Operator ist über den Try-Trait implementiert — eine Stdlib-Schnittstelle, die definiert, wie ein Typ Early-Return-Semantik unterstützt.
// Vereinfachte Darstellung:
// expr? expandiert grob zu:
//
// match Try::branch(expr) {
// ControlFlow::Continue(wert) => wert,
// ControlFlow::Break(residual) => return FromResidual::from_residual(residual),
// }Die Details sind technisch und ändern sich gelegentlich, aber das mentale Modell ist: der ? ist syntaktischer Zucker für ein Match plus Early-Return. Der Try-Trait abstrahiert über verschiedene Typen — Result, Option, ControlFlow, und potenziell eigene Typen.
In stabilem Rust ist der Try-Trait nicht für eigene Typen verfügbar — du kannst aktuell keinen eigenen Typ definieren, auf dem ? funktioniert. Das ist als Nightly-Feature in Entwicklung und wird in einer zukünftigen Version stable werden. Bis dahin kannst du ? auf den drei genannten Stdlib-Typen verwenden.
Klassische Stolperfallen
Vergessenes ?
use std::fs;
// Subtiler Bug: kein ? bedeutet, dass das Result NICHT propagiert wird
fn lese_vergessen(pfad: &str) -> String {
let r = fs::read_to_string(pfad);
// r ist ein Result, kein String — kompiliert nicht
// r // Compile-Fehler: mismatched types
r.unwrap_or_default() // Workaround mit Default
}Ein häufiger Anfänger-Fehler: ? vergessen und dann verwirrt sein, warum der Code nicht kompiliert. Der Compiler hilft hier mit klarer Diagnose — er zeigt, dass ein Result da ist, wo ein String erwartet wird, und schlägt ? oder unwrap_or_default() vor.
Falscher Rückgabe-Typ
// Funktion mit ungeeignetem Rückgabe-Typ
fn falsch() -> i32 {
let r: Result<i32, &str> = Ok(42);
// let n = r?; // Compile-Fehler: ? in einer Funktion ohne Result
r.unwrap_or(0)
}Wenn die Funktion keinen Result-Typ zurückgibt, schlägt ? fehl. Die Lösung: entweder den Rückgabe-Typ auf Result ändern oder eine andere Form der Fehler-Behandlung wählen (unwrap_or, expliziter Match).
Fehlende From-Impl
use std::num::ParseIntError;
#[derive(Debug)]
struct AppError;
// Ohne From<ParseIntError> for AppError:
// fn parse() -> Result<i32, AppError> {
// let n: i32 = "abc".parse()?; // Compile-Fehler
// Ok(n)
// }
// Mit map_err — funktioniert
fn parse_mit_map() -> Result<i32, AppError> {
let n: i32 = "abc".parse().map_err(|_: ParseIntError| AppError)?;
Ok(n)
}Wenn die Fehler-Typen nicht zusammenpassen und keine From-Impl existiert, gibt es einen Compile-Fehler. Die Lösung: entweder eine From-Implementation schreiben (gut für wiederkehrende Konvertierungen, siehe nächster Artikel) oder map_err für inline-Konvertierung verwenden.
Praxis: ?-Operator im echten Code
Datei-Verarbeitung
use std::fs;
use std::io;
pub fn kopiere_und_zaehle(quelle: &str, ziel: &str) -> io::Result<usize> {
let inhalt = fs::read_to_string(quelle)?;
let zeilen = inhalt.lines().count();
fs::write(ziel, &inhalt)?;
Ok(zeilen)
}Drei I/O-Operationen, jeweils potenziell fallible. Mit ? läuft jede Operation nacheinander durch; bei einem Fehler wird die Funktion sofort verlassen, der Aufrufer bekommt den Fehler.
Parser mit ?-Kette
fn parse_header(zeile: &str) -> Result<(String, String), String> {
let pos = zeile.find(':').ok_or("kein Doppelpunkt")?;
let name = zeile[..pos].trim().to_string();
if name.is_empty() {
return Err("leerer Name".into());
}
let wert = zeile[pos + 1..].trim().to_string();
Ok((name, wert))
}find(':').ok_or("...") macht aus Option<usize> ein Result<usize, &str>, der ? propagiert ggf. den Fehler. Die explizite Domain-Validierung (name.is_empty()) nutzt direkten return Err.
Result-zu-Option-Konvertierung in Pipelines
fn main() {
let raw = ["1", "abc", "3", "x", "5"];
// Mit ok() — Result → Option, dann filter_map
let valid: Vec<i32> = raw.iter()
.filter_map(|s| s.parse::<i32>().ok())
.collect();
assert_eq!(valid, vec![1, 3, 5]);
}In Iterator-Pipelines ist ? selten direkt einsetzbar (Iteratoren haben keinen Result-Rückgabe-Typ). Stattdessen .ok() für die Konvertierung zu Option, dann filter_map für die Sammlung der erfolgreichen Werte.
main mit Result-Rückgabe
use std::fs;
use std::env;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = env::args().collect();
let pfad = args.get(1).ok_or("Bitte Pfad angeben")?;
let inhalt = fs::read_to_string(pfad)?;
let zahl: i32 = inhalt.trim().parse()?;
println!("Wert: {zahl}");
Ok(())
}Eine typische CLI-main-Signatur. Box<dyn std::error::Error> ist ein „accepts everything"-Error-Typ — durch die Blanket-Impl von From<E: Error> werden alle Standard-Fehler-Typen automatisch konvertiert. Der ? funktioniert auf jedem fallible-Aufruf in der Funktion.
Multi-Source-Validierung
#[derive(Debug)]
struct UserInput { name: String, age: i32 }
#[derive(Debug)]
enum ValidationError {
NameLeer,
AlterUngueltig(String),
AlterAusserhalb(i32),
}
fn parse_und_validiere(raw_name: &str, raw_age: &str) -> Result<UserInput, ValidationError> {
let name = raw_name.trim();
if name.is_empty() {
return Err(ValidationError::NameLeer);
}
let age: i32 = raw_age.trim().parse()
.map_err(|e: std::num::ParseIntError| ValidationError::AlterUngueltig(e.to_string()))?;
if !(0..=120).contains(&age) {
return Err(ValidationError::AlterAusserhalb(age));
}
Ok(UserInput { name: name.to_string(), age })
}Mischung aus direktem return Err (Domain-Validierung), map_err (Stdlib-Fehler in Domain-Fehler umwandeln), und ? (Propagation). Die Funktion ist immer noch linear lesbar — kein Indent-Aufstieg.
Verschachtelte ?
use std::fs;
use std::path::Path;
fn lade_konfig_aus_verzeichnis(dir: &Path) -> std::io::Result<String> {
let pfad = dir.join("config.toml");
let inhalt = fs::read_to_string(&pfad)?;
// Verschachtelt mit weiteren ?:
let basis_dir = pfad.parent()
.ok_or_else(|| std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"Pfad hat kein Parent-Verzeichnis"
))?;
println!("Geladen aus: {}", basis_dir.display());
Ok(inhalt)
}Option::ok_or_else mit ? zur Konvertierung von Option zu Result, im selben Funktions-Body wie andere fallible Operationen. Alles in einer kohärenten Fehler-Behandlung.
Result-Rückgabe in einer Closure
fn main() -> Result<Vec<i32>, std::num::ParseIntError> {
let inputs = ["1", "2", "3"];
// Closure mit Result-Rückgabe — ? funktioniert intern
let result: Result<Vec<i32>, _> = inputs.iter()
.map(|s| {
let n: i32 = s.parse()?;
Ok(n * 2)
})
.collect();
result
}? funktioniert auch in Closures, sofern die Closure einen Result-Rückgabe-Typ hat. Bei der Iterator-Verarbeitung kann das nützlich sein — die Closure verarbeitet ein Element, ggf. mit Sub-Fehlern, der collect::<Result<Vec, E>>() sammelt das atomar.
Besonderheiten
? ist syntaktischer Zucker für Match plus Early-Return.
Bei Ok(x) wird x extrahiert. Bei Err(e) wird return Err(From::from(e)) ausgeführt. Das ist die ganze Magie. Aber es spart in der Praxis dramatisch viel Boilerplate.
Funktion muss Result, Option oder ControlFlow zurückgeben.
Der ?-Operator funktioniert nur in Funktionen mit einem Try-Typ als Rückgabe. In einer fn foo() -> i32 oder einer Funktion ohne Rückgabe-Typ schlägt ? fehl.
? ruft automatisch From::from auf dem Fehler.
Das ist das mächtigste Feature des Operators. Wenn der Quell-Fehler nicht direkt dem Ziel-Fehler entspricht, sucht der Compiler nach einer From-Impl und nutzt sie. Damit werden Multi-Quell-Fehler-Funktionen trivial.
main kann Result zurückgeben.
fn main() -> Result<(), Box<dyn Error>> ist die idiomatische Form für CLI-Programme. Der ?-Operator funktioniert überall, und Fehler werden mit Debug-Output und Exit-Code 1 behandelt.
Option::ok_or für Option-zu-Result-Konvertierung.
Wenn du Option-basierte Operationen in einer Result-zurückgebenden Funktion verwenden willst, brauchst du ok_or oder ok_or_else. Damit wird aus None ein Err mit deinem gewählten Fehler-Wert, und ? funktioniert wie üblich.
? auf Option macht Early-Return mit None.
Analog zu Result, aber für Option. Funktion muss Option zurückgeben. Praktisch in Funktionen, die selbst optionale Werte berechnen und auf Option-basierte Operationen zugreifen.
map_err als Alternative ohne From-Impl.
Wenn keine From-Impl existiert (oder du sie nicht schreiben willst), nutze expr.map_err(|e| ZielError::from(e))?. Inline-Konvertierung. Bei wiederkehrenden Konvertierungen lohnt sich eine echte From-Impl.
? funktioniert in Closures mit Result-Rückgabe.
Closures, die Result zurückgeben, unterstützen ?. Klassisch in Iterator::map, wenn die Map-Operation fehlbar ist, und du anschließend mit collect::<Result<Vec, E>>() sammeln willst.
Weiterführende Ressourcen
Externe Quellen
- The Rust Book – The ? Operator
- std::ops::Try – Trait-Doc
- Rust Reference – The ? operator
- RFC 0243 – Trait-based exception handling