Die Mühsal mit java.util.Date & Co

Eine Weile  her hatte ich mich im deutschen Java-Forum für Anfänger in eine Debatte über die Altersberechnung eingeschaltet.

Die Frage zielte darauf ab, wie ausgehend vom Geburtsdatum (gegeben durch Jahr, Monat und Tag als Strings) das Alter einer Person berechnet werden kann. Es klingt einfach, wenn die richtigen Bibliotheken verwendet werden. Alle folgenden Code-Listings entsprechen nicht exakt dem Debattenstand, verfälschen aber nicht die algorithmischen Grundideen.

Bemerkenswerterweise hat ein Vielschreiber im Forum trotz aller Gegenargumente verbissen an folgendem Lösungsvorschlag festgehalten, der wahrscheinlich und unglücklich so auch vom Fragesteller akzeptiert wurde (letzterer hatte die Debatte nicht mehr weiter verfolgt).

package net.time4j.experiment;
 import java.text.DateFormat;
 import java.text.ParseException;
 import java.text.SimpleDateFormat;
 import java.util.Calendar;
 import java.util.Date;
 import java.util.GregorianCalendar;

 public class Altersberechnung1 {
   public static void main(String[] args) throws ParseException {
     SimpleDateFormat sdf = (SimpleDateFormat) DateFormat.getDateInstance();
     Date geb = sdf.parse("19.06.1970");
     int a = -1;
     GregorianCalendar gebc = new GregorianCalendar();
     gebc.setTime(geb);
     GregorianCalendar nowc = new GregorianCalendar();

     while (gebc.before(nowc)) {
       a++;
       gebc.add(Calendar.YEAR, 1);
     }
     System.out.println("Alter1: " + a);
  }
}

Hier gibt es mehrere Probleme, siehe die nachfolgenden Code-Kommentierungen:

package net.time4j.experiment;

import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;

public class Altersberechnung2 {

  public static void main(String[] args) throws ParseException {
    System.out.println("Alter2: " + alter("1972", "02", "29")); // 40 falsch
    System.out.println("Alter2: " + alter("1972", "02", "28")); // 40 richtig
  }

  private static int alter(
    String year,
    String month,
    String day
  ) throws ParseException {
    SimpleDateFormat sdf = (SimpleDateFormat) DateFormat.getDateInstance();
    // Verstecktes Problem:
    // Implizite Zeitzonenabhängigkeit hat Auswirkung auf Date-Vergleich
    Date geb = sdf.parse(day + "." + month + "." + year);

    int a = -1;
    GregorianCalendar gebc = new GregorianCalendar();
    gebc.setTime(geb);
    GregorianCalendar nowc = new GregorianCalendar();

    // Testbeispiel: HEUTE = 1 Sekunde nach Mitternacht am 28. Februar 2012
    Date now = new Date(sdf.parse("28.02.2012").getTime() + 1000);
    nowc.setTime(now);

    System.out.println(gebc.getTime());
    System.out.println(nowc.getTime());

    while (gebc.before(nowc)) { // zu Mitternacht fehlerhafter Vergleich
      a++;
      gebc.add(Calendar.YEAR, 1); // macht aus 29.2. immer 28.2 => Fehler
    }
    return a;
  }

}

Ein anderer Forenteilnehmer hatte diesen zählenden Algorithmus sogar als professionell bezeichnet, war aber in Wahrheit wohl eher von der Komplexität der Lösung beeindruckt. Seine einfache, aber weitaus bessere Idee (mit einer kleinen Korrektur meinerseits) sah so aus:

package net.time4j.experiment;

import java.util.GregorianCalendar;

public class Altersberechnung3 {

  public static void main(String... args) {
    System.out.println("Alter3: " + alter("1972", "02", "29")); // 39 richtig
    System.out.println("Alter3: " + alter("1972", "02", "28")); // 40 richtig

    // vereinfachter Performance-Test (grob)
    long start = System.nanoTime();
    for (int i = 0; i < 1000; i++) {
      alter("1972", "02", "29");
    }
    long end = System.nanoTime() - start;
    System.out.println(end); // ~ 0.03sec

  }

