Programmiersprachen-Design, Teil 2: Ein alternatives Sprachdesign für Gleichheit

Die Unterscheidung von Gleichheit und Identität gehört zu den Grundfesten der Programmierung. Doch diese Einteilung und ihre Umsetzung in vielen Programmiersprachen hat ihre Schwächen. Hier setzt ein alternativer Ansatz, die Unterscheidung von Übereinstimmung und Ähnlichkeit, einen anderen Schnitt.

In Pocket speichern vorlesen Druckansicht 21 Kommentare lesen
Programmiersprachen-Design, Teil 2: Ein alternatives Sprachdesign für Gleichheit
Lesezeit: 15 Min.
Von
  • Beate Ritterbach
Inhaltsverzeichnis

Dabei handelt es sich nicht lediglich um eine Umbenennung, sondern um eine grundlegend andere Einteilung: Übereinstimmung umfasst, abhängig von der Art der verglichenen Gegenstände, sowohl Identität bei Objekten als auch Gleichheit bei Werten. Ähnlichkeit dagegen beschreibt eine andere, vorwiegend bei Objekten anzutreffende Art von "Gleichheit".

Mehr Infos

Bei der Unterscheidung verschiedener Gleichheiten geht es nicht um "richtig" oder "falsch", sondern darum, wie sich mit der jeweiligen Sichtweise arbeiten lässt. Das lässt sich anhand des historischen Beispiels von geozentrischem und heliozentrischem Weltbild veranschaulichen: Weil Körper sich relativ zueinander bewegen, kann man sowohl die Erde als auch die Sonne als Bezugspunkt wählen. Relativ zur Erde vollführen die anderen Planeten komplexe, verschnörkelte Bewegungen. Mit der Sonne als Bezugspunkt werden die Planetenbahnen zu Ellipsen und damit zu einfachen, mathematisch gut beschreibbaren Gebilden. Deshalb hatte das heliozentrische Weltbild das geozentrische abgelöst: Es erlaubt in vielerlei Hinsicht eine einfachere Handhabung.

Dieser Beitrag will zeigen, welche Vereinfachungen die Unterscheidung von Übereinstimmung und Ähnlichkeit ermöglicht. Angenommen, es soll eine neue Programmiersprache entworfen werden. Wie lassen sich, ausgehend von den obigen Überlegungen, Übereinstimmung und Ähnlichkeit gestalten?

Übereinstimmung wird seitens der Sprache einheitlich unterstützt. Alle Typen verwenden für sie dasselbe Symbol, unabhängig davon, ob Objekte oder Werte verglichen werden, und unabhängig davon, wie Übereinstimmung implementierungstechnisch umgesetzt ist. Es macht die Programmiersprache klarer und aussagekräftiger, für dieselbe Bedeutung überall dasselbe Symbol zu verwenden.

Als ein geeigneter Kandidat bietet sich das Gleichheitszeichen an, denn es wird in der Mathematik seit langem in genau dieser Bedeutung verwendet. Diese Wahl kollidiert mit der in Java, C# und anderen syntaktisch von C abgeleiteten Sprachen verwendeten Notation. Sie haben das Gleichheitszeichen "okkupiert", für die Zuweisung zweckentfremdet und müssen deshalb für das Vergleichen ein anderes Symbol, nämlich "==", verwenden. Eingängiger und in etlichen anderen Sprachen üblich ist das Symbol "=" für das Vergleichen und ":=" für die Zuweisung.

Ähnlichkeit erhält keine dedizierte Unterstützung durch die Programmiersprache, kein eigenes Symbol, keine spezielle Methode (wie equals). Ein Grund für diese Zurückhaltung: Ähnlichkeit hat keine einheitliche Bedeutung. Klar ist nur, worin sie sich von Übereinstimmung unterscheidet: Bei Ähnlichkeit liegen zwei Exemplare vor, bei Übereinstimmung ein einziges. Was dagegen Ähnlichkeit kennzeichnet, ist vage und hängt von den jeweiligen Anforderungen ab. Wann sind zum Beispiel zwei Bücher "gleich"? Genügen Autor und Titel? Oder fließen auch Verlag und Auflage ein? Spielt es eine Rolle, wenn das eine Buch einen festen Einband hat und das andere als Taschenbuch vorliegt? Welche Merkmale sind im Allgemeinen relevant dafür, zwei Objekte als "gleich" anzusehen?

