Layer 8Ehrlicher Tech-Support

Zwei gleich aussehende Namen gelten als ungleich (NFC/NFD)

Zwei Namen sehen identisch aus, der Vergleich sagt trotzdem ungleich. Schuld ist nicht der Code, sondern die Unicode-Normalisierung: NFC gegen NFD.

Aufwand:
ca. 20 Min.
Voraussetzung:
Python 3, Ein Texteditor, der nichts stillschweigend umschreibt
Gilt für:
Alle Systeme mit Unicode-Text, Besonders im Austausch zwischen macOS und Windows/Linux

Das Symptom

Zwei Zeilen stehen untereinander und sehen bis aufs Pixel gleich aus:

Müller
Müller

Trotzdem findet die Suche den Namen nicht, der Abgleich zweier Listen meldet hunderte Unterschiede, der Datenbank-Eintrag wird ein zweites Mal angelegt, und a == b gibt hartnäckig False zurück. Besonders zuverlässig passiert das, sobald Dateien oder Tabellen zwischen einem Mac und dem Rest der Welt hin- und hergehen.

Die „Layer 8“-Wahrheit

Der Vergleich hat recht. Ein ü ist für Unicode nicht ein Ding, sondern zwei zulässige Schreibweisen desselben Dings: entweder ein einzelnes fertiges Zeichen oder ein u, dem ein kombinierendes Trema folgt. Beide Fassungen sind kanonisch gleichwertig, beide sollen laut Standard identisch aussehen und sich identisch verhalten – aber sie bestehen nun einmal aus verschiedenen Zeichen. Ein Vergleich, der Zeichen für Zeichen prüft, meldet folgerichtig einen Unterschied. Er lügt nicht, er sieht nur etwas, das der Bildschirm nicht zeigt. Deshalb ist die Länge der Zeichenkette der schnellste Verräter: sechs Zeichen hier, sieben dort, gleiche Anzeige.

Das ist ausdrücklich nicht das übliche Kodierungsdrama mit ä und UnicodeDecodeError – dort ist der Text sichtbar kaputt, hier ist er sichtbar in Ordnung. Und genau das macht ihn so zäh: Man sucht den Fehler in der Suchfunktion, im Import, in der Datenbank, überall außer in den Zeichen selbst. Die Lösung ist eine einzige Zeile Code, aber an der richtigen Stelle. Normalisiert wird an der Systemgrenze, einmal, in eine festgelegte Form – für gespeicherten Text und fürs Web üblicherweise NFC. Danach vergleicht man wieder ganz normal mit ==, weil dann tatsächlich dasselbe dasteht. Wer das an jeder Vergleichsstelle einzeln nachholt, hat es nicht gelöst, sondern nur vertagt.

Die echte, verifizierte Lösung

1000 % faktenbasiert. Jeder Befehl ist real, jeder Pfad existiert. Schritt für Schritt:

  1. 1.

    Nachsehen, ob wirklich derselbe Text dasteht

    Erster Schritt ist immer die Länge. Sehen zwei Zeichenketten gleich aus und haben unterschiedlich viele Zeichen, ist der Fall geklärt: Eine davon enthält kombinierende Zeichen. Das Wort Müller ist zusammengesetzt sechs Zeichen lang und zerlegt sieben.

    python3 -c "import unicodedata; a='Müller'; b=unicodedata.normalize('NFD', a); print(len(a), len(b), a == b)"
    # 6 7 False
    
  2. 2.

    Die Codepunkte sichtbar machen

    Wer es genau wissen will, lässt sich die Codepunkte einzeln ausgeben. Dann steht schwarz auf weiß, dass einmal ein fertiges ü mit dem Codepunkt U+00FC gespeichert ist und einmal ein u, gefolgt von U+0308, dem kombinierenden Trema.

    python3 -c "import unicodedata; a='Müller'; b=unicodedata.normalize('NFD', a); print([hex(ord(c)) for c in a]); print([hex(ord(c)) for c in b])"
    # ['0x4d', '0xfc', '0x6c', '0x6c', '0x65', '0x72']
    # ['0x4d', '0x75', '0x308', '0x6c', '0x6c', '0x65', '0x72']
    
  3. 3.

    Vor jedem Vergleich normalisieren

    Die Lösung ist eine Zeile: Beide Seiten in dieselbe Normalform bringen, dann vergleichen. Danach stimmen die Zeichenketten überein, weil sie jetzt tatsächlich aus denselben Zeichen bestehen – nicht, weil man den Vergleich weichgespült hätte.

    python3 -c "import unicodedata; n=lambda s: unicodedata.normalize('NFC', s); a='Müller'; b=unicodedata.normalize('NFD', a); print(n(a) == n(b))"
    # True
    
  4. 4.

    An der Grenze normalisieren, nicht überall im Code

    Normalisiert wird dort, wo Text ins System kommt: beim Einlesen der Datei, beim Entgegennehmen des Formulars, beim Import aus der Fremdliste. Wer stattdessen an jeder Vergleichsstelle nachträglich normalisiert, vergisst irgendwann eine – und genau die wird später der Fehlerbericht.

    python3 -c "import unicodedata; zeilen=[unicodedata.normalize('NFC', z) for z in open('namen.txt', encoding='utf-8')]; print(len(zeilen))"
    
  5. 5.

    Auch Dateinamen normalisieren, bevor man Listen abgleicht

    Beim Vergleich zweier Verzeichnisse gilt dasselbe. Kommt eine Seite von einem Mac, liegen die Namen dort zerlegt vor – Apples Technote zu HFS Plus schreibt genau das fest. Wer beide Listen vor dem Abgleich durch dieselbe Normalform schickt, bekommt statt hunderter Scheinunterschiede die echten zu sehen.

    python3 -c "import os, unicodedata; print(sorted(unicodedata.normalize('NFC', n) for n in os.listdir('.'))[:5])"
    
  6. 6.

    NFC und NFKC nicht verwechseln

    Die K-Formen sind etwas anderes und keine bessere Version. NFKC ersetzt Zeichen durch ihre Kompatibilitätsentsprechung: Aus der römischen Ziffer eins wird ein normales lateinisches I. Für Suchindizes kann das gewollt sein, für gespeicherte Namen ist es Datenverlust – der Unterschied verschwindet unwiederbringlich. Zum Vergleichen von Namen nimmt man NFC, nicht NFKC.

    python3 -c "import unicodedata; print(ascii(unicodedata.normalize('NFKC', 'Ⅰ')))"
    # 'I'
    

