Öffnen Sie eine als UTF-8 gespeicherte Datei in einem Tool, das eine andere Kodierung annimmt, und aus café wird café. Die Bytes auf der Festplatte haben sich nicht geändert. Geändert hat sich nur die Regel, mit der sie wieder in Zeichen zurückverwandelt werden, und die beiden Regeln sind sich nicht einig, was das Byte 0xC3 bedeutet.
Die meisten Bugs mit “komischen Zeichen” laufen auf dasselbe hinaus: kaputte Dateinamen, zerstörte Formulareingaben, ein Emoji, das einen Datenbank-Insert zum Absturz bringt, zwei Strings, die identisch aussehen, aber beim Vergleich nicht gleich sind. Jedes Mal werden zwei getrennte Dinge verwechselt: Unicode und UTF-8.
Unicode vs. UTF-8: Codepunkte sind keine Bytes
Unicode ist eine einzige Tabelle, die jedem Zeichen eine Nummer zuweist, genannt Codepunkt, geschrieben als U+ gefolgt von Hex: A ist U+0041, é ist U+00E9, 😀 ist U+1F600. Die Tabelle reicht von U+0000 bis U+10FFFF. Das deckt jede aktuell und historisch genutzte Schrift ab, dazu Symbole, Emoji und für private Nutzung reservierte Bereiche.
Unicode sagt nichts über Bytes. Es beantwortet eine einzige Frage: Welche Nummer hat dieses Zeichen? Diese Nummer in Bytes zu verwandeln, die sich in einer Datei speichern oder über einen Socket senden lassen, ist Aufgabe einer Kodierung. UTF-8, UTF-16 und UTF-32 sind drei Kodierungen derselben Tabelle. Sie schreiben dieselben Codepunkte als unterschiedliche Byte-Sequenzen.
Es ist dieselbe Art von Lücke, die dazu führt, dass 0.1 + 0.2 etwas anderes als 0.3 ergibt: Der abstrakte Wert und die Bits im Speicher sind zwei verschiedene Ebenen, und der Bug zeigt sich in dem Moment, in dem man sie als austauschbar behandelt.
Wie UTF-8 einen Codepunkt in Bytes verwandelt
UTF-8 hat variable Breite. Jeder Codepunkt wird zu 1 bis 4 Bytes, und zwar genau so vielen, wie er braucht.
| Codepunkt-Bereich | Verwendete Bytes | Byte-Muster |
|---|---|---|
U+0000–U+007F | 1 | 0xxxxxxx |
U+0080–U+07FF | 2 | 110xxxxx 10xxxxxx |
U+0800–U+FFFF | 3 | 1110xxxx 10xxxxxx 10xxxxxx |
U+10000–U+10FFFF | 4 | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
Jedes Byte, das mit 10 beginnt, ist ein Fortsetzungsbyte: ein Teil eines Mehrbyte-Zeichens, nie ein Zeichen für sich. Das macht die Kodierung selbstsynchronisierend. Springen Sie mitten in einen UTF-8-Strom, scannen Sie rückwärts über alle Fortsetzungsbytes hinweg, und Sie landen am Anfang eines Zeichens.
Die erste Zeile erklärt, warum UTF-8 sich durchgesetzt hat. Die Codepunkte U+0000–U+007F sind genau ASCII, gespeichert mit demselben einzelnen Byte, das ASCII schon immer benutzt hat, also ist jede reine ASCII-Datei bereits gültiges UTF-8, Byte für Byte. Alles außerhalb dieses Bereichs kostet zusätzliche Bytes. é ist U+00E9, in der zweiten Zeile, braucht also zwei:
| |
😀 ist U+1F600, in der vierten Zeile, braucht also vier:
| |
Dieses Paar 0xC3 0xA9 ist der Übeltäter aus dem Eingangsbeispiel. Dekodieren Sie es als UTF-8, erhalten Sie é. Dekodieren Sie es als Latin-1, das jedes Byte auf ein Zeichen abbildet, wird 0xC3 zu Ã, während 0xA9 zu © wird. Nichts in einem rohen Byte-Strom verrät, welche Kodierung ihn erzeugt hat. Die Bytes sind nicht falsch. Falsch ist die Annahme des Lesers über sie.
Warum .length keine Zeichen zählt
“Wie lang ist dieser String” hat drei verschiedene Antworten, weil “Zeichen” drei verschiedene Einheiten bedeuten kann.
Pythons len() zählt Codepunkte:
| |
Ein Codepunkt beim str, vier Bytes beim kodierten bytes. Zwei verschiedene Fragen, zwei verschiedene Antworten.
JavaScript-Strings sind intern UTF-16, und .length zählt 16-Bit-Codeeinheiten, keine Codepunkte. Codepunkte oberhalb von U+FFFF, zu denen jedes Emoji gehört, brauchen zwei UTF-16-Einheiten – ein sogenanntes Surrogatpaar:
| |
Das Familien-Emoji "👨👩👧👦" macht es noch schlimmer. Es sind 7 Codepunkte: 4 Personen-Emoji, verbunden durch 3 Zero-Width-Joiner. Jedes der 4 Emoji ist ein Surrogatpaar, also meldet .length 11. Was ein Leser als ein Zeichen sieht, ist ein Grapheme-Cluster, und der kann sich über eine beliebige Anzahl Codepunkte erstrecken. Einen String nach Codeeinheit oder Codepunkt zu schneiden oder umzudrehen kann dieses Emoji mittendurch zerschneiden. Intl.Segmenter zählt so, wie es ein Mensch tun würde:
| |
Die variable Breite hat auch einen Preis. In einem UTF-8-Puffer bedeutet das Auffinden des 50. Codepunkts, vom Anfang an zu laufen und zu zählen, also ist Indizieren nach Zeichenposition O(n), nicht der Zugriff in konstanter Zeit, den ein Array mit fester Breite bietet. Sprachen gehen damit unterschiedlich um. Rust und Go speichern Strings als UTF-8 und indizieren sie nach Byte, man iteriert also, wenn man Zeichen will. CPython speichert jeden str mit einer festen Breite pro String (1, 2 oder 4 Bytes pro Codepunkt, abhängig vom breitesten Zeichen, das er enthält), was s[i] in konstanter Zeit hält, zum Preis zusätzlichen Speichers, sobald ein einziges Emoji auftaucht.
Häufige UTF-8-Kodierungsfehler in Produktion
Abschneiden nach Byte-Anzahl. Einen UTF-8-String auf genau 100 Bytes zu kürzen landet manchmal mitten in einem Mehrbyte-Zeichen und hinterlässt eine Teilsequenz, die sich nicht dekodieren lässt. Schneiden Sie nach Anzahl der Codepunkte, oder kürzen Sie die Bytes und dekodieren Sie mit errors="ignore", wobei Sie akzeptieren, dass das letzte Teilzeichen verloren geht. Keiner der beiden Ansätze respektiert Grapheme-Cluster: ein Schnitt nach Codepunkt kann immer noch ein e von seinem kombinierenden Akzent trennen oder ein Familien-Emoji auseinanderreißen. Für sichtbaren Text schneiden Sie an Grapheme-Grenzen.
Die Kodierung bei Datei-I/O implizit lassen. open("data.txt") ohne das Argument encoding= verwendet die lokale Kodierung des Betriebssystems. Auf Linux und macOS ist das meist UTF-8. Auf Windows war es historisch eine Codepage wie cp1252. Eine UTF-8-Datei mit dem falschen Standard zu lesen, erzeugt je nach Byte-Inhalt einen UnicodeDecodeError oder stillen Mojibake. PEP 686 macht UTF-8 ab Python 3.15 überall zum Standard, aber viel Code läuft noch auf älteren Versionen oder setzt PYTHONUTF8=0. Geben Sie die Kodierung explizit an, dann stellt sich die Frage gar nicht erst:
| |
Eine MySQL-Spalte, die nur dem Namen nach UTF-8 ist. Der Zeichensatz utf8 von MySQL (heute ein Alias für utf8mb3) speichert höchstens 3 Bytes pro Zeichen. Er kann keine Codepunkte oberhalb von U+FFFF aufnehmen, also keine Emoji und nichts außerhalb der Basic Multilingual Plane. Fügen Sie 😀 in eine utf8-Spalte ein, erhalten Sie Incorrect string value. Die Lösung ist utf8mb4, MySQLs vollständige UTF-8-Implementierung und seit MySQL 8.0 der Standard. Eine Tabelle konvertiert man mit ALTER TABLE t CONVERT TO CHARACTER SET utf8mb4, pro Tabelle ausgeführt – und auch der Verbindungs-Zeichensatz muss utf8mb4 sein, sonst verstümmelt der Client die Emoji, bevor sie die Spalte erreichen. Bei älteren InnoDB-Row-Formaten beachten Sie das 767-Byte-Limit für Indexpräfixe: ein indizierter VARCHAR(255) passt in utf8mb3 (765 Bytes), aber nicht in utf8mb4 (1.020 Bytes).
Zwei Byte-Sequenzen für dasselbe sichtbare Zeichen. é kann der einzelne vorkomponierte Codepunkt U+00E9 sein (NFC-Form), oder der Buchstabe e (U+0065), gefolgt von einem kombinierenden Akut-Akzent U+0301 (NFD-Form). Sie werden identisch dargestellt, unterscheiden sich aber Byte für Byte, sodass zwei café-Strings beim Vergleich ungleich sein können, je nachdem, wo jeder getippt oder eingefügt wurde. Dateinamen unter macOS und Text, der aus manchen PDFs kopiert wird, kommen oft in NFD an. Ein nach einer Form indiziertes Dictionary kann bei der anderen Form stillschweigend einen Lookup verfehlen. Normalisieren Sie, bevor Sie vergleichen oder speichern:
| |
Eine ungebetene Byte-Order-Mark. Eine UTF-8-Datei kann mit den 3 Bytes EF BB BF beginnen, einem BOM, das manche Editoren (darunter das alte Windows-Notepad) schreiben, um die Kodierung zu kennzeichnen. UTF-8 hat keine Bytereihenfolge, die markiert werden müsste, also trägt dieses BOM keine Information. Es ist in den meisten Editoren unsichtbar, aber das Dekodieren mit einfachem utf-8 lässt ein Zeichen am Anfang des Strings zurück, was strikte JSON-Parser scheitern lässt und verhindert, dass ein #!-Shebang erkannt wird. Dekodieren Sie mit Pythons utf-8-sig-Codec, wenn Sie eines erwarten, und schreiben Sie selbst keines.
Wie man Kodierungsfehler an der Grenze stoppt
Wählen Sie UTF-8 und geben Sie es überall explizit an, wo Text in Ihr Programm hinein- oder herausgeht: Dateizugriffe, Datenbankverbindungen, HTTP-Bodys, Pipes zu Subprozessen. Dekodieren Sie zu einem String, sobald die Bytes ankommen, arbeiten Sie intern mit dekodierten Strings, und kodieren Sie erst an der nächsten Grenze wieder in Bytes zurück. Behalten Sie errors="strict" (Pythons Standard) bei, damit eine fehlerhafte Byte-Sequenz eine Exception auslöst, statt still zu U+FFFD zu werden oder zu verschwinden.
Testen Sie dann mit Eingaben, die nicht ASCII sind. Fixtures, die nur U+0000–U+007F enthalten, lassen Kodierungsfehler bis in die Produktion durchrutschen, weil jeder Codepfad zufällig mit einem Byte pro Zeichen funktioniert, bis der Name eines echten Nutzers diese Annahme zerbricht. Fügen Sie zu jeder Testebene, die Sie bereits schreiben, zwei Fälle für jedes Textfeld, Suchfeld, jeden Dateinamen-Handler oder Export hinzu: einen String mit einem akzentuierten Buchstaben, geschrieben sowohl in NFC als auch in NFD, und einen mit dem Familien-Emoji. Überleben sie einen Umlauf durch Ihren Speicher und kommen gleich zurück, machen die Grenzen ihren Job.