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> und
  • Option<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 Optionen

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 Gematche zu Gange. Glücklicherweise bringt Option die Methode and_then mit. Damit lassen sich sowohl Optionen als auch Resultate 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 Optionen 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.

  1. Identität von links
    return(v).bind(f) == f(v)
    
    ist erfüllt, da
    Some(v).and_then(f) == f(v)
    
    wobei f: impl FnOnce(T) -> Option<U>.
  2. Identität von rechts
    m.bind(return) == m
    
    ist erfüllt, da
    m.and_then(Some) == m
    
  3. Assoziativität
    m.bind(f).bind(g) == m.bind(|x| f(x).bind(g))
    
    ist erfüllt, da
    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 Optionen und Resultaten 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 binds. 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 Optionen 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 Optionen 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.
  • 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.

Die folgenden Links haben zur Erstellung dieses Artikels beigetragen.