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_cart liest nur cart, daher hält es einen &cart.
  • add_item ruft push auf, daher benötigt es &mut cart. Die Closure-Variable muss auch mit mut deklariert sein, da der Aufruf den inneren Zustand ändert.
  • Der Code kompiliert dank non-lexical lifetimes. print_cart wird 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 er Fn.
  • Der zweite Closure erhöht count, daher ist er FnMut. Ihn an run_twice zu übergeben würde fehlschlagen.
  • Der letzte Closure nutzt name + "...". Der + Operator auf String nimmt sich den Ownership von name, daher kann der Closure nur einmal aufgerufen werden und implementiert nur FnOnce.

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 in pick_rule haben unterschiedliche Typen, daher müssen sie zu einem gemeinsamen Trait Object verpackt werden.
  • move in make_discount ist erforderlich. Ohne es würde der Closure percent borgen, aber percent ist 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 prices ruft into_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ächlich next auf 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, der bool zurückgibt. Der Parameter o hat den Typ &&Order, weil filter eine Referenz auf das Element weitergibt, und das Element von iter() ist bereits &Order. Auto-Deref lässt dich o.paid normal 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ückgabetyp Vec<&str> der Funktion.
  • fold nimmt 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 = String deklariert den Typ von Elementen, die der Iterator liefert.
  • next aktualisiert den Zustand über &mut self und gibt None zurück, wenn end überschritten wird.
  • zip paart 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 eine for-Schleife oder for_each.

Einige andere Fehler, denen du begegnen wirst:

  • collect kennt den Zieltyp nicht. let v = it.collect(); schlägt fehl mit “type annotations needed”. Du musst let v: Vec<_> = ... schreiben oder Turbofish collect::<Vec<_>>() verwenden.
  • into_iter() verwenden und danach die Collection nochmal nutzen. Wenn du nur lesen willst, nutze iter().
  • Closure hält Borrow zu lange. Ein FnMut Closure, der &mut cart erfasst, hindert dich daran, cart zu lesen, bis der letzte Gebrauch des Closures vorbei ist. Grenze den Closure-Scope ein oder rufe ihn auf und nutze dann cart.
  • ? Operator im map brauchen. Der ? Operator in einem Closure gibt nur aus dem Closure zurück, nicht aus der umgebenden Funktion. Nutze collect::<Result<Vec<_>, _>>(). Das stoppt beim ersten Fehler und gibt Err zurück.

Wann solltest du nicht alles in eine Iterator-Kette zwingen:

  • Die Logik hat viele Branches, viele break/continue Stellen oder aktualisiert mehrere Variablen gleichzeitig. Eine klare for-Schleife ist leichter zu lesen als ein zehn-zeiliges fold.
  • 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 dessen enum oder 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.

rustiteratorclosurefunctional programming