Ein weiterer Grund: Anders als Übereinstimmung ist Ähnlichkeit kein universelles Konzept. Für viele Klassen wird ein derartiger Vergleich gar nicht benötigt. Umgekehrt kann es Klassen geben, die mehrere Ähnlichkeiten brauchen. Ähnlichkeit ist eine fachliche Operation wie jede andere. Es ergibt keinen Sinn, sie mit einem speziellen Sprachprimitiv zu unterstützen. Es erwartet auch niemand, dass fachliche Operationen wie "Mehrwertsteuer berechnen" oder "Schadenhöhe ermitteln" in der Sprache vordefiniert sind.

Ähnlichkeit nicht als in der Sprache vordefinierte Operation zu unterstützen, ist deshalb kein Mangel, sondern hat viele Vorteile:

  • Wenn eine Klasse keine Ähnlichkeit benötigt, ist sie auch nicht standardmäßig vorhanden und kann damit keine Verwirrung stiften.
  • Ähnlichkeit lässt sich, wenn eine Klasse sie benötigt, als "normale" Methode implementieren – mit einem passenden Namen und einer geeigneten Signatur. Weil sie damit einen anderen Namen beziehungsweise ein anderes Symbol als Übereinstimmung erhält, können die beiden Vergleiche nicht miteinander verwechselt oder vermengt werden.
  • Benötigt eine Klasse mehrere Ähnlichkeiten, können Programmierer dafür mehrere Methoden definieren: unter verschiedenen Namen, die die Besonderheiten der jeweiligen Vergleiche zum Ausdruck bringen.

Es gibt einen dritten Grund, Ähnlichkeit weder durch die Programmiersprache zu unterstützen noch ihr Vorhandensein für jede Klasse zu verlangen: Sie ist fehleranfällig. Eine Gleichheit muss eine Reihe von Bedingungen einhalten (beschrieben z. B. von Odersky, Spoon und Venners). Um einige kritische Punkte herauszugreifen:

  1. Sie muss eine Äquivalenzrelation bilden (d. h. reflexiv, symmetrisch und transitiv sein). Insbesondere die Transitivität ist programmiertechnisch schwer umzusetzen.
  2. Sie darf nicht von änderbarem Zustand abhängen.
  3. Es sind Abhängigkeiten zu Methoden wie hashcode zu berücksichtigen.

Verstöße gegen diese Regeln resultieren in überraschendem, indeterministischem Programmverhalten und schwer auffindbaren Laufzeitfehlern (eingehend beschrieben im eben genannten Blog von Odersky, Spoon und Venners).

Bei Übereinstimmung sind die genannten Regeln fast immer automatisch erfüllt, da es sich hier per Definition um ein einziges Exemplar (Objekt bzw. Wert) handelt. Bei Ähnlichkeit dagegen ist die Gefahr besonders groß, gegen die Regeln zu verstoßen:

  • zu 1.: Wenn eine Ähnlichkeit kleine Abweichungen toleriert, das heißt, wenn sie zwei Objekte bei nur geringfügigen Abweichungen ihrer relevanten Merkmale als "gleich" einstuft, können sich diese Abweichungen aufaddieren und die Transitivität zerstören.
  • zu 2.: Ähnlichkeit basiert, anders als Übereinstimmung, auf "relevanten" Merkmalen. Diese Merkmale sind Bestandteil des Objektzustands und damit im Prinzip änderbar.
  • zu 3.: Es kann Merkmale eines Objekts geben, die für Ähnlichkeit nicht relevant sind. Wenn sie in die Ermittlung des hashcode einfließen, können solche Objekte einen unterschiedlichen hashcode haben, mit entsprechenden Konsequenzen in hashcode-basierten Kontexten, etwa in vielen Collections.

