Was ist das 'Expression Problem'?
Das sogenannte ‚Expression Problem‘ ist das Problem, dass sich Programme in zwei Richtungen weiterentwickeln können, nämlich:
- neue Operationen für bestehende Datentypen, und
- neue Datentypen für bestehende Operationen,
und dass man sich gerne beide Möglichkeiten offen halten möchte, ohne das Programmieren wesentlich komplizierter zu machen. Komplizierter wird es zum Beispiel, wenn der bestehenden Code dazu geändert oder neu kompiliert werden muss. (Phillip Wadler, der den Begriff geprägt hat, formuliert es hier etwas enger).
Der grundlegende Ansatz der objekt-orientierten Programmierung macht es leicht neue Datentypen hinzuzufügen und schwer neue Operationen hinzuzufügen, während es bei klassischer funktionaler Programmierung genau umgekeht ist (siehe z.B. hier).
Im diesem Artikel möchte ich das Problem anhand einfacher Beispiele erläutern, und einige Lösungen auflisten, die in diversen Sprachen dafür angeboten werden.
Aus objekt-orientierter Sicht
Betrachten wir das Problem zunächst am Beispiel einer
objekt-orientierten Sprache, wie z.B. Java. Beginnen wir mit einem Typ
für Ausdrücke, Expr
, zwei Ausprägungen dieses Typs (Const
und
Add
), und einer Operation toString
zur hübschen Darstellung des
Ausdrucks als String:
Was nun in einer objekt-orientierten Sprache einfach ist, ist das
Hinzufügen einer neuen Art von Ausdruck, indem man einfach eine neue
Ableitung von Expr
schreibt, z.B. für Negation:
Für die bestehende Operation auf Ausdrücken, toString
, gibt man in
der abgeleiteten Klasse einfach eine Implementierung an, wodurch man
diese Operation auf die neue Art von Ausdruck erweitert hat - und das
ohne den bestehenden Code geändert zu haben!
Wenn es aber nun darum geht eine neue Operation für Ausdrücke
hinzuzufügen, also z.B. eine Funktion evaluate
die den Wert eines
Ausdrucks errechnet, dann geht das in diesem Fall nicht ohne die
bestehende abstrakte Klasse Expr
zu modifizieren, genauso wie alle
Ableitungen davon (oder zumindest viele davon).
Wie bereits erwähnt, ist es in funktionalen Sprachen üblicherweise genau andersherum, wie der nächste Abschnitt zeigt.
Aus funktionaler Sicht
Um Ausdrücke verschiedener Ausprägung und Operationen darauf zum
Beispiel in Haskell zu definieren, würde
man üblicherweise einen Datentyp Expr
mit dem Konstrukt data
definieren, und die Operationen darauf über Pattern-Matching:
Eine Operation wie eine Auswertungsfunktion ist dann ganz einfach zu implementieren:
Hierfür musste der bestehende Code nicht angefasst werden! Aber wie
man leicht erkennt, ist die Erweiterung um einen Negations-Ausdruck
nicht mehr möglich ohne die Definition des Datentyps, sowie die
Definitionen der Operationen toString
und dann auch evaluate
zu
ändern.
Haskells Typklassen
Eine Möglichkeit dem Problem in Haskell zu begegnen ist, keinen Datentyp für Ausdrücke zu definieren, sondern einen eigenen Typ für jede Art von Ausdruck, und dann sogenannte Typklassen für die Operationen darauf.
Typklassen definieren eine Gruppe von Funktionen und deren Signaturen, ähnlich wie Interfaces in Java. Für beliebige Typen kann man dann jederzeit Instanzen dieser Typen implementieren, sowie für polymorphe Funktionen die Einschränkung deklarieren, dass für einen Typparameter eine Instanz von bestimmten Typklassen vorhanden sein muß.
Bevor wir je eine Erweiterung in beide „Richtungen“ machen, hier
zunächst die beiden Typen für Konstanten und Additionsausdrücke und
die erste Typklasse für die toString
-Operation:
Besonders zu beachten ist dabei, dass der Datentyp für
Additions-Ausdrücke keinerlei Einschränkungen für die Typen der beiden
Teilausdrücke (a
und b
) macht. Man könnte also auch eine AddExpr
mit zum Beispiel zwei String-Listen bilden. Die Einschränkung kommt
dann erst in der zugehörigen Implementierung von ToString
- der Teil
links vom =>
. Diesen kann man in etwa so lesen: Wenn die Typklasse
ToString
auf einem Typ a
und einem Typ b
implementiert ist, dann
kann ToString
auf dem Typ AddExpr a b
folgendermaßen implementiert
werden.
Jetzt ist es möglich, eine Erweiterung der Ausdrücke auf
Negations-Ausdrücke zu machen, indem wir einen neuen Typ definieren,
sowie Instanzen für die bestehende Operation toString
:
Fügt man einen neuen Typ hinzu, muss man entsprechend neue Instanzen für alle Operationen implementieren, die dieser Typ unterstützen soll. Der bestehende Code muß nicht modifiziert werden! Also genau das was wir erreichen wollten!
Und neue Operationen hinzuzufügen, geht nach wie vor. Man definiert eine neue Typklasse, und Implementierungen für jeden bestehenden Typ:
Auch hier muss der bestehende Code nicht angefasst werden!
Nur ein kleiner Wermutstropfen: Vergisst man eine Typklasse zu implementieren, bekommt man eine Fehlermeldung eventuell an einer ganz anderen Stelle als gehofft - aber man bekommt eine! Aus diesem und anderen Gründen, ist diese Art der Programmierung allerdings in Haskell nicht sehr verbreitet, und man sollte sie nur wählen, wenn man die Erweiterungsfähigkeit der Typen wirklich benötigt.
Eine ganz ähnliche Lösung ist übrigens in Scala möglich, mit den gleichen Eigenschaften wie die Haskell-Lösung mit Typklassen - siehe dazu zum Beispiel dieses PDF
Clojures Protocols
Mit der JVM-Sprache Clojure lässt sich das Expression Problem ebenfalls gut lösen, und zwar mit dem Sprach-Feature Protocols. Protocols definieren ebenfalls eine oder mehrere Operationen, ähnlich wie Typklassen. Sie können dann separat für verschiedene Typen implementiert werden - extend nennt sich das in Clojure. Da Clojure ein dynamisches Typsystem besitzt, fallen gegenüber Haskell die Deklarationen der Einschränkungen weg - man kann sie aber in Clojure als Annotation hinzufügen, wenn man das möchte.
Das Expression-Problem lässt sich in Clojure also wie folgt lösen (reduziert auf zwei Typen und zwei Operationen, aber das Prinzip ist das gleiche):
In diesem Artikel ist diese Lösung etwas genauer beschrieben.
Zusammenfassung
Es gibt viele weitere Lösungen des Expression-Problems in diversen Sprachen, aber auch viele Sprachen in denen sich nur sehr umständliche Lösungen oder gar keine Lösungen finden lassen. Beim Design einer Software-Komponente sollte man sich dann schon recht früh entscheiden, ob sich diese eher in Richtung „mehr Operationen“ oder in Richtung „mehr Typen“ weiterentwickeln wird - in die andere Richtung kann man dann nur noch mit sehr großem Aufwand gehen.
Das Beste ist daher natürlich auf eine moderne (funktionale) Sprache zu setzen, in der das Expression Problem gelöst werden kann!