Funktionen
Funktionen bilden
- wie mathematische Funktionen deterministisch Eingabeparameter auf Ausgabewerte ab,
- führen sogenannte Seiteneffekte aus wie z.B. die Ausgabe von Text auf dem Bildschirm oder
- sind eine Mischung beider vorgenannter Punkte.
Beispiele
Ein Beispiel einer allgegenwärtigen ist die Funktion main
. Sie dient als Einstiegspunkt für Rustprogramme.
Eine Funktionsdefinition beginnt mit dem Schlüsselwort fn
. Darauf folgen der Name, die Argumente in Klammern,
der Rückgabewert nach einem Pfeil und der Funktionsrumpf in geschweiften Klammern. Der Funktionsrumpf definiert das Verhalten
einer Funktion.
Beispielsweise definiert die Funktion
fn main() { println!("Hallo Welt."); }
den Einstiegspunkt eines Rustprogramms, das Hallo Welt.
ausgibt. Aus main
können nun weitere Funktionen
aufgerufen werden.
fn print_hallo_welt() { println!("Hallo Welt."); } fn main() { print_hallo_welt(); }
Folgende Funktion addiert 2 Zahlen und gibt das Ergebnis zurück. Funktionsparameter werden immer mit Typen annotiert. Die Typen der Funktionsrückgabewerte werden hinter einen nach rechts gerichteten Pfeil gesetzt.
#![allow(unused)] fn main() { fn add(x: f32, y: f32) -> f32 { x + y } }
Wenn einer Funktionssignatur ein Rückgabewert fehlt, wird das leere Tupel zurückgegeben. Das heißt
fn main() {}
und
fn main() -> () {}
sind äquivalent.
Man beachte, dass println!
keine Funktion
in Rust ist, sondern ein Macro, das übrigens ebenfalls ()
zurückgibt. Macros erkennt man
am Ausrufezeichen. Wir verschieben das Verständnis von Macros auf später und verwenden
sie für den Moment einfach. Es sei an dieser Stelle nur erwähnt, dass Macros in Rust nicht einfache
Textersetzungswerkzeuge sind wie in der Programmiersprache C. Neben Text kann println!
auch
mit Hilfe der Syntax
#![allow(unused)] fn main() { let x = 5; println!("{x}"); }
oder
#![allow(unused)] fn main() { let x = 5; println!("{}", x); }
den Wert von Variablen und Ausdrücken ausgeben.
Ausdrücke und Anweisungen
Der Rumpf einer Funktion besteht aus einer Reihe von Anweisungen (engl. statements) und endet möglicherweise
mit einem Ausdruck (engl. expression).
Während Anweisungen eine Aktion durchführen ohne einen Wert zurückzugeben, geben Ausdrücke immer auch einen
Wert zurück. Beispielsweise ist die Variablenzuweisung eines Ausdrucks let x = 4;
eine Anweisung. In diesem
Fall ist 4
also ein Ausdruck, während das Semikolon das Ende der Anweisung bedeutet. Eine Zuweisung ist
im Unterschied zu anderen Programmiersprachen wie C kein Ausdruck in Rust. Die folgende Tabelle beinhaltet
Beispiele für Ausdrücke.
Beschreibung | Beispiel |
---|---|
Funktionsaufrufe ohne expliziten Rückgabewert | print_hello_world() |
Funktionsaufrufe mit explizitem Rückgabewert | add(1.0, 1.1) |
Macroaufrufe | println!("Hallo Welt.") |
arithmetische Operationen | 5.0 + 7.5 |
Literale | 4 |
Variablen | x |
Wenn ein Ausdruck mit einem Semikolon terminiert, wird das Zurückgeben des Wertes unterdrückt. Während
#![allow(unused)] fn main() { fn returns_seven() -> usize { let x = 7; x } }
den Wert 7 zurückgibt, bekommt man von
#![allow(unused)] fn main() { fn returns_nothing() { let x = 7; x; } }
keine 7.
Quiz
Welchen Wert hat
x
nachlet x = returns_nothing();
?
Funktionen als Parameter
Funktionen können anderen Funktionen als Parameter übergeben werden oder auch der Rückgabewert einer Funktion sein1. Wir schauen uns ein Beispiel an, in dem wir unseren Code durch die Verwendung von Funktionsparametern modular gestalten. Angenommen wir möchten komponentenweises Addieren und Subtrahieren von 3d-Punkten implementieren. Punkte stellen wir im Code als Array mit 3 Elementen dar. Ein einfacher Weg sieht folgendermaßen aus.
/// Add two arrays fn arr_add(arr1: [f64; 3], arr2: [f64; 3]) -> [f64; 3] { let mut arr_res = [0.0; 3]; arr_res[0] = arr1[0] + arr2[0]; arr_res[1] = arr1[1] + arr2[1]; arr_res[2] = arr1[2] + arr2[2]; arr_res } /// Subtract two arrays fn arr_sub(arr1: [f64; 3], arr2: [f64; 3]) -> [f64; 3] { let mut arr_res = [0.0; 3]; arr_res[0] = arr1[0] - arr2[0]; arr_res[1] = arr1[1] - arr2[1]; arr_res[2] = arr1[2] - arr2[2]; arr_res } fn main() { let arr1 = [1.0, 1.0, 1.0]; let arr2 = [2.0, 3.0, 4.0]; let addition = arr_add(arr1, arr2); println!("{addition:?}"); let subtraction = arr_sub(arr1, arr2); println!("{subtraction:?}"); }
Die beiden Funktionen arr_add
und arr_sub
sind korrekt und addieren bzw. subtrahieren 2
Arrays der Länge 3 voneinander komponentenweise. Bei genauerer Betrachtung fällt auf,
dass beide Funktionsrümpfe bis auf das Plus- bzw. Minuszeichen identisch sind. Eine elegantere Lösung
übergibt einer allgemeinen Funktion für komponentenweise Operationen auf Arrays die Addition oder
Subtraktion als Parameter wie im folgenden Schnipsel ersichtlich.
Der Funktionsparameter
f
beinhaltet genau diese Funktion, die 2 Parameter vom Typ f64
bekommt und eine Gleitkommazahl zurückgibt.
Den Typen dieser Funktion annotieren wir mit fn(f64, f64) -> f64
.
/// Binary array operator fn bin_arr_op( arr1: [f64; 3], arr2: [f64; 3], f: fn(f64, f64) -> f64 ) -> [f64; 3] { let mut arr_res = [0.0; 3]; arr_res[0] = f(arr1[0], arr2[0]); arr_res[1] = f(arr1[1], arr2[1]); arr_res[2] = f(arr1[2], arr2[2]); arr_res } fn add(x: f64, y: f64) -> f64 { x + y } /// Add two arrays fn arr_add(arr1: [f64; 3], arr2: [f64; 3]) -> [f64; 3] { bin_arr_op(arr1, arr2, add) } fn subtract(x: f64, y: f64) -> f64 { x - y } /// Subtract two arrays fn arr_sub(arr1: [f64; 3], arr2: [f64; 3]) -> [f64; 3] { bin_arr_op(arr1, arr2, subtract) } fn main() { let arr1 = [1.0, 1.0, 1.0]; let arr2 = [2.0, 3.0, 4.0]; let addition = arr_add(arr1, arr2); println!("{addition:?}"); let subtraction = arr_sub(arr1, arr2); println!("{subtraction:?}"); }
Wir können jetzt einfach weitere binäre Operationen wie Multiplikation auf Skalaren definieren und diese
mit Hilfe der Funktion bin_arr_op
auf Arrays anwendbar machen.
Rust bietet ein Feature, das es erlaubt Funktionen, die als Parameter übergeben werden, einfach an Ort und Stelle zu definieren.
/// Binary array operator fn bin_arr_op( arr1: [f64; 3], arr2: [f64; 3], f: fn(f64, f64) -> f64 ) -> [f64; 3] { let mut arr_res = [0.0; 3]; arr_res[0] = f(arr1[0], arr2[0]); arr_res[1] = f(arr1[1], arr2[1]); arr_res[2] = f(arr1[2], arr2[2]); arr_res } /// Add two arrays fn arr_add(arr1: [f64; 3], arr2: [f64; 3]) -> [f64; 3] { bin_arr_op(arr1, arr2, |x, y| x + y) } /// Subtract two arrays fn arr_sub(arr1: [f64; 3], arr2: [f64; 3]) -> [f64; 3] { bin_arr_op(arr1, arr2, |x, y| x - y) } fn main() { let arr1 = [1.0, 1.0, 1.0]; let arr2 = [2.0, 3.0, 4.0]; let addition = arr_add(arr1, arr2); println!("{addition:?}"); let subtraction = arr_sub(arr1, arr2); println!("{subtraction:?}"); }
Im letzten Schnipsel sind die Funktionen add
und subtract
verschwunden und wurden durch die anonymen Funktionen
|x, y| x + y
bzw. |x, y| x - y
ersetzt. Mit dieser Syntax kann man eine Funktion innerhalb
einer anderen Funktion in einer Zeile definieren. Der Compiler versucht die Typen der Funktionsparameter
ähnlich zu let
Anweisungen herzuleiten. Das ist aber nicht immer möglich. Manchmal bittet der Compiler um
Typannotationen. Wenn der Rumpf einer anonymen Funktion aus mehreren Anweisungen und Ausdrücken
bestehen soll, kann man geschweifte Klammern verwenden. Die folgende Funktion berechnet
beispielsweise ob die Summe aus ihren Eingaben durch 2 teilbar ist.
#![allow(unused)] fn main() { |x: i32, y: i32| { let sum = x + y; sum % 2 == 0 }; }
Anonyme Funktionen haben zwar keine Namen können jedoch wie im folgenden Beispiel Variablen zugewiesen werden.
#![allow(unused)] fn main() { let is_even = |x, y| { let sum = x + y; sum % 2 == 0 }; let a = 2; let b = 3; if is_even(a, b) { println!("the sum of {a} and {b} is even") } else { println!("the sum of {a} and {b} is odd") } }
Weitere Bezeichnungen anonymer Funktionen in Rust sind Lambda-Funktionen oder Closures. Solange man Closures als einfache Funktionen an andere Funktionen übergeben möchte, muss man darauf achten, dass sie keine Variablen verwenden, die außerhalb ihres Geltungsbereichs liegen. Die beiden folgenden Schnipsel zeigen ein entsprechendes Beispiel.
#![allow(unused)] fn main() { /// Unary array operator fn unary_arr_op( arr: [f64; 3], f: fn(f64) -> f64 ) -> [f64; 3] { let mut arr_res = [0.0; 3]; arr_res[0] = f(arr[0]); arr_res[1] = f(arr[1]); arr_res[2] = f(arr[2]); arr_res } fn increase_by_10(arr: [f64; 3]) -> [f64; 3]{ // kompiliert nicht, da die Variable `ten` außerhalb des Geltungsbereichs // der anonymen Funktion incr ist // und incr somit keine einfache Funktion mehr ist let ten = 10.0; let incr = |x| x + ten; unary_arr_op(arr, incr) } }
Dem vorstehenden Beispiel wird der Compiler mit dem Hinweis, ein fn pointer sei kein closure die Übersetzung verweigern. Der Typ einfacher Funktionen in Rust heißt auch Funktionspointer.
#![allow(unused)] fn main() { /// Unary array operator fn unary_arr_op( arr: [f64; 3], f: fn(f64) -> f64 ) -> [f64; 3] { let mut arr_res = [0.0; 3]; arr_res[0] = f(arr[0]); arr_res[1] = f(arr[1]); arr_res[2] = f(arr[2]); arr_res } fn increase_by_10(arr: [f64; 3]) -> [f64; 3]{ // kompiliert, da 10 innerhalb des Geltungsbereichs ist // und incr somit eine einfache Funktion ist let incr = |x| x + 10.0; unary_arr_op(arr, incr) } }
Closures können durchaus auf den umliegenden Geltungsbereich zugreifen und sind damit ein sehr nützliches Werkzeug aber eben nicht notwendigerweise einfache Funktionen. Wie man Closures, die Variablen außerhalb ihres Geltungsbereichs verwenden, gewinnbringend einsetzen kann, werden wir in einer späteren Einheit erleben.
1: Funktionen, die andere Funktionen als Parameter begrüßen oder Funktionen zurückgeben, heißen Funktionen höherer Ordnung.