Java, char und Unicode
von Hubert Schmid vom 2012-01-01
Vor einigen Tagen habe ich in einem Internet-Forum eine Diskussion verfolgt, in der es darum ging, wie man in C++ am sinnvollsten mit Unicode umgehen sollte. Das ist für mich ein guter Anlass über Java und Unicode zu schreiben. Denn in beiden Programmiersprachen ist der Umgang mit dem Universal Character Set (UCS) nicht trivial.reverseIch beginne mit einem kleinem Beispiel, das ich selbst bereits in einem Vorstellungsgespräch erlebt habe. Und ich gehe fest davon aus, dass solche Aufgabenstellungen bei Einstellungstests und im akademischen Bereich häufiger anzutreffen sind. 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 darüber diskutieren, ob der Methodenrumpf zu komplex ist und besser in mehrere Methoden aufgeteilt werden sollte. Aber darum geht es mir nicht. Ich stelle lediglich zur Diskussion, ob diese Implementierung funktional korrekt ist.Aus meiner Sicht ist diese Frage nicht einfach zu beantworten. Denn die Aufgabenstellung war etwas vage. Diese Aussage mag ein wenig überraschen, und ich gehe gleich genauer darauf ein. Zunächst soll soviel reichen: 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.lettersIch habe das obige Beispiel gewählt, weil es mir in dieser Form bereits begegnet ist. Ich möchte aber kurz zu einem anderen Beispiel wechseln, 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 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. Aber 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 zurm variable-width Encoding UTF-16. Konkret bedeutet das, dass seitdem in Java ein Zeichen entweder aus einem char oder zwei aufeinanderfolgenden chars besteht. Der letztere Fall wird als Surrogate-Paar bezeichnet, und lässt sich am einfachsten mit den entsprechenden Methoden der Klasse Character erkennen.Kompliziert wird die Arbeit mit Zeichen in Java auch dadurch, dass nicht jede Folge von chars 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 einfach nur für Müll.reverse againZurü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 einfach nur Müll. Da ich den Anwendungsfalls einer solchen Methode aber grundsätzlich in Frage stelle, verzichte ich auf eine weitere Implementierung. Wer sich trotzdem dafür interessiert, sollte einen Blick in den Quelltext zum StringBuilder werfen. Denn dort findet sich eine Methode, die versucht, die Surrogate-Pairs zu berücksichtigen.UTF-8, UTF-16 und UTF-32In der anfangs angesprochener 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.FazitAuch 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 ...