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.
blog comments powered by Disqus