Apri un file salvato come UTF-8 in uno strumento che assume una codifica diversa e café diventa café. I byte sul disco non sono cambiati. È cambiata solo la regola usata per trasformarli di nuovo in caratteri, e le due regole non sono d’accordo su cosa significhi il byte 0xC3.
La maggior parte dei bug da “caratteri strani” — nomi di file corrotti, moduli web illeggibili, emoji che fanno fallire un insert nel database, due stringhe identiche all’occhio ma diverse nel confronto — nasce dalla stessa confusione tra due cose distinte: Unicode e UTF-8.
Unicode vs UTF-8: i code point non sono byte
Unicode è una tabella unica che assegna a ogni carattere un numero, chiamato code point, scritto come U+ seguito dall’esadecimale: A è U+0041, é è U+00E9, 😀 è U+1F600. La tabella va da U+0000 a U+10FFFF. Copre ogni scrittura in uso oggi o nel passato, più simboli, emoji e intervalli riservati a usi privati.
Unicode non dice nulla sui byte. Risponde a una sola domanda: quale numero ha questo carattere? Trasformare quel numero in byte da salvare in un file o inviare su una socket è un compito separato, svolto da una codifica. UTF-8, UTF-16 e UTF-32 sono tre codifiche della stessa tabella. Scrivono gli stessi code point come sequenze di byte diverse.
È lo stesso scarto per cui 0.1 + 0.2 restituisce qualcosa di diverso da 0.3: il valore astratto e i bit in memoria sono due livelli diversi, e il bug emerge nel momento in cui li tratti come intercambiabili.
Come UTF-8 trasforma un code point in byte
UTF-8 è a larghezza variabile. Ogni code point diventa da 1 a 4 byte, usando solo quelli che servono.
| Intervallo code point | Byte usati | Pattern di byte |
|---|---|---|
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 |
Ogni byte che inizia con 10 è un byte di continuazione: un pezzo di un carattere multi-byte, mai un carattere a sé. Questo rende la codifica auto-sincronizzante. Entra nel mezzo di un flusso UTF-8, scorri indietro oltre i byte di continuazione, e arrivi all’inizio di un carattere.
La prima riga è il motivo per cui UTF-8 ha vinto. I code point U+0000–U+007F sono esattamente l’ASCII, memorizzati con lo stesso singolo byte che l’ASCII già usava, quindi qualsiasi file ASCII puro è già UTF-8 valido, byte per byte. Tutto ciò che sta fuori da quell’intervallo costa byte extra. é è U+00E9, nella seconda riga, quindi ne occupa due:
| |
😀 è U+1F600, nella quarta riga, quindi ne occupa quattro:
| |
Quella coppia 0xC3 0xA9 è la colpevole dell’esempio di apertura. Decodificala come UTF-8 e ottieni é. Decodificala come Latin-1, che mappa ogni byte a un carattere, e 0xC3 diventa à mentre 0xA9 diventa ©. Nessun byte grezzo registra quale codifica l’ha prodotto. I byte non sono sbagliati. È l’assunzione di chi li legge a esserlo.
Perché .length non conta i caratteri
“Quanto è lunga questa stringa” ha tre risposte diverse, perché “carattere” può significare tre unità diverse.
len() in Python conta i code point:
| |
Un code point sulla str, quattro byte sui bytes codificati. Due domande diverse, due risposte diverse.
Le stringhe JavaScript sono UTF-16 internamente, e .length conta le unità di codice a 16 bit, non i code point. I code point sopra U+FFFF, che includono ogni emoji, richiedono due unità UTF-16, chiamate coppia surrogata:
| |
L’emoji famiglia "👨👩👧👦" peggiora le cose. Sono 7 code point: 4 emoji di persona unite da 3 zero-width joiner. Ognuna delle 4 emoji è una coppia surrogata, quindi .length riporta 11. Quello che un lettore vede come un carattere è un grapheme cluster, e può estendersi su un numero qualsiasi di code point. Tagliare o invertire una stringa per unità di codice o per code point può spezzare quell’emoji a metà. Intl.Segmenter conta come farebbe una persona:
| |
La larghezza variabile ha un costo. In un buffer UTF-8, trovare il cinquantesimo code point significa partire dall’inizio e contare, quindi indicizzare per posizione di carattere è O(n), non il lookup a tempo costante che ottieni da un array a larghezza fissa. I linguaggi gestiscono questo aspetto in modo diverso. Rust e Go memorizzano le stringhe come UTF-8 e le indicizzano per byte, quindi itera quando vuoi i caratteri. CPython memorizza ogni str a una larghezza fissa per stringa (1, 2 o 4 byte per code point, in base al carattere più largo che contiene), il che mantiene s[i] a tempo costante al prezzo di memoria extra non appena compare una singola emoji.
Bug di codifica UTF-8 comuni in produzione
Troncare per numero di byte. Tagliare una stringa UTF-8 esattamente a 100 byte a volte finisce nel mezzo di un carattere multi-byte, lasciando una sequenza parziale che non si decodifica. Tronca per numero di code point, oppure tronca i byte e decodifica con errors="ignore", accettando che l’ultimo carattere parziale venga scartato. Nessuno dei due approcci rispetta i grapheme cluster: un taglio per code point può ancora separare una e dal suo accento combinante o spezzare un’emoji famiglia. Per il testo visibile all’utente, taglia sui confini dei grapheme.
Lasciare la codifica implicita nell’I/O sui file. open("data.txt") senza l’argomento encoding= usa la codifica locale del sistema operativo. Su Linux e macOS è di solito UTF-8. Su Windows storicamente è stata una code page come cp1252. Leggere un file UTF-8 con il default sbagliato produce un UnicodeDecodeError o del mojibake silenzioso, a seconda dei byte. La PEP 686 rende UTF-8 il default ovunque a partire da Python 3.15, ma molto codice continua a girare su versioni precedenti o impostare PYTHONUTF8=0. Passa la codifica in modo esplicito e il problema non si pone più:
| |
Una colonna MySQL che è UTF-8 solo di nome. Il character set utf8 di MySQL (oggi un alias per utf8mb3) memorizza al massimo 3 byte per carattere. Non può contenere code point sopra U+FFFF, quindi niente emoji e niente fuori dal Basic Multilingual Plane. Inserisci 😀 in una colonna utf8 e ottieni Incorrect string value. La soluzione è utf8mb4, l’implementazione UTF-8 completa di MySQL e il default dalla versione 8.0. Convertire una tabella è ALTER TABLE t CONVERT TO CHARACTER SET utf8mb4, eseguito per ogni tabella, e anche il charset della connessione deve essere utf8mb4, altrimenti il client corrompe le emoji prima che arrivino alla colonna. Sui row format InnoDB più vecchi, attenzione al limite di 767 byte sul prefisso dell’indice: un VARCHAR(255) indicizzato entra in utf8mb3 (765 byte) ma non in utf8mb4 (1.020 byte).
Due sequenze di byte per lo stesso carattere visibile. é può essere il singolo code point precomposto U+00E9 (forma NFC), oppure la lettera e (U+0065) seguita da un accento acuto combinante U+0301 (forma NFD). Si visualizzano in modo identico ma differiscono byte per byte, quindi due stringhe café possono risultare diverse nel confronto in base a dove sono state digitate o incollate. I nomi di file su macOS e il testo copiato da alcuni PDF arrivano spesso in NFD. Un dizionario indicizzato in una forma può non trovare una chiave nell’altra, senza che compaia nessun errore. Normalizza prima di confrontare o salvare:
| |
Un byte-order mark ospite indesiderato. Un file UTF-8 può iniziare con i 3 byte EF BB BF, un BOM scritto da alcuni editor (tra cui il vecchio Notepad di Windows) per segnalare la codifica. UTF-8 non ha un ordine dei byte da segnalare, quindi quel BOM non porta nessuna informazione. È invisibile nella maggior parte degli editor, ma decodificarlo con utf-8 semplice lascia un carattere all’inizio della stringa, che fa fallire i parser JSON strict e impedisce il riconoscimento di uno shebang #!. Decodifica con il codec utf-8-sig di Python quando ti aspetti un BOM, e non scriverne uno.
Come fermare i bug di codifica al confine
Scegli UTF-8 e dichiaralo esplicitamente ovunque il testo entri o esca dal tuo programma: letture di file, connessioni al database, corpi HTTP, pipe verso subprocess. Decodifica in stringa appena arrivano i byte, lavora internamente su stringhe decodificate, e codifica di nuovo in byte solo al confine successivo. Mantieni errors="strict" (il default di Python) così una sequenza di byte invalida genera un’eccezione invece di trasformarsi silenziosamente in U+FFFD o scomparire.
Poi testa con input che non è ASCII. Fixture che contengono solo U+0000–U+007F lasciano passare i bug di codifica in produzione, perché ogni percorso di codice funziona per caso a un byte per carattere finché il nome di un utente reale non rompe quell’assunzione. Aggiungi due casi a qualsiasi livello di test che già scrivi per ogni campo di testo, casella di ricerca, gestore di nomi file o export: una stringa con una lettera accentata, scritta sia in NFC che in NFD, e una con l’emoji famiglia. Se sopravvivono a un round trip nel tuo storage e tornano uguali, i confini stanno facendo il loro lavoro.