Interviewfragen zu Rust
Diese Fragen tauchen in Rust-Interviews immer wieder auf, von Junior- bis Senior-Stellen. Jede Antwort ist kurz genug, um sie laut auszusprechen, und verlinkt auf die Lektion, die das Thema ausführlich erklärt.
Lern die Antworten nicht Wort für Wort auswendig. Das merken Interviewer, und meistens stellen sie eine Nachfrage, um zu sehen, ob du es wirklich verstanden hast. Nutze diese Seite, um dein Wissen zu prüfen. Wo du dich noch unsicher fühlst, liest du die verlinkte Lektion. Wenn du eine Antwort in eigenen Worten erklären und ein kleines Beispiel dazu schreiben kannst, bist du bereit.
Grundlagen
Abschnitt betitelt „Grundlagen“F: Was ist Cargo?
Abschnitt betitelt „F: Was ist Cargo?“Cargo ist das offizielle Build-Tool und der Paketmanager von Rust. Es wird mit Rust mitgeliefert. Mit einem einzigen Werkzeug kannst du:
- ein Projekt anlegen (
cargo new), - es bauen und ausführen (
cargo build,cargo run), - Tests ausführen (
cargo test), - Bibliotheken, sogenannte Crates, von crates.io hinzufügen (
cargo add serde).
Die Einstellungen und Abhängigkeiten eines Projekts stehen in Cargo.toml. Die genauen Versionen werden in Cargo.lock festgehalten, sodass Builds reproduzierbar sind. Ein guter Zusatzpunkt: Andere Sprachen brauchen dafür oft mehrere Werkzeuge. In Rust benutzen alle Cargo, deshalb kommt dir jedes Projekt sofort vertraut vor.
Mehr dazu: Cargo und Crates, Erste Schritte mit Rust.
F: Was ist der Unterschied zwischen Shadowing und mut?
Abschnitt betitelt „F: Was ist der Unterschied zwischen Shadowing und mut?“Mit mut kannst du den Wert derselben Variable ändern. Der Typ muss gleich bleiben.
Shadowing (Überschatten) heißt, dass du noch einmal let mit demselben Namen schreibst. Das erzeugt eine neue Variable, die die alte verdeckt. Die neue darf sogar einen anderen Typ haben.
fn main() { let spaces = " "; let spaces = spaces.len(); // Shadowing: eine neue Variable, jetzt eine Zahl println!("{spaces}");
let mut count = 1; count += 1; // mut: dieselbe Variable bekommt einen neuen Wert println!("{count}");}Ausgabe:
32Shadowing ist praktisch, wenn du einen Wert Schritt für Schritt umwandelst (zum Beispiel Text in eine Zahl), ohne Namen wie input_str und input_num erfinden zu müssen. Und die letzte Variable ist trotzdem unveränderbar.
Mehr dazu: Variablen in Rust.
F: Was ist der Unterschied zwischen einem Makro und einer Funktion?
Abschnitt betitelt „F: Was ist der Unterschied zwischen einem Makro und einer Funktion?“Eine Funktion wird zur Laufzeit mit einer festen Anzahl typisierter Argumente aufgerufen. Ein Makro läuft dagegen zur Compile-Zeit: Es nimmt Code entgegen und schreibt daraus weiteren Code, bevor der Compiler ihn prüft. Makros erkennst du am !, zum Beispiel println!, vec! und format!.
Makros können Dinge, die Funktionen nicht können: etwa eine variable Anzahl von Argumenten annehmen (println!("{} {}", a, b)) oder einen Format-String schon beim Kompilieren prüfen. Außerdem arbeiten Rust-Makros auf der Struktur des Codes und nicht auf reinem Text. Dadurch vermeiden sie die klassischen Fehler von C-Makros:
macro_rules! square { ($x:expr) => { $x * $x };}
fn main() { // In C würde ein textbasiertes Makro 2 + 1 * 2 + 1 = 5 ergeben. println!("{}", square!(2 + 1));}Ausgabe:
9Der ehrliche Nachteil: Makros sind schwerer zu lesen, zu schreiben und zu debuggen. Nimm eine Funktion, wenn eine Funktion die Aufgabe erledigen kann.
Mehr dazu: Makros in Rust.
F: Was bedeutet „Zero-Cost Abstractions“?
Abschnitt betitelt „F: Was bedeutet „Zero-Cost Abstractions“?“Es bedeutet, dass Features auf hohem Abstraktionsniveau dein Programm nicht langsamer machen sollen als der Low-Level-Code, den du von Hand schreiben würdest. Sinngemäß: „Du zahlst nicht für das, was du nicht benutzt. Und was du benutzt, könntest du selbst nicht besser schreiben.“
Ein Beispiel: Eine Iterator-Kette wie .iter().filter(...).map(...).sum() wird meistens zu demselben Maschinencode kompiliert wie eine handgeschriebene Schleife. Generics sind ein weiteres Beispiel: Der Compiler erzeugt für jeden Typ, den du verwendest, eine eigene, spezialisierte Kopie (das heißt Monomorphisierung). Zur Laufzeit muss also nichts nachgeschlagen werden.
Sei ehrlich beim Thema „Kosten“: Gemeint sind null Kosten zur Laufzeit. Mit Compile-Zeit bezahlst du trotzdem, und Generics können die Programmdatei größer machen.
Mehr dazu: Iteratoren in Rust, Generics in Rust.
F: Sind Iteratoren in Rust „lazy“?
Abschnitt betitelt „F: Sind Iteratoren in Rust „lazy“?“Ja. Methoden wie .map() und .filter() erledigen für sich allein keine Arbeit. Sie bauen nur eine Beschreibung der Arbeit. Es passiert erst etwas, wenn etwas den Iterator verbraucht, zum Beispiel .collect(), .sum() oder eine for-Schleife.
fn main() { let numbers = vec![1, 2, 3]; let doubled = numbers.iter().map(|n| { println!("verdopple {n}"); n * 2 });
println!("Bisher ist noch nichts gelaufen."); let result: Vec<i32> = doubled.collect(); println!("{:?}", result);}Ausgabe:
Bisher ist noch nichts gelaufen.verdopple 1verdopple 2verdopple 3[2, 4, 6]Deshalb warnt dich der Compiler auch, wenn du numbers.iter().map(...); schreibst und das Ergebnis nie benutzt: Der Code würde nichts tun.
Mehr dazu: Iteratoren in Rust.
Ownership & Borrowing
Abschnitt betitelt „Ownership & Borrowing“F: Was sind die Ownership-Regeln?
Abschnitt betitelt „F: Was sind die Ownership-Regeln?“Es gibt drei:
- Jeder Wert in Rust hat genau einen Owner (Besitzer), nämlich eine Variable.
- Es kann immer nur einen Owner gleichzeitig geben.
- Wenn der Owner seinen Gültigkeitsbereich (Scope) verlässt, wird der Wert gedroppt (sein Speicher wird freigegeben).
Dank dieser Regeln gibt Rust den Speicher genau im richtigen Moment frei – ohne Garbage Collector und ohne dass du selbst free aufrufen musst. Der Compiler prüft die Regeln. Fehler wie „use after free“ (Zugriff nach dem Freigeben) oder „double free“ (doppeltes Freigeben) können in sicherem Code deshalb nicht passieren.
Mehr dazu: Ownership (Eigentum), Gültigkeitsbereich (Scope).
F: Was ist der Unterschied zwischen Move, Copy und Clone?
Abschnitt betitelt „F: Was ist der Unterschied zwischen Move, Copy und Clone?“- Move: Wenn du einen Wert zuweist oder übergibst, wechselt der Owner. Die alte Variable kannst du danach nicht mehr benutzen. Das ist das Standardverhalten für Typen wie
StringundVec. Kopiert werden nur ein kleiner Pointer, die Länge und die Kapazität. - Copy: Bei kleinen, einfachen Typen wie
i32,f64,boolundcharerzeugt eine Zuweisung eine billige bitweise Kopie, und beide Variablen bleiben benutzbar. - Clone:
.clone()ist eine explizite tiefe Kopie. Bei einemStringwird der ganze Text in neuen Speicher kopiert. Das kann teuer sein, deshalb musst du es in Rust ausdrücklich hinschreiben.
fn main() { let x = 5; let y = x; // Copy: x ist weiterhin benutzbar println!("{x} {y}");
let a = String::from("hallo"); let b = a.clone(); // Clone: eine vollständige Kopie des Textes let c = a; // Move: `a` ist ab dieser Zeile nicht mehr benutzbar println!("{b} {c}");}Ausgabe:
5 5hallo halloMehr dazu: Ownership (Eigentum).
F: Was ist der Copy-Trait, und welche Typen haben ihn?
Abschnitt betitelt „F: Was ist der Copy-Trait, und welche Typen haben ihn?“Copy ist ein Marker-Trait. Er sagt: „Dieser Typ lässt sich einfach durch Kopieren seiner Bits verdoppeln.“ Typen mit Copy werden bei einer Zuweisung kopiert statt verschoben.
Ganze Zahlen, Gleitkommazahlen, bool, char, gemeinsame Referenzen (&T) sowie Tupel und Arrays, die nur aus Copy-Typen bestehen, sind alle Copy. String, Vec und Box sind es nicht. Sie besitzen Speicher an einer anderen Stelle, und wenn man nur den Pointer kopieren würde, gäbe es zwei Owner.
Für eigene Typen kannst du Copy per derive ableiten, aber nur, wenn jedes Feld Copy ist:
#[derive(Clone, Copy)]struct User { name: String, age: u32,}error[E0204]: the trait `Copy` cannot be implemented for this type --> src/main.rs:2:8 |1 | #[derive(Clone, Copy)] | ---- in this derive macro expansion2 | struct User { | ^^^^3 | name: String, | ------------ this field does not implement `Copy`Der Compiler sagt hier: Copy kann für diesen Typ nicht implementiert werden, weil das Feld name (ein String) nicht Copy ist.
Copy setzt außerdem immer Clone voraus. Und ein Typ, der Drop implementiert, kann nicht Copy sein.
Mehr dazu: Wichtige Traits, Ownership (Eigentum).
F: Was ist der Unterschied zwischen String und &str?
Abschnitt betitelt „F: Was ist der Unterschied zwischen String und &str?“Stringist ein Text, der dir gehört, wachsen kann und auf dem Heap liegt. Du kannst ihn verändern, zum Beispiel mitpush_str.&str(ein „String Slice“) ist eine ausgeliehene Ansicht auf irgendeinen Text. Er besteht nur aus einem Pointer und einer Länge. String-Literale wie"hallo"sind&str.
Eine gute Faustregel: Funktionen, die Text nur lesen, sollten &str nehmen. Dann akzeptieren sie beides, denn ein &String wird automatisch zu einem &str.
fn greet(name: &str) { println!("Hallo, {name}!");}
fn main() { let owned: String = String::from("Ana"); let literal: &str = "Ben";
greet(&owned); // &String funktioniert dort, wo &str erwartet wird greet(literal);
let mut full = owned; full.push_str(" Schmidt"); // nur ein String kann wachsen greet(&full);}Ausgabe:
Hallo, Ana!Hallo, Ben!Hallo, Ana Schmidt!Mehr dazu: Strings in Rust, Slices in Rust.
F: Was sind die Borrowing-Regeln?
Abschnitt betitelt „F: Was sind die Borrowing-Regeln?“Zu jedem Zeitpunkt kannst du für einen bestimmten Wert entweder:
- beliebig viele gemeinsame Referenzen (
&T) haben, oder - genau eine veränderbare Referenz (
&mut T),
aber nie beides gleichzeitig. Außerdem muss jede Referenz immer auf gültige Daten zeigen.
Warum? Wenn jemand Daten ändern könnte, während jemand anderes sie liest, bekämst du Fehler wie einen Vektor, der seinen Speicher verschiebt, während du noch einen Pointer hinein hältst. Bei Threads ist genau das ein Data Race. Rust verhindert das schon zur Compile-Zeit. Seit der Edition 2018 erkennt der Compiler clever, wann ein Borrow endet (das heißt „Non-Lexical Lifetimes“): Ein Borrow dauert nur bis zu seiner letzten Benutzung, nicht bis zum Ende des Blocks.
Mehr dazu: Borrowing (Ausleihen).
F: Was sind Lifetimes?
Abschnitt betitelt „F: Was sind Lifetimes?“Eine Lifetime (Lebensdauer) ist der Name, den der Compiler dafür verwendet, „wie lange eine Referenz gültig ist“. Jede Referenz hat eine. Meistens findet der Compiler sie selbst heraus (das heißt Lifetime Elision), sodass du sie nie zu Gesicht bekommst.
Du schreibst sie nur dann hin, wenn der Compiler nicht erkennen kann, wie die zurückgegebene Referenz mit den Eingaben zusammenhängt. Zum Beispiel bei einer Funktion, die einen von zwei ausgeliehenen Strings zurückgibt:
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str { if a.len() >= b.len() { a } else { b }}
fn main() { let first = String::from("borrow checker"); let second = String::from("lifetimes"); println!("{}", longest(&first, &second));}Ausgabe:
borrow checkerDie beiden Texte bleiben auf Englisch, weil die Funktion ihre Länge in Bytes vergleicht. Umlaute belegen in UTF-8 zwei Bytes und würden das Ergebnis verändern.
Ein wichtiger Punkt fürs Interview: Lifetime-Annotationen ändern nicht, wie lange irgendetwas lebt. Sie beschreiben nur Beziehungen, damit der Compiler prüfen kann, dass keine Referenz länger lebt als die Daten, auf die sie zeigt.
Mehr dazu: Lifetimes in Rust.
Typen & Traits
Abschnitt betitelt „Typen & Traits“F: Rust hat kein null. Was benutzt es stattdessen?
Abschnitt betitelt „F: Rust hat kein null. Was benutzt es stattdessen?“Option<T>, ein Enum mit zwei Varianten: Some(value) oder None. Weil das ein anderer Typ als T ist, zwingt dich der Compiler, den Fall None zu behandeln, bevor du den Wert benutzen kannst. Eine Null-Prüfung kannst du nicht vergessen, denn sonst kompiliert der Code nicht.
fn find_user(id: u32) -> Option<&'static str> { match id { 1 => Some("Ana"), 2 => Some("Ben"), _ => None, }}
fn main() { for id in [1, 3] { match find_user(id) { Some(name) => println!("{name} gefunden"), None => println!("Kein Nutzer mit der ID {id}"), } }}Ausgabe:
Ana gefundenKein Nutzer mit der ID 3Ein schönes Detail für Fortgeschrittene: Option<&T> und Option<Box<T>> brauchen nicht mehr Speicher als ein einfacher Pointer. Rust benutzt intern das „Null“-Bitmuster, um None darzustellen. Die Sicherheit kostet hier also nichts.
Mehr dazu: Option in Rust, If Let / While Let in Rust.
F: Was ist ein Trait? Ist das dasselbe wie ein Interface?
Abschnitt betitelt „F: Was ist ein Trait? Ist das dasselbe wie ein Interface?“Ein Trait legt eine Reihe von Methoden fest, die ein Typ implementieren kann. Er ähnelt einem Interface in Java, C# oder Go, und damit kannst du deine Antwort gut beginnen. Dann nennst du die Unterschiede:
- Traits können Standard-Implementierungen für Methoden haben.
- Du kannst einen Trait für einen Typ implementieren, nachdem der Typ geschrieben wurde – sogar für Typen, die du nicht selbst geschrieben hast, etwa deinen eigenen Trait für
i32. (Die „Orphan Rule“ verlangt, dass entweder der Trait oder der Typ von dir stammt.) - Traits dienen als Einschränkungen für Generics (
T: Display), was zur Compile-Zeit aufgelöst wird, und als Trait-Objekte (dyn Display) zur Laufzeit. - Rust hat keine Vererbung von Klassen. Traits plus Komposition ersetzen sie.
Mehr dazu: Traits in Rust, Wichtige Traits.
F: Was ist der Unterschied zwischen Generics und Trait-Objekten?
Abschnitt betitelt „F: Was ist der Unterschied zwischen Generics und Trait-Objekten?“Mit beidem kann eine Funktion mit vielen Typen arbeiten. Der Unterschied ist, wann der Typ festgelegt wird:
- Generics (
fn f<T: Speak>(x: &T)) verwenden statische Bindung (Static Dispatch). Der Compiler erzeugt für jeden Typ eine eigene Kopie der Funktion. Aufrufe sind so schnell wie normale Funktionsaufrufe, aber das Programm kann größer werden. - Trait-Objekte (
&dyn Speak,Box<dyn Speak>) verwenden dynamische Bindung (Dynamic Dispatch). Es gibt nur eine Kopie der Funktion, und die richtige Methode wird zur Laufzeit in einer Tabelle (einer „vtable“) nachgeschlagen. Das ist ein kleines bisschen langsamer, aber du kannst verschiedene Typen in einer Collection mischen.
trait Speak { fn speak(&self) -> String;}
struct Dog;struct Cat;
impl Speak for Dog { fn speak(&self) -> String { String::from("Wuff") }}
impl Speak for Cat { fn speak(&self) -> String { String::from("Miau") }}
fn speak_static<T: Speak>(animal: &T) -> String { animal.speak()}
fn speak_dynamic(animal: &dyn Speak) -> String { animal.speak()}
fn main() { println!("{}", speak_static(&Dog)); println!("{}", speak_dynamic(&Cat));
// Nur Trait-Objekte können verschiedene Typen in einem Vec mischen: let animals: Vec<Box<dyn Speak>> = vec![Box::new(Dog), Box::new(Cat)]; for animal in &animals { println!("{}", animal.speak()); }}Ausgabe:
WuffMiauWuffMiauFaustregel: Fang mit Generics an. Nimm Trait-Objekte, wenn du eine Collection mit verschiedenen Typen brauchst oder Compile-Zeit und Programmgröße klein halten willst.
Mehr dazu: Generics in Rust, Trait-Objekte (dyn).
F: Was ist der Unterschied zwischen Box<dyn Trait> und impl Trait?
Abschnitt betitelt „F: Was ist der Unterschied zwischen Box<dyn Trait> und impl Trait?“Mit beiden sagst du „irgendein Typ, der diesen Trait implementiert“, ohne ihn beim Namen zu nennen. Der Unterschied liegt darin, wie viele konkrete Typen erlaubt sind:
impl Traitsteht für einen bestimmten Typ, den der Compiler kennt, den du aber nicht hinschreiben musst. Das ist statischer Dispatch: keine Heap-Allokation, keine vtable, genauso schnell, als hättest du den Typ selbst genannt. An Argumentposition (fn f(x: impl Display)) ist es einfach eine kürzere Schreibweise für Generics. An Rückgabeposition gibst du damit Werte mit langen oder unbenennbaren Typen zurück, etwa Closures und Iterator-Ketten.Box<dyn Trait>ist ein Trait-Objekt auf dem Heap. Der tatsächliche Typ kann jedes Mal ein anderer sein und wird erst zur Laufzeit festgelegt. Die Methode wird über eine vtable gefunden. Das kostet eine Allokation und einen indirekten Aufruf. Dafür ist es der einzige Weg, einen von mehreren Typen zurückzugeben oder sie in einem gemeinsamenVeczu speichern.
use std::fmt::Display;
// impl Trait: ein versteckter Typ, beim Kompilieren festgelegt.fn evens(limit: u32) -> impl Iterator<Item = u32> { (0..limit).filter(|n| n % 2 == 0)}
// Box<dyn Trait>: Der echte Typ kann sich zur Laufzeit ändern.fn label(as_number: bool) -> Box<dyn Display> { if as_number { Box::new(42) } else { Box::new("zweiundvierzig") }}
fn main() { let v: Vec<u32> = evens(10).collect(); println!("{v:?}"); println!("{}", label(true)); println!("{}", label(false));}Ausgabe:
[0, 2, 4, 6, 8]42zweiundvierzigEin gutes Detail für das Gespräch: Wenn du label so änderst, dass es impl Display zurückgibt, und die Boxen entfernst, kompiliert der Code nicht mehr. Das if liefert ein i32 und das else ein &str – aber impl Trait muss ein einziger Typ sein. Faustregel: Nimm standardmäßig impl Trait und wechsle zu Box<dyn Trait>, wenn du wirklich zur Laufzeit zwischen Typen wählen musst.
Mehr dazu: Trait-Objekte (dyn), Generics in Rust.
F: Warum muss match vollständig sein?
Abschnitt betitelt „F: Warum muss match vollständig sein?“Ein match muss jeden möglichen Wert behandeln, sonst kompiliert der Code nicht. Das heißt: Wenn du einem Enum eine neue Variante hinzufügst, zeigt dir der Compiler jede Stelle, die angepasst werden muss. In anderen Sprachen macht ein vergessener case in einem switch einfach still gar nichts.
Du kannst _ als Auffangmuster für alles Übrige benutzen. Aber Vorsicht: So ein Auffangmuster versteckt auch neue Varianten, die du später hinzufügst. Dann verlierst du dieses Sicherheitsnetz.
Mehr dazu: Match in Rust, Pattern Matching, Enums in Rust.
Fehlerbehandlung
Abschnitt betitelt „Fehlerbehandlung“F: Wie behandelt Rust Fehler ohne Exceptions?
Abschnitt betitelt „F: Wie behandelt Rust Fehler ohne Exceptions?“Behebbare Fehler sind ganz normale Werte vom Typ Result<T, E>: entweder Ok(value) oder Err(error). Eine Funktion, die fehlschlagen kann, sagt das in ihrem Rückgabetyp, und der Aufrufer muss sich darum kümmern. Nichts springt unsichtbar den Stack hinauf, wie es eine Exception tut.
Die Vorteile: Du siehst schon an der Signatur einer Funktion, dass sie fehlschlagen kann, und der Compiler warnt dich, wenn du ein Result ignorierst. Der Preis ist etwas mehr Tipparbeit – und die nimmt dir der ?-Operator größtenteils ab.
Mehr dazu: Result in Rust.
F: Wann solltest du panic! benutzen, statt ein Result zurückzugeben?
Abschnitt betitelt „F: Wann solltest du panic! benutzen, statt ein Result zurückzugeben?“- Gib ein
Resultzurück, wenn ein Fehlschlag zu erwarten ist und der Aufrufer sinnvoll darauf reagieren kann: eine fehlende Datei, eine falsche Benutzereingabe, ein Netzwerk-Timeout. - Löse eine Panic aus, wenn es sich um einen Bug handelt: Das Programm ist in einem Zustand, der „eigentlich unmöglich“ sein sollte, und Weitermachen wäre falsch. Genau deshalb löst ein Index hinter dem Ende eines Vektors eine Panic aus.
In Tests, Beispielen und schnellen Prototypen sind Panics auch in Ordnung. In einer Bibliothek solltest du bei Eingaben, die der Aufrufer kontrolliert, keine Panic auslösen. Gib einen Fehler zurück und lass ihn entscheiden.
Mehr dazu: Panic in Rust, Result in Rust.
F: Was ist der Unterschied zwischen unwrap, expect und ??
Abschnitt betitelt „F: Was ist der Unterschied zwischen unwrap, expect und ??“unwrap()gibt dir den Wert inOk/Some– oder löst mit einer allgemeinen Meldung eine Panic aus.expect("msg")macht dasselbe, löst die Panic aber mit deiner Meldung aus. Damit erklärst du, warum du glaubst, dass es nicht schiefgehen kann.?löst keine Panic aus. BeiErr/Nonegibt es den Fehler aus der aktuellen Funktion zurück, damit der Aufrufer ihn behandeln kann. Die Funktion muss dafür selbst einResultoderOptionzurückgeben.
use std::num::ParseIntError;
fn double(text: &str) -> Result<i32, ParseIntError> { let n: i32 = text.parse()?; // bei einem Fehler: an den Aufrufer zurückgeben Ok(n * 2)}
fn main() { println!("{:?}", double("21")); println!("{:?}", double("abc"));
let n: i32 = "7".parse().expect("eine fest eingetragene Zahl sollte sich immer parsen lassen"); println!("{n}");}Ausgabe:
Ok(42)Err(ParseIntError { kind: InvalidDigit })7InvalidDigit ist Rusts eigener Name für den Fehler „ungültige Ziffer“.
In Produktivcode solltest du ? bevorzugen. Nimm expect, wenn ein Fehlschlag ein Bug wäre, und heb dir unwrap für Tests und schnelle Experimente auf.
Mehr dazu: Result in Rust, Option in Rust.
F: Wie erstellst du eigene Fehlertypen?
Abschnitt betitelt „F: Wie erstellst du eigene Fehlertypen?“Meistens mit einem Enum, das aufzählt, auf welche Arten dein Code fehlschlagen kann, plus Implementierungen von std::fmt::Display und std::error::Error. Wenn du From für andere Fehlertypen implementierst, kann ? sie automatisch umwandeln.
In der Praxis benutzen viele Projekte Hilfs-Crates: thiserror (auf Englisch), um in Bibliotheken Fehlertypen zu definieren, und anyhow (auf Englisch) für Anwendungen, die Fehler einfach mit Kontext nach oben weiterreichen wollen. Für schnelle Programme kann Box<dyn Error> jeden beliebigen Fehler aufnehmen.
Mehr dazu: Eigene Fehlertypen.
F: Wann nimmst du anyhow und wann thiserror?
Abschnitt betitelt „F: Wann nimmst du anyhow und wann thiserror?“Die beiden lösen zwei Hälften desselben Problems. Die ehrliche Antwort lautet deshalb oft: „beide“.
thiserrorist für Bibliotheken gedacht – oder für jeden Code, dessen Aufrufer auf bestimmte Fehler reagieren müssen. Du schreibst dein eigenes Fehler-Enum, und#[derive(Error)]ergänzt den Boilerplate-Code fürDisplayundError. Aufrufer bekommen einen echten Typ, auf den siematchen können, etwaConfigError::MissingField.anyhowist für Anwendungen gedacht, in denen die meisten Fehler nur gemeldet und nicht einzeln behandelt werden müssen. Jede Funktion gibtanyhow::Result<T>zurück,?akzeptiert fast jeden Fehlertyp, und.context("beim Lesen der Konfiguration")legt eine verständliche Erklärung obendrauf. Der Preis: Aufrufer können nicht mehr so einfach auf den Fehlermatchen.
Ein typischer Aufbau ist thiserror in den Bibliotheks-Crates und anyhow in main und im Rest des Programms. Keins der beiden Crates ist Magie. Beide nehmen dir nur Boilerplate rund um den Standard-Trait std::error::Error ab – du könntest alles auch von Hand schreiben.
Mehr dazu: anyhow und thiserror, Eigene Fehlertypen.
Speicher & Smart Pointer
Abschnitt betitelt „Speicher & Smart Pointer“F: Hat Rust einen Garbage Collector?
Abschnitt betitelt „F: Hat Rust einen Garbage Collector?“Nein. Speicher wird freigegeben, wenn sein Owner den Gültigkeitsbereich verlässt – an einer Stelle, die der Compiler schon im Voraus kennt (das heißt RAII, und die Aufräum-Methode ist Drop). Das gibt dir vorhersehbare Performance ohne GC-Pausen.
Wenn du wirklich geteiltes Ownership brauchst, entscheidest du dich bewusst für Referenzzählung (Rc oder Arc). Dann wird der Wert freigegeben, sobald der letzte Owner weg ist.
Mehr dazu: Ownership (Eigentum), Smart Pointer.
F: Wann benutzt du Box, Rc, Arc und RefCell?
Abschnitt betitelt „F: Wann benutzt du Box, Rc, Arc und RefCell?“Box<T>: ein Owner, Wert auf dem Heap. Nimm ihn für rekursive Typen (wie einen Baum oder eine verkettete Liste), große Werte oder Trait-Objekte (Box<dyn Trait>).Rc<T>: mehrere Owner in einem Thread. Er zählt die Referenzen und gibt den Wert frei, wenn der Zähler null erreicht. Die Daten sind nur lesbar.Arc<T>: wieRc, aber der Zähler ist atomar. Dadurch kannst du ihn sicher über Threads hinweg teilen. Etwas langsamer alsRc.RefCell<T>: Damit kannst du Daten über eine gemeinsame Referenz verändern („Interior Mutability“, innere Veränderbarkeit). Die Borrowing-Regeln werden zur Laufzeit statt zur Compile-Zeit geprüft, und wer sie bricht, löst eine Panic aus.
Man kann sie kombinieren. Rc<RefCell<T>> heißt „geteilt und veränderbar, in einem Thread“. Arc<Mutex<T>> ist die Thread-sichere Variante.
use std::cell::RefCell;use std::rc::Rc;
fn main() { let shared = Rc::new(RefCell::new(vec![1, 2])); let other_owner = Rc::clone(&shared);
other_owner.borrow_mut().push(3);
println!("{:?}", shared.borrow()); println!("Besitzer: {}", Rc::strong_count(&shared));}Ausgabe:
[1, 2, 3]Besitzer: 2Mehr dazu: Smart Pointer.
F: Kann es in sicherem Rust Speicherlecks geben?
Abschnitt betitelt „F: Kann es in sicherem Rust Speicherlecks geben?“Ja, und es ist ehrlich, das zuzugeben. Rust garantiert Speichersicherheit (kein Use-after-free, keine hängenden Pointer, keine Data Races). Ein Speicherleck gilt aber nicht als unsicher. Zwei typische Wege:
- Ein
Rc-Zyklus: Zwei Werte, die mitRcaufeinander zeigen, erreichen nie den Zählerstand null. Die Lösung: Mach eine Richtung zu einerWeak-Referenz. - Funktionen wie
Box::leakundstd::mem::forget, die absichtlich Speicher „verlieren“.
Im Alltagscode sind Lecks selten, weil Ownership fast alles automatisch freigibt.
Mehr dazu: Smart Pointer.
F: Was ist unsafe, und wann wird es benutzt?
Abschnitt betitelt „F: Was ist unsafe, und wann wird es benutzt?“unsafe markiert einen Block (oder eine Funktion), in dem du fünf zusätzliche Dinge tun darfst, die der Compiler nicht prüfen kann. Zum Beispiel: Raw Pointer dereferenzieren, unsafe-Funktionen aufrufen (auch C-Code über FFI) oder auf veränderbare statische Variablen zugreifen. Innerhalb dieses Blocks versprichst du, dass die Regeln eingehalten werden.
Wichtig: unsafe schaltet weder den Borrow Checker noch andere Prüfungen ab. Es schaltet nur diese wenigen zusätzlichen Operationen frei.
Man braucht es, um mit C-Bibliotheken zu sprechen, um Low-Level-Datenstrukturen zu schreiben und um Performance herauszuholen, wo der Compiler nicht beweisen kann, dass etwas sicher ist. Die Standardbibliothek selbst benutzt unsafe im Inneren von sicheren APIs wie Vec. Gute Praxis: Halte unsafe-Blöcke klein, verpacke sie in eine sichere API und schreib einen // SAFETY:-Kommentar, der erklärt, warum der Code korrekt ist. Der meiste Anwendungscode braucht unsafe nie.
Mehr dazu: Unsafe Rust.
Nebenläufigkeit & Async
Abschnitt betitelt „Nebenläufigkeit & Async“F: Was bedeuten Send und Sync?
Abschnitt betitelt „F: Was bedeuten Send und Sync?“Das sind Marker-Traits, mit denen der Compiler für Thread-Sicherheit sorgt:
Send: Ein Wert dieses Typs darf in einen anderen Thread verschoben werden.Sync: Ein Wert dieses Typs darf zwischen Threads per Referenz geteilt werden (&TistSend).
Die meisten Typen sind beides, und der Compiler implementiert diese Traits automatisch. Die berühmte Ausnahme ist Rc, dessen Zähler nicht Thread-sicher ist. Versuch, einen Rc an einen Thread zu schicken, und der Code kompiliert nicht:
use std::rc::Rc;use std::thread;
fn main() { let data = Rc::new(5); let handle = thread::spawn(move || { println!("{data}"); }); handle.join().unwrap();}error[E0277]: `Rc<i32>` cannot be sent between threads safely ... = help: within `{closure@src/main.rs:6:32: 6:39}`, the trait `Send` is not implemented for `Rc<i32>`Die Meldung heißt: Rc<i32> kann nicht sicher zwischen Threads verschickt werden, weil es Send nicht implementiert.
Tausch Rc gegen Arc, und es funktioniert. Das ist ein großer Teil dessen, was mit Rusts „Fearless Concurrency“ (furchtlose Nebenläufigkeit) gemeint ist: Fehler bei der Thread-Sicherheit sind Compiler-Fehler.
Mehr dazu: Nebenläufigkeit in Rust.
F: Wie teilst du veränderbare Daten zwischen Threads?
Abschnitt betitelt „F: Wie teilst du veränderbare Daten zwischen Threads?“Die klassische Antwort ist Arc<Mutex<T>>. Mit Arc können mehrere Threads den Wert besitzen. Mutex sorgt dafür, dass immer nur ein Thread ihn gleichzeitig ändern kann: Du rufst .lock() auf und bekommst einen Guard, der automatisch wieder entsperrt, wenn er seinen Gültigkeitsbereich verlässt.
use std::sync::{Arc, Mutex};use std::thread;
fn main() { let counter = Arc::new(Mutex::new(0)); let mut handles = Vec::new();
for _ in 0..4 { let counter = Arc::clone(&counter); handles.push(thread::spawn(move || { for _ in 0..1000 { *counter.lock().unwrap() += 1; } })); }
for handle in handles { handle.join().unwrap(); }
println!("Gesamt: {}", *counter.lock().unwrap());}Ausgabe:
Gesamt: 4000Erwähne auch die Alternative: Statt Speicher zu teilen, kannst du mit Channels (std::sync::mpsc) Nachrichten zwischen Threads verschicken. Und für einfache Zähler kommen atomare Typen wie AtomicUsize ganz ohne Lock aus.
Mehr dazu: Nebenläufigkeit in Rust.
F: Wie schlagen sich Channels im Vergleich zu geteiltem Zustand mit Mutex?
Abschnitt betitelt „F: Wie schlagen sich Channels im Vergleich zu geteiltem Zustand mit Mutex?“Das sind zwei Arten, Arbeit zwischen Threads aufzuteilen:
- Channels (
std::sync::mpsc) verschicken Nachrichten. Ein Wert wird in den Channel verschoben und kommt auf der anderen Seite heraus. Er gehört also immer nur einem Thread. Channels passen zu Pipelines und Worker-Pools: „Worker erzeugen Ergebnisse, ein Thread sammelt sie ein.“ Meist sind sie leichter zu durchschauen, weil nie zwei Threads dieselben Daten anfassen. - Geteilter Zustand (
Arc<Mutex<T>>) bedeutet, dass mehrere Threads dieselben Daten benutzen und sich über ein Lock abwechseln. Das passt, wenn viele Threads eine Struktur lesen und ändern müssen, etwa einen Cache oder eine Tabelle mit Zählern. Die Risiken sind Deadlocks und Verlangsamung, wenn Threads ständig auf das Lock warten.
Hier ist der Zähler aus der vorigen Antwort, umgeschrieben mit einem Channel. Jeder Worker zählt für sich, ohne Lock, und schickt sein Ergebnis:
use std::sync::mpsc;use std::thread;
fn main() { let (tx, rx) = mpsc::channel();
for _ in 0..4 { let tx = tx.clone(); thread::spawn(move || { let mut count = 0; for _ in 0..1000 { count += 1; } tx.send(count).unwrap(); }); } drop(tx); // den ursprünglichen Sender freigeben, sonst endet rx.iter() nie
let total: i32 = rx.iter().sum(); println!("Gesamt: {total}");}Ausgabe:
Gesamt: 4000Die Zeile drop(tx) ist ein tolles Detail für das Gespräch: rx.iter() wartet, bis jeder Sender weg ist. Vergisst du sie, bleibt das Programm hängen. Bei beiden Stilen prüft Rust die Thread-Sicherheit beim Kompilieren. Die Wahl hängt davon ab, welcher Entwurf für dein Problem klarer ist – und viele Programme benutzen beide.
Mehr dazu: Channels in Rust, Nebenläufigkeit in Rust.
F: Verhindert Rust alle Fehler bei Nebenläufigkeit?
Abschnitt betitelt „F: Verhindert Rust alle Fehler bei Nebenläufigkeit?“Nein – und wenn du das sagst, zeigst du echtes Verständnis. Sicheres Rust verhindert Data Races (zwei Threads greifen gleichzeitig auf denselben Speicher zu, und mindestens einer schreibt). Es verhindert nicht:
- Deadlocks, zum Beispiel wenn zwei Threads jeweils auf einen Lock warten, den der andere hält.
- Race Conditions in deiner Logik, zum Beispiel wenn du einen Wert prüfst und erst danach handelst, nachdem ein anderer Thread ihn schon geändert hat.
Rust beseitigt also eine ganze Klasse schwieriger Fehler. Dein Locking musst du aber trotzdem sorgfältig planen.
Mehr dazu: Nebenläufigkeit in Rust.
F: Was ist der Unterschied zwischen Async und Threads?
Abschnitt betitelt „F: Was ist der Unterschied zwischen Async und Threads?“- Threads werden vom Betriebssystem ausgeführt. Jeder hat seinen eigenen Stack, und das Betriebssystem wechselt zwischen ihnen. Für rechenintensive Arbeit sind sie super. Tausende Threads brauchen aber viel Speicher.
- Async-Tasks sind viel leichter. Eine
async fngibt einFuturezurück, und eine Runtime (meistens Tokio) führt viele Futures auf wenigen Threads aus. Wenn ein Task wartet (auf das Netzwerk, einen Timer, eine Datei), lässt.awaitandere Tasks laufen. Das ist ideal für Programme, die viel warten, etwa Webserver mit Tausenden Verbindungen.
use tokio::time::{sleep, Duration};
async fn fetch(name: &str, ms: u64) -> String { sleep(Duration::from_millis(ms)).await; // so tun, als würden wir auf das Netzwerk warten format!("{name} fertig")}
#[tokio::main]async fn main() { // Beide laufen gleichzeitig, deshalb dauert das etwa 200 ms und nicht 300 ms. let (a, b) = tokio::join!(fetch("erster", 200), fetch("zweiter", 100)); println!("{a}"); println!("{b}");}Ausgabe:
erster fertigzweiter fertigZwei ehrliche Details, die Interviewer gern hören: Futures in Rust sind „lazy“ und tun nichts, bis sie mit .await abgewartet werden. Und die Standardbibliothek enthält keine Runtime – du suchst dir selbst eine aus. Außerdem gilt: Blockierender Code (wie eine lange Berechnung oder std::thread::sleep) in einem Async-Task blockiert auch die anderen Tasks auf diesem Thread.
Mehr dazu: Async/Await in Rust, Nebenläufigkeit in Rust.
Tipps für die Antworten im Interview
Abschnitt betitelt „Tipps für die Antworten im Interview“- Fang einfach an und geh dann in die Tiefe. Gib zuerst eine Antwort in einem Satz („
Rcist ein Pointer mit Referenzzählung für geteiltes Ownership in einem Thread“). Ergänze Details, wenn dein Gegenüber interessiert wirkt. - Erklär das Warum. Die Regeln von Rust gibt es, um bestimmte Fehler zu verhindern. „Die Borrowing-Regeln verhindern Data Races und hängende Pointer“ klingt stärker, als nur die Regeln aufzusagen.
- Nutze ein kleines Beispiel. Drei Zeilen Code zu schreiben oder zu beschreiben, erklärt oft mehr als eine lange Rede.
- Sei ehrlich bei den Nachteilen. Rust hat seinen Preis: längere Compile-Zeiten, eine Lernkurve, mehr Tipparbeit bei der Fehlerbehandlung. Wenn du das zugibst, zeigt das Erfahrung, nicht Schwäche.
- Sag „Das weiß ich nicht“, wenn du es nicht weißt, und denk dann laut nach. „Ich habe
Pinnoch nicht direkt benutzt, aber ich weiß, dass es um Futures geht, die sich im Speicher nicht bewegen dürfen“ ist eine großartige Antwort. - Halte eine Geschichte bereit. Interviewer fragen oft: „Erzähl mir von einer Situation, in der dich der Borrow Checker gestoppt hat.“ Überleg dir ein echtes Beispiel aus deinem eigenen Code und was du daraus gelernt hast.
- Übe, indem du Code schreibst. Die Seite Rust-Übungen und die Projekte Ratespiel und To-do-Liste sind gute Aufwärmübungen.
Kurzer Check
Abschnitt betitelt „Kurzer Check“Kurzer Check
1. Welchen Smart Pointer würdest du benutzen, um nur lesbare Daten zwischen Threads zu teilen?
2. Was macht der ?-Operator, wenn er auf ein Err trifft?
3. Was davon verhindert sicheres Rust schon zur Compile-Zeit?
4. Ändern Lifetime-Annotationen, wie lange ein Wert lebt?
Punkte: 0 / 4
Weiterlesen
Abschnitt betitelt „Weiterlesen“- Die Programmiersprache Rust (das Rust-Buch): das offizielle Buch in der deutschen Übersetzung. Die meisten Antworten auf dieser Seite stammen direkt aus seinen Kapiteln zu Ownership, Traits, Fehlern und Nebenläufigkeit.
- Rust by Example (auf Englisch): kurze ausführbare Beispiele, praktisch, um die Syntax kurz vor einem Interview aufzufrischen.
- The Rust Reference (auf Englisch): die genauen Regeln der Sprache – für den Moment, in dem jemand fragt: „Aber wann genau passiert das?“
- Das Rustonomicon (auf Englisch): der Leitfaden zu
unsafeRust, mit tieferem Hintergrund zuSend,Syncund dem Speicherlayout für Fragen auf Senior-Niveau. - Asynchronous Programming in Rust (auf Englisch): wie Futures,
async/.awaitund Runtimes zusammenspielen, im offiziellen Async-Buch des Rust-Projekts.