Unicode Regex-Stolpersteine

Ich bekomme ab und zu wohlgemeinte Ratschläge, ich solle meine regulären Ausdrücke doch mit dem Flag /i oder Backslash-Zeichenklassen wie \d oder \s etwas prägnanter machen. Ich vermeide diese Konstrukte aber nicht aus Unwissenheit, sondern mit voller Absicht. Und weshalb?

In Bezug auf Programmiersprachen sind viele Entwicklerinnen heute ziemlich polyglott, und ich bin da keine Ausnahme. Ich wechsele ständig zwischen C, JavaScript, Go, Java, Perl, Python, Ruby und noch etlichen anderen Sprachen hin und her. Das ist ziemlich einfach, weil all diese Sprachen eigentlich relativ ähnlich sind. Das trifft größtenteilxs auch auf die jeweiligen Implementierungen regulärer Ausdrücke zu, basieren sie doch alle auf den regulären Ausdrücken von Perl oder sind davon zumindest stark inspiriert.

Die Art und Weise wie nicht-US-ASCII-Zeichen behandelt werden, variert allerdings stark und das nicht nur, weil die Unicode-Unterstützung in den verschiedenen Sprachen variiert, sondern auch weil hier unterschiedliche Design-Entscheidungen zu Tage treten.

What bedeutet "Case-insensitive"?

"Case-insensitive" bedeutet offensichtlich, dass Groß- und Kleinschreibung ignoriert werden soll, und in den guten alten Zeiten von US-ASCII erschloss sich das auch unmittelbar. Es bedeutete, dass "basic", "BASIC" und sogar "bASIc" für ein und dasselbe standen. Aber als die Einschränkungen von US-ASCII einmal überwunden waren, stellten sich auch die Frage, wie "café" und "CAFE" (oder "CAFÉ") oder kyrillische Zeichenketten wie "ягода" und "ЯГОДА" zu behandeln sind.

Solche Fragen können sogar sicherheitsrelevant sein, wie der homographische Angriff zeigte. Vereinfacht beruht dieser Angriffsvektor darauf, dass viele Zeichen in vielen Zeichensätzen die gleiche oder zumindest sehr ähnliche graphische Darstellung haben. Zum Beispiel ist ein großgeschriebenes lateinisches A in der Regel nicht von einem großen griechischen Alpha oder einem großgeschriebenen kyrillischen A zu unterscheiden.

Das /i-Flag

Wird ein regulärer Ausdruck mit dem Flag /i kompiliert, wird beim Matching Groß- und Kleinschreibung ignoriert. Wie dieses Verhalten aktiviert wird, unterscheidet sich von Programmiersprache zu Programmiersprache:

# Perl: 
$string =~ /foobar/i;
// JavaScript:
string.match(/foobar/i);
// Java:
Pattern.compile("foobar", CASE_INSENSITIVE);

Dass "Q" und "q" in diesen Fällen äquivalent sind, erschließt sich ohne weiteres. Aber was ist mit "Ä" und "ä"?

Ignoriert der für den regulären Ausdruck verwendete Apparat Unicode, ist das Zeichen "Ü" äquivalent zu den zwei(!) Bytes mit den Werten 0xc3 und 0x9c bzw. der Zeichenfolge "Ãœ" in Windows-1252. Ein in UTF-8 kodiertes kleines "ü" entspricht der Zwei-Byte-Folge "ü", und es überrascht nicht, dass auch bei Ignorieren von Groß- und Kleinschreibung der Vergleich in diesem Falle false liefert.

Aber wie behandeln die verschiedenen Implementierungen für reguläre Ausdrücke diesen Fall? Ich beschränke mich im Folgenden auf Perl, JavaScript und Java, weil diese drei Sprachen einen großen Bereich abdecken.

Perl

Die meisten Sprache nutzen "Perl-kompatible reguläre Ausdrücke", und Perl verdient es daher, zuerst betrachtet zu werden:

my $uuml = "Ü";
my $re = "ü";
if ($uuml =~ /^$re$/i) {
    print "match\n";
} else {
    print "no match\n";
}

