zurück zum Artikel

Ferris Talk #3: Neue Rust-Edition 2021 ist da – Closures, das Cinderella-Feature

Rainer Stropek
Ferris Talk – Neuigkeiten zu Rust. Eine Heise-Kolumne von Rainer Stropek und Stefan Baumgartner für Rustaceans

Die dritte Ausgabe der Rust-Kolumne dreht sich um Closures, zu denen es in Rust mehr zu beachten gibt als in verwalteten Sprachen wie C#, Java oder JavaScript.

Alle heute weit verbreiteten Programmiersprachen haben eine spezielle Syntax für kompakt geschriebene, anonyme Funktionen. In C# heißen sie beispielsweise Lambda Expressions, in JavaScript Arrow Functions und in Python Lambda Functions. Rust ist keine Ausnahme. Die kompakte Schreibweise anonymer Funktionen werden hier Closures genannt. Es folgt ein einfaches Beispiel für Leserinnen und Leser, die noch wenig oder keine Rust-Erfahrung haben:

fn main() {
    let x = 21;
    let get_answer = |y: i32| x + y;
    println!("{:?}", get_answer(21));
}

Der Code weist der Variable get_anser eine Closure, also eine anonyme Funktion zu. Closure heißt die Sprachfunktion, weil sie das allgemein in der Informatik bekannte Closure-Konzept [1] abbildet: Die oben gezeigte Funktion hat Zugriff auf ihren Erstellungskontext. Sie kann also auf x zugreifen, obwohl x weder eine lokale Variable innerhalb der Funktion noch ein Parameter ist.

Ferris Talks – die Kolumne für Rustaceans

Konzeptionell entsteht im Hintergrund eine eigene Datenstruktur (in Rust wäre das eine Struktur), die aus der Funktion und den von ihr benötigten Variablen besteht. Insofern bezeichnet Closure nicht nur die Funktion selbst, sondern die Funktion samt den an sie gebundenen Speicherstrukturen. Das Binden von Variablen an die Closure heißt Capturing.

Die neue Rust-Edition 2021 [17] bringt eine tiefgreifende Änderung beim Capturing von Variablen in Closures. In dieser Ausgabe der Kolumne wollen wir erst die Grundlagen von Closures in Rust erläutern, um darauf aufbauend beschreiben zu können, welche neuen Möglichkeiten die brandneue Rust-Edition mitbringt.

Folgendes Beispiel zeigt die Syntax von Rust Closures. Der Code enthält unterschiedliche Arten für eine Funktion [18] add, die zwei Zahlen addiert. Ausgangspunkt ist die Syntax einer normalen Funktion. Die anschließenden Umsetzungen vereinfachen die Logik Schritt für Schritt, um zu zeigen, wie kompakt Closures werden können und wie eine Closure sich aufrufen lässt.

#[allow(unused_variables)]

fn main() {
    // Regular function
    fn add(x: i32, y: i32) -> i32 { x + y }
    let f = add;
    
    // Add function written as closure
    let f = |x: i32, y: i32| { x + y };
    
    // Simplified closure because of single expression
    let f = |x: i32, y: i32| x + y;
    
    // Closure with inferred parameter types
    let f = |x     , y     | x + y;
    
    let result = f(1, 2);
    println!("{}", result);
    
    // Inline closure incl. function call
    println!("{}", (|x, y| x + y)(1, 2));
}

Closures ohne Capturing lassen sich in Rust überall dort verwenden, wo ein Function Pointer (fn) erwartet wird, wie folgendes Beispiel illustriert [19]. Die Funktion calc_and_print erwartet einen Pointer auf eine Funktion. Die Anforderung lässt sich durch den Verweis auf eine reguläre Funktion oder durch eine Closure ohne Capturing erfüllen.

Der letzte Aufruf von calc_and_print, der im Beispielcode auskommentiert ist, würde nicht kompilieren. Der Grund ist, dass die Closure nicht mehr nur aus der Funktion, sondern aus der Funktion samt Captured Variable z besteht. Die Closure wird in diesem Fall konzeptionell zu einer Struktur: Ein Function Pointer reicht daher nicht mehr aus, um sie repräsentieren.

