Im Mittelpunkt stehen hier einige der Konstrukte, die jeder angehende Informatiker stundenlang in der Schlule pauken musste: Call By Ref und Call By Value. Meiner Meinung nach zu Unrecht...
Bei all meinem Lästern über die Schwächen von Java muss ich doch mal an einer Stelle eine Lanze für die Sprache brechen, ebenso für C. Doch von vorn..
Eine der wegweisenden Entscheidungen bei der Entwicklung von C war meiner Meinung nach, in der Sprache nur Call-By-Value anzubieten. Nur Call-By-Value? Die meisten die C kennen, werden jetzt anmerken, dass doch gerade die Sprache für ihre wilden Pointerreferenzen bekannt geworden ist! Das ist richtig, aber es ist trotzdem kein eigenes Call-By-Reference-Sprachkonstrukt. Während andere Sprachen wie z.B. Pascal Call-By-Ref als syntaktisches Konstrukt in die Sprache einbauen mussten, war dies in C bewusst nicht vorgesehen.
Die Pointerarithmetik bot nur einen Ausweg, mit dem man Call-By-Ref über Umwege realisieren konnte - auch wenn dies meist sehr haklig war. Um genauer zu sein: Es wurde sogar als der Fluch von C gesehen. Es zeigte sich, dass eine starke Orientierung an der Maschine nicht immer eine gute Idee sein muss:
Implementierung in C
// this is C
void add(int* a, int b){
*a = *a + b;
}
int a = 3, b = 4;
add(&a, b);
println(a);
Diese Methode soll zu einem Eingangswet a einen Wert b hinzufügen. Will man das Konstrukt als Call-By-Ref benutzen, d.h. einen Eingangsparameter ändern, dann muss man, wie man oben sieht, aus dem Eingangsparameter a einen Pointer machen. Außerdem muss die Methode add entweder mit einer Pointer-Variablen aufgerufen werden, oder wie hier, mit einem normalen Parameter und einem &: &a. (Dies liefert die Adresse von a zurück, wodurch a in der Methode als Pointer verwendet werden kann).
Da zur Zeit von C Call-By-Ref gang und gäbe war, ist dieses Vorgehen nicht unbedingt auf ungeteilte Begeisterung gestoßen. In C++ hat man es sogar als so dermaßen umständlich empfunden dass man die Referenzparameter erfunden hat:
Implementierung in C++
// this is C++
void add(int& a, int b){
a = a + b;
}
int a = 3, b = 4;
add(a, b)
println(a);
Zugegebenermaßen schon etwas einfacher - C++ hat also Call-By-Ref gehabt. Allerdings ist das, wie vieles bei C++, mit einem Overhead an Komplexität erkauft worden: C++ hat jetzt mehrere Konstrukte zur Deklaration von Parametern, die man alle kennen muss: Werte, Pointer, Referenzen, ... Inbesondere beim Verwenden von Pointern, Referenzen und Werten auf Objekte konnte schnell Verwirrung entstehen.
Ich persönlich finde da C's Lösung, die auch alles kann, aber mit weniger Komplexität auskommt, schöner. Zumal C Call-By-Ref mit einer expliziten &-Syntax bestraft. Das einzige was hier dreckig ist, ist dass Pointer und Ints implizit ineinander umgewandelt werden. Ohne diese "Hilfestellung" wären wahrscheinlich Myriaden von Fehlern der Art "& bei Referenzparameter vergessen" niemals aufgetaucht. Überhaupt sind die impliziten Konvertierungen meist viel eher die Wurzel alles Bösen in C als die Pointerarithmetik. Aber egal....
Denn dann kam Java.
Java's Idee, woher auch immer sie geklaut war, war wirklich gut: Es gibt nur zwei Arten von Variablentypen: Werte und Referenzen. Keine Pointer mehr. Die Aufgabe der Pointer übernehmen nun konsequent die Referenzen. Gleichzeitig wurde die Regel erlassen, dass die Logik, ob etwas Wert oder Referenz ist, vom Typ abhängt: "Objekte" sind immer Referenzen, "Werte" hingegen werden direkt abgelegt.
Dadurch hat in Java nur noch eine Art, Variablen zu deklarieren - kein & oder * mehr! Call-By-Ref für einfache Werte ist nun überhaupt nicht mehr möglich. Das kann man als alter C-Hase auch als Nachteil empfinden...
1. Java-Versuch: Mit Werten
// this is java
void add(int a, int b){
a = a + b;
}
int a = 3, b = 4;
add(a, b);
System.out.println(a);
Funktioniert nicht! a ist ein Wertparameter, der nicht von außen geändert werden kann! Hm.. Und nun? Die Lösung: Wir benutzen Referenzen, d.h. Objekte, weil über die ja Änderungen möglich sein sollten!
2. Java-Versuch: Mit Referenzen
// this is java
void add(Integer a, Integer b){
a = a + b;
}
Integer a = 3, b = 4;
add(a, b);
System.out.println(a);
Möp. Funktioniert auch nicht! Die Zuweisung verändert nur die Referenz a, aber diese Änderung wird aber nicht nach Außen weitergegeben! Die einzige Möglichkeit, in Java Veränderungen von Eingangsparametern herbeizuführen, ist einen veränderlichen Wert hereinzugeben, und diesen zu verändern.
Und jetzt wirds interessant: Integer verhält sich genau wie int! Ich kann einen Integer nicht intern verändern. Objekte, die sich nicht intern verändern lassen, können in Java's Modell niemals als veränderliche Eingangsparameter benutzt werden!
Es gibt aber einen Ausweg:
3. Java-Versuch: "Array-Hack"
// this is java
void add(int[] a, int b){
a[0] = a[0] + b;
}
int[] a = {3};
int b = 4;
add(a, b);
System.out.println(a[0]);
Warum funktioniert das Ganze jetzt? Weil ein Array ein sogenanntes mutable Objekt ist, d.h. ein Objekt dass es erlaubt über seine externen Schnittstellen von außen intern geändert zu werden.
Integer ist hingegen ein immutable Objekt. Ein immutable Objekt kann nicht von außen geändert werden. Sein nach außen sichtbarer Inhalt (Obserable State) bleibt von seiner Erzeugung an über die gesamte Laufzeit konstant.
Immutable Objekte haben für Call-By-Value zwei Eigenschaften die sie sehr praktisch (für C-Hasen: sehr doof) machen:
- Sie können sicher an alle Methoden übergeben werden, ohne dass diese Methode eine Möglichkeit hat, eine Veränderung an dem Wert herbeizuführen. In Sprachen wie Java kommt dazu noch der Vorteil, dass es auch nicht mehr möglich ist, einer externen Variable einen entsprechenden Wert zu injecten (mangels Call-By-Ref).
- Sie verhalten sich in jeder Hinsicht genauso wie die Werte wie int, long, .. Besser formuliert: Normale Werte verhalten sich genauso wie immutable Referenz-Objekte.
So.. und jetzt kommt der wirklich interessante Teil:
Aus obiger (2) folgt: An allen Stellen, an denen man in Java Werte wie int, long einsetzt, kann man auch die immutable Referenz-Wrapper Integer, Long, .. einsetzen und semantisch identischen Code schreiben. Abgesehen von der schlechteren Performance und dem schlechteren syntaktischen Support durch die Sprache, versteht sich.
Daraus wiederum folgt: Eigentlich braucht man überhaupt keine einfachen Werte!
- Werte sind vollständig durch immutable Referenzen abbildbar, da sich diese genauso verhalten wie die Werte.
- Die in Java getroffene Entscheidung für mehr Performance ist Augenwischerei. Man kann einen Compiler ohne Probleme so schreiben, dass er die Entscheidung trifft, wann ein Wert eine Referenz sein muss und wann er als performanterer Wert abbildbar ist (in Java ist eine Verwendung in Collections nur mit Referenzen möglich, sonst können eigentlich fast immer direkt die Werte verwendet werden). Zusätzlich muss er noch die impliziten Konvertierungen von Werten in Referenzen durchführen et volia: Die letzte Stufe der obigen Vereinfachung ist erreicht.
Entering Scala:
- Scala kennt die aus Java bekannten Primitiven nicht. Werte sind normale Objekte, die von einem besonderen Typ ("Value") ableiten. Durch die obigen Optimierungen erreicht der Compiler dieselbe Performance.
- Semantisch kennt Scala nur noch das Konzept der Referenz auf der einen Seite und des Mutable bzw. Immutable Objekts auf der anderen Seite. Immutable Objekte treten dabei an die Stelle der obigen Primitiven wie int, long, boolean, ....
- Das verwendete Call-Konstrukt ist wie in Java immer Call-By-Value Mit Referenzen. Dies mag jetzt verwirren, aber Call-By-Value bedeutet, dass eine externe Variable immer unverändert bleiben muss. Call-By-Reference bedeutet dass eine Wertänderung sich auf die externe Variable auswirkt.
Scalas Typsystem hat eine Stufe der Optimierung erreicht, bei der mit sehr wenigen Konstrukten all das erreicht werden kann, was in anderen Sprachen aufwändiger und zum Teil nur mit expliziten Typkonvertierungen herbeizuführen ist. Auf Call-By-Ref wird aufgrund der unkontrollierbaren Seiteneffekte bewusst verzichtet. Meiner Meinung nach ist dies einer der Gründe, warum sich Scala so gut als Lernsprache eignet: Es delegiert mehr Entscheidungen an den Compiler als die anderen Sprachen und macht die wirklich richtigen Konstrukte einfacher.
Zum Abschluss
Eines sollte klar sein: Der einzig richtige Weg, das obige Beispiel zu konstruieren ist natürlich in allen obigen Sprachen wie folgt:
// this is scala
def add(a: Int, b: Int) = {
a + b
}
var a = 3, b = 4
a = add(a, b)
println(a)
Das Beispiel sollte auch für List, Array oder SuperComplexDataTyp geltene:
Anstatt sich Gedanken darüber zu machen, mit welchen Semantiken man die Eingangsparameter überlädt, und wie man mutable Objekte sinnvoll gestalten kann, sollte man lieber eines tun: Mit Immutables arbeiten und neue Ausgangswerte erzeugen! => Funktional Programmieren.
Da Scala anders als alle anderen hier genannten Sprachen es auch erlaubt, einfach mehrere Ausgangswerte zu benutzen, stirbt der wichtigste Nutzen von Call-By-Ref einen stillen Tod: Es ist es nie nötig, mit dem bösartigen und schwer zu lesenden Call-By-Ref zu arbeiten, nur um mehrere Ausgangswerte zu erzeugen.
Ich hoffe das diese "Beweisführung" zeigen konnte, dass man manchmal weniger Konstrukte mehr sind. Und das man bestimmte Entscheidungen, z.B. die von den Primitiven, am besten dem Compiler überlässt. In anderen Bereichen, wie der Garbage Collection, haben wir es ja auch schon getan. :-)