Im Ergebnis kommt ein Sprachdesign heraus, das nur einen einzigen Vergleich unterstützt: Übereinstimmung. In heutigen Programmiersprachen und Anwendungssystemen finden sich bereits einige Indizien dafür, dass ein einziger Vergleich genügt:

  • Für etliche Typen gibt es von vornherein nur einen einzigen Vergleich. Zum Beispiel lassen sich Basisdatentypen in Java (int, float, char, boolean etc.) nur mit dem Operator "==" vergleichen (der die Wertgleichheit abbildet), nicht mit equals.
  • Für Klassen, bei denen mehrere Vergleiche zur Verfügung stehen, kommt meist trotzdem nur ein einziger davon praktisch zum Einsatz. In Java werden beispielsweise Exemplare der Klassen wie String, Date oder BigInteger nahezu immer mit der Methode equals verglichen. Anwendungsbeispiele, bei denen "==" zum Einsatz kommt (und korrekt ist!), muss man fast schon gewaltsam konstruieren.
  • Für Klassen wie Person, Fahrzeug, Vertrag oder Abteilung dagegen ist in der Regel "==" der fachlich sinnvolle Vergleich. Sie lassen sich zwar auch mit equals vergleichen, doch ist diese Methode meist lediglich ein Wrapper für "==" und kommt vorwiegend in polymorphen Kontexten zum Einsatz.

Wenn Übereinstimmung, wie vorgeschlagen, Teil der Programmiersprache ist, ergibt sich die Frage, durch wen und wie sie implementiert wird. Durch die Sprachumgebung, zum Beispiel den Compiler, ohne eine Möglichkeit der Einflussnahme durch Anwendungsprogrammierer? Oder erhalten Programmierer einer Klasse die Gelegenheit, die Implementierung selbst festzulegen, ähnlich, wie sie das in Java bei der Methode equals können?