#[allow(unused_variables)]

fn main() {
    fn add(x: i32, y: i32) -> i32 { x + y }

    fn calc_and_print(x: i32, y: i32, calculator: fn(i32, i32) -> i32) {
        let result = calculator(x, y);
        println!("{}", result);
    }
    
    calc_and_print(1, 2, add);
    calc_and_print(1, 2, |x, y| x + y);
    
    let z = 3;
    // The following closure does not work because it captures z.
    // Therefore, it cannot act as a function pointer. The close
    // consists of the function plus the captured variable.
    // calc_and_print(1, 2, |x, y| x + y + z);
}

Um Closures mit Capturing an eine Funktion übergeben zu können, verwendet man statt eines Function Pointers (fn) den Trait Fn (std::ops::Fn). Jede Rust Closure implementiert den Fn-Trait. Technisch gesehen vereinfacht diese Aussage die Dinge ein wenig, da sie nur für Immutable References gilt. Die weiteren Details folgen weiter unten. Doch für den Moment reicht es, bei der vereinfachten Variante zu verweilen.

betterCode() Rust – Dein Einstieg und Deep Dive in Rust
Heise-Konferenz zu Rust, Einstieg und Deep Dive, 13. Oktober 2021 online, mit Rainer Stropek und Stefan Baumgartner

Heise richtete am 13. Oktober 2021 eine Online-Konferenz zu Rust [20] für Einsteiger und Experten aus. Die beiden Ferris-Talk-Kolumnisten waren dort mit Vorträgen und Workshops präsent und vermitteln ihr Wissen daran anschließend in vertiefenden Workshops.

Rust: Online-Workshops von Stropek & Baumgartner (jeweils 9-17 Uhr)

Das folgende Beispiel zeigt eine neue Variante der bereits oben eingeführten Funktion [23] calculate_and_print. Diesmal nimmt die Funktion als Parameter ein Trait Object vom Typ Fn entgegen. Um genau zu sein, handelt es sich um eine Box. Die Box ist notwendig, da Trait Objects in Rust eine dynamische Größe haben, Parameter aber von statischer Größe sein müssen.

Für Rust-Anfängerinnen und -Anfänger sind Trait Objects in Verbindung mit einer Box erfahrungsgemäß etwas verwirrend. Wer mit diesem Konstrukt von Rust noch nicht vertraut ist, findet weitere Informationen in der Rust-Dokumentation unter Trait Object Types [24].

fn main() {
    fn add(x: i32, y: i32) -> i32 { x + y }
    
    fn calc_and_print(x: i32, y: i32, calculator: Box<dyn Fn(i32, i32) -> i32 + '_>) {
        let result = calculator(x, y);
        println!("{}", result);
    }
    
    calc_and_print(1, 2, Box::new(add));
    calc_and_print(1, 2, Box::new(|x, y| x + y));
    
    // Now we can also pass a closure with capturing
    // to calc_and_print.
    let z = 3;
    calc_and_print(1, 2, Box::new(|x, y| x + y + z));
}

Konzeptionell wird aus der Close mit Capturing eine Struktur, die die Funktion und die gebundene Variable enthält. Ausgeschrieben würde das in etwa so aussehen [25]:

struct AdderClosure { z: i32 }
trait MyAdder { fn add(&self, x: i32, y: i32) -> i32; }
impl MyAdder for AdderClosure {
    fn add(&self, x: i32, y: i32) -> i32 { x + y + self.z }
}

fn main() {
    fn calc_and_print(x: i32, y: i32, calculator: Box<dyn MyAdder>) {
        let result = calculator.add(x, y);
        println!("{}", result);
    }

    let closure = AdderClosure{ z: 3 };
    calc_and_print(1, 2, Box::new(closure));
}

Der Hinweis auf den Trait Fn war vereinfacht: In Wirklichkeit gibt es nicht nur Fn, sondern drei Closure-bezogene Traits, aus denen der Rust-Compiler je nach Verwendungszweck der gebundenen Variablen auswählt:

FnOnce übernimmt die Ownership der gebundenen Variablen, konsumiert sie also. Der Zusatz Once verdeutlicht, dass eine FnOnce-Closure sich nur einmal aufrufen lässt. Jede Closure ist kompatibel mit FnOnce, da jede Closure sich mindestens einmal aufrufen lässt.

