3.2.3 Active Patterns

Technik: Active Patterns
Zweck: Funktionale Erweiterung von Pattern Matching
Problem: Nur primitive und algebraische Datentypen können in Pattern Matching verwendet werden, aber oft wird mit objektorientierten Klassen gearbeitet. Wiederholende Aufgaben wie Typprüfung und Wertextraktion von Objekten können in Funktionen gekapselt werden, die häufige Nutzung von Hilfsfunktionen verschleiert aber die Intention. Vorhandene Patterns sind nicht aussagekräftig oder tauchen als Duplikate auf.
Lösung: Active Patterns erweitern die Möglichkeit von Pattern Matching, indem Funktionen vor oder während des Matchings auf den Ausdruck angewendet wird. Konstrukte: (|X|), (|X|_|), (|X|Y|)
Performanz: Hängt von Pattern Matching und benutzten Funktionen ab.
Alternative: Explizite Funktionsaufrufe und explizite Definition von algebraischen Datentypen für das Ergebnis der Funktionen.

Um Active Patterns zu verstehen, ist es sinnvoll, kleine Beispiele dafür in expliziter Form zu sehen und diese mit dem entsprechenden Active Pattern zu vergleichen. Der einfachste Anwendungsfall ist der Klassifizierer.

Klassifizierer

type Vorzeichen = Negativ | Null | Positiv
let vorzeichen x =
  if x < 0 then Negativ else if x = 0 then Null else Positiv

let expliziteVerwendungVonFunktionen =
  match (vorzeichen -23, vorzeichen 0, vorzeichen 42) with
  | (Negativ, Null, Positiv) -> printfn "Test bestanden."
  | _ -> failwith "Test nicht bestanden."

Hier wird zunächst ein algebraischer Datentyp definiert, der das Ergebnis der Klassifikation bestimmt. Dann wird eine Funktion geschrieben, die eine ganze Zahl nach ihrem Vorzeichen klassifiziert. In einem kleinen Beispiel wird dies getestet.

Im folgenden wird eine kürzere Variante gezeigt, die Active Patterns benutzt und dasselbe leistet. Auffällig ist dabei der Name der zuvor vorzeichen genannten Funktion und das Wegfallen der Typdefinition und der Funktionsaufrufe. Active Patterns sind lediglich Funktionen, d.h. (|Negativ|Null|Positiv|) ist eine Funktion mit einem speziellen Namen, die in Pattern Matching automatisch verwendet wird.

let (|Negativ|Null|Positiv|) x =
  if x < 0 then Negativ else if x = 0 then Null else Positiv

let impliziteForm =
  match (-23, 0, 42) with
  | (Negativ, Null, Positiv) -> printfn "Test bestanden."
  | _ -> failwith "Test nicht bestanden."

An dieser Stelle wird deutlich: Das Muster (Negativ, Null, Positiv) passt auf alle Tripel-Werte, deren erste Komponente eine negative Ganzzahl ist, deren zweite 0 und deren dritte eine positive Ganzzahl ist. Auch wenn dieses Spielbeispiel nicht besonders hilfreich erscheint, ließen sich damit schon folgende Refactorings durchführen: Muster mit guard der Form ...x... when x > 0 lassen sich durch Benutzung von (Positiv as x) vereinfachen. Und wo auch immer Funktionen mit Ganzzahlen arbeiten, die positiv sein müssen, ließe sich ein anfänglicher Test der Form
let f(x) = if not(x > 0) then invalidArg "x" "x > 0 muss gelten" else Körper
auf der Parameter-Stelle von x in der Form
let f(Positiv as x) = Körper
einbauen (die Fehlernachricht ist dann allerdings nicht aussagekräftig).

Der erste Vorschlag führt zur zweiten Form von Active Patterns: Partial Active Patterns, die in Kürze gezeigt werden.

Der zweite genannte Anwendungsfall, die Validierung eines Argumentes, führt zu einem Active Pattern, welches eine Exception werfen kann. Diese Art von Active Pattern nenne ich Validierer. In der folgenden Variante handelt es sich um ein Parameterised Active Pattern, da name ein Parameter ist, der wie beispielsweise "n" in (NichtNegativ "n" n) übergeben wird.

