Monaden, wie sie in der funktionalen Programmierung vorkommen, erscheinen mir abgefahren und schwer verständlich. Vor allem beispielsfreie Erklärungen und Bezüge zum mathematischen Teilgebiet der Kategorientheorie empfinde ich als verwirrend. In diesem Post versuche ich ein Konzept, das ich selbst nicht sonderlich tief durchdrungen habe, einführend, anwendungsbezogen und oberflächlich zu erklären, damit man auf der nächsten Party imperativer Nerds zum Obernerd emporsteigen kann.
Beispiele in Rust
Alle Monaden sind Typen der Form M<T>
, die innere
Werte*Die inneren Werte zählen. eines Typs T
kapseln.
Beispiele für Monaden in Rust sind
Result<T, E>
undOption<T>
.
Wer Result
und Option
nicht kennt, kann in
meinem Skript nachlesen oder dieses illustrative Video anschauen, in dem Option
unter dem Namen Maybe
auftaucht.
Der Typ Result
ist zur Fehlerbehandlung dienlich. Neben dem Resultat
einer Berechnung im Erfolgsfall beinhaltet Result
einen Fehler von Typ E
im Misserfolgsfall.
Ähnlicherweise modelliert Option
das Vorhandensein oder Nichtvorhandensein eines Wertes.
Wir können durch
let xr = Ok(5.0);
bzw.
let xo = Some(5.0);
entsprechende innere Werte in Result
und Option
kapseln.
Komposition von Option
en
Zunächst schauen wir uns eine Beispielanwendung an. Wir wollen einen
sicheren Rechner für ganze Zahlen bauen. Der Benutzer übergibt eine kommaseparierte Zeichenkette von zwei Zahlen und ein
weiteres Zeichen, das entweder +
, -
, *
oder /
ist. Das Parsen
der Zeichen und ihrer Ketten kann fehlschlagen und auch die Rechnungen können im Falle eines Overflows oder der Division durch 0 fehlschlagen.
Wir verwenden der Einfachheit
halber Option
und nicht Result
um Fehler zu behandeln. Dabei signalisiert der Wert None
das Vorkommen eines Fehlers.
Die Parsefunktionen haben folgende Signaturen.
// Nur +, -, * und / sind valide Argumente.
fn parse_operation(
op: char
) -> Option<fn((u16, u16)) -> Option<u16>>;
// Die Zeichenkette muss ein Komma beinhalten.
fn split(s: &str) -> Option<(&str, &str)>;
// Argumente müssen Zahlen sein.
fn parse_operands(
(n, m): (&str, &str)
) -> Option<(u32, u32)>;
Folgendermaßen kann unser sicherer Rechner implementiert werden.
fn safe_calculate(numbers_str: &str, op: char) -> Option<u32> {
let op = parse_operation(op);
match op {
Some(op) => match split(numbers_str) {
Some(number_strs) => match parse_operands(number_strs) {
Some(numbers) => op(numbers),
None => None,
},
None => None,
},
None => None,
}
}
Da ist viel händisches Gematch
e zu Gange. Glücklicherweise bringt Option
die Methode and_then
mit.
Damit lassen sich sowohl Option
en als auch Result
ate
komponieren.
Die Rust-Methode and_then
des Aufzählungstyps Option
hat folgende Signatur.
pub fn and_then<U, F>(self, f: F) -> Option<U>
where
F: FnOnce(T) -> Option<U>
Unsere Rechenfunktion mündet in ihrer deutlich kompakteren Form.
fn calculate(numbers_str: &str, op: char) -> Option<u32> {
let op = parse_operation(op);
op.and_then(|op|
split(numbers_str)
.and_then(parse_operands)
.and_then(op))
}
Auch die Methode map
von Option
mit folgender Signatur ist ein Werkzeug zur Monadenkomposition.
pub fn map<U, F>(self, f: F) -> Option<U>
where
F: FnOnce(T) -> U
Allerdings ist map
nicht ausreichend, wenn f
fehlschlagen kann. Denn dann wäre der Typ ihres
Resultats sowas wie U = Option<V>
. Somit stünden wir am Ende einem Option<Option<V>>
gegenüber.
Um verschachtelte Option
en auszupacken, gibt es die Methode flatten
. Die Methode and_then
entspricht also der Anwendung von map
gefolgt von flatten
. Daher heißt and_then
in anderen
Sprachen oft flat_map
.
Monadendefinition
In funktionalen Programmiersprachen wird anstatt and_then
oder flat_map
auch oft der Name bind
verwendet. Das
Einwickeln eines Wertes v
von innerem Typ T
in eine Option<T>
durch Some(v)
entspricht in funktionalen Sprachen einer
Funktion, die oft return
oder unit
heißt.
Eine genauere Erklärung von bind
und return
findet sich im OCaml-Tutorial*OCaml ist eine funktionale Programmiersprache..
In einer funktionalen Sprache*Wir verwenden im Folgenden Pseudocode, den Rustprogrammierer hoffentlich gut lesen können. erhalten wir für alle Monaden M
mit innerem Typ T
also durch
let m = M::return(v)
eine Instanz m
von M<T>
.
Die Instanz m
wird durch
let n = m.bind(f)
mit Hilfe einer Funktion f: T -> M<U>
auf die Instanz n
von M<U>
abgebildet.
Analog zu and_then
in Rust entspricht die Signatur von bind
dem folgenden Schnipsel.
bind: (M<T>, T -> M<U>) -> M<U>
Bemerke, dass bind
Monaden gleichen äußeren Typs M
aufeinander abbildet, wobei der Typ des inneren Wertes sich ändern darf.
Neben der Existenz von bind
- und return
-Analogien für Option
und Result
werden noch folgende Regeln erfüllt,
wodurch sich Option
und Result
Monaden nennen dürfen.
- Identität von links
ist erfüllt, da
return(v).bind(f) == f(v)
wobeiSome(v).and_then(f) == f(v)
f: impl FnOnce(T) -> Option<U>
. - Identität von rechts
ist erfüllt, da
m.bind(return) == m
m.and_then(Some) == m
- Assoziativität
ist erfüllt, da
m.bind(f).bind(g) == m.bind(|x| f(x).bind(g))
m.and_then(f).and_then(g) == m.and_then(|v| f(v).and_then(g))
Im Gegensatz zu Option
und Result
sind Implementierungen des Traits Iterator
streng genommen keine Monaden,
denn es gibt keine Methode die einem bind
entspricht. Am ähnlichsten ist flat_map
einem bind
.
Doch flat_map
bildet jede Instanz des Iterators iter::Filter
auf eine iter::FlatMap
ab.
Von bind
erwarten wir aber, dass die gleiche Monade am Ende rausfällt, die reingesteckt wurde,
auch wenn sich der Typ des inneren Werts ändert.
Trotzdem verfügt auch Iterator
über einige monadische Charakteristiken.
Wofür das Ganze?
Eine Kerneigenschaft funktionaler Sprachen ist das Separieren von Seiteneffekten und reinen Funktionen. Beispiele für Seiteneffekte sind
- Zustandsänderungen lokaler Variablen jedenfalls im lokalen Geltungsbereich,
- das Ausgeben von Text auf einen Bildschirm oder
- das Löschen von Dateien.
Reine Funktionen sind Funktionen die deterministisch von ihren Argumenten abhängen und außerhalb ihres Scopes keine Zustände ändern.
Die Vorteile von Monaden:
- Monaden sind ein Werkzeug um die Separierung zwischen Seiteneffekten und reinen Funktionen vorzunehmen.
- Dabei werden die Effekte bereits durch ihren Typ kenntlich gemacht.
- Des Weiteren ist es monadisches Ansinnen, modulare Programmstruktur zu ermöglichen oder zu vereinfachen.
- Programmiersprachen wie Haskell, die Monaden nativ unterstützen, stellen Funktionen bereit, die für beliebige Monaden anwendbar sind.
Neben Option
en und Result
aten existieren andere Beispiele für Monaden. Wir schauen uns noch zwei weitere
Beispiele an, die insbesondere zum Behandeln von Seiteneffekten verwendet werden.
Eine Zustandsmonade
In der rein funktionalen Sprache Haskell gibt es beispielsweise eine Zustandsmonade, die es
ermöglicht große Teile des Programms in reinen Funktionen zu schreiben. Durch bind
können diese zur Zustandsmanipulation verkettet werden. In Rust könnte eine simple
Zustandsmonade folgendermaßen aussehen.
#[derive(Clone)]
struct StateMonad<T> {
state: T,
}
impl<T> StateMonad<T> {
pub fn new(state: T) -> Self {
StateMonad { state }
}
pub fn and_then<G>(
self,
f: impl FnOnce(T) -> StateMonad<G>
) -> StateMonad<G> {
f(self.state)
}
}
Mit der Zustandsmonaden können wir z.B. das Builder-Pattern umsetzen.
struct House {
has_garden: bool,
has_basement: bool,
area: f64,
n_rooms: u8,
description: String,
}
impl House {
fn new() -> Self {
House {
has_garden: false,
has_basement: false,
area: 80.0,
n_rooms: 4,
description: "A beautiful house".to_string(),
}
}
}
fn add_basement(h: House) -> StateMonad<House> {
StateMonad::new(House {
has_basement: true,
..h
})
}
fn add_garden(h: House) -> StateMonad<House> {
StateMonad::new(House {
has_garden: true,
..h
})
}
fn set_description(h: House, description: String) -> StateMonad<House> {
StateMonad::new(House { description, ..h })
}
fn build_a_house_with_garden(description: String) -> House {
StateMonad::new(House::new())
.and_then(add_basement)
.and_then(add_garden)
.and_then(|h: House| set_description(h, description))
.state
}
Eine IO Monade
In Haskell existiert eine Input/Output-Monade, die unter anderem
dafür sorgt, dass Text auf dem Bildschirm erscheint. Alle Funktionen in Haskell, die einen
Input/Output-Seiteneffekt wie das Anzeigen von
Text auf dem Bildschirm ausführen wollen, sind entsprechend markiert. In ihrer Signatur kommt
die IO-Monade vor. Das heißt, Funktionen ohne eine IO-Monade in ihrer Signatur sind frei von
entsprechenden Seiteneffekten. Die Funktionen, die wir in Haskell dem bind
oder and_then
übergeben, sind
Funktionen, die keine IO-Zustände manipulieren, da sie IO Monaden zwar zurückgeben, ihren Effekt aber nicht aufrufen.
Das passiert erst innerhalb des nächsten bind
s.
Die IO Monade kapselt als inneren Wert den Rückgabewert des Seiteneffekts. Es folgt
eine vereinfachte Implementierung in Rust.
struct IoMonad<F, T>
where
F: FnOnce() -> io::Result<T>,
{
effect: io::Result<F>,
}
impl<F, T> IoMonad<F, T>
where
F: FnOnce() -> io::Result<T>,
{
pub fn from_err(error: io::Error) -> Self {
Self { effect: Err(error) }
}
pub fn new(effect: F) -> Self {
IoMonad { effect: Ok(effect) }
}
pub fn and_then<G, U>(
self,
f: impl FnOnce(T) -> IoMonad<G, U>
) -> IoMonad<G, U>
where
G: FnOnce() -> io::Result<U>,
{
match self.apply() {
Ok(x) => f(x),
Err(e) => IoMonad::from_err(e),
}
}
pub fn apply(self) -> io::Result<T> {
self.effect.and_then(|effect| effect())
}
}
Die IO Monade könnte jetzt verwendet werden, um alle IO Effekte vom Rest des Programms zu isolieren.
Allerdings können wir uns in Rust nicht darauf verlassen, dass außerhalb einer Monade keine
Effekte ausgeführt werden.
Wir können verschiedene IO-Monaden wie im Fall der Zustandsmonaden und Option
en verketten.
fn house_str(h: House) -> String {
format!(
"{} {} {} {}\n{}",
h.has_garden, h.has_basement, h.area, h.n_rooms, h.description
)
}
fn main() -> Result<(), io::Error> {
let file_path = "description.txt".to_string();
let read_desc = IoMonad::new(|| std::fs::read_to_string(&file_path));
read_desc
.and_then(|desc| {
let h = build_a_house_with_garden(desc);
let h_str = house_str(h);
IoMonad::new(move || io::stdout().write_all(h_str.as_bytes()))
})
.apply()?;
}
Lese- und Schreibeoperationen passieren innerhalb der and_then
- bzw. apply
-Methode.
Funktionen wie house_str
oder set_description
sind reiner Art.
Ein Monaden-Trait
Die Methoden new
und and_then
werden von allen Monadenbeispielen verwendet. Ein Monaden-Trait
könnte hilfreich sein, dessen Antlitz dem folgenden Schnipsel entsprechen könnte.
pub trait Monad<T> {
fn new(self, x: T) -> Self;
fn and_then(self, f: impl FnOnce(T) -> Self<U>) -> Self<U>;
}
Es gibt allerdings kein Self<U>
in Rust. Das ist ein sogenannter höherwertiger Typ (higher kinded type),
die Rust noch nicht unterstützt. Ein Lösungsversuch findet sich in diesem Artikel*Versuch meine ich wertungsfrei. Ich habe mich nicht
im Detail mit dem Artikel beschäftigt..
Muss ich das wissen als Rust-User?
Nein. Was eine Monade ist, braucht man nicht wissen, um in Rust effektiv programmieren zu können.
Es ist ausreichend und deutlich wichtiger, die Funktionsweise der konkreten Typen wie Option
oder Result
zu kennen, selbst wenn das verallgemeinernde Konzept von Monaden nicht bekannt ist oder verinnerlicht wurde.
Auch das Builder-Pattern kann verstanden werden, ohne zu wissen was eine Monade ist.
Das ist übrigens auch die Strategie der rein funktionalen Sprache Elm. Sie lässt Programmierer mit Monaden
arbeiten, ohne diese explizit als Monaden zu kennzeichnen.
Da Rust Monaden nicht nativ unterstützt, gibt es auch keine Hilfsfunktionen, die beliebige Monaden als Parameter
erhalten können. In Haskell gibt es beispielsweise eine Funktion sequence
, die als Argumente eine Instanz eines traversierbaren
Typs L<M<T>>
mit monadischen Elementen des Typs M<T>
erhält. Das Ergebnis ist die Vertauschung von M
und L
.
Nehmen wir an, ich habe eine Liste von Maybe
-Monaden also Option
en
in Rust. Wenn ich darauf sequence
anwende ist das Ergebnis eine Maybe
-Monade der Liste. Rust implementiert ähnliches Verhalten
in der collect
-Methode von Iteratoren. Durch
[Some(1), Some(2), Some(3)].iter().collect::<Option<Vec<_>>>()
erhalten wir
Some([1, 2, 3])
da der Typ, in den wir sammeln, Option<Vec<_>>
ist und nicht Vec<Option<_>>
.
In Rust sind also viele monadische Konzepte für konkrete Typen oder Traits implementiert, auch wenn es das Konzept von Monaden nicht gibt und die konkreten Typen wie Iteratoren streng genommen keine Monaden sein müssen.
Andererseits gibt es einige Gründe die für eine Kenntnis monadischer Grundlagen sprechen.
- Ein Blick über den Tellerrand hilft, den Horizont zu erweitern.
- Rust unterstützt viele Aspekte funktionaler Programmierung. Wenn man sich weiter in Richtung funktionaler Programmierung orientieren möchte, ist das ein hilfreicher Schritt.
- Gerade funktionale Sprachen unterstützen oft Monaden. Code Beispiele anderer Sprachen können hilfreich und inspierend sein.
- Um zu beurteilen ob ein Sprachfeature wichtig ist oder nicht, ist seine Kenntnis hilfreich. Es schadet ja auch nicht, einige Design-Patterns der objekt-orientierten Programmierung zu kennen, selbst wenn man sie nicht verwenden möchte.
- Mein persönlicher Hauptgrund, mich mit Monaden zu beschäftigen, ist schlicht Neugier.
Links
Die folgenden Links haben zur Erstellung dieses Artikels beigetragen.