Empfohlener redaktioneller Inhalt

Mit Ihrer Zustimmmung wird hier ein externes YouTube-Video (Google Ireland Limited) geladen.

Ich bin damit einverstanden, dass mir externe Inhalte angezeigt werden. Damit können personenbezogene Daten an Drittplattformen (Google Ireland Limited) übermittelt werden. Mehr dazu in unserer Datenschutzerklärung [26].

Das folgende Beispiel zeigt exemplarisch eine Closure [27], die eine gebundene Variable result ändert. Es ist also eine Closure mit Mutable Borrow, der entsprechende Trait ist daher FnMut, nicht mehr wie in den oben gezeigten Beispielen Fn.

fn main() {
    let mut result = 0;
    
    // Closure with mutable borrow
    let mut calc_result = |x, y| { result = x + y; };
    calc_result(1, 2);
    println!("{}", result);
    
    // Store closure in `FnMut` variable before calling it
    let mut result_calculator: Box<dyn FnMut(i32, i32)>
        = Box::new(|x, y| { result = x + y; });
    result_calculator(1, 2);
    drop(result_calculator);
    println!("{}", result);
}

Wenn die Closure die Ownership über alle gebundenen Variablen übernehmen soll, gibt man vor der Closure das Schlüsselwort move an. Eine solche Closure implementiert dann den FnOnce-Trait. Das folgende Beispiel zeigt den Zusammenhang [28]:

fn main() {
    // Closure consuming iterator
    let numbers_iter = vec![1, 2, 3, 5, 6].into_iter();
    let sum_calculator = move || numbers_iter.sum();
    let result: i32 = sum_calculator();
    println!("{}", result);

    // Store closure in [code]FnOnce[/code] variable before calling it
    let numbers_iter = vec![1, 2, 3, 5, 6].into_iter();
    let sum_calculator: Box<dyn FnOnce() -> i32>
        = Box::new(move || numbers_iter.sum());
    let result: i32 = sum_calculator();
    println!("{}", result);
}

Besonders relevant ist move in Verbindung mit Multithreading [29]. Das folgende Beispiel zeigt, wie die main-Methode die Ownership über zwei Variablen (counter und counter_string) an die Closure übergibt, die im Hintergrund-Thread ausgeführt wird. In der Praxis ließe sich damit beispielsweise ein Channel für Interprozesskommunikation an einen Thread weitergeben [30].

use std::time::Duration;
use std::thread;

fn main() {
    let mut counter = 0;
    let mut counter_string = String::new();
    
    // Move ownership of captured variables into child thread.
    let background = thread::spawn(move || {
        loop {
            counter = counter + 1;
            
            // Add counter to counter string
            if counter_string.len() > 0 {
                counter_string.push_str(", ");
            }
            
            counter_string.push_str(&counter.to_string());
            
            // Print counter string on the screen
            println!("{}", counter_string);
            
            // Exit after five iterations
            if counter == 5 { break; }
            
            // For demo purposes, wait a moment
            thread::sleep(Duration::from_millis(100));
        }
    });

    // Wait for background thread to complete    
    background.join().unwrap();
    
    // The next line would not compile because ownership
    // has been moved into the child thread's closure.
    //println!("{}", counter_string);
}

In Rust 2021 wurde die Logik des Capturings der Variablen in den Closures verfeinert. Der technische Name der neuen Logik ist Disjoint Capture in Closures. Sie führt dazu, dass Closures nicht mehr Ownership über die gesamte Struktur übernehmen, wenn ihr Code nur auf Teile der Struktur zugreift. Klarer wird das an einem kurzen Codebeispiel [31]. Es kompiliert nicht mit Rust 2018. Erst mit Rust 2021 lässt sich der Code ausführen. Der Beispielcode enthält Kommentarzeilen, in denen der Unterschied zwischen den Rust-Editionen in Hinblick auf das Capturing in den Closures erklärt wird.

struct Something {
    anything: String,
    anything_else: String,
}