Häufige Fragen (FAQ)

?Ist das dasselbe wie der UnicodeDecodeError oder wie diese ä-Zeichen?

Nein, es ist genau der gegenteilige Fall. Beim Mojibake und beim UnicodeDecodeError geht es um die Kodierung, also darum, welche Bytes zu welchen Zeichen gehören – dort sieht der Text kaputt aus. Hier ist die Kodierung völlig in Ordnung, beide Zeichenketten sind sauberes UTF-8 und sehen auf dem Bildschirm identisch aus. Der Unterschied liegt eine Ebene höher: Ein ü kann ein einzelnes Zeichen sein oder ein u mit einem nachfolgenden kombinierenden Trema, also zwei Zeichen. Der Text ist heil, nur die Zeichenfolge dahinter ist eine andere – und ein Vergleich, der Zeichen für Zeichen prüft, sagt deshalb korrekt ungleich.

?Welche Form soll ich nehmen, NFC oder NFD?

Für gespeicherten Text und für das Web ist NFC die übliche Wahl, weil sie zusammensetzt statt zu zerlegen und damit die kürzere, kompaktere Darstellung liefert. Das W3C beschreibt in seinem Zeichenmodell ausdrücklich Umwandler, die beim Übersetzen aus Alt-Kodierungen sicherstellen, dass das Ergebnis in Unicode Normalization Form C vorliegt. Wichtig ist aber weniger, welche Form du wählst, als dass du überhaupt eine wählst und sie an einer einzigen Stelle erzwingst. Zwei Programmteile mit unterschiedlicher Meinung sind schlimmer als jede der beiden Formen.

?Warum kommen die zerlegten Zeichen so oft von einem Mac?

Weil Apples Dateisystem HFS Plus Zeichenketten laut Apples eigener Technote TN1150 vollständig zerlegt und in kanonischer Reihenfolge speichert. Ein Dateiname mit Umlaut, der über einen Mac gelaufen ist, kommt daher gern in der zerlegten Form beim Kollegen an, während Windows und die meisten Web-Formulare die zusammengesetzte Form liefern. Beide Seiten sind korrekt, beide zeigen dasselbe an – und der Abgleich von zwei Dateilisten findet trotzdem keine einzige Übereinstimmung. Das ist der Klassiker, mit dem diese Sache ihre Opfer holt.

Belege

Diese Seite ist gegen die folgenden Primärquellen geprüft – Hersteller, Standard oder Gesetzestext. Wer es nicht glaubt, muss es nicht: Da steht, wo es nachzulesen ist.

Zuletzt geprüft am . Dokumentation altert – ein Datum daneben ist der einzige ehrliche Umgang damit.

Auch zu finden unter

Verwandte Fehler

SWLästigCERTIFICATE_VERIFY_FAILED

pip: SSL: CERTIFICATE_VERIFY_FAILED

pip lädt keine Pakete wegen SSL-Zertifikatsfehler? Meist schneidet ein Firmen-Proxy mit oder die Zertifikate sind veraltet — beides ist lösbar.