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:
abstract class Expr {
String toString();
}
class Const extends Expr {
public int value;
String toString() {
return Integer.toString(value);
}
}
class Add extends Expr {
public Expr left;
public Expr right;
String toString() {
return left.toString() + " + " + right.toString();
}
}
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:
class Neg extends Expr {
public Expr base;
String toString() {
return "-" + base.toString();
}
}
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:
data Expr =
Const Int
| Add Expr Expr
toString (Const i) = show i
toString (Add left right) = (toString left) ++ " + " ++ (toString right)
Eine Operation wie eine Auswertungsfunktion ist dann ganz einfach zu
implementieren:
evaluate (Const i) = i
evaluate (Add left right) = (evaluate left) + (evaluate right)
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:
data ConstExpr = Const Int
data AddExpr a b = Add a b
class ToString e where
toString :: e -> String
instance ToString ConstExpr where
toString (Const i) = show i
instance (ToString a, ToString b) => ToString (AddExpr a b) where
toString (Add a b) = (toString a) ++ " + " ++ (toString b)
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:
data NegExpr e = Neg e
instance (ToString e) => ToString (NegExpr e) where
toString (Neg e) = "-" ++ (toString e)
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:
class Evaluate a where
evaluate :: a -> Int
instance Evaluate ConstExpr where
evaluate (Const i) = i
instance (Evaluate a, Evaluate b) => Evaluate (AddExpr a b) where
evaluate (Add a b) = (evaluate a) + (evaluate b)
instance (Evaluate e) => Evaluate (NegExpr e) where
evaluate (Neg e) = 0 - (evaluate e)
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):
(defrecord Const [e])
(defprotocol ToString
(toString [this] "Return arbitrary string representation"))
(extend Const ToString
{ :toString (fn [this] (str (:e this))) })
;; neuen Typ hinzufügen
(defrecord Add [a b])
(extend Add ToString
{ :toString (fn [this]
(str (toString (:a this)) " + " (toString (:b this))))
})
;; neue Operation hinzufügen
(defprotocol Evaluate
(evaluate [this] "Evaluate expression"))
(extend Const Evaluate
{ :evaluate (fn [this] (:e this)) })
(extend Add Evaluate
{ :evalute (fn [this]
(+ (evaluate (:a this)) (evaluate (:b this))))
})
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!