impl Something {
    fn new() -> Something {
        Something {
            anything: "Foo".to_string(),
            anything_else: "Bar".to_string()
            
        }
    }
}

fn main() {
    let st = Something::new();
    drop(st.anything);
    
    // In Rust 2018, the following closure captures st.
    // Therefore, the code does not compile because a part of
    // st has already been dropped (see previous line of code).
    // In Rust 2021, the closure only captures st.anything_else.
    // Therefore, the code compiles.
    println!("{}", (|| st.anything_else)());
}

Es stellt sich die Frage, ob die neue Rust-Sprachfunktion in der Praxis überhaupt eine Rolle spielt. Tatsächlich ermöglicht sie Datenstrukturen, die sich bisher in Rust mit Closures nicht verarbeiten ließen, wie das folgende Beispiel zeigt [32]. Es bildet ein Problem aus dem Märchen Aschenputtel ab: Am Boden liegen Linsen (Lentils), und Tauben sollen die schlechten, kleinen Linsen fressen, die guten, großen Linsen hingegen übrig lassen. Linsen und die "Verarbeitungslogik" für die Tauben sind in der Datenstruktur CinderellaTask zusammengefasst. In der Methode sort_lentils wird die Verarbeitungslogik auf alle Linsen angewandt, und genau dabei kommt die neue Capturing-Logik von Rust 2021 zum Einsatz. Erst sie ermöglicht, dass Daten (lentils) und Closure (eat) in einer Struktur zusammengefasst sind.

#[derive(Debug)]
struct Lentil {
    size: f32
}

struct CinderellaTask {
    lentils: Vec<Lentil>,
    eat: Box<dyn Fn(&Lentil) -> bool>,
}

impl CinderellaTask {
    fn sort_lentils(&mut self) {
        // The following line works in Rust 2021, it does not in 2018.
        // In the 2018 edition, the closure captures the entire [code]self[/code].
        // In the 2021 edition, the closure just captures [code]self.eat[/code].
        // So using [code]self.lentils[/code] outside is now possible.
        self.lentils.retain(|l| !(self.eat)(l));
    }
}

fn main() {
    let lentils = vec![
        Lentil{ size: 5.0 },
        Lentil{ size: 6.0 },
        Lentil{ size: 1.0 },
        Lentil{ size: 2.0 },
    ];

    let mut task = CinderellaTask {
        lentils: lentils,
        eat: Box::new(|l| l.size < 5.0),
    };
    
    task.sort_lentils();
    println!("{:?}", task.lentils);
}

Wer üblicherweise eine Sprache mit Garbage Collector wie C#, Java oder JavaScript verwendet, schüttelt an dieser Stelle vielleicht den Kopf angesichts der Komplexität von Closures in Rust. Tatsächlich gibt es in Rust einige Dinge mehr zu beachten und zu verstehen, bevor man Closures in der Praxis produktiv einsetzen kann. Ein Garbage Collector kann viele Details verstecken. Der Komfort geht jedoch zulasten der Performance.

Rust kommt ohne Garbage Collector aus und kann dadurch eine Performance ähnlich wie C oder C++ bieten. Die Closures inklusive dem Capturing von Variablen aus dem Erstellungskontext stehen zur Verfügung, ohne dass man dafür Performancenachteile in Kauf nehmen müsste. Die Sprache Rust hält damit ihr Versprechen von Zero-Cost Abstractions ein.


Ferris Talk – Neuigkeiten zu Rust. Die Kolumnisten:
Rainer Stropek, timecockpit.com, Rust Meetup Linz, Autor der Ferris Talks, der Kolumne über die Programmiersprache Rust bei Heise Developer

Rainer Stropek, Autor von Ferris Talk #3

ist Softwareentwickler, Trainer, Autor und Vortragender im Microsoft-Umfeld und seit über 25 Jahren als Unternehmer in der IT-Industrie tätig.

Er gründete und führte in dieser Zeit mehrere IT-Dienstleistungsunternehmen und entwickelt neben seiner Tätigkeit als Trainer und Berater in seiner Firma software architects [33] mit seinem Team die preisgekrönte Software time cockpit [34].