  private static int alter(String year, String month, String day) {
    // Heute-Datum bestimmen
    GregorianCalendar gcal = new GregorianCalendar(); // Zeitfresser!
    int tagheute = 28; //gcal.get(Calendar.DAY_OF_MONTH);
    int monatheute = 2; // gcal.get(Calendar.MONTH) + 1;
    int jahrheute = 2012; // gcal.get(Calendar.YEAR);

    int geburtstag = Integer.parseInt(day);
    int geburtsmonat = Integer.parseInt(month);
    int geburtsjahr = Integer.parseInt(year);

    if (
      (tagheute>=geburtstag && geburtsmonat==monatheute)
      ||(monatheute>geburtsmonat)
    ) {
      return jahrheute-geburtsjahr;
    } else {
      return jahrheute-geburtsjahr-1;
    }
  }

}

Hieran besticht schon die Einfachheit. Das einzige Problem damit ist lediglich ein gewisser Mangel an sprachlicher Flüssigkeit (fluent programming style). Trotzdem hatte ich dem Vielschreiber noch eine korrigierte Version seines Zählalgorithmus präsentiert.

package net.time4j.experiment;

import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.TimeZone;

public class Altersberechnung4 {

  public static void main(String[] args) throws ParseException {
    System.out.println("Alter4: " + alter("1972", "02", "29")); // 39 richtig
    System.out.println("Alter4: " + alter("1972", "02", "28")); // 40 richtig
  }

  private static int alter(
    String year,
    String month,
    String day
  ) throws ParseException {
    // Format ohne Uhrzeit => getDateInstance()
    SimpleDateFormat sdf = (SimpleDateFormat) DateFormat.getDateInstance();
    sdf.setTimeZone(utc());
    Date geb = sdf.parse(day + "." + month + "." + year);
    Date now = sdf.parse("28.02.2012");

    // vereinfachter Performance-Test nur für den Zählalgorithmus (grob)
    // SimpleDateFormat-parse() ist hier beschönigend nicht mitgerechnet!
    long start = System.nanoTime();
    for (int i = 0; i < 1000; i++) {
      alter(geb, now);
    }
    long end = System.nanoTime() - start;
    System.out.println(end); // > 0.5sec

    return alter(geb, now);
  }

  private static int alter(Date geb, Date now) throws ParseException {
    int a = -1;

    GregorianCalendar gebc = createCalendar();
    gebc.setTime(geb);

    GregorianCalendar nowc = createCalendar();
    nowc.setTime(now);

    //        System.out.println(gebc.getTime());
    //        System.out.println(nowc.getTime());
    //        System.out.println("Hour: " + gebc.get(Calendar.HOUR_OF_DAY));

    boolean schalttag =
      (gebc.get(Calendar.MONTH) == Calendar.FEBRUARY)
      && (gebc.get(Calendar.DAY_OF_MONTH) == 29);

    while (!gebc.after(nowc)) {
      a++;
      gebc.add(Calendar.YEAR, 1);
      if (
        schalttag
        && gebc.isLeapYear(gebc.get(Calendar.YEAR))
        && (gebc.get(Calendar.DAY_OF_MONTH) == 28)
      ) {
        gebc.add(Calendar.DATE, 1);
      }
    }

    return a;
  }

  // synthetische Zeitzone ohne Sommerzeitumstellung
  private static GregorianCalendar createCalendar() {
    return new GregorianCalendar(utc());
  }

  private static TimeZone utc() {
    return TimeZone.getTimeZone("GMT+00:00");
  }
}

