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.

BeschreibungBeispiel
Funktionsaufrufe ohne expliziten Rückgabewertprint_hello_world()
Funktionsaufrufe mit explizitem Rückgabewertadd(1.0, 1.1)
Macroaufrufeprintln!("Hallo Welt.")
arithmetische Operationen5.0 + 7.5
Literale4
Variablenx

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