Validierer und Parameterised Active Pattern

let (|NichtNegativ|) name x =
  if x >= 0 then x
  else invalidArg name (name + " darf nicht negativ sein.")

let kleinerGauß(NichtNegativ "n" n) = n * (n+1) / 2

Eine sehr einfache Verwendung der Form (|X|) ist eine Abbildung, die immer gelingt und einen Wert liefert:

Konvertierter

let (|Betrag|) x = abs x
let (|AlsString|) x = string x
let (|AlsDouble|)(x: obj) = System.Convert.ToDouble(x)

Auffällig ist vielleicht die Verwendung von Funktionen, die lediglich angewendet werden. Ein Muster der Form let (|Apply|) f x = f x könnte diese beiden gezeigten ersetzen (als Muster (Betrag betrag) oder (Apply abs derBetrag) und (AlsString text) oder (Apply string derText)). Dieses Active Pattern ist im Allgemeinen nicht sinnvoll, weil Active Patterns die Lesbarkeit verbessern sollen, indem sprechende Namen für häufige Umwandlungen und Prüfungen verwendet werden.

Parser und Partial Active Patterns

Partial Active Patterns zeichnen sich dadurch aus, dass sie partiell definiert sind. Dies wird durch die Verwendung des Option-Typs als Rückgabetyp ausgedrückt und im Namen durch einen abschließenden Unterstrich gekennzeichnet.

open System // Dort ist der Typ Int32 definiert
let (|AlsGanzzahl|_|) x =
  match Int32.TryParse(x) with
  | true, wert -> Some(wert)
  | _ -> None

let test =
  match "24" with
  | AlsGanzzahl x -> "die Ganzzahl " + string x
  | _ -> "keine Ganzzahl"

Als Nächstes zeige ich, dass die zwei Konzepte partielle Definition und Klassifikation kollidieren, insbesondere mit der beliebigen Kombinierbarkeit von Patterns: Die Variante (|X|Y|_|) darf (und kann glücklicherweise) nicht verwendet werden, wie folgendes hypothetisches Beispiel zeigt.
Im Folgenden wird auch auf die Ausführungsreihenfolge eingegangen, insbesondere auf den Zeitpunkt, wann die als Active Patterns definierten Funktionen auf den Wert angewendet werden.

open System // Dort sind die Typen Int32 und Double definiert
let (|Ganzzahl|Kommazahl|_|) x =
// Anm.: Dieser Funktionsname wird vom Parser nicht akzeptiert.
  match Int32.TryParse(x) with // Versuche Wert als Ganzzahl zu parsen:
  | true, wert -> Some(Ganzzahl(wert)) // OK: Ganzzahl
  | _ -> match Double.TryParse(x) with // Ansonsten als Kommazahl:
         | true, wert -> Some(Kommazahl(wert)) // OK: Kommazahl
         | _ -> None // Sonst: weder Ganz- noch Kommazahl

let test =
  match "24" with // führt (|Ganzzahl|Kommazahl|_|) aus,
                  // liefert Some(Ganzzahl(42))
  | Ganzzahl 42 -> "die Ganzzahl 42" // Scheitert bei match 24 with 42.
  | Kommazahl x -> "irgend eine Kommazahl" // Wird ignoriert, denn
    // die Zahl wurde als Ganzzahl, nicht als Kommazahl klassifiziert.
    // Intuitiv wäre ein erneutes Parsen, das Kommazahl(24.0) liefert.
  | _ -> "gar keine Zahl" // Dieser Fall trifft zu!

Stattdessen werden hier die kleinstmöglichen Einheiten, die als Partial Active Patterns definiert sind, (|Ganzzahl|_|) und (|Kommazahl|_|) verwendet, die sich erwartungsgemäß verhalten.

Diese Varianten stellen durch die Verwendung Klassifizierer dar, in jedem Fall aber sind es Parser.

open System // Dort sind die Typen Int32 und Double definiert
let (|Ganzzahl|_|) x =
  match Int32.TryParse(x) with
  | true, wert -> Some(Ganzzahl(wert))
  | _ -> None

let (|Kommazahl|_|) x =
  match Double.TryParse(x) with
  | true, wert -> Some(Kommazahl(wert))
  | _ -> None

