↑Programmieren in Rust
Der klassische Kontrollfluss in der prozeduralen Programmierung verläuft so: Ein Unterprogramm ruft ggf. ein weiteres Unterprogramm auf, und dieses dann ggf. noch ein weiteres Unterprogramm usw. Der Kontrollfluss besteht aus Verzweigungen, Schleifen und Unterprogrammen.
Den Unterprogrammen kommt hierbei eine besondere Bedeutung zu. Diese modularisieren das Programm, indem sie wiederkehrende Funktionalität zusammenfassen, die internen Details verbergen und über ihre Signatur eine Schnittstelle zum Rest des Programms herstellen.
Nun kann man sich eine Situation überlegen, in der es eine Fallunterscheidung zwischen sehr vielen Unterprogrammen gibt. Da dies noch handhabbar erscheint, will ich diese Überlegung sogleich verschärfen. Was tut man denn, wenn das gewählte Unterprogramm nicht im Vorhinein bekannt ist?
Da ich mathematisch-technisch belastet bin, fallen mir dazu keine besseren Beispiele als numerische Verfahren ein. Betrachten wir das Newton-Verfahren zur Ermittlung von Nullstellen einer Funktion. Man kann dieses Verfahren nun für eine bestimmte reelle Funktion programmieren. Der Algorithmus setzt aber eigentlich keine spezielle Funktion voraus. Für eine allgemeine Formulierung muss die Funktion ein Argument des Verfahrens sein. Ermöglicht wird dies durch das Speichern einer Referenz auf die Funktion in einer Variable, man spricht auch von einem Funktionenzeiger.
Das Newton-Verfahren benötigt die erste Ableitung der Funktion. Diese lässt sich ebenfalls mit einem numerischen Verfahren approximieren. Als Beispiel sei die Funktion f(x) = x2 − 2 gewählt. Die positive Nullstelle ist die Wurzel von zwei.
fn diff(f: fn(f64)->f64, x: f64) -> f64 { let h = 0.001; (f(x+h) - f(x-h))/(2.0*h) } fn newton(f: fn(f64)->f64, x0: f64) -> f64 { let mut x = x0; for _ in 0..20 { x = x - f(x)/diff(f, x); } x } fn main() { fn f(x: f64) -> f64 {x*x - 2.0} println!("{}", newton(f, 1.0)); }
Der wesentliche Vorteil herbei ist, dass wir numerische Verfahren nun in allgemeiner Form in Bibliotheken aufbewahren können. Das geht nur, wenn das Verfahren nicht an eine feste Funktion gekoppelt ist.
Funktionenzeiger sind einer Beschränkung unterworfen, die ihre Brauchbarkeit stark reduziert. Zentral für die funktionale Programmierung ist, dass sich Funktionen aus Daten erzeugen lassen. Dies wird erreicht durch Bindung der Werte von Variablen aus der Umgebung der Funktion, man spricht von einem Closure. Die Umgebung wird auch Erstellungskontext genannt.
Angenommen wir wollten mit dem Newton-Verfahren nun die
Quadratwurzel-Funktion sqrt
implementieren. Die
Funktion ergäbe sich offenbar so:
fn sqrt(a: f64) -> f64 { fn f(x: f64) -> f64 {x*x - a} newton(f, 1.0) }
Diese Funktion würgt der Compiler jedoch ab, denn die innere
Funktion f
vermag es nicht, den Wert der Variablen
a
zu speichern. Diese Variable entstammt ja aus der
Umgebung der inneren Funktion.
Allerdings gestattet leichte Modifikation des Programms die explizite Benutzung von Closures und Closurezeigern. In abstrakteren Programmiersprachen sind Funktionen und Closures vereinheitlicht. In Rust ist das etwas komplizierter, weil Closures zur effizienten Programmierung mit den Eigenschaften des Typsystems verwoben sind.
Die verallgemeinerten Schnittstellen bezüglich Closurezeigern gestatten sowohl Funktionen als auch Closures als Argumente. Zwischen Funktionen und Closures besteht einen kleiner Unterschied in der Syntax. Anstelle von
fn f(x: f64) -> f64 {x*x - 2.0}
schreibt man:
let f = |x: f64| -> f64 {x*x - 2.0};
oder mittels Typinferenz kurz:
let f = |x| x*x - 2.0;
Das gewünschte Programm:
fn diff(f: &dyn Fn(f64)->f64, x: f64) -> f64 { let h = 0.001; (f(x+h) - f(x-h))/(2.0*h) } fn newton(f: &dyn Fn(f64)->f64, x0: f64) -> f64 { let mut x = x0; for _ in 0..20 { x = x - f(x)/diff(f, x); } x } fn sqrt(a: f64) -> f64 { let f = |x| x*x - a; newton(&f, 1.0) } fn main() { println!("{}", sqrt(2.0)); }
Nun stellen sich Fragen. Was hat es mit der Typsignatur
&dyn Fn(X)->Y
auf sich? Das ist doch eine
Referenz auf ein Trait-Objekt, oder nicht? Muss man sich ohne
Closures geschlagen geben?
Man muss sich nicht geschlagen geben, ein Verzicht auf Closures ist möglich, denn diese lassen sich als Objekte mit einer einzigen Methode modellieren. Auf diese Art sind Closures hinter den Kulissen auch in Rust umgesetzt.
Nämlich kann man den Funktionenzeiger und die Variable
a
zu einem Objekt zusammenfassen. Dem
Funktionenzeiger gibt man nun zusätzlich ein Argument zum Zugriff
auf das eigene Objekt und damit a
. Das ist nichts
anderes als das self-Argument, die Funktion wird damit zu einer
Methode. Schließlich ist der letzte wesentliche Schritt noch die
Abstraktion des Aufrufs – dieser sei call
genannt
– über einen Trait Fun<X,Y>
,
die Funktionalität ist damit generisch. Ein expliziter
Funktionenzeiger wird dabei nicht mehr benötigt, da dieser in Form
des Aufrufs call
schon über die Dispatch-Tabelle des
Trait-Objekts erzeugt wird.
Die Formulierung ist nun lediglich ein klein wenig umständlicher:
trait Fun<X, Y> { fn call(&self, x: X) -> Y; } fn diff(f: &dyn Fun<f64, f64>, x: f64) -> f64 { let h = 0.001; (f.call(x+h) - f.call(x-h))/(2.0*h) } fn newton(f: &dyn Fun<f64, f64>, x0: f64) -> f64 { let mut x = x0; for _ in 0..20 { x = x - f.call(x)/diff(f, x); } x } fn sqrt(a: f64) -> f64 { struct F {a: f64} impl Fun<f64,f64> for F { fn call(&self, x: f64) -> f64 {x*x - self.a} } newton(&F {a}, 1.0) } fn main() { println!("{}", sqrt(2.0)); }
Warum es ein Trait-Objekt sein muss, wird nun klar: Jedes Closure ergibt ein Objekt von einem jeweils neuen Datentyp, da nicht im Vorhinein bekannt ist, welche und wie viele Werte der Umgebung gebunden werden. Daher muss man von allen diesen Datentypen über eine gemeinsame Schnittstelle abstrahieren. Die Schnittstelle ist hierbei der Funktionsaufruf.
Die Komposition (Verkettung) zweier reeller Funktionen g, f an der Stelle x, das ist
compose(g, f, x) := g(f(x)),
ist implementierbar als:
fn compose<G, F>(g: G, f: F, x: f64) -> f64 where G: Fn(f64) -> f64, F: Fn(f64) -> f64 { g(f(x)) }
Die Funktion compose
muss parametrisch polymorph über
alle Typen G
, F
sein, weil der konkrete
Typ wie gesagt bei jedem Closure unterschiedlich ist.
Nun wollen wir compose
abändern zu
compose(g, f) := |x| g(f(x)).
Die Frage stellt sich, wie das zu typisieren ist, da nun
der Rückgabewert ein Closure darstellt. Man könnte denken,
der Rückgabetyp sei ebenfalls einfach durch eine Typvariable
zu ersetzen. Denken wir genauer darüber nach, stellen wir jedoch fest,
dass dies nicht richtig sein kann. Parametrische Polymorphie bedeutet,
dass die Typvariablen durch die konkreten Typen ersetzt werden
dürfen, die bei der Applikation der Funktion vorgefunden werden.
Zum Rückgabewert entsteht jedoch bei der Applikation ein
neuer Typ. Über diesen Typ wissen wir lediglich, dass dieser den
Trait Fn(f64)->f64
besitzt. Was wir hier vorliegen
haben, ist ein opaker Typ, und der erfüllte Trait ist dessen
Schnittstelle zur Außenwelt.
Das Typsystem von Rust unterstützt solche opaken Typen. Der
opake Typ mit Trait A
als Schnittstelle wird
impl A
geschrieben – man kann das lesen
als »opaker Typ, der A implementiert«. Die gewünschte Funktion
wird damit formulierbar:
fn compose<G, F>(g: G, f: F) -> impl Fn(f64) -> f64 where G: Fn(f64) -> f64, F: Fn(f64) -> f64 { move |x| g(f(x)) }
Man kann auch die Typen der Argumente als solche opaken Typen schreiben, was zunächst etwas verwirrend sein dürfte:
fn compose(g: impl Fn(f64) - >f64, f: impl Fn(f64) - >f64) -> impl Fn(f64) -> f64 { move |x| g(f(x)) }
Was hat es damit auf sich? Obwohl die beiden Typsignaturen nicht
identisch sind, verhalten sie sich äquivalent.
Die Idee dabei ist, dass die parametrische Polymorphie aus Sicht
außerhalb der Funktion einer opaken Typisierung aus Sicht innerhalb
der Funktion entspricht. Was wissen wir denn innerhalb der Funktion
über die Typen von g
, f
? Wir wissen
nichts über sie, außer dass sie die Schnittstelle
Fn(f64)->f64
zu erfüllen haben.
Für die allgemeine Signatur der Komposition, wie sie in der
Mathematik definiert ist, ersetzen wir ferner die Typen zu Argumenten
und Rückgabewerten von g
, f
durch Typvariablen und gelangen schließlich zu:
fn compose<X, Y, Z>(g: impl Fn(Y) -> Z, f: impl Fn(X) -> Y) -> impl Fn(X) -> Z { move |x| g(f(x)) }
Wie gesagt ist parametrische Polymorphie nicht identisch mit opak typisierten Argumenten. So darf man bei
fn print<T: std::fmt::Display>(x: T) { println!("{}", x); }
den Typparameter explizit belegen, also bspw.
print::<i32>
schreiben. Bei
fn print(x: impl std::fmt::Display) { println!("{}", x); }
ist das allerdings nicht erlaubt. Mit diesen Wissen sieht man,
dass die vollständig explizite Angabe der Typparameter bei
compose
nur in beschränkter Weise sinnfällig ist,
da Closure-Typen per se nicht benennbar sind.
Die Funktion map
wendet eine Funktion auf jedes
Element eines Arrays an.
fn map<X, Y>(a: &[X], f: &dyn Fn(&X) -> Y) -> Vec<Y> { let mut acc = Vec::with_capacity(a.len()); for x in a { acc.push(f(x)); } acc } fn main() { let a = vec![1, 2, 3 ,4]; let b = map(&a, &|x| x.to_string()); println!("{:?}", b); }
Alternativ ist hier auch die Signatur
f: impl Fn(&X)->Y
erlaubt, bzw. äquivalent:
fn map<X, Y, F>(a: &[X], f: F) -> Vec<Y> where F: Fn(&X) -> Y
Dies kann zu besser optimiertem Code führen, da die Monomorphisierung mehr Inlining ermöglicht.
Weitere Abstraktion erlaubt die Anwendung der Funktion auf beliebige iterierbare Objekte:
fn map<X, Y, A, F>(a: A, f: F) -> Vec<Y> where A: IntoIterator<Item = X>, F: Fn(&X) -> Y { let it = a.into_iter(); let mut acc = Vec::with_capacity(it.size_hint().0); for x in it { acc.push(f(&x)); } acc } fn main() { let b = map(1..=4, |x| x.to_string()); println!("{:?}", b); }
Die Funktion map
der Standardbibliothek ist
nochmals vielseitiger. Diese arbeitet auf ausgeklügelte Weise
mit Iteratoren und abstrahiert so von den iterierbaren
Datenstrukturen. Man benutzt sie so:
fn main() { let a = [1, 2, 3, 4]; let b: Vec<_> = a.iter().map(|x| x.to_string()).collect(); assert_eq!(b, ["1", "2", "3", "4"]); }
Führt man map
eine fehlbare Operation zu,
ist das Ergebnis wie erwartet ein Array von Resultaten. Bspw.
macht das Programm
fn main() { let a = ["1", "2", "3", "4"]; let b: Vec<_> = a.iter().map(|x| x.parse::<i32>()).collect(); println!("{:?}", b); }
die Ausgabe:
[Ok(1), Ok(2), Ok(3), Ok(4)]
Man kann collect
aber auch zu verstehen geben, dass
beim ersten auftretenden Fehler abgebrochen werden soll. So liefert
fn main() { let a = ["1", "2", "3", "4"]; let b: Result<Vec<_>, _> = a.iter() .map(|x| x.parse::<i32>()).collect(); println!("{:?}", b); }
die Ausgabe:
Ok([1, 2, 3, 4])
So wie parse
ist collect
eine
generische Funktion, dessen Typparameter durch Typinferenz
bestimmt werden kann, wofür der Kontext allerdings genügend
Information über die Typstruktur enthalten muss. Die Notation
let b: Typ = it.collect();
ist demnach mit
let b = it.collect::<Typ>();
gleichbedeutend.
Die Funktion filter
trennt von einem Array alle
Elemente ab, welche ein mitgegebenes Prädikat erfüllen. Dem Prädikat
widersprechende Elemente werden verworfen.
Das folgende Programm zeigt die Implementierung speziell für
Vec
. Die Funktion greift aus dem Array a
Elemente ab, welche das Prädikat p
erfüllen.
fn filter<T>(a: Vec<T>, p: impl Fn(&T) -> bool) -> Vec<T> { let mut acc = Vec::new(); for x in a { if p(&x) {acc.push(x);} } acc } fn divisors(n: u32) -> Vec<u32> { let a: Vec<u32> = (1..=n).collect(); filter(a, |&k| n%k == 0) } fn main() { println!("{:?}", divisors(12)); }
Zur Anwendung kam filter
in diesem Programm zur
Berechnung der Teilerliste einer Zahl. Die Erzeugung eines Arrays
als Zwischenwert war dabei jedoch unnötig. Ersetzung von
Vec
gegen eine Typvariable I
vom
Trait IntoIterator
verallgemeinert filter
dergestalt dass man die Erzeugung des Zwischenwertes entfallen
lassen kann.
fn filter<T, I, P>(a: I, p: P) -> Vec<T> where I: IntoIterator<Item = T>, P: Fn(&T) -> bool { let mut acc = Vec::new(); for x in a { if p(&x) {acc.push(x);} } acc } fn divisors(n: u32) -> Vec<u32> { filter(1..=n, |&k| n%k == 0) }
Die Funktion filter
der Standardbibliothek ist nochmals
vielseitiger. So wie map
arbeitet diese auf ausgeklügelte
Weise mit Iteratoren und abstrahiert so von den iterierbaren
Datenstrukturen. Man benutzt sie wie folgt zur Berechnung der
Teilerliste:
fn divisors(n: u32) -> Vec<u32> { (1..=n).filter(|&k| n%k == 0).collect() }
Wer das erste Mal die Definition der Funktion fold
einschließlich ihrer Typsignatur zu Gesicht bekommt, dem mag
diese Funktion ein wenig verworren erscheinen. Das hat wohl damit
zu tun, dass im Gegensatz zu konkreten Algorithmen aufgrund der
Abstraktion nicht unmittelbar klar wird, was diese Funktion denn
bewerkstelligen soll.
Daher möchte ich fold
ausgehend von konkreten
Funktionen verdeutlichen. Betrachten wir die Algorithmen zur Berechnung
der Summe und des Produktes der Elemente eines Arrays.
fn sum(a: &[f64]) -> f64 { let mut acc = 0.0; for &x in a {acc = acc + x;} acc } fn prod(a: &[f64]) -> f64 { let mut acc = 1.0; for &x in a {acc = acc*x;} acc }
Beide Funktionen sind irgendwie von der gleichen Gestalt. Betrachtet man das genauer, kommt man zur folgenden Präzisierung: Eigentlich unterscheidet sich lediglich der Anfangswert und die Operation. Eine Abstraktion entsteht demnach, wenn diese als Argumente an den Algorithmus übergeben werden. Wir gelangen zu:
fn fold(a: &[f64], init: f64, op: impl Fn(f64, f64) -> f64) -> f64 { let mut acc = init; for &x in a {acc = op(acc, x);} acc } fn sum(a: &[f64]) -> f64 { fold(a, 0.0, |acc, x| acc + x) } fn prod(a: &[f64]) -> f64 { fold(a, 1.0, |acc, x| acc*x) }
Ist das Prinzip klar geworden, möchte man fold
im Weiteren über den Element-Typ parametrisieren. Eine nochmals
weitergehende Verallgemeinerung entsteht dadurch, dass man
für linkes und rechtes Argument der Operation einen unterschiedlichen
Typ zulässt, womit fold
einen zweiten Typparameter erhält.
Außerdem verschiebt man die Dereferenzierung der Laufvariable
x
am besten in die Operation op
, sodass
der Element-Typ nicht geklont werden braucht.
fn fold<X, Y>(a: &[X], init: Y, op: impl Fn(Y, &X) -> Y) -> Y { let mut acc = init; for x in a {acc = op(acc, x);} acc } fn sum(a: &[f64]) -> f64 { fold(a, 0.0, |acc, &x| acc + x) }
Auch fold
ist ohne besondere Schwierigkeiten
über beliebige iterierbare Typen parametrisierbar. Wir gelangen
schließlich zu:
fn fold<X, Y, I, F>(a: I, init: Y, f: F) -> Y where I: IntoIterator<Item = X>, F: Fn(Y, X) -> Y { let mut acc = init; for x in a {acc = f(acc, x);} acc }
Die Standardbibliothek implementiert fold
nicht für IntoIterator
, sondern für
Iterator
. Das heißt, falls nicht bereits ein Iterator
vorliegt, muss man vorher erst einen bilden. Bei sum
geht das mit iter
:
fn sum(a: &[f64]) -> f64 { a.iter().fold(0.0, |acc, &x| acc + x) }
Die arithmetischen Operationen der gewöhnlichen primitiven Datentypen sind eigentlich nicht fehlerfrei zu verwenden, dergestalt dass es während der Laufzeit aufgrund von Überlauf und Division durch null zum Programmabbruch kommen kann. Mathematisch gesehen spricht man von einer partiellen Funktion. Damit ist gemeint, dass die Operation bzw. Funktion für bestimmte Werte des Definitionsbereichs nicht definiert ist. Wir nehmen jetzt einfach mal den Standpunkt ein, uns genüge das nicht und wir wollten, dass arithmetische Berechnungen immer terminieren.
Eine naheliegende Überlegung ist die Einschränkung des Definitionsbereichs, die allerdings zu kurz gedacht ist. Nämlich haben die arithmetischen Operationen ja die Signaturen M → M und M×M → M. Aufgrund der Übereinstimmung von Definitionsbereich und Zielmenge ist die Einschränkung widersprüchlich.
Stattdessen definieren wir einen neuen Typ Ru32
,
ein Resultat-Typ zu u32
, der zusätzlich den
Fehlerwert undefiniert enthält, im Folgenden intern kodiert
als Ru32(None)
. Mit diesem Winkelzug erreicht man nämlich
wieder totale Funktionen, d. h. solche die für alle Werte des
Definitionsbereichs definiert sind und daher Funktionen im
mathematischen Sinn darstellen.
mod ru32 { #[derive(Debug, Clone, Copy)] pub struct Ru32(Option<u32>); impl Ru32 { pub fn new(x: u32) -> Self {Self(Some(x))} } impl std::ops::Add<Self> for Ru32 { type Output = Self; fn add(self, y: Self) -> Self { match self.0 { Some(x) => match y.0 { Some(y) => Self(x.checked_add(y)), None => Self(None) }, None => Self(None) } } } } use ru32::Ru32; fn main() { let x = Ru32::new(1); println!("{:?}", x + x); }
Auffällig sind die umständlichen Fallunterscheidungen die für jede arithmetische Operation zu implementieren sind. Nun kommt man aber auch zu der Einsicht, dass es sich bei diesen Fallunterscheidungen immer um das gleiche Muster handelt, was die Vermutung nahe legt, das Muster lässt sich aus der Implementation herausfaktorisieren.
Im Zusammenhang damit steht die Eigenschaft von Ru32
,
ein monadischer Typ zu sein. Damit ist gemeint, dass sich für
Ru32
mehr oder weniger praktisch die Funktionen
unit
und bind
so formulieren lassen, dass
sie die Axiome für Monaden erfüllen. Zum Verständnis dieser Funktionen
betrachten wir die folgende Umformulierung, wobei zunächst nur
bind
für uns wesentlich ist.
mod ru32 { trait Monad<T> where Self: Sized { fn unit(x: T) -> Self; fn bind(self, f: impl Fn(T)->Self) -> Self; } #[derive(Debug, Clone, Copy)] pub struct Ru32 (Option<u32>); impl Ru32 { pub fn new(x: u32) -> Self {Self(Some(x))} } impl Monad<u32> for Ru32 { fn unit(x: u32) -> Self {Self::new(x)} fn bind(self, f: impl Fn(u32)->Self) -> Self { match self.0 { Some(x) => f(x), None => Self(None) } } } impl std::ops::Add<Self> for Ru32 { type Output = Self; fn add(self, y: Self) -> Self { self.bind(|x| y.bind(|y| Self(x.checked_add(y)))) } } }
Bevor es mit Monaden weitergeht, ein kurzer Zusatz. Das
ursprüngliche Ziel ist schließlich mit einer weiteren Hilfsfunktion
bind_binary
erreichbar:
fn bind_binary(x: Ru32, y: Ru32, f: impl Fn(u32,u32)->Option<u32> ) -> Ru32 { x.bind(|x| y.bind(|y| Ru32(f(x,y)))) } impl std::ops::Add<Self> for Ru32 { type Output = Self; fn add(self, y: Self) -> Self { bind_binary(self, y, u32::checked_add) } }
Currying ist die Umformung einer mehrstelligen Funktion in eine äquivalente Funktion höherer Ordnung, bei der die Argumente nacheinander einstellig aufgerufen werden. Betrachten wir bspw. die Funktion
f: Z×Z×Z → Z, (x, y, z) ↦ x + y + z,
die in der Form f(x, y, z) aufgerufen wird. Currying führt zu der Funktion
f: Z → Z → Z → Z, x ↦ y ↦ z ↦ x + y + z,
die in der Form f(x)(y)(z) aufgerufen wird.
Weil Rust Closures bietet, erlaubt es auch Currying.
Allerdings ergibt sich dabei eine kleinere Komplikation, die
damit zusammenhängt, dass impl Trait
nicht
an jeder Stelle verwendbar ist. Betrachten wir
die Entsprechung von f in Rust.
fn f(x: i32, y: i32, z: i32) -> i32 { x + y + z }
Currying des ersten Arguments stellt kein Problem dar:
fn f(x: i32) -> impl Fn(i32, i32) -> i32 { move |y, z| x + y + z }
Vollständiges Currying wäre nun:
fn f(x: i32) -> impl Fn(i32) -> impl Fn(i32) -> i32 { move |y| move |z| x + y + z }
Leider ist das nicht erlaubt. Es würde sich durch Boxen umgehen lassen:
fn f(x: i32) -> Box<dyn Fn(i32) -> Box<dyn Fn(i32) -> i32>> { Box::new(move |y| Box::new(move |z| x + y + z)) }
bzw.
fn f(x: i32) -> impl Fn(i32) -> Box<dyn Fn(i32) -> i32> { move |y| Box::new(move |z| x + y + z) }
Allerdings führt das nun zu unnötigen Speicherallokationen. Ein gängiges Mittel zur Lösung dieser Art von Problemen ist die Einführung einer Helfer-Struktur.
struct FunFromf {x: i32, y: i32} impl FunFromf { fn call(self, z: i32) -> i32 {self.x + self.y + z} } fn f(x: i32) -> impl Fn(i32) -> FunFromf { move |y| FunFromf {x, y} }
Oder alternativ:
struct FunFromf {x: i32} impl FunFromf { fn call(self, y: i32) -> impl Fn(i32)->i32 { move |z| self.x + y + z } } fn f(x: i32) -> FunFromf { FunFromf {x} }