Rainer hat Abschlüsse der höheren technischen Schule für Informatik Leonding (AT) sowie der University of Derby (UK). Er ist Autor mehrerer Fachbücher und Artikel in Magazinen im Umfeld von Microsoft .NET und C#, Azure, Go und Rust. Seine technischen Schwerpunkte sind Cloud Computing, die Entwicklung verteilter Systeme sowie Datenbanksysteme.

Rainer tritt regelmäßig als Speaker und Trainer auf namhaften Konferenzen in Europa und den USA auf. 2010 wurde Rainer von Microsoft zu einem der ersten MVPs (Most Valuable Professionals) für die Azure-Plattform ernannt. Seit 2015 ist er Microsoft Regional Director. 2016 hat er zudem den MVP Award für Visual Studio und Developer Technologies erhalten.

Fellow Rustacean und Co-Kolumnist
Stefan Baumgartner, Dynatrace.at, Rust Meetup Linz, Autor der Ferris Talks, der Kolumne über die Programmiersprache Rust bei Heise Developer

Stefan Baumgartner

lebt und arbeitet als Software-Architekt und Entwickler bei Dynatrace im österreichischen Linz mit Schwerpunkt auf Webentwicklung, Serverless und Cloud-basierte Architekturen.

Für den Smashing Magazine Verlag veröffentlichte er 2020 mit “TypeScript in 50 Lessons” sein zweites Buch [35], seine Onlinepräsenz fettblog.eu [36] enthält Artikel, Tutorials und Guides zum Thema TypeScript, Rust, React, und Software Engineering im Allgemeinen.

Stefan organisiert Meetups und Konferenzen, wie Stahlstadt.js [37], die DevOne, ScriptConf, Rust Meetup Linz [38], und das legendäre Technologieplauscherl [39]. Außerdem ist er regelmäßig Gastgeber im Working Draft [40], dem deutschsprachigen Podcast über Webtechnologien. Wenn noch ein wenig Freizeit bleibt, genießt er italienische Pasta, belgisches Bier und britischen Rock.

Ferris Talk – Neuigkeiten zu Rust. Die Kolumne für Rustaceans

Vielfach heißt es, dass sich die Welt im Bereich der Softwareentwicklung schneller ändert als in anderen Bereichen. Auch wenn das stimmen mag, sind fundamentale Umbrüche mit langfristiger Wirkung auch in der Informatik selten. Nicht jedes JavaScript-Framework stellt die Art, Software zu entwickeln, komplett auf den Kopf. Rust aber gehört zu den seltenen Änderungen, die nachhaltig, langfristig und unserer Einschätzung nach positiv wirken werden.

In dieser Kolumne möchten die beiden Rust-Experten Rainer Stropek und Stefan Baumgartner abwechselnd regelmäßig über Neuerungen und Hintergründe im Bereich Rust berichten. Sie soll Teams, die Rust schon verwenden, helfen, auf dem Laufenden zu bleiben. Einsteigerinnen und Einsteiger sollen durch die Kolumne tiefere Einblicke in die Funktionsweise von Rust erhalten.

Kolumnenautoren vom Rust Meetup Linz

Der Kolumnen-Titel nimmt Bezug auf Ferris, das krabbenförmige inoffizielle Maskottchen [41] der Rust-Gemeinde. Die Ferris Talks schreiben Stropek und Baumgartner ab sofort monatlich und im Wechsel – mehr zu den Autoren steht am Ende des Artikels. Die beiden Kolumnisten sind überzeugte Rustaceans und organisieren das Rust Meetup Linz [42]. Wer die beiden beruflich treffen möchte, kann sie unter anderem als Vortragende und als Workshop-Trainer bei der Rust-Konferenz 2021 [43] von Heise erleben.

(sih [46])


URL dieses Artikels:
https://www.heise.de/-6222248

