Eine Einführung in Denotational Design Teil II: Zeitreihen
Dieser Artikel ist der zweite einer Serie von Artikeln über Denotational Design. Im vorherigen Teil dieser Reihe hatten wir die theoretische Grundlage von Denotational Design kennen gelernt: Die denotationelle Semantik. In diesem Artikel wollen wir dieses Werkzeug nutzen, um eine konkrete Fachlichkeit zu spezifizieren.
Denotational Design
Die denotationelle Semantik entstand wie gesagt zur Erforschung von Programmiersprachen. Dieselbe Methode lässt sich aber ganz einfach auf beliebige zusammengesetzte Programmelemente übertragen. Wir wollen dies im Folgenden anhand von Zeitreihen illustrieren. Das Beispiel kommt aus einem großen Industrieprojekt direkt aus unserer täglichen Arbeit.
Grundlage unserer Überlegungen ist eine simple Architektur: Im Zentrum steht unser System. Dieses speist sich mit Inhalten aus einem Datenservice, welcher eine Fassade vor einer Menge von Sensoren bildet. Die Dateninhalte sind im Wesentlichen Zeitreihen. Wir werden später sehen, dass das nicht ganz präzise ist, aber für den Moment können wir so darüber nachdenken. Unser System erlaubt den Nutzer:innen die Visualisierung von Zeitreihen und die Beschreibung von Transformationen von Zeitreihen. Aus der Domäne kommen nun einige Anforderungen, welche Operationen auf und mit den Zeitreihen möglich sein sollen:
- Einlesen vom Datenservice
- Einstellige Operationen wie abs/log/exp/…
- Mehrstellige Operationen wie Addition/Subtraktion/Multiplikation
- Interpolation bzw. Resampling
- Integration und Differentiation
- Glättung
- …
In den meisten Softwareprojekten sieht die „Anforderungsspezifikation“ genau so aus wie diese Liste. Dann wird implementiert. Das Wort implementieren erfordert aber eigentlich ein Objekt im Satz. Was wollen wir überhaupt implementieren? Das ist bisher noch gar nicht klar.
Was ist eine Zeitreihe?
Beim Denotational Design starten wir mit der Frage: Was bedeuten die elementaren Bestandteile meiner Domäne, der Fachlichkeit, für die ich Software schreiben möchte? In diesem Impetus deckt sich Denotational Design noch mit der herkömmlichen funktionalen Programmierung, bei der wir auch bestrebt sind, Objekte der Wirklichkeit als Daten und Zusammenhänge als Funktionen zu modellieren. Für Zeitreihen könnten wir einen solchen Versuch wagen und mit folgender Modellierung starten. Unser Zeitreihen-Modul besteht aus einem Typ für Zeitreihen, einer Menge von Konstruktoren, einer Menge von Kombinatoren und einer Menge von Accessoren.
type TimeSeries :: * -> *
-- Ein Konstruktor
fromDataService :: [(UTCTime, a)] -> TimeSeries a
-- Ein Kombinator
addTS :: TimeSeries Float -> TimeSeries Float
-- Ein Accessor
lookup :: UTCTime -> TimeSeries a -> a
TimeSeries
ist also ein simpler Typkonstruktur und lookup
ist eine
(von mehreren) Funktion, die diesen Typkonstruktur verwendet. Nach
kurzem Nachdenken kommen wir aber drauf, dass die Signatur für
lookup
nicht ganz richtig sein kann. „Zeitreihe“ klingt doch so,
als gäbe es da nur an bestimmten Zeitpunkten zugeordnete Werte. Die
Signatur von lookup
legt aber nahe, dass wir für jeden beliebigen
Zeitpunkt (UTCTime
) einen Wert rausbekommen könnten. Mit einer
kleinen Korrektur landen wir bei:
lookup :: UTCTime -> TimeSeries a -> Maybe a
Jetzt bekommen wir manchmal einen Wert und manchmal eben Nothing
.
Woran haben wir diese Änderung in der Signatur von lookup
eigentlich
festgemacht? Offensichtlich hatten wir eine Idee davon im Kopf, was
TimeSeries
ist und was lookup
tut. Diese Idee geht über die reinen
Signaturen hinaus und sie leitet auch die nachfolgende
Implementierung. Denn wenn wir nur der Signatur folgen würden, dann
wäre dies eine korrekte Implementierung:
-- Eine vermutliche falsche Implementierung:
lookup _ _ = Nothing
Korrekt wäre diese Implementierung insofern, als das Programm
erfolgreich kompilierbar wäre. Die Implementierung ist aber alles
andere als korrekt in Bezug auf unsere Idee, wie lookup
funktionieren soll. Wir müssten den Code korrigieren auf Basis
unserer Idee. Beim herkömmlichen Programmieren geben wir uns damit
zufrieden: Wir behalten die Idee im Hinterkopf und programmieren daran
entlang. Denotational Design zeigt eine alternative Vorgehensweise
auf: Wenn es diese abstrakte Idee gibt, dann können wir doch mal
versuchen, diese auch formal aufzuschreiben. Die denotationelle
Semantik gibt uns das Werkzeug, um diese Formulierung zu
bewerkstelligen: Wir müssen eine mathematische Struktur finden und
jede Operation auf eine entsprechende Operation in dieser Struktur
abbilden.
Erster Versuch
Die erste Idee für eine solche mathematische Struktur ist selten die richtige, doch irgendwo müssen wir beginnen. Wir beginnen für unsere Zeitreihen deshalb mit der naheliegenden Betrachtung: Zeitreihen, das sind Listen1 von Zeit-Wert-Tupeln. Das klingt schon stark nach Implementierung und tatsächlich könnten wir dieses Modell von Zeitreihen ebenfalls mit Listen von Tupeln implementieren. Es kann also durchaus sein, dass Modell und Implementierung nah beieinanderliegen. Wir sollten uns jedoch in jedem Fall nicht davor scheuen, diese beiden Welten im Laufe der Evolution unserer Software auseinanderlaufen zu lassen, denn die beiden Welten haben unterschiedlichen Anforderungen zu genügen.
type TimeSeries a -- Modell: Liste von (Time, a)-Tupeln.
Jetzt müssen wir die oben genannten Funktionen beschreiben, indem wir
ihre Bedeutung in der Sprache von Zeitreihen als Listen von
Zeit-Wert-Tupeln ausdrücken. Der Konstruktor fromDataService
scheint
trivial.2
𝛍 (fromDataService xs) = 𝛍 xs
-- Beispiel:
𝛍 (fromDataService []) = []
𝛍 (fromDataService [(t1, v)]) = [(𝛍 t1, 𝛍 v)]
Nun haben wir aber schon einen kleinen Salat: Die Listen [(t1, x),
(t2, y)]
und [(t2, y), (t1, x)]
sind natürlich nicht gleich, obwohl
sie eigentlich dieselben Informationen ausdrücken. Das Eigentlich
ist der Knackpunkt. Immer, wenn wir uns ein Eigentlich denken, ist
das ein Hinweis, dass unser Modell noch nicht ganz treffend ist. Was
wir uns bei den Listen von Zeit-Wert-Tupeln eigentlich gedacht haben,
sind wahrscheinlich Mengen von Zeit-Wert-Tupeln, wobei es für jeden
Zeitpunkt maximal ein Tupel gibt:
type TimeSeries a -- Modell: Mengen von (Time, a)-Tupeln, wobei die Zeitpunkte eindeutig sind.
Mengen von Tupeln sind schlicht Relationen und Relationen, wo die
linke Komponente eindeutig ist (sog. Rechtseindeutigkeit) nennt man
partielle Funktionen. Partielle Funktionen können wir in
Haskell mit Maybe
im Ergebnistyp ausdrücken. Diesen Trick nutzen wir
auch in unserer mathematischen Modellierung.3
type TimeSeries a -- Modell: Time -> Maybe a
Dieses Modell drückt jetzt schon eher das aus, was wir mit Zeitreihe
meinen. Der Konstruktor fromDataService
wird etwas komplexer:
𝛍 (fromDataService []) = λ _ . Nothing
𝛍 (fromDataService (t1, v):xs) = λ t2 . if t1 == t2
then Just v
else 𝛍(fromDataService xs) t2
Die beiden Ausdrücke fromDataService [(t1, x), (t2, y)]
und
fromDataService [(t2, y), (t1, x)]
führen jetzt zu demselben
Ergebnis – unser Modell ist sozusagen schlanker geworden.
Die Accessor-Funktion lookup
ist einfach Funktionsapplikation.
𝛍(lookup t ts) = 𝛍 ts t
Jetzt fehlt noch addTS
. Bevor wir die Bedeutung aufschreiben,
schauen wir uns einige Beispiele an. In dem Fall, dass sich die beiden
Eingangszeitreihen in ihren Zeitstempeln decken, sollen wohl die
Punkte addiert werden:
addTS [(t1, 4), (t2, 7)] [(t1, 9), (t2, 14)]
-- => [(t1, 13), (t2, 21)]
Was soll aber passieren, wenn es Tupel gibt, die keinen Gegenspieler
haben? Eine Möglichkeit wäre, diesen Fall als Programmierfehler
anzusehen und mit error
abzustürzen.
addTS [(t1, 4), (t2, 7)] [(t1, 9), (t3, 42)]
-- => Error!
Wir könnten auch die Punkte ohne Gegenspieler ignorieren.
addTS [(t1, 4), (t2, 7)] [(t1, 9), (t3, 42)]
-- => [(t1, 13)]
Oder wir könnten die Punkte ohne Gegenspieler unverändert mit ins Ergebnis aufnehmen.
addTS [(t1, 4), (t2, 7)] [(t1, 9), (t3, 42)]
-- => [(t1, 13), (t2, 7), (t3, 42)]
Alternativ könnten wir zur Einsicht gelangen, dass die Signatur von
addTS
falsch war und eine Addition lediglich ein Maybe (TimeSeries Float)
ausgeben sollte.
addTS :: TimeSeries Float -> TimeSeries Float -> Maybe (TimeSeries Float)
Oder wir möchten übereinstimmende Tupel mit Just
auszeichnen und
nicht-übereinstimmende mit Nothing
.
addTS :: TimeSeries Float -> TimeSeries Float -> TimeSeries (Maybe Float)
Vielleicht fallen uns noch ein paar weitere Möglichkeiten
ein. Unumstößlich und ausschließlich richtig ist keine der
Optionen. Alle haben ihre Berechtigung. Eine funktional vollständige
Programmierschnittstelle müsste also entweder alle oben genannten
Optionen mitliefern oder wir müssten noch nach einer passenden
Abstraktion suchen. Wir möchten den letzteren Weg beschreiten, denn
mindestens einen kleinen Vereinfachungsschritt bei der Modellierung
können wir uns noch gönnen. Time -> Maybe a
ist ganz gut, aber Time
-> a
wäre noch einfacher und allgemeiner.
Zweiter Versuch: Zeitreihen Zeitfunktionen
Vielleicht sollten wir also von unserer Idee von Zeitreihen ablassen und stattdessen über Zeitfunktionen sprechen.
type TimeFunction a -- Modell: Time -> a
fromDataService :: [(Time, a)] -> TimeFunction (Maybe a)
𝛍 (fromDataService []) = λ _ . Nothing
𝛍 (fromDataService (t1, v):xs) = λ t2 . if t1 == t2
then Just v
else 𝛍(fromDataService xs) t2
lookup :: TimeFunction a -> UTCTime -> a
𝛍 (lookup t ts) = 𝛍 ts t
fromDataService
und lookup
ergeben auch für Zeitfunktionen Sinn
und die Definitionen sind dieselben wie zuvor. Zeitfunktionen können
wir auch addieren und jetzt ist die punktweise Addition die
offensichtlich richtige Definition. Wir benutzen dazu direkt die
naheliegende Abstraktion. Damit können wir auch Subtraktion und
Multiplikation ausdrücken.
liftTF :: (a -> b -> c) -> TimeFunction a -> TimeFunction b -> TimeFunction c
𝛍 (liftTF f x y) = \t -> f (𝛍 x t) (𝛍 y t)
addTF :: TimeFunction Float -> TimeFunction Float -> TimeFunction Float
addTF = liftTF (+)
subTF :: TimeFunction Float -> TimeFunction Float -> TimeFunction Float
subTF = liftTF (-)
mulTF :: TimeFunction Float -> TimeFunction Float -> TimeFunction Float
mulTF = liftTF (*)
liftTF
ist das zentrale Element unseres Modells, also unserer
Programmierschnittstelle, die wir anderen zur Verfügung stellen
können. Damit kann ein Benutzer dieser Schnittstelle selbst
entscheiden, welche Funktion er in die Domäne der Zeitfunktionen heben
möchte.
Bislang haben wir unser ursprüngliches Problem – Addition von
Zeitreihen – noch nicht gelöst. Die Verantwortung für die korrekte
Auswahl der Addition können wir dank liftTF
jetzt einfach in die
Hand der Nutzer:innen legen. Beispielsweise:
-- TimeSeries a ist jetzt TimeFunction (Maybe a)
addTS1 :: TimeFunction (Maybe Float) -> TimeFunction (Maybe Float) -> TimeFunction (Maybe Float)
addTS1 = liftTF (liftA2 (+))
addTS2 :: TimeFunction (Maybe Float) -> TimeFunction (Maybe Float) -> TimeFunction (Maybe Float)
addTS2 = liftTF (\x y -> case (x, y) of
(Just x', Just y') -> Just (x' + y')
(Just x', Nothing) -> Just x'
(Nothing, Just y') -> Just y'
(Nothing, Nothing) -> Nothing)
Wir könnten auch einen neuen Zeitfunktionskombinator interpolate
einführen und dann addTF
nutzen.
interpolate :: a -> TimeFunction (Maybe a) -> TimeFunction a
𝛍 (interpolate dflt tf) = λ t . let tl be the largest timestamp such that tl <= t and 𝛍 tf tl == Just v
if such a tl exists
then v
else dflt
Die Bedeutungsfunktion von interpolate
nutzt Quantoren und scheut
nicht vor der Unendlichkeit zurück. Spätestens hier wird klar, dass
die Bedeutungsebene nicht mehr mit der Implementierung in eins
fällt. Im einem der nächsten Artikel werden wir sehen, wie wir
gewährleisten können, dass Implementierungen ihre Bedeutung
(Spezifikation) einhalten.
Zusammenfassung
Wir haben angefangen mit einer eher vagen Idee von unserer Domäne: Zeitreihen. Wir sind mit der ersten Modellierung allerdings auf Probleme gestoßen. Eine einfache Operation wie die Addition ließ sich nur ungelenk abbilden. Wir haben uns deshalb überlegt, was ein geeignetes mathematisches Modell für Zeitreihen sein könnte. Wir haben dieses Modell nach und nach vereinfacht und sind dabei zu der Einsicht gelangt, dass Zeitreihen nur ein Sonderfall eines allgemeineren Konzepts sind: Zeitfunktionen. Mit Zeitfunktionen konnten wir unser Modell so mächtig gestalten, dass Detailfragen einfach die Nutzer:innen selbst beantworten können.
-
Listen sind sozusagen keine nativen mathematischen Objekte. Wir basteln uns mathematische Listen mit dem Null-Tupel
()
und dem Zweier-Tupel(Wert, Restliste)
alsCons
. ↩ -
Wir gehen im Fortgang des Artikels stets davon aus, dass
𝛍
überladen ist – hier bspw. für Listen, Tuple,UTCTime
unda
. ↩ -
Auch
Maybe
findet sich nicht unmittelbar in der Standardbibliothek der Mengentheorie (auf welcher wir hier aufbauen). Wir könnenNothing
mit dem Null-Tupel()
ausdrücken undJust x
ist das Einer-Tupel(x)
. ↩