Hätten Sie, verehrte Leser, jedes Detail auf Anhieb parat gehabt. Ich auch nicht. Ein nicht unheblicher Teil der Schwierigkeiten liegt letztlich daran, daß das JDK keinen Datentyp für ein reines Datum bietet. So müssen evtl. sogar Zeitzoneneffekte beachtet werden, obwohl das von der Fragestellung her nicht einleuchtet. Der Code sieht fürchterlich komplex aus, kostet erhebliche Entwicklerressourcen und ist voll potentieller Fallstricke, die zwar nur selten zuschlagen. Aber da halte ich es mit Murphy: Was schief gehen kann, wird in der Praxis auch schief gehen. Am problematischsten ist aktuell die Behandlung der Schalttagskinder.

Wie geht es nun aber am besten? Meiner Meinung nach mit Time4J (und so ähnlich auch mit dem neuen JSR 310):

package net.time4j.experiment;

import net.time4j.IsoDate;
import static net.time4j.StdDateUnit.YEARS;

public class Altersberechnung5 {

  public static void main(String... args) {
    System.out.println("Alter5: " + alter("1972", "02", "29")); // 39 richtig
    System.out.println("Alter5: " + alter("1972", "02", "28")); // 40 richtig

    // vereinfachter Performance-Test (grob)
    long start = System.nanoTime();
    for (int i = 0; i < 1000; i++) {
      alter("1972", "02", "29");
    }
    long end = System.nanoTime() - start;
    System.out.println(end); // ~ 0.06sec today() / 0.01sec festes Heute-Datum

  }

  private static long alter(String year, String month, String day) {
    int geburtstag = Integer.parseInt(day);
    int geburtsmonat = Integer.parseInt(month);
    int geburtsjahr = Integer.parseInt(year);

    IsoDate geb = new IsoDate(geburtsjahr, geburtsmonat, geburtstag);
    IsoDate now = new IsoDate(2012, 2, 28); // IsoDate.today();

    return YEARS.betweenDates(geb, now);
  }

}

Mit YEARS.betweenDates(geb, now) ist de facto ein Einzeiler als Lösung möglich. Dazu ist die Lösung intuitiv und performant (mindestens ca. 10x so schnell wie der komplexe Zählalgorithmus vorgestellt im Listing Alterberechnung4). Zwar hatte der Vielschreiber mit Hinweis auf das begrenzte Lebensalter von Personen mein Performance-Argument für irrelevant erklärt, aber ich meine: Wenn z.B. in einer Personentabelle mit 1000 Datensätzen online für jede Zeile das Alter berechnet werden soll und wenn alleine dafür bei nicht optimaler Hardware mehr als eine halbe Sekunde verstreicht, dann ist das zuviel für den User, vor allem, wenn man bedenkt, daß diese Zeit extra zur Abfragezeit einer solchen Personentabelle kommt (meist werden User schon nach 2 Sekunden unruhig).

Hier sei noch ein Hinweis auf JodaTime gestattet. Diese Bibliothek erlaubt ein ähnlich kurzes und intuitives Programm zur Alterberechnung zu schreiben, berechnet aber die Differenz zwischen 1972-02-29 und 2013-02-28 fehlerhaft als 41 statt 40 Jahre, weshalb ich für JodaTime hier keine vollständig positive Empfehlung aussprechen möchte. Interessanterweise rechnet JodaTime die Differenz für ein Jahr früher richtig mit 39 Jahren aus, nämlich für 2012-02-28.

Fazit:

Die alten Klassen java.util.Date und Co. führen zu Horror-Code – und finden trotzdem ihre ergebenen Anhänger, die komplexes Programmieren mit gutem Programmieren verwechseln. Sie dürften trotz neuerer Bibliotheken wie dem kommenden JSR 310 noch sehr lange weiterleben, zumal Oracle sich nicht dazu durchringen kann, diese alten Klassen komplett für deprecated zu erklären oder noch besser, sie im Rahmen einer Modularisierungsstrategie ganz rauszunehmen.

Die Kommentarfunktion ist geschlossen.