Links in diesem Artikel:
[1] https://de.wikipedia.org/wiki/Closure_(Funktion)
[2] https://www.heise.de/hintergrund/Ferris-Talk-1-Iteratoren-in-Rust-6175409.html
[3] https://www.heise.de/hintergrund/Ferris-Talk-2-Abstraktionen-ohne-Mehraufwand-Traits-in-Rust-6185053.html
[4] https://www.heise.de/hintergrund/Ferris-Talk-3-Neue-Rust-Edition-2021-ist-da-mit-Disjoint-Capture-in-Closures-6222248.html
[5] https://www.heise.de/hintergrund/Ferris-Talk-4-Asynchrone-Programmierung-in-Rust-6299096.html
[6] https://www.heise.de/hintergrund/Ferris-Talk-5-Tokio-als-asynchrone-Laufzeitumgebung-ist-ein-Fast-Alleskoenner-6341018.html
[7] https://www.heise.de/hintergrund/Ferris-Talk-6-Ein-neuer-Trick-fuer-die-Formatstrings-in-Rust-6505377.html
[8] https://www.heise.de/hintergrund/Ferris-Talk-7-Vom-Ungetuem-zur-Goldrose-eine-kleine-Rust-Refactoring-Story-6658167.html
[9] https://www.heise.de/hintergrund/Ferris-Talk-8-Wasm-loves-Rust-WebAssembly-und-Rust-jenseits-des-Browsers-7064040.html
[10] https://www.heise.de/hintergrund/Ferris-Talk-9-Vom-Builder-Pattern-und-anderen-Typestate-Abenteuern-7134143.html
[11] https://www.heise.de/hintergrund/Ferris-Talk-10-Constant-Fun-mit-Rust-const-fn-7162074.html
[12] https://www.heise.de/hintergrund/Ferris-Talk-11-Memory-Management-Speichermanagement-in-Rust-mit-Ownership-7195773.html
[13] https://www.heise.de/hintergrund/Ferris-Talk-12-Web-APIs-mit-Rust-erstellen-7321340.html
[14] https://www.heise.de/hintergrund/Ferris-Talk-13-Rust-Web-APIs-und-Mocking-mit-Axum-7457143.html
[15] https://www.heise.de/hintergrund/Ferris-Talk-14-Rust-bekommt-endlich-asynchrone-Methoden-in-Traits-8929334.html
[16] https://www.heise.de/hintergrund/Ferris-Talk-15-Bedingte-Kompilierung-in-Rust-9337115.html
[17] https://doc.rust-lang.org/edition-guide/rust-2021/index.html
[18] https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=12e6fc5537750ac2f3025e451a92b79e
[19] https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=864e7dc4330959e121b50a74791f8308
[20] https://rust.bettercode.eu/
[21] https://rust.bettercode.eu/veranstaltung-13435-se-0-wasm-module-fuer-den-browser-mit-rust.html
[22] https://rust.bettercode.eu/veranstaltung-13654-se-0-netzwerk-applikationen-mit-dem-tokio-stack.html
[23] https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=864e7dc4330959e121b50a74791f8308
[24] https://doc.rust-lang.org/reference/types/trait-object.html
[25] https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=4fecb1fb997ed3162dace71528062e82
[26] https://www.heise.de/Datenschutzerklaerung-der-Heise-Medien-GmbH-Co-KG-4860.html
[27] https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=1b6080999a7f808eb62d4f24574a5402
[28] https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=f6519e111f570feebcb76570f60bac59
[29] https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=63b722c5704413d9d1ee7a3134c24640
[30] https://doc.rust-lang.org/std/sync/mpsc/fn.channel.html
[31] https://play.rust-lang.org/?version=nightly&mode=debug&edition=2018&gist=6aec9ed972c35574e63a4d778917e597
[32] https://play.rust-lang.org/?version=nightly&mode=debug&edition=2021&gist=49c4151f3269b1b6e4ad12f7411f111b
[33] https://www.linkedin.com/company/software-architects-og/about/
[34] https://www.timecockpit.com/
[35] https://typescript-book.com/
[36] https://fettblog.eu/
[37] https://stahlstadt.js.org/
[38] https://rust-linz.at/
[39] https://technologieplauscherl.at/
[40] https://workingdraft.de/
[41] https://rustacean.net/
[42] https://rust-linz.at/
[43] https://rust.bettercode.eu/#workshops
[44] https://www.youtube.com/playlist?list=PL85XCvVPmGQgL3lqQD5ivLNLfdAdxbE_u
[45] https://rust.bettercode.eu/#workshops
[46] mailto:sih@ix.de