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

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.

In Pocket speichern vorlesen Druckansicht 2 Kommentare lesen
Ferris Talk – Neuigkeiten zu Rust. Eine Heise-Kolumne von Rainer Stropek und Stefan Baumgartner für Rustaceans
Lesezeit: 7 Min.
Von
  • Rainer Stropek
Inhaltsverzeichnis

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 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 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 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. 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 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 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.

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:

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));
}