Java, char und Unicode
Ich habe vor ein paar Tagen eine Diskussion verfolgt, in der es um Unicode und die geeignete Kodierung innerhalb von Programmen ging. Dabei ist mir aufgefallen, dass gerade im Zusammenhang mit Java große Verständnislücken vorhanden sind. Ein guter Grund darüber einen Artikel zu schreiben.
reverse
Ich beginne mit einem kleinem Beispiel, von dem ich weiß, dass es in Bewerbungsgesprächen gelegentlich verwendet wird. Die Aufgabe besteht darin, zu einer existierenden Zeichenkette eine neue Zeichenkette mit umgekehrter Reihenfolge der Zeichen zu erzeugen. Dabei sollen Bibliotheksfunktionen nur soweit notwendig verwendet werden.
String reverse(String value) {
char[] chars = value.toCharArray();
// reverse array in place
for (int p = 0, q = chars.length; p < q; ++p, --q) {
// swap chars[p] and chars[q - 1]
char t = chars[p];
chars[p] = chars[q - 1];
chars[q - 1] = t;
}
// create new string with reversed chars
return new String(chars);
}
Ich stelle mir eine mögliche Umsetzung ungefähr so vor. Dabei kann man sicherlich verschiedene, nicht-funktionale Eigenschaften des Codes diskutieren. Darum geht es mir allerdings nicht. Die wichtige Frage ist, ob diese Implementierung funktional korrekt ist.
Aus meiner Sicht ist diese Frage nicht einfach zu beantworten. Denn die Aufgabenstellung war dafür zu vage. Die Aussage mag ein wenig überraschen, und ich gehe gleich genauer darauf ein. Zunächst nur soviel: Welcher Anwendungsfall steckt überhaupt hinter dieser Aufgabenstellung? Oder anders ausgedrückt: Ich kann mich nicht daran erinnern, dass ich so eine Methode jemals in produktivem Code verwendet habe.
letters
Ich habe das obige Beispiel gewählt, weil es mir in dieser Form bereits begegnet ist. Ich setze aber zunächst mit einem anderen Beispiel fort, an dem sich die Thematik einfacher darstellen lässt. Gesucht ist diesmal eine Methode, die feststellt, ob eine Zeichenkette ausschließlich aus Buchstaben besteht – entsprechend den Unicode-Kategorien.
boolean containsOnlyLetters(String value) {
for (char c : value.toCharArray()) {
if (!Character.isLetter(c)) {
return false;
}
}
return true;
}
Diese Methode sieht auf den ersten Blick wiederum ganz gut aus. Doch dieser Eindruck täuscht. Und im Gegensatz zum ersten Beispiel kann ich hier klar sagen, dass die Implementierung definitiv falsch ist. Bevor ich genauer auf die Probleme dieser Implementierung eingehe, zeige ich zunächst eine mögliche korrekte Implementierung.
boolean containsOnlyLetters(String value) {
boolean result = true;
char[] chars = value.toCharArray();
for (int p = 0; p < chars.length; ++p) {
char c = chars[p];
if (Character.isLowSurrogate(c)) {
// invalid encoding (alternatively throw exception)
return false;
} else if (!Character.isHighSurrogate(c)) {
result &= Character.isLetter(c);
} else if (p + 1 >= chars.length
|| !Character.isLowSurrogate(chars[p + 1])) {
// invalid encoding (alternatively throw exception)
return false;
} else {
++p;
result &= Character.isLetter(Character.toCodePoint(c, chars[p]));
}
}
return result;
}
Auffallend ist auf den ersten Blick – abgesehen von der erhöhten Komplexität – das wiederholte Auftauchen des Begriffs Surrogate
. Was hat es damit auf sich?
Die ursprüngliche Idee von Java war, dass der primitive Datentyp char
verwendet wird, um Zeichen aus dem Universal Character Set zu repräsentieren. Da Java im Jahr 1995 erschienen ist und UCS zu diesem Zeitpunkt weniger als 35.000 Zeichen enthielt, wurde die Größe des Datentyp char
auf 16‑Bit festgelegt. Damit konnte jedes Zeichen aus dem Unicode Character Set zu der damaligen Zeit in einem char
abgebildet werden.
Die Größe des UCS ist über die folgenden Jahre allerdings schnell gewachsen. Und so reichten die 16‑Bit bereits 2001 nicht mehr aus, um alle Zeichen abzubilden. Um weiterhin den vollständigen Unicode-Zeichensatz zu unterstützen, wechselte Java im Jahr 2004 von dem fixed-width Encoding UCS‑2 zum variable-width Encoding UTF‑16. Konkret bedeutet das, dass seitdem in Java ein Zeichen entweder aus einem char
oder zwei aufeinanderfolgenden char
s besteht. Der letztere Fall wird als Surrogate-Paar bezeichnet, und lässt sich am einfachsten mit den entsprechenden Methoden der Klasse Character
erkennen.
Zusätzlich komplizierter wird die Zeichenverarbeitung in Java, weil nicht jede Folge von char
s eine korrekte Kodierung repräsentiert. Und interessanterweise erlaubt selbst die Klasse String
beliebige solcher Folgen, unabhängig davon ob sie für eine korrekte Folge von Zeichen stehen – oder für uninterpretierbaren Datenmüll.
reverse again
Zurück zum ersten Beispiel: Nun ist klar, warum man die oben gezeigte Implementierung als falsch bezeichnen kann. Denn die Methode berücksichtigt keine Surrogate-Pairs. Und dadurch wird aus einer syntaktisch korrekten Repräsentation einer Zeichenkette potentiell Datenmüll. Da ich den Anwendungsfalls einer solchen Methode aber grundsätzlich in Frage stelle, verzichte ich auf eine weitere Implementierung. Wer sich dafür interessiert, kann einen Blick in den Quelltext des StringBuilder
werfen. Dort findet sich eine entsprechende Methode, die versucht die Surrogate-Pairs zu berücksichtigen.
UTF‑8, UTF‑16 und UTF‑32
In der anfangs angesprochenen Diskussion ging es um die Frage, welche Kodierung für Unicode in C++ eingesetzt werden sollte. Und das Ergebnis war, dass es keine klare Aussage dazu gibt. Denn jede dieser Kodierungen hat ihre Vor- und Nachteile.
Für UTF‑32 spricht, dass die Verarbeitung einzelner Zeichen besonders einfach ist. Denn jedes Zeichen wird durch genau ein Element repräsentiert. Dagegen spricht hauptsächlich der Speicherverbrauch im Vergleich zu den anderen Kodierungen – insbesondere wenn hauptsächlich Zeichen aus dem ASCII-Bereich verwendet werden.
Für UTF‑8 spricht entsprechend der sparsame Umgang mit Speicher für die in vielen Ländern verwendeten Schriftzeichen. Dafür ist die korrekte Verarbeitung einzelner Zeichen durchaus komplex, da diese aus bis zu vier aufeinanderfolgenden Oktetts bestehen können.
UTF‑16 ist in beiderlei Hinsicht ein Kompromiss aus UTF‑8 und UTF‑32. Der Speicherverbrauch liegt insbesondere für europäische Zeichen zwischen UTF‑8 und UTF‑16. Und auch die Komplexität liegt zwischen den anderen beiden Varianten. Denn auch wenn es schwierig ist, UTF‑16 korrekt zu verarbeiten, so ist es zumindest sehr einfach, den Code so zu schreiben, dass er für weite Teile der Welt korrekt funktioniert.
Fazit
Auch wenn ich hier auf einige Probleme im Zusammenhang mit Unicode hingewiesen habe, so sollte man insgesamt doch berücksichtigen, dass das für viele Applikationen keine große Rolle spielt. Denn obwohl die meisten Applikationen Zeichenketten verarbeiten, so beschränken sich die dort benötigten Use-Cases neben der Ein- und Ausgabe hauptsächlich auf das Zusammenhängen von Zeichenketten. Letzteres ist in jeder der genannten UTF-Kodierungen trivial. Und die Ein- und Ausgabe wird in der Regel über Bibliotheken und Frameworks realisiert, die sich um die korrekte Verarbeitung kümmern. Also alles halb so wild ...