let test =
  match "24" with
  | Ganzzahl 42 -> "die Ganzzahl 42" // Führt (|Ganzzahl|_|) aus,
  // liefert Some(24), scheitert bei match 24 with 42. Nächster Fall:
  | Kommazahl x -> "irgend eine Kommazahl" // Führt (|Kommazahl|_|),
  // liefert Some(24.0), trifft zu und bindet 24.0 an x.
  | _ -> "gar keine Zahl"

Übersicht der Anwendungsfälle


Durch diese Anwendungsfälle lassen sich folgende Nutzungen von Active Patterns herauskristallisieren:

(|X|) Konvertiere zu X (Konvertierer), Überprüfe auf Eigenschaft X (Validierer) und wirf eine Exception im Fehlerfall.
(|X|Y|) Klassifiziere nach Eigenschaften X, Y, … (bis zu 7 Eigenschaften sind möglich) (Klassifizierer)
(|X|_|) Klassifiziere nach Eigenschaft X oder scheitere (Klassifizierer), parse Wert als X (Parser)

Die Benutzung von Active Patterns kann zu verständlicherem Code führen, indem Pattern Matching damit auf beliebigen Datentypen benutzt werden kann.

Bearbeitet: Arbeit über Active Patterns referenziert.

In der Arbeit über Active Patterns werden unter anderem für häufig verwendete objektorientierten Typen wie Type und XmlDocument Active Patterns definiert, die eine Verwendung der Objekte dieses Typs wie Varianten eines algebraischen Datentypen zulässt [ActivePattern, vgl. S. 4, 7-8].

Ein Beispiel, das besonders durch Active Patterns deklarativ ist, sind diese drei Definitionen, die die Verarbeitung von XML-Dokumenten enorm erleichtert. Hier werden für die Typen der System.Linq.XObject-Hierarchie Active Patterns definiert. Das anschließende Beispiel zeigt, wie diese zu verwenden sind.

open System.Xml.Linq

// (|Node|_|): string -> XNode -> XNode seq
let (|Node|_|)(name: string)(node: XNode) =
  match node with
  | :? XElement as element
    when element.Name.LocalName = name ->
    Some(element.Nodes())
  | _ -> None
    
// (|Text|_|): XNode -> string option
let (|Text|_|)(node: XNode) =
  match node with
  | :? XElement -> None
  | _ -> Some(node.ToString())

// (|Attribute|_|): string -> XNode -> string option
let (|Attribute|_|)(name: string)(node: XNode) =
  match node with
  | :? XElement as element ->
    match element.Attribute(XName.Get(name)) with
    | null -> None
    | x -> Some(x.Value)
  | _ -> None

let rec traverseAll = Seq.iter traverseNode
and traverseNode = function
| Text text -> printfn "    %s" (text.Trim())
| Node "Matches" children -> traverseAll children
| Node "Match" children & Attribute "Winner" winner
  & Attribute "Loser" loser & Attribute"Score" score ->
  printfn "%s won against %s with score %s" winner loser score
  traverseAll children

traverseNode(XElement.Load("matches.xml"))

Für das Beispieldokument matches.xml:

<Matches>
  <Match Winner="A" Loser="B" Score="1:0">
    Description of the first match...
  </Match>
  <Match Winner="A" Loser="C" Score="1:0">
    Description of the second match...
  </Match>
</Matches>

erscheint diese Ausgabe:

A won against B with score 1:0
    Description of the first match...
A won against C with score 1:0
    Description of the second match...


Durch dieses Beispiel wird vor allem die Kompositionierbarkeit (Konjunktion mit &) von Active Patterns deutlich. Das folgende Muster passt genau auf einen Knoten Match mit den Attributen Winner, Loser sowie Score und extrahiert gleichzeitig Kindelemente des Knotens und die Attributwerte:

Node "Match" children & Attribute "Winner" winner & Attribute "Loser" loser & Attribute"Score" score

Dieser Ansatz führt zu einer weiteren Denkweise hinter deklarativer Programmierung: Domänenspezifische Sprachen (domain-specific languages, DSL). Die Regeln, die zuvor definiert wurden stellen eine interne DSL dar.