Dies liefert "no match", denn Perl vergleicht hier byteweise, weil sowohl die Eingabezeichenkette als auch der Quellcode als Bytefolge und nicht als Folge von Unicode-Zeichen angesehen wird.

Dieses Verhalten lässt sich ändern, indem beide als Unicode-Zeichenketten markiert werden. Eine Möglichkeit dazu sieht so aus:

use Encode;

my $uuml = "Ü";
my $re = "ü";
Encode::_utf8_on($uuml);
Encode::_utf8_on($re);
if ($uuml =~ /^$re$/i) {
    print "match\n";
} else {
    print "no match\n";
}

Jetzt erkennt Perl einen Match, weil sowohl Eingabe als auch der Quellcode des regulären Ausdrucks als Folge von (UTF-8 kodierten) Zeichen angesehen wird. Ja, das ist ein ziemlich zweifelhaftes Konzept.

JavaScript

Sehen wir uns JavaScript an.

var uuml = "Ü";
if (uuml.match(/^ü$/i)) {
    console.log("match");
} else {
    console.log("no match");
}

Auch JavaScript erkennt hier einen Match wegen des Flags /i. Der von JavaScript verwendete Automat interpretiert - zumindest in diesem Fall - alle Zeichenketten als Unicode.

Java

Die Implementierung von Java kommt Entwicklern am meisten entgegen:

import java.util.regex.*;

public class PlayGround{
    public static void main(String args[]) {
        int flags = Pattern.CASE_INSENSITIVE; 
        boolean match = Pattern.compile("^ü$", flags)
                               .matcher("Ü")
                               .matches();
        System.out.println(match);
        
        flags = Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE;
        match = Pattern.compile("^ü$", flags)
                       .matcher("Ü")
                       .matches();
        System.out.println(match);
    }
}

So sollte es sein. Die Umwandlung von Groß- zu Kleinschreibung und umgekehrt kann mit dem Flag UNICODE_CASE exakt beeinflusst werden. Weshalb ist das gut (ein Synonym für Do the Right Thing™)? Die Umwandlung ist trivial für ASCII, aber komplex und potenziell teuer für Unicode, wie ein kurzer Blick in ftp://ftp.unicode.org/Public/UNIDATA/CaseFolding.txt zeigt.

Grenzfälle für /i

Wie sieht die kleingeschriebene Version von "K" aus? Wer jetzt denkt, dass die Antwort darauf völlig klar ist, hat sich entweder einen Hexdump des Quelltextes dieser Seite angeschaut oder ganz gepflegt ignoriert, dass das lateinische K, das griechische Kappa, das kyrillische Ka, und(!) das Kelvin-Zeichen praktisch ununterscheidbar sind. Test: Wieviele verschiedene Zeichen gibt es in "KΚКK"? Es sind vier verschiedene Zeichen!

Und das ist mehr als eine interessante Randnotiz, weil das Kelvin-Zeichen eine kleine Überraschung bereit hält. In der Unicode-Umwandlungstabelle. findet sich diese Zeile

212A; C; 006B; # KELVIN SIGN

Dies ist so zu lesen: Das Kelvin-Zeichen \u212a in der allgemeinen (C wie common) Umwandlung hat die kleingeschriebene Variante \u006b und das ist das hundsgewöhnliche kleingeschriebene k. Mit anderen Worten, die Umwandlung ist von [A-Z] zu [a-z] ist nicht eineindeutig.

Das wirkt sich auf das Parsen von Quell-Code mit regulären Ausdrücken aus. Dabei ist eine Variable oft durch das Muster /^[_a-zA-Z][_a-zA-Z0-9]+$/ oder seinem Äquivalent /^[_a-z][_a-z0-9]+$/i definiert. Aber sind sie tatsächlich äquivalent?

Das weiter oben verlinkte Unicode-Dokument enthält folgenden einführenden Kommentar:

Werden alle Zeichen der unten beschriebenen vollen Umwandlung unterzogen, gibt es keine Unterschiede in Bezug auf Groß- und Kleinschreibung gemäß UnicodeData.txt und SpecialCasing.txt mehr.

Wir wollen daher überprüfen, ob das Kelvin-Zeichen dem regulären Ausdruck /[a-z]/i entspricht. Wir beginnen wieder mit Perl.

