Saturday, October 06, 2007

Double != double

In Java existieren von je her für alle primitiven Typen wie int, double entsprechende Wrapperklassen. Für int ist dies die Klasse java.lang.Integer. double hat java.lang.Double als Gegenstück.

Die Verwendung von Wertdatentypen in Sammlungsklassen wie ArrayList oder HashMap war immer etwas beschwerlich. Die Collections-Klassen können nämlich nur mit echten Objekten umgehen. Wie man an dem nachfolgenden Beispiel sieht, muß man beim Einfügen und Herausholen eine manuelle Konvertierung - Boxing - vom primitiven Typ in das Wrapperobjekt durchführen:

int i = 99; //value to add
List list = new ArrayList();
list.add(Integer.valueOf(i));
[...]
int j = list.get(0).intValue();

Mit Java 1.5 wurde neben den Generics (oben schon gesehen) auch das so genannte Auto-Boxing eingeführt. Damit wird, wenn notwendig, automatisch eine Konvertierung zwischen primitiven Typen und den entsprechenden Wrapperklassen vorgenommen. Das gleiche Beispiel sieht unter Java 1.5 so aus:

int i = 99;
List list = new ArrayList();
list.add(i);
[...]
int j = list.get(0);

Der Code ist offensichtlich wesentlich aufgeräumter, aber Auto-Boxing ist primär "Syntactic Sugar", weil die notwendigen Methodenaufrufe (und Casts) immer noch ausgeführt werden.
Sie müssen nur nicht mehr explizit niedergeschrieben werden.

Auch in vielen anderen Fällen kann man mit den Objekten auf den ersten Blick so rechnen wie mit den Werten. Dies ermöglicht zum Beispiel diesen (realen) Code:

public final Double getDuration() {
if (duration == 0.0) {
duration = syncDuration + headerLength * secPerByte;
duration += enclosedPacket.getDuration();
}
return duration;
}

Konzeptionell sähe die Methode bei der Ausführung so aus:

public final Double getDuration() {
if (duration == 0.0) {
duration = syncDuration + headerLength * secPerByte;
duration += enclosedPacket.getDuration().doubleValue();
}
return Double.valueOf(duration);
}

Dieses Beispiel zeigt aber auch warum man stets zwischen Double und double unterscheiden sollte, auch wenn es so aussieht, als ob man den Unterschied weitgehend ignorieren könnte.

Performance

Ein Profiling der Anwendung, aus der dieses Codestück stammt, hat ergeben, dass die Anwendung 38% der Zeit in der Methode Double.valueOf verbringt und 14% der Zeit in Double.doubleValue.
Die Aufrufe sind nicht direkt erfolgt, sondern diese Methoden werden "unsichtbar" durch das Autoboxing erzeugt. [*]

Die Performance der Anwendung wurde halbiert auf Grund des Unterschiedes zwischen Double und double - Ein Buchstabe, Große Wirkung!

Nur weil man die Methodenaufrufe im Vergleich zu Java 1.4 nicht mehr sieht, sind sie trotzdem nicht weg. Der Unterschied zwischen Objektdatentyp und Werttyp ist nicht aufgehoben, sondern nur die Konvertierung ist einfacher. Die Methoden Double.valueOf und Double.doubleValue sind absolut triviale Methoden, aber es kann sich aufsummieren. In diesem Fall auf 50% der gesamtes Laufzeit. [**]

NullPointerExceptions

NullPointerExceptions (NPE) sind schon in normalen Fällen eine böse Sache, weil sie keinen wirklichen Hinweis darauf geben, was die wahre Ursache für den Fehler ist. Oftmals liegt der Fehler, der zu einer NullPointerException führt, an einer ganz anderen Stelle im Code als der Ursprung der Exception. Nicht umsonst liest man oft den Rat Nullen gar nicht erst in den Code zu lassen.

Noch schlimmer sind NullPointerExceptions, die durch Autoboxing entstehen.
Was passiert bei folgenden Code:

Integer obj;
[a lot of lines]
int i = obj * 10;

Richtig. Es wird eine NullPointerException geworfen, da obj nicht auf eine Referenz gesezt wurde. Wäre obj vom Typ int, so wäre es perfekt legaler Code bei dem i = 0 wäre.
Jeder Versuch ein auf null gesetztes Nummernobjekt automatisch zu konvertieren, endet in einer NPE.
Während es bei normalen NPE noch den Dereferenzierungsoperator "." als Hinweis gibt, sind Autoboxing-NPE quasi unsichtbar.

Mit Nummernobjekten ist es auch aufwändiger über die Korrektheit zu argumentieren. Der mögliche Zustandsraum ist um diesen elenden null-Fall größer als bei Werttypen. Man muß stets überlegen, ob das Objekt an der Codestelle null sein kann und wie darauf reagiert werden muß z.B. durch eine extra Prüfung auf != null oder eine inhaltlich sinnvolle Exception wie IllegalArgumentException.

Die Möglichkeit eine Variable auf null zu setzen fügt dem Zustandsraum eine zusätzliche Äquivalenzklasse hinzu, über die man extra nachdenken und die man i.d.R. mit eigenen Testfällen testen muß.

Beachtet man diesen wichtigen Unterschied nicht, so führt dies schnell zu ziemlich fragilen, unrobusten Programmen.

Vergleich zu C#

C# hat hier das Glück des Zweitgeborenen. Es konnte aus den Ungereimtheiten von Java lernen und dort wurde die Problematik gelöst, in dem int, long, etc. weitgehend normale Objekte sind und so auch in Collections eingefügt werden können. Der wichtigste Unterschied zu normalen Objekten ist, dass man die Wertobjekte nicht auf Null setzen kann. Mit .NET 2.0 wurde diese Möglichkeit durch eine Spracherweiterung (int?, double?, ...) nachgeliefert.

double ist dort nur ein Alias für den Frameworktyp System.Double. Vielleicht ist dies auch der Grund für den performance-hungrigen Code oben. Unter C# würde es dort kein Problem geben. Möglicherweise war der Entwickler eher in der C#-Welt zu Hause.

Fazit

Die Nummbernobjekte und Auto-Boxing sollten primär in Zusammenhang mit den Collections-Klassen oder ähnlichen Anwendungsfällen verwendet werden.
Im Zweifel kann man die Nummernobjekte auch verwendet, wenn man ausdrückten muß, dass ein Wert nicht gesetzt wurde (null).

Oftmals ist dies auch keine so gute Idee, aber es ist doch auf jeden Fall besser als Double.NaN und normalen Vergleichoperating für diesen Zweck einzusetzen (wie in der gleichen Anwendung an anderer Stelle gesehen).

Für alle anderen Fälle insbesondere in Berechnungen sollte man eher die Werttypen verwenden.
Die Dokumentationsseite der Java-API sagt dazu auch:

So when should you use autoboxing and unboxing? Use them only when there is an "impedance mismatch" between reference types and primitives, for example, when you have to put numerical values into a collection. It is not appropriate to use autoboxing and unboxing for scientific computing, or other performance-sensitive numerical code.

An Integer is not a substitute for an int; autoboxing and unboxing blur the distinction between primitive types and reference types, but they do not eliminate it.

1) Es gab noch ein paar Nummernobjekte in der Anwendung (, die bis auf eine Ausnahme alle besser Werte gewesen wären. 2) Ich bin der erste der zugeben würde, dass die Duration-Berechnung auch unabhängig von der Verwendung des Double-Objektes unglücklich entworfen und implementiert ist 3) Es ist nicht allein dieser Codestück für das Profilresultat verantwortlich, aber es war das zentrale Problem.

* Dieses Beispiel ist in meinen Augen auch ein Musterbeispiel für den Optimierungsgrundregel, dass man - wenn möglich - auf Basis von Messungen optimieren sollte. Diese kleine unscheinbare Methode war das absolute Performancebottleneck. Nur durch "scharfes Hinsehen" hätte man dies niemals herausfinden können.

No comments:

Post a Comment