Es ist von Vorteil, wenn die Sprachumgebung, wann immer möglich, Übereinstimmung generiert und vollständig unter Kontrolle hat. Es verkürzt den Programmcode und verhindert potenzielle Fehler. Bei Objekten kann und sollte Übereinstimmung, das heißt die Objektidentität, als Sprachprimitiv zur Verfügung gestellt werden. Genau das leisten die meisten heutigen objektorientierten Sprachen. Der entsprechende Operator ("==" in Java oder C#) ist Teil der Sprache; Anwendungsprogrammierer können ihn benutzen, aber nicht implementieren. Technisch liegt der Objektidentität ein Vergleich von Speicheradressen (Pointern, Referenzen) zugrunde, und auf die Speicherverwaltung haben Anwendungsprogrammierer aus guten Gründen keinerlei Zugriff (Ausnahme: systemnahe Sprachen wie C++ oder D).

Bei Werten dagegen – Klassen wie String oder BigInteger, Datum oder Geldbetrag – ergibt ein Vergleich von Speicheradressen nicht die Übereinstimmung, also die Wertgleichheit. Der Grund: Ein Wert kann mehrere Speicherrepräsentationen haben. (Das gilt unabhängig davon, ob der Speicherung Referenz- oder Wertsemantik zugrunde liegt.) Wertgleichheit ist inhaltlich zu ermitteln, das heißt auf Basis der Datenfelder (oder bei primitiven Typen anhand der Bitmuster). Für einige Autoren besteht genau darin sogar das kennzeichnende Merkmal von Werten. Beispielsweise definiert Martin Fowler Werte als "objects, [...] whose equality isn't based on identity" [1].

Bei vielen wertartigen Klassen genügt es, Wertgleichheit durch paarweisen Vergleich der Datenfelder zu ermitteln: Bei Datum werden Jahr, Monat und Tag verglichen, bei komplexen Zahlen Real- und Imaginärteil. Auch hier kann die Sprachumgebung Anwendungsprogrammierern Arbeit abnehmen und diesen Vergleich generieren: Die Datenfelder sind in der Definition der Klasse festgelegt, und der Vergleich folgt einer einfachen Formel, beispielsweise im Fall von komplexen Zahlen c1 und c2 mit den Datenfeldern real und img (hier in Pseudocode dargestellt):

return c1.real = c2.real AND c1.img = c1.img

Dass sich ein derartiger Vergleich ohne weiteres generieren lässt, zeigen Programmiersprachen, die genau das in speziellen Fällen tun: Scala bei case classes, Kotlin bei data classes.

Es gibt aber auch Fälle, in denen der paarweise Vergleich der Datenfelder nicht zum korrekten Ergebnis führt, nämlich Klassen, bei denen ein Wert verschiedene Repräsentationen haben kann. Ein Beispiel dafür ist eine Klasse Rational zur Abbildung rationaler Zahlen, mit einer Repräsentation bestehend aus zaehler und nenner. Zum Beispiel lässt sich 2/3 auch als 4/6 oder 10/15 darstellen. Damit versagt bei Rational die Standardformel:

return r1.zaehler = r2.zaehler AND r1.nenner = r2.nenner

Sie würde gleiche Werte als ungleich ausweisen. Hier lässt sich die Wertgleichheit mit der folgenden Formel ermitteln:

return r1.zaehler * r2.nenner = r2.zaehler * r1.nenner

Bei solchen Klassen gibt es für die fachlich korrekte Formel der Wertgleichheit kein allgemein gültiges Rezept. Programmierer müssen deshalb die Möglichkeit erhalten, die Wertgleichheit manuell zu implementieren. Der von der Sprache generierte Vergleich aller Datenfelder kann damit lediglich eine Default-Implementierung sein.

Insgesamt ergibt sich folgendes Sprachdesign:

  • Es gibt auf Sprachebene nur einen Vergleich; er hat für alle Typen dieselbe Bedeutung: Übereinstimmung.
  • Seine Implementierung wird von der Sprache generiert: bei Objektidentität als Blackbox, ohne Einflussmöglichkeit durch Programmierer; bei Wertgleichheit als Default, und Programmierer der Klasse können bei Bedarf eine abweichende Implementierung angeben.

Die folgende Abbildung fasst das Implementierungsschema zusammen:

Zusammenfassend eine Übersicht über die Vorteile des auf "Übereinstimmung versus Ähnlichkeit" beruhenden Sprachdesigns:

  • Einheitlichkeit: Die Sprache unterstützt nur einen einzigen Vergleich. Er hat in allen Klassen dasselbe Symbol und dieselbe Bedeutung, nämlich Übereinstimmung – wenn auch möglicherweise, unsichtbar für Klienten der Klasse, unterschiedliche Implementierungen.
  • Konzeptionelle Klarheit: Übereinstimmung ("ein- und dasselbe Exemplar") und Ähnlichkeit ("zwei Exemplare mit übereinstimmenden relevanten Merkmalen") sind klar voneinander getrennt.
  • Geringe Fehleranfälligkeit: Weil auf Ebene der Programmiersprache nur ein Vergleich vorhanden ist, besteht keine Gefahr der Verwechslung oder der versehentlichen Benutzung einer "falschen" Gleichheit.
  • Kein Vergleich von Werten auf Basis technischer Adressen: Das Sprachdesign stellt sicher, dass Übereinstimmung bei wertartigen Typen basierend auf den Datenfeldern implementiert ist. Eine Gleichheit, die auf den Vergleich von technischen Speicheradressen zurückgeht, wie in Java beim Vergleich von Strings mit "==", ist bei Werten ausgeschlossen.
  • Keine Abhängigkeit von änderbarem Zustand: Übereinstimmung kann per Konstruktion nicht von änderbarem Zustand abhängen: bei Objekten aufgrund des zugrunde liegenden Vergleichs von technischen Adressen, bei Werten, weil hier der Vergleich auf den Datenfeldern basiert und diese bei Werten per se unveränderlich sind. Das trägt dazu bei, inkonsistentes Verhalten zu verhindern.
  • Weniger Fehleranfälligkeit und Vereinfachung bei Collections: In Collections, die ihre Elemente auf Basis von Übereinstimmung verwalten, kann es (anders als bei Ähnlichkeit) nicht zu inkonsistentem, widersprüchlichen oder indeterministischem Verhalten kommen. Des Weiteren muss es von solchen Collections nur eine einzige Variante geben.

Das hier beschriebene Sprachdesign lässt sich nur beim Entwurf einer neuen Programmiersprache zugrunde legen. Mehrere Gleichheiten – wie in vielen aktuellen Sprachen gängig – im Nachhinein zu einer zu verschmelzen, ist aus Gründen der Aufwärtskompatibilität kaum praktikabel.

Ein Problem kann das hier vorgeschlagene Sprachdesign nicht lösen: Die aufgrund von Synonymen und Homonymen verwirrende umgangssprachliche Benennung der Übereinstimmung: Identität bei Objekten, Gleichheit bei Werten. Ein historisch gewachsener Sprachgebrauch lässt sich nur schwer ändern. Zumindest aber kann die Programmiersprache durch ein einziges Symbol mit einheitlicher Bedeutung die Programmierung klarer, einfacher und sicherer machen – und letztlich das Weltbild der Programmierer beeinflussen, die diese Sprache benutzen.

Eine wesentliche Voraussetzung für das hier beschriebene Sprachdesign ist die Trennung von Werten und Objekten. Eine Klasse muss per Deklaration festlegen, ob sie Werte oder Objekte abbildet, das heißt, in der Sprache muss es Wertklassen und Objektklassen geben. Anderenfalls kann die Sprachumgebung nicht "wissen", welche Implementierung von Übereinstimmung die jeweilige Klasse benötigt: Objektidentität oder Wertgleichheit. Anhand der Datenfelder ist das nämlich nicht zweifelsfrei erkennbar. Beispielsweise kann eine Klasse mit zwei numerischen Datenfeldern Werte abbilden, etwa komplexe Zahlen mit Real- und Imaginärteil. Eine solche Klasse kann aber ebenso gut Objekte darstellen, zum Beispiel Fahrzeuge mit den Eigenschaften Hubraum und Leistung.

Eine Unterscheidung von Wert- und Objektklassen ist nicht nur zur Herstellung des hier beschriebenen einheitlichen Vergleichs brauchbar. Werte und Objekte sind fundamental so verschieden, dass ihre Trennung eine Programmiersprache ausdrucksstärker macht. Auch funktioniert Vererbung und Subtyping bei diesen
beiden Abstraktionen unterschiedlich (s. dazu einen früheren Artikel der Autorin), was ebenfalls für ihre sprachliche Trennung spricht.

Einige objektorientierte Programmiersprachen unterstützen eine Unterscheidung von objekt- und wertartigen Klassen bereits. In C# und in Swift gibt es class und struct. Auch Java plant in der nächsten Sprachversion die Unterstützung wertartiger Typen (Projekt Valhalla, siehe dazu den Beitrag von Henning Schwentner: "Project Valhalla: Value Types in Java"). Allerdings enthalten die genannten Sprachen nach wie vor zwei Vergleiche, nicht das hier beschriebene Konzept der Übereinstimmung.

Ausgangspunkt der Überlegungen war eine Sichtweise, die Vergleiche entsprechend ihrer fachlichen Aussage in Übereinstimmung und Ähnlichkeit einteilt (statt in die "klassischen" Kategorien Gleichheit und Identität). Sie führt unmittelbar zu einem Programmiersprachen-Design, das für jede Klasse nur einen einzigen Vergleich unterstützt, nämlich Übereinstimmung (d. h. nach "altem" Sprachgebrauch bei Objekten die Objektidentität, bei Werten die Wertgleichheit).

Dagegen verzichtet das Design bewusst auf die Aufnahme eines eigenen Sprachfeatures für Ähnlichkeit (nach "altem" Sprachgebrauch: Objektgleichheit). Der einheitliche Vergleich vereinfacht die Programmierung und beseitigt viele Fehlerquellen, die der Umgang mit Gleichheit und Identität heute in vielen Sprachen aufwirft.

Beate Ritterbach
arbeitet seit 25 Jahren freiberuflich als Softwareentwicklerin. Daneben befasst sie sich mit Programmiersprachenkonzepten, schwerpunktmäßig mit der Verbindung von wertorientierter und objektorientierter Programmierung.

  1. Martin Fowler; Patterns of Enterprise Application Architecture; Addison-Wesley 2003

(ane)