print "match\n" if "\x{212a}" =~ /^[a-z]$/i;

Die Zeichenkette "\x{212a}" ist das Kelvin-Zeichen und - Überraschung! - es ist offensichtlich Teil der Zeichenklasse /[a-z]/i. Und das ist mit Verlaub das korrekte Verhalten.

Jetzt JavaScript:

console.log("\u212a".match(/^[a-z]$/i));

JavaScript sieht hier keinen match. Ist das ein Bug? Der gesunde Menschenverstand sagt laut ja, besonders wenn wir diesen Code-Schnipsel ausführen:

console.log("\u212a".toLowerCase());

Das spuckt nämlich ein kleingeschriebenes "k" aus, ganz wie der Unicode-Standard es vorsieht.

Am Ende des Tages tut /[a-z]/i genau das, was die meisten Menschen erwarten würden. Aber genaugenommen wird hier ein überraschendes Detail in Unicode mit einem überraschenden Verhalten der regulären Ausdrücke in JavaScript kompensiert.

Deshalb sollte man diese Unklarheiten vermeiden, und /[a-zA-Z]/ schreiben, wenn man es meint (auch wenn das in Firefox geringfügig langsamer ist).

Anmerkung: Im Falle des Kelvin-Zeichens ändert das Flag /u, das in ECMAScript 2015 eingeführt wurde, das Verhalten. Siehe dazu https://mathiasbynens.be/notes/es6-unicode-regex!

Und was ist eine Ziffer?

Ähnliche Probleme treten bei den Shortcuts für Zeichenklassen auf, zum Beispiel \d für Ziffern oder \s für Whitespace.

Einmal mehr ist Perl relativ strikt:

print "match" if  "\x{6f3}\x{6f2}" =~ /\d/;

"\x{6f3}\x{6f2}" entspricht "۴۲", also 42 mit arabischen Zeichen geschrieben.

Genauso matcht \s auf ideographic space:

print "match" if  "\x{3000}" =~ /\s/;

Das Verhalten von JavaScript ist gelinde gesagt überraschend. Arabische Ziffern sind keine Ziffern:

console.log("\u06f3\u06f2".match(/\d/));

Dies liefert false. Aber ideographic space matcht dann doch auf \s:

console.log("\u3000".match(/\d/));

Wie sich die oft verwendeten Shortcuts \b und \w und die großgeschriebenen Varianten verhalten, bleibt ebenso eigenen Nachforschungen überlassen wie die Frage, wie sich das in ES2015 eingeführte Flag /u jeweils darauf auswirkt.

Es sollte nicht unerwähnt bleiben, dass Ruby noch eine Variante parat hat. So ist zum Beispiel /\d/ äquivalent zu /[0-9]/, aber /[[:digit:]]/ matcht auf Ziffern in allen Schriftsystemen. Das gleiche gilt für \s und [:space:] und so weiter.

Schlussfolgerung

Mit der Verwendung /i, \d, \s, \b, /w fängt man sich leicht subtile Bugs ein, und erschwert das Verständnis des exakten Verhaltens des eigenen Codes. Man sollte daher - soweit möglich - von ihrer Verwendung absehen. Die Rock-Stars im eigenen Team werden es so oder so verstehen. Leute, die Besseres zu tun haben, als irgendwelche Spezifikationen zu studieren, werden dagegen dankbar sein.

Leave a comment
Diese Website verwendet Cookies und ähnliche Technologien, um gewisse Funktionalität zu ermöglichen, die Benutzbarkeit zu erhöhen und Inhalt entsprechend ihren Interessen zu liefern. Über die technisch notwendigen Cookies hinaus können abhängig von ihrem Zweck Analyse- und Marketing-Cookies zum Einsatz kommen. Sie können ihre Zustimmung zu den vorher erwähnten Cookies erklären, indem sie auf "Zustimmen und weiter" klicken. Hier können sie Detaileinstellungen vornehmen oder ihre Zustimmung - auch teilweise - mit Wirkung für die Zukunft zurücknehmen. Für weitere Informationen lesen sie bitte unsere Datenschutzerklärung.