Iterator und Closure in Rust
1. Überblick
Closure und Iterator sind zwei Werkzeuge zum funktionalen Verarbeiten von Daten in Rust, dabei wird die Leistung von Hand geschriebenen Schleifen gewahrt. Dieser Artikel erklärt, wie Closure Variablen aus ihrer Umgebung erfassen (capture) und die drei Traits Fn, FnMut, FnOnce. Danach behandeln wir das Iterator Trait, häufig verwendete Adapter und das Schreiben eines eigenen Iterators. Nach diesem Artikel kannst du Pipelines wie filter → map → collect sicher schreiben. Du wirst auch verstehen, warum der Compiler deinen Code manchmal ablehnt.
Alle Beispiele verwenden Rust 1.80+ (Edition 2021) und nur die Standard Library.
2. Closure Grundlagen
2.1. Syntax
Ein Closure ist eine anonyme Funktion, die Variablen aus der umgebenden Scope “sehen” kann:
fn main() {
let discount = 10; // Prozentpunkte
let apply_discount = |price: u32| price - price * discount / 100;
println!("{}", apply_discount(250));
println!("{}", apply_discount(1000));
}
225
900
Parameter stehen zwischen zwei Pipes |...|, der Closure-Body folgt unmittelbar. Der Closure nutzt die Variable discount, obwohl sie nicht übergeben wurde. Eine normale fn-Funktion könnte das nicht tun.
Parametertypen und Rückgabetypen werden üblicherweise automatisch hergeleitet. Du kannst |price| ... schreiben, wenn der Compiler genug Informationen hat. Allerdings wird der Typ nur einmal hergeleitet. Wenn du den Closure beim ersten Aufruf mit u32 aufrufst und beim zweiten mit i64, gibt es einen Compile-Fehler.
2.2. Drei Arten zum Erfassen von Variablen
Ein Closure erfasst Variablen auf die “leichteste” Weise, die der Body benötigt. Die Priorität ist: unveränderliche Borrow, dann veränderliche Borrow, schließlich Ownership.
fn main() {
let mut cart = vec!["Stift"];
let print_cart = || println!("Wagen: {:?}", cart); // borrow &cart
print_cart();
let mut add_item = |item| cart.push(item); // borrow &mut cart
add_item("Heft");
add_item("Lineal");
println!("Am Ende: {:?}", cart);
}
Wagen: ["Stift"]
Am Ende: ["Stift", "Heft", "Lineal"]
Analyse für jeden Closure:
print_cartliest nurcart, daher hält es einen&cart.add_itemruftpushauf, daher benötigt es&mut cart. Die Closure-Variable muss auch mitmutdeklariert sein, da der Aufruf den inneren Zustand ändert.- Der Code kompiliert dank non-lexical lifetimes.
print_cartwird nach dem ersten Aufruf nicht mehr verwendet, daher endet die unveränderliche Borrow, bevor die veränderliche Borrow beginnt.
Wenn der Closure Ownership von Daten benötigt, nutzt du das Schlüsselwort move. Ein häufiger Fall ist, einen Closure an einen anderen Thread zu übergeben:
use std::thread;
fn main() {
let customer = String::from("Lan");
let handle = thread::spawn(move || {
println!("Verarbeite Bestellung für {customer}");
});
handle.join().unwrap();
}
Verarbeite Bestellung für Lan
Ohne move lehnt der Compiler ab. Der neue Thread könnte länger leben als main, daher ist eine Referenz auf customer nicht sicher. Mit move wird der String direkt in den Closure verschoben. Nach dieser Zeile kannst du customer in main nicht mehr verwenden.
3. Die drei Traits Fn, FnMut und FnOnce
Jeder Closure implementiert automatisch ein oder mehrere dieser drei Traits. Welche Traits implementiert werden, hängt davon ab, was der Closure mit den erfassten Variablen macht:
| Trait | Closure macht mit erfasster Variable | Kann aufgerufen werden |
|---|---|---|
Fn |
Nur Lesen | Mehrere Male |
FnMut |
Verändern | Mehrere Male |
FnOnce |
Verbrauchen (move nach außen) | Einmal |
Diese drei Traits stehen in einer Vererbungsbeziehung: Jedes Fn ist auch FnMut, und jedes FnMut ist auch FnOnce. Wenn du eine Funktion schreibst, die einen Closure nimmt, solltest du das lockerste Trait wählen, das die Funktion benötigt. So haben Aufrufer die meisten Optionen.
fn run_twice<F: Fn()>(f: F) {
f();
f();
}
fn run_twice_mut<F: FnMut()>(mut f: F) {
f();
f();
}
fn run_once<F: FnOnce() -> String>(f: F) -> String {
f()
}
fn main() {
let name = String::from("Minh");
run_twice(|| println!("Hallo {name}"));
let mut count = 0;
run_twice_mut(|| count += 1);
println!("count = {count}");
let receipt = run_once(move || name + " - bezahlt");
println!("{receipt}");
}
Hallo Minh
Hallo Minh
count = 2
Minh - bezahlt
Die drei Aufrufe entsprechen den drei Traits:
- Der erste Closure liest nur
name, daher ist erFn. - Der zweite Closure erhöht
count, daher ist erFnMut. Ihn anrun_twicezu übergeben würde fehlschlagen. - Der letzte Closure nutzt
name + "...". Der+Operator aufStringnimmt sich den Ownership vonname, daher kann der Closure nur einmal aufgerufen werden und implementiert nurFnOnce.
3.1. Closure aus einer Funktion zurückgeben
Jeder Closure hat seinen eigenen anonymen Typ. Daher kannst du den Typnamen nicht schreiben. Es gibt zwei Möglichkeiten, Closures zurückzugeben:
fn make_discount(percent: u32) -> impl Fn(u32) -> u32 {
move |price| price - price * percent / 100
}
fn pick_rule(is_vip: bool) -> Box<dyn Fn(u32) -> u32> {
if is_vip {
Box::new(|price| price * 80 / 100)
} else {
Box::new(|price| price)
}
}
fn main() {
let summer_sale = make_discount(15);
println!("{}", summer_sale(200));
let rule = pick_rule(true);
println!("{}", rule(500));
}
170
400
impl Fn(...)verwende, wenn die Funktion immer den gleichen festen Closure-Typ zurückgibt. Das hat keine Heap-Allokation und keinen Overhead für dynamische Aufrufe.Box<dyn Fn(...)>verwende, wenn verschiedene Branches verschiedene Closures zurückgeben. Die zwei Closures inpick_rulehaben unterschiedliche Typen, daher müssen sie zu einem gemeinsamen Trait Object verpackt werden.moveinmake_discountist erforderlich. Ohne es würde der Closurepercentborgen, aberpercentist weg, wenn die Funktion endet.
4. Das Iterator Trait
4.1. Das Herz: Die next Methode
Jeder Iterator in Rust dreht sich um ein sehr simples Trait:
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
// ... Dutzende von vorhandenen Methoden wie map, filter, sum ...
}
next gibt Some(element) zurück, bis die Daten zu Ende gehen, dann gibt es None zurück. Eine for-Schleife ist in Wahrheit nur Syntaxzucker für wiederholtes Aufrufen von next.
4.2. iter, iter_mut und into_iter
Eine Collection kann drei verschiedene Arten von Iteratoren erzeugen:
fn main() {
let mut prices = vec![100, 200, 300];
for p in prices.iter() { // p: &i32
print!("{p} ");
}
println!();
for p in prices.iter_mut() { // p: &mut i32
*p += 10;
}
for p in prices.into_iter() { // p: i32, vector wird verschoben
print!("{p} ");
}
println!();
// println!("{:?}", prices); // Fehler: Wert nach move verwendet
}
100 200 300
110 210 310
iter()gibt unveränderliche Referenzen. Die Collection kann danach noch verwendet werden.iter_mut()gibt veränderliche Referenzen zum Ändern von Elementen an Ort und Stelle.into_iter()nimmt sich den Ownership.for p in pricesruftinto_iter()implizit auf.
5. Adapter und Consumer
Methoden auf Iteratoren fallen in zwei Gruppen:
- Adapter (
map,filter,take,zip,enumerate…) wandeln diesen Iterator in einen anderen um. Sie sind lazy: führen nichts aus, bis jemand sie verbraucht. - Consumer (
collect,sum,count,fold,for_each,find…) rufen tatsächlichnextauf und liefern ein Ergebnis.
Wir verwenden ein Order Struct als Beispieldaten:
#[derive(Debug)]
pub struct Order {
pub id: u32,
pub customer: String,
pub total: u32,
pub paid: bool,
}
pub fn unpaid_total(orders: &[Order]) -> u32 {
orders.iter()
.filter(|o| !o.paid)
.map(|o| o.total)
.sum()
}
pub fn big_customers(orders: &[Order], threshold: u32) -> Vec<&str> {
orders.iter()
.filter(|o| o.total >= threshold)
.map(|o| o.customer.as_str())
.collect()
}
fn main() {
let orders = vec![
Order { id: 1, customer: "An".into(), total: 150_000, paid: true },
Order { id: 2, customer: "Bình".into(), total: 80_000, paid: false },
Order { id: 3, customer: "Chi".into(), total: 320_000, paid: false },
];
println!("Noch nicht eingezogen: {}", unpaid_total(&orders));
println!("Große Kunden: {:?}", big_customers(&orders, 100_000));
let summary = orders.iter()
.fold(String::new(), |acc, o| acc + &format!("#{} ", o.id));
println!("Bestellungen: {summary}");
}
Noch nicht eingezogen: 400000
Große Kunden: ["An", "Chi"]
Bestellungen: #1 #2 #3
Schritte im Beispiel:
filter(|o| !o.paid)nimmt einen Closure, derboolzurückgibt. Der Parameterohat den Typ&&Order, weilfiltereine Referenz auf das Element weitergibt, und das Element voniter()ist bereits&Order. Auto-Deref lässt dicho.paidnormal schreiben.map(|o| o.total)verwandelt jede Bestellung in einen Betrag.sum()addiert alles.collect()sammelt das Ergebnis in eine Collection. Der Zieltyp kommt aus dem RückgabetypVec<&str>der Funktion.foldnimmt einen Initialwert und einen Closure(akkumulator, element) -> neuer_akkumulator. Das ist der allgemeinste Consumer.
5.1. Laziness
Dieses Beispiel zeigt, dass Adapter nur ausgeführt werden, wenn sie verbraucht werden:
fn main() {
let ids = vec![1, 2, 3];
let doubled = ids.iter().map(|x| {
println!("map {x}");
x * 2
});
println!("Noch nichts passiert");
let result: Vec<i32> = doubled.collect();
println!("{result:?}");
}
Noch nichts passiert
map 1
map 2
map 3
[2, 4, 6]
Dank Laziness wird die Pipeline filter → map → sum nur einmal durch die Daten iterieren und erstellt keine Zwischensammlungen. Der Compiler optimiert diese Pipeline gewöhnlich zu Maschinencode, der einer Hand geschriebenen Schleife entspricht. Deshalb werden Iteratoren als zero-cost abstraction bezeichnet.
6. Einen eigenen Iterator schreiben
Du musst nur next implementieren. Alle anderen Adapter kommen umsonst. Das folgende Beispiel erzeugt Rechnungsnummern im Format HD-0001:
pub struct InvoiceNumbers {
current: u32,
end: u32,
}
impl InvoiceNumbers {
pub fn new(start: u32, end: u32) -> Self {
Self { current: start, end }
}
}
impl Iterator for InvoiceNumbers {
type Item = String;
fn next(&mut self) -> Option<Self::Item> {
if self.current > self.end {
return None;
}
let number = format!("HD-{:04}", self.current);
self.current += 1;
Some(number)
}
}
fn main() {
let customers = ["An", "Bình", "Chi"];
for (invoice, customer) in InvoiceNumbers::new(7, 100).zip(customers) {
println!("{invoice} -> {customer}");
}
}
HD-0007 -> An
HD-0008 -> Bình
HD-0009 -> Chi
Erklärung:
type Item = Stringdeklariert den Typ von Elementen, die der Iterator liefert.nextaktualisiert den Zustand über&mut selfund gibtNonezurück, wennendüberschritten wird.zippaart zwei Iteratoren und stoppt, wenn der kürzere Iterator zu Ende ist. Deshalb werden nur drei Zeilen gedruckt, obwohl die Nummernspanne bis 100 geht.
7. Tests schreiben
Rust hat ein eingebautes Test-Framework. Du platzierst ein Test-Modul direkt in der Datei mit unpaid_total und InvoiceNumbers:
#[cfg(test)]
mod tests {
use super::*;
fn order(id: u32, total: u32, paid: bool) -> Order {
Order { id, customer: format!("KH{id}"), total, paid }
}
#[test]
fn unpaid_total_only_counts_unpaid_orders() {
let orders = vec![order(1, 100, true), order(2, 50, false), order(3, 70, false)];
assert_eq!(unpaid_total(&orders), 120);
}
#[test]
fn unpaid_total_of_empty_slice_is_zero() {
assert_eq!(unpaid_total(&[]), 0);
}
#[test]
fn invoice_numbers_stop_after_end() {
let numbers: Vec<String> = InvoiceNumbers::new(9, 11).collect();
assert_eq!(numbers, vec!["HD-0009", "HD-0010", "HD-0011"]);
}
}
cargo test
running 3 tests
test tests::invoice_numbers_stop_after_end ... ok
test tests::unpaid_total_of_empty_slice_is_zero ... ok
test tests::unpaid_total_only_counts_unpaid_orders ... ok
test result: ok. 3 passed; 0 failed; 0 ignored
Der zweite Test prüft einen Grenzfall. sum() auf einem leeren Iterator gibt 0 zurück, nicht panic.
8. Häufige Fehler und wann man Iteratoren nicht verwenden sollte
Hinweis: Den Consumer zu vergessen ist der häufigste Fehler. Die Zeile
orders.iter().map(|o| println!("{}", o.id));druckt nichts. Der Compiler warnt: “iterators are lazy and do nothing unless consumed”. Wenn du nur Nebeneffekte brauchst, nutze einefor-Schleife oderfor_each.
Einige andere Fehler, denen du begegnen wirst:
collectkennt den Zieltyp nicht.let v = it.collect();schlägt fehl mit “type annotations needed”. Du musstlet v: Vec<_> = ...schreiben oder Turbofishcollect::<Vec<_>>()verwenden.into_iter()verwenden und danach die Collection nochmal nutzen. Wenn du nur lesen willst, nutzeiter().- Closure hält Borrow zu lange. Ein
FnMutClosure, der&mut carterfasst, hindert dich daran,cartzu lesen, bis der letzte Gebrauch des Closures vorbei ist. Grenze den Closure-Scope ein oder rufe ihn auf und nutze danncart. ?Operator immapbrauchen. Der?Operator in einem Closure gibt nur aus dem Closure zurück, nicht aus der umgebenden Funktion. Nutzecollect::<Result<Vec<_>, _>>(). Das stoppt beim ersten Fehler und gibtErrzurück.
Wann solltest du nicht alles in eine Iterator-Kette zwingen:
- Die Logik hat viele Branches, viele
break/continueStellen oder aktualisiert mehrere Variablen gleichzeitig. Eine klarefor-Schleife ist leichter zu lesen als ein zehn-zeiligesfold. - Du musst Closures aus mehreren Branches zurückgeben, aber machst dir Sorgen um Performance.
Box<dyn Fn>hat Allokations- und dynamischen Aufruf-Overhead. Erwäge statt dessenenumoder generics.
9. Fazit
Closures in Rust erfassen Variablen auf die leichteste Weise: Borrow, veränderlicher Borrow oder move. Daraus ergibt sich, dass Closures automatisch Fn, FnMut oder FnOnce implementieren. Wenn du eine Funktion schreibst, die einen Closure nimmt, wähle das lockerste Trait, das die Funktion benötigt.
Iterator beruhen nur auf einer Methode: next. Adapter wie map, filter, zip sind lazy, während Consumer wie collect, sum, fold die Pipeline tatsächlich ausführen. Indem du diese beiden Werkzeuge kombinierst, schreibst du prägnanten, speichersicheren und schnellen Code für Datenverarbeitung, der Hand geschriebenen Schleifen gleichkommt. Du solltest auch wissen, wann eine einfache for-Schleife die bessere Wahl ist.