Zeitarbeit

Der Umgang mit Zeit- und Währungsformaten in Java ist kompliziert, schwer durchschaubar und somit eine potenzielle Fehlerquelle. Wer die Tücken der Standard-APIs kennt und passende Frameworks einsetzt, ist jedoch auf der sicheren Seite.

In Pocket speichern vorlesen Druckansicht 28 Kommentare lesen
Lesezeit: 14 Min.
Von
  • Markus Eisele

Die Java-Version 1.0 lieferte zum Darstellen und Manipulieren von Datumswerten nur die Klasse java.util.Date. Damit ließ sich ein Datumsobjekt aus Jahr, Monat, Tag, Minute und Sekunde erzeugen sowie eine Abfrage darauf durchführen. Die Klasse war zudem für das Ausgeben und Verarbeiten von Datumszeichenketten zuständig. Fehlende Internationalisierung sowie einige Fehler veranlassten Sun, mit Java1.1 zusätzlich die Klassen java.util.Calendar und java.text.DateFormat einzuführen. Das stiftete allerdings Verwirrung, denn von den einst sechs Konstruktoren der Klasse java.util.Date blieben nur zwei übrig. Die anderen sind zwar noch vorhanden, stehen seit Java 1.1 allerdings auf „deprecated“ (nicht mehr zu verwenden).

Auch wenn die Date-Klasse noch so verlockend erscheint, sie bildet nur eine Hülle um die Unix-Zeit, eine Zahl, die die abgelaufenen Sekunden seit dem 1.1.1970 Mitternacht enthält. Die naheliegenden Methoden wie getDate() und getYear() sind wertlos, weil ihnen der Bezug zur aktuellen Zeitzone fehlt. Kurzum: Die Sache bleibt java.util.Calendar überlassen. Der abstrakt und generisch ausgelegte Kalender kann nämlich Lokalisierung und Zeitzonen berücksichtigen. Je nach Bedarf sollte Java nicht nur den java.util.GregorianCalendar, sondern auch andere, etwa julianische, buddhistische oder islamische Kalendersysteme abbilden. Das scheiterte jedoch an der viel zu komplizierten Umsetzung. Bis zum aktuellen Java 7 gibt es lediglich den gregorianischen Kalender. Und wer den einfach nur benutzt, macht schon den ersten Fehler:

Calendar c = new GregorianCalendar(); 

liefert nämlich den Standardkalender mit Bezug zur aktuellen Zeit in der Standardzeitzone und der Standard-Locale – wird also beim gleichzeitigen Ausführen auf zwei unterschiedlich konfigurierten Systemen zwei voneinander abweichende Ergebnisse liefern. Die Angabe einer Zeitzone ist daher notwendig. Es gibt mehrere Wege, dies zu tun, aber nicht alle führen zum Ziel. Am einfachsten erscheint dem Mitteleuropäer die Version:

Calendar c = new GregorianCalendar(TimeZone.
getTimeZone("CST"));

um die Central Standard Time (der USA) zu erhalten. Leider falsch, es könnte auch die China Standard Time gemeint sein. Wer die API genau untersucht, kann das wissen: Der Dreibuchstaben-Code steht seit Java 1.1 ebenfalls auf „deprecated“. Richtig ist daher:

Calendar c = new GregorianCalendar(TimeZone.
getTimeZone("GMT-5"));

Es existieren noch weitere Fallstricke. Nicht nur die Zeitzone, sondern auch die Locale beeinflussen die Java-Zeit. Im Zweifel gilt die systemweite Standardeinstellung. Ratsam ist also, detailliert mit verbindlicher TimeZone zu konfigurieren und sich nicht auf etwas anderes zu verlassen. Es gibt noch mehr Gründe, warum Entwickler Javas Datums- und Zeit-API als ausgesprochen lästig empfinden. Beispielsweise kann man sowohl Calendar als auch Date beliebig ändern. Verwendet man eins von beiden ohne explizite Kopien (Date date2 = date1), lassen sich wichtige Zeitstempel überschreiben. Auch die Nachsichtigkeit von Calendar verursacht allerlei Missverständnisse:

c.set(Calendar.MONTH, 13); // gesetzt am 01.09.2011 

führt zur Ausgabe von 01.02.2012. Einen dreizehnten Monat gibt es bekanntlich nicht. Anstatt eine Fehlermeldung auszugeben, versucht die Calendar-Klasse die Angabe zu interpretieren: Sie erhöht die Jahreszahl um eins und setzt den Monat auf Februar. Darüber hinaus beginnen die Monate des Calendar bei 0, Tage, Jahre und alles andere bei 1. Eine Fehlerquelle, über die gerade Anfänger gern stolpern. Für den Umgang mit Zeit in Datenbanken existieren die drei Klassen java.sql.Time, java.sql.Date und java.sql.Timestamp. Eigentlich sind es Unterklassen von java.util.Date, und sie sollten jeweils nur eine Zeit und ein Datum enthalten. Da sie jedoch durch modifizierte .equals- und .hashCode-Methoden andere Funktionen mitbringen, erzeugen die Klassen im java.sql.*-Package Durcheinander:

Date date = new Date();
Timestamp timestamp = new Timestamp(date.getTime());
System.out.println(timestamp.equals(date)); // false
System.out.println(date.equals(timestamp)); // true

Wer in der API nach höherwertigen Funktionen sucht, um beispielsweise Perioden, Intervalle oder Kalenderwochen zu berechnen, dürfte schnell enttäuscht sein – es gibt keine. Mangelnde Performance ist ein zusätzliches Ärgernis: Die zahlreichen Objekte zum Berechnen und Darstellen verhindern ein flottes Arbeiten. Größter Kritikpunkt ist jedoch das interne Verhalten der Klassen. Die Berechnungen finden zu unbestimmten Zeitpunkten statt, was eine realistische Einschätzung der Performance unmöglich macht. Eine letzte Schwachstelle bilden die Zeitzoneninformationen. Sie stammen zwar aus der vom ICANN betreuten sogenannten Olson database (auch tz database), die mehrmals im Jahr aktualisiert wird. Wer eine Java-Installation auf einem aktuellen Stand halten will, sollte aber das Java-Werkzeug TimezoneUpdater benutzen. Ein automatischer Mechanismus existiert leider nicht. Unbedarfte Anwender sind daher unter Umständen lange Zeit mit nicht aktuellen Zeitzoneninformationen unterwegs, denn ein Update gibt es automatisch nur mit einer neuen Java-Version.

Entwickler, die sich mit der Standard-API nicht auseinandersetzen wollen, haben nur eine Alternative, nämlich die Joda Time. Stephen Colebourne rief sie 2004 ins Leben und erleichterte den Java-Programmierern damit das Leben erheblich. Die Bibliothek lässt sich via Maven in Projekte einbinden. Sie ersetzt die Java-Kernbibliotheken (java.util.Date et cetera) auf funktionaler Ebene.

Grundkonzepte von Joda Time sind der Augenblick (Instant) und der Teil (Partial). Während Instant einen fixen Punkt im Datumszeitraum bezogen auf die koordinierte Weltzeit UTC (Coordinated Universal Time) enthält, formatiert Partial die Ausgabe so, dass Menschen sie lesen können. Wichtigste Klasse ist DateTime. Wie alle Augenblicke ist sie unveränderbar und lässt sich aus java.util.Date erzeugen. Für Calendar, String, Long und andere org.joda.time.DateTime-Objekte existiert ebenfalls ein Konstruktor. Wer einmal erzeugte Datumsobjekte ändern möchte, kann mit der MutableDateTime-Klasse arbeiten. Will man den aktuellen Monat wissen, geht das folgendermaßen:

DateTime dt = new DateTime(2011, 9, 27, 0, 0, 0, 0); 
int month = dt.getMonthOfYear(); // 9

Bei den Merkmalen Monat und Tag beginnt die Zählung bei 1 und nicht bei 0, wie in der Java Core API. Die Merkmale sind aber nur ein schneller Weg, auf die Eigenschaften eines Datums zuzugreifen. Es funktioniert eleganter, wenn man sich der Feldfunktionen bedient:

DateTime.Property mOY = dt.monthOfYear();
String strST = mOY.getAsShortText(); // Sep
String strST = mOY.getAsText(Locale.FRENCH); // septembre

Sie besitzen keinen Getter und liefern eine DateTime.Property zurück. Der wiederum lässt sich mit und ohne Locale die textuelle Repräsentation entnehmen. Die komplette Ausgabe von Datum und Zeit (im ISO8601-Format mit dem Pattern yyyy-MM-ddTHH:mm:ss.SSS) wird durch die einfache Variante erledigt:

String iso8601 = dt.toLocalDateTime()// 2011-09-27T00:00:00.000 

Wer das weiter formatieren möchte, muss mit Pattern arbeiten:

String formatiert = dt.toLocalDateTime()toString.
("dd:MM:yy") //27.09.11 00:00

Dies sind lediglich die einfachen Optionen. Auch Joda Time kennt Formatierer (DataTimeFormatter). Anders als bei der Core API ist man jedoch nicht gezwungen, sie überall einzusetzen. Am beeindruckendsten ist die Möglichkeit, den Kalender zu wechseln. Was in Java nur theoretisch funktioniert, beherrscht Joda Time anstandslos:

DateTimedtBuddhist = dt.withChronology
(BuddhistChronology.getInstance());
intyear = dtBuddhist.getYear() // 2554

Funktionen für Datumsarithmetik stellt Joda Time ebenfalls bereit. Neben Intervallen kennt sie die Begriffe Dauer und Periode. Intervall ist ein Objekt, das die Zeit zwischen zwei Augenblicken in Millisekunden abbildet (Listing 1).

Mehr Infos

Listing 1: Intervall

DateTime start = new DateTime(1976, 9, 27, 0, 0, 0, 0);
DateTimeende = new DateTime(2011, 9, 27, 0, 0, 0, 0);
Interval intervall= new Interval(start, ende);
DateTimestartInt = intervall.getStart();
DateTimeendeInt = intervall.getEnd();

Die Dauer eines Intervalls, ebenfalls in Millisekunden, wird durch die Duration-Klasse repräsentiert:

Duration dauer = intervall.toDuration(); 

Eine Dauer lässt sich wie ein Augenblick verwenden. „Augenblick“ addiert mit „Dauer“ ergibt den neuen Augenblick. Die Periode wiederum rechnet mit Datums- und Zeitfeldern:

Period sechsTageSechsStunden = new Period(0, 0, 0, 6, 6, 0, 0, 0); 

Manipulationen an Datumswerten sind dann einfach:

String zukunft = ende.withYear(2028).plus(sechs
TageSechsStunden).toString("dd.MM.yyyy hh:mm");
// 27.09.2028 06:00

Die Dauer in Jahren lässt sich mit einem entsprechenden PeriodFormatter ausgeben. Selbst für komplizierte Fälle erreicht man hier mit dem PeriodFormatterBuilder alle gewünschten Darstellungen (Listing 2).

Mehr Infos

Listing 2: PeriodFormatterBuilder

Period period = new Period(start, end);
PeriodFormatterjahre = new PeriodFormatterBuilder().appendYears().appendSuffix("
Jahre").toFormatter();
String alter = jahre.print(period); // 35 Jahre

Joda Time bietet erheblich mehr als die im Java Core vorhandenen Klassen. Eine Zeitlang bestand Hoffnung, dass Javas Date- und Time-Klassen durch Joda Time ersetzt würden. Das ist jedoch bisher nicht geschehen. Allerdings gibt es den von Colebourne selbst betreuten JSR-310, mit dessen Hilfe eine neue Zeitrechnung in Java Einzug halten dürfte. Es wird eine Weiterentwicklung der Joda Time sein, die die noch vorhandenen kleineren Probleme beseitigen soll. Auf Java 8 und die dann möglicherweise verbesserten Core-Klassen können heutige Projekte nicht warten. Daher bleibt die Empfehlung, bei aktuellen Vorhaben auf Joda Time zu setzen.

Der Umgang mit Währungen in Java ist ebenfalls nicht unproblematisch. Einfache Typen scheiden aufgrund der fehlenden Nachkommastellen grundsätzlich aus. Vermutlich hat jeder Programmierer seine ersten Gehversuche mit float und double gestartet. Beide sind bei einfachen Beispielen verdächtig unauffällig. Aber schnell merkt man, dass etwas nicht stimmt:

float a = 100000.12f + 100000.31f;
String euro = NumberFormat.getCurrencyInstance().
format(a) //Falsch: 20.000,44 €

Begibt man sich in Bereiche mit größeren Zahlen, erhält man noch ungenauere Ergebnisse. Das Grundproblem liegt jedoch nicht bei Java, sondern in der Spezifikation von Fließkommazahlen (IEEE 754). Hier sind float und double 32- beziehungsweise 64-Bit-Datentypen mit definiertem Aufbau (Vorzeichen, Basis, Exponent, Mantisse) beschrieben. Alle ihnen anvertrauten Zahlen werden bestmöglich durch Normalisierung in dieses Format übertragen (Details siehe iX-Link). Darunter leidet die Präzision, auch wenn beide Umsetzungen schnell und speicherschonend arbeiten. Sie sind jedoch für Währungsberechnungen kaum zu gebrauchen. Jede Java-Grundlagenschulung empfiehlt daher BigDecimal für Geldwerte:

BigDecimal b = new BigDecimal("100000.12").
add(new BigDecimal("100000.31"));
String euro = NumberFormat.getCurrencyInstance
().format(b) //Richtig: 20.000,43 €

Etwas unschön sind hier die String-Konstruktoren, es existieren jedoch auch welche für double und float, die man laut JavaDoc allerdings nicht benutzen soll. BigDecimal bietet die klassischen arithmetischen Funktionen (Addieren, Subtrahieren, Multiplizieren und Dividieren) mit deren Hilfe sich vernünftig mit Geldwerten arbeiten lässt. Darüber hinaus unterstützt BigDecimal eine Reihe von Rundungsalgorithmen (Listing 3), die man entweder explizit über den RoundingMode oder implizit über den MathContext angeben kann. Letzterer kapselt zusätzlich die „Präzision“ des Datentypen, also die Anzahl der zu verwendeten Zahlen:

BigDecimal preis = new BigDecimal(7).divide
(new BigDecimal(3), 2, RoundingMode.
HALF_UP);
String einzelPreis = NumberFormat.get
CurrencyInstance().format (preis) // 2,33 €
Mehr Infos

Listing 3: Rundungsalgorithmen

ROUND_CEILING (Runden Richtung positiv)
ROUND_FLOOR (Runden Richtung negativ)
ROUND_HALF_DOWN (Runden in Richtung negativ ab 0.5)
ROUND_HALF_EVEN (Banker's Rounding)
ROUND_HALF_UP (Runden zum nächsten Nachbarn in Richtung positiv ab 0.5)
ROUND_DOWN (Runden zum nächsten Nachbarn in Richtung negativ)
ROUND_UP (Runden zum nächsten Nachbarn in Richtung positiv)

Im Gegensatz zu float und double erweist sich das Arbeiten mit BigDecimal als angenehm. Primitive Typen sind allerdings grundsätzlich kleiner, leichter und schneller als Objekte und weniger speicherhungrig. BigDezimal-Objekte lassen sich ändern, das heißt, jede Modifikation oder Berechnung erzeugt ein neues Objekt. Bei der Geschwindigkeit kann der Unterschied zwischen Berechnungen mit primitiven Typen oder BigDecimal-Typen durchaus den Faktor 100 erreichen. Was bei vereinzelten Berechnungen kein großes Problem darstellt, kann in komplexen finanzmathematischen Analysen jedoch ein kritischer Faktor sein. Dann bleiben die schnellen Fließkommazahlen vielfach die einzige Option, auch wenn sie einen zwingen, die Rundungsalgorithmen selbst zu erarbeiten und die Rundungsfehler eigenhändig zu korrigieren. Weitere Alternativen können eigene Klassen sein, die die benötigte Anzahl von Nachkommastellen auf int oder long abbilden. Auch hier muss der Entwickler die entsprechenden arithmetischen Funktionen selbst schreiben. Das Fehlen einer Geld- oder Währungsklasse in Java ist vor diesem Hintergrund unverständlich.

Bislang ging es nur um Werte und Berechnungen. Währungen bringen allerdings noch andere Komplikationen mit sich. Zum einen benötigen sie ein zumeist landesspezifisches Kürzel oder Zeichen, eine standardisierte Abkürzung (ISO 4217) sowie definierte Nachkommastellen. Der deutsche Euro hat das alphanumerische Kurzzeichen EUR mit dem Code 978 sowie zwei Nachkommastellen. In Java ist Currency der richtige Ort für solcherlei Informationen. Hier existiert kein öffentlicher Konstruktur, da es pro Währung nur ein Currency-Objekt geben soll. Zugriff bekommt man via:

Currency.getInstance(Locale.GERMANY); 

Dabei werden die Sprache und die Variante der Locale ignoriert. Für Deutschland und die USA sieht das so aus:

String einzelPreisDE = NumberFormat.get
CurrencyInstance(Locale.GERMANY).
format(preis) // 2,33 €
String einzelPreisUS = NumberFormat.get Currency
Instance(Locale.US).format(preis) // $2,33

Das Programmieren in Multiwährungssystemen ist nicht einfach, da zu jedem Betrag die entsprechende Währung erfasst werden muss. Und im Gegensatz zum obigen Beispiel sind $2,33 ≠ 2,33 €. Hier muss der Entwickler neben der korrekten Darstellung der jeweiligen Währung vor allem die Umrechnungen berücksichtigen. Sie ändern sich allerdings dynamisch und benötigen eine Schnittstelle zu den Devisenmärkten.

So schwierig, wie es auf den ersten Blick erscheint, ist das Arbeiten mit Zeit und Geld in Java zwar nicht. Dennoch kann man sich in zahlreichen Fallstricken verheddern, die aus einer nicht ausreichenden Standard-API sowie fehlenden Funktionen resultieren. In diesem Umfeld gibt Java ein angestaubtes Bild ab. Und auch die Geschwätzigkeit der Sprache zeigt sich an vielen Stellen. Dass es einfacher geht, beweisen moderne Sprachen wie Scala.

Markus Eisele
arbeitet bei der msg systems AG in München. Er betreibt einen Blog über Enterprise Java unter http://blog.eisele.net.

Alle Links: www.ix.de/ix1202128 (jd)