CodeGym /Cours /Kotlin SELF /Encodages : UTF‑8 / UTF‑16 / ASCII

Encodages : UTF‑8 / UTF‑16 / ASCII

Kotlin SELF
Niveau 44, Leçon 1
Disponible

1. ASCII — comment tout a commence

ASCII, c’est un peu le grand-pere dans le monde des encodages : il a deja beaucoup d’annees, il ne connait ni les emojis ni le cyrillique, mais il est encore present aux fetes de famille. Il est important de comprendre ASCII non pas comme « un vieux truc qu’on peut oublier », mais comme un point de reference de base : c’est a partir de lui que sont nees les compatibilites et de nombreuses regles pratiques.

ASCII (dans sa forme classique) est une table de 128 caracteres (7 bits) : alphabet latin, chiffres, signes de ponctuation et caracteres de controle comme le saut de ligne. Autrement dit Hello, 123, A-Z, {}, + — ok. En revanche Hello ou 你好 — non, « pas prevu ».

Que se passe-t-il si l’on essaie de « faire rentrer » le cyrillique dans ASCII

En pratique, lorsque vous encodez une chaine en ASCII et que la chaine contient un caractere que ASCII ne connait pas, il est remplace par un caractere de substitution. Tres souvent, c’est le point d’interrogation ?. Ce n’est pas une « erreur du compilateur » ni un « mauvais Kotlin » — c’est une reaction honnete de l’encodage : « je ne sais pas faire, voici un substitut ».

fun main() {
    val original = "Hello"
    val bytes = original.toByteArray(Charsets.US_ASCII)

    val decoded = String(bytes, Charsets.US_ASCII)
    println(decoded) // ??????
}

Le piege, c’est que le programme ne plante pas. Il « abime simplement les donnees en silence ». C’est l’une des classes de bugs les plus desagreables : tout fonctionne, mais le resultat est faux.

Pourquoi ASCII reste important aujourd’hui

ASCII est important pour deux raisons. D’abord, de nombreux protocoles et formats (surtout anciens) ont ete historiquement concus pour lui. Ensuite, UTF‑8 (on en parle bientot) est concu de sorte que tout texte purement ASCII en UTF‑8 est identique octet pour octet a celui en ASCII. C’est pourquoi ASCII se « cache » souvent a l’interieur de UTF‑8 comme cas particulier.

2. UTF‑8 — le standard de facto

Si ASCII est le grand-pere, alors UTF‑8 est ce « langage universel » que parle aujourd’hui la majeure partie d’internet. Il sait encoder tout Unicode (c’est-a-dire presque tout ce que les humains ont imagine ecrire) et conserve en meme temps une propriete cruciale : la compatibilite avec ASCII pour le texte simple.

L’idee pratique principale de UTF‑8 : un encodage a longueur variable. Un caractere peut occuper 1, 2, 3 ou 4 octets. Cela signifie que « 1 caractere = 1 octet » ne fonctionne que dans un monde tres etroit, compose de l’alphabet latin et de quelques signes.

Compatibilite ASCII de UTF‑8 : pourquoi Hello est identique

Verifions a la main : en ASCII et en UTF‑8, pour un texte anglais, les octets seront identiques. C’est une des raisons pour lesquelles UTF‑8 est si pratique a utiliser partout : les anciennes donnees ASCII ne cassent pas.

fun main() {
    val s = "Hello"

    val ascii = s.toByteArray(Charsets.US_ASCII)
    val utf8 = s.toByteArray(Charsets.UTF_8)

    println(ascii.size)                // 5
    println(utf8.size)                 // 5
    println(ascii.contentEquals(utf8))  // true
}

Autrement dit, si votre fichier ne contient que des caracteres ASCII, vous ne remarquerez souvent meme pas la difference entre « ASCII » et « UTF‑8 » (ce qui detend parfois — et c’est justement pour cela que ca fait mal quand apparait la premiere lettre Ã).

Pourquoi Hello en UTF‑8 est plus grand que prevu

Le cyrillique en UTF‑8 occupe generalement 2 octets par lettre. Dans le mot « Hello », il y a 6 lettres — donc environ 12 octets. Regardons :

fun main() {
    val ru = "Hello"

    val utf8 = ru.toByteArray(Charsets.UTF_8)
    println("utf8 bytes = ${utf8.size}") // utf8 bytes = 12
}

C’est normal. Ce ne sont pas des « octets en trop », c’est le prix de l’universalite : UTF‑8 est economique pour l’anglais, mais s’etend honnetement pour les autres langues.

Emojis en UTF‑8 : pourquoi 4 octets, c’est normal

Les emojis sont un exemple classique de caracteres qui, en UTF‑8, occupent 4 octets.

fun main() {
    val emoji = "🙂"
    val bytes = emoji.toByteArray(Charsets.UTF_8)

    println(bytes.size) // 4
}

Si un jour vous devez « limiter un message a N octets » (par exemple, un protocole reseau, une limite de champ, la taille d’un fichier), vous voyez deja pourquoi un text.length naif ne garantit absolument pas le resultat attendu.

3. UTF‑16 et la realite de la JVM

UTF‑16 est souvent percu comme une idee simple : « donc c’est 2 octets par caractere ». Dans le langage courant, c’est parfois vrai… jusqu’au premier emoji. Et aussi jusqu’au moment ou vous vous heurtez a l’ordre des octets et aux elements de service au debut d’un fichier (mais aujourd’hui, on ne fera qu’y faire allusion, sans plongée en profondeur).

Le tableau pratique de UTF‑16 est le suivant : il encode Unicode via des unites de code sur 16 bits. Sur la JVM, le type Char est justement une valeur sur 16 bits. Kotlin sur la JVM herite ici du modele Java : Char stocke une valeur sur 16 bits (plus precisement, une unite de code UTF‑16).

Pourquoi String.length peut « mentir » avec les emojis

Choc cognitif tres frequent : une chaine composee d’un seul emoji peut avoir une longueur de 2.

fun main() {
    val s = "🙂"
    println(s.length) // 2
}

Cela se produit parce qu’en UTF‑16, certains caracteres (hors plan de base) sont encodes par une paire de valeurs sur 16 bits. Ce n’est pas un sujet « pour memoriser des tables », mais un avertissement pratique important : sur la JVM, length est le nombre de Char, et non le « nombre de caracteres visibles ».

Si, a ce stade, cela ressemble a « je voudrais juste lire un fichier, pas faire de la philosophie sur Unicode », alors c’est bon : notre objectif n’est pas d’approfondir, mais d’arreter de faire des suppositions dangereuses.

Taille de UTF‑16 en octets : pourquoi il y a parfois 2 octets « en trop »

UTF‑16 pour le cyrillique donne en general 2 octets par lettre (autrement dit, « Hello » ≈ 12 octets), mais en pratique, lors de l’encodage avec Charsets.UTF_16, des octets de service supplementaires peuvent apparaitre au debut. C’est pourquoi il vaut mieux comparer les tailles avec prudence et en comprenant que UTF_16 dans l’API peut inclure un marqueur d’ordre des octets.

Voici une maniere sure de voir que la taille differe :

fun main() {
    val ru = "Hello"

    val utf8 = ru.toByteArray(Charsets.UTF_8)
    val utf16 = ru.toByteArray(Charsets.UTF_16)

    println("utf8=${utf8.size}")   // utf8=12
    println("utf16=${utf16.size}") // utf16=14
}

Pourquoi 14 et pas 12 ? Parce qu’aux 12 octets de donnees se sont ajoutes 2 octets de service au debut. Oui, c’est exactement le « piege » qui finit par casser la lecture de la premiere colonne d’un CSV, parce que le premier en-tete commence soudain non pas par i, mais par « un element invisible + i ». Les details de cet « invite invisible » meritent d’etre etudies a part, afin de ne pas tout melanger.

Comment voir les octets UTF‑16 : afficher en hex

Afficher directement un Byte n’est pas tres agreable : il est signed (de -128 a 127), et on se perd vite dans la console. Pour diagnostiquer, il est pratique d’afficher les octets en hex.

fun main() {
    val bytes = "A".toByteArray(Charsets.UTF_16)

    val hex = bytes.joinToString(" ") { b ->
        b.toUByte().toString(16).padStart(2, '0')
    }
    println(hex) // fe ff 00 41
}

Ici, on voit : d’abord fe ff (octets de service), puis 00 41 (c’est la lettre A). Meme si vous ne savez pas encore « ce que signifie fe ff », vous voyez deja l’idee principale : en UTF‑16, le debut de la sequence d’octets peut contenir non pas des “donnees du texte”, mais une partie de service.

4. Mini-pratique : un tracker de depenses et un format texte

Pour que nos connaissances ne soient pas separees de la realite, relions-les a l’application que nous developpons dans le cours. Supposons que nous ayons un tracker de depenses en console, qui stocke les donnees ligne par ligne, par exemple ainsi :

100;food;Lunch
250;transport;Taxi

La logique de parsing ressemble souvent a « lire une ligne → split sur ; → parser les nombres ». Et c’est ici que les encodages commencent a influer de maniere inattendue : pas sur split, mais sur ce qui est considere comme des caracteres de la chaine.

Aujourd’hui, nous n’allons pas aborder la lecture/ecriture d’un fichier en precisant l’encodage (c’est un sujet a part). Mais nous pouvons simuler le probleme « en memoire » : prendre une chaine, l’encoder en UTF‑16, puis la decoder comme UTF‑8. Ce sont les « caracteres illisibles » classiques.

fun main() {
    val header = "amount;category;comment"

    val bytesUtf16 = header.toByteArray(Charsets.UTF_16)
    val wrongText = String(bytesUtf16, Charsets.UTF_8)

    println(wrongText == header) // false
}

Point cle : nous ne sommes pas obliges d’afficher wrongText (dans la console, cela peut apparaitre differemment), mais nous savons avec certitude que le contrat est viole. Nous avons encode selon certaines regles et lu selon d’autres — et nous avons obtenu un autre texte.

C’est pourquoi, dans les programmes reels, le probleme « le fichier se lit bien chez moi, mais se casse chez un collegue » se resume tres souvent au fait qu’un cote utilise silencieusement UTF‑8, tandis que l’autre utilise silencieusement UTF‑16 ou l’encodage systeme.

5. Regles pratiques pour travailler avec les encodages

Sur le sujet des encodages, il est facile de se mettre a « bricoler » : remplacer UTF_8 par UTF_16, puis par Windows-1251, puis revenir, jusqu’a ce que cela devienne « joli ». Cela fonctionne un peu comme soigner un mal de tete en changeant le papier peint : parfois ca aide, mais vous n’avez pas compris la cause.

Une discipline d’ingenierie normale ressemble a ceci.

Comparaison UTF‑8 / UTF‑16 / ASCII en pratique

Quand on compare des encodages, on peut facilement partir sur Wikipedia et perdre le fil. Nous allons faire plus simple : comparer comme pense un developpeur qui ecrit une application console et veut juste que le fichier se lise de la meme maniere chez tout le monde.

D’abord — un tableau court. Pas pour bachoter, mais pour se reperer.

Encodage Capacites Taille pour l’anglais Taille pour le cyrillique Emojis Cas typique
ASCII ~128 caracteres 1 octet/caractere ne sait pas (remplacement par ?) ne sait pas protocoles/donnees tres anciens, contraintes strictes
UTF‑8 tout Unicode 1 octet/caractere souvent 2 octets/caractere 4 octets fichiers, reseau, JSON, « par defaut » dans le monde moderne
UTF‑16 tout Unicode souvent 2 octets/caractere (+ octets de service) souvent 2 octets/caractere (+ octets de service) souvent 4 octets (comme une paire d’unites 16 bits) representation interne dans certains systemes, parfois monde Windows

Maintenant, retenons la conclusion pratique essentielle qui sauve les nerfs : les octets dependent de l’encodage, donc la taille du fichier, la « visibilite » des caracteres et la correction du parsing aussi.

Des regles qui aident a ne pas se transformer en chamane

D’abord, gardez en tete que ASCII n’est pas un encodage universel. Si vous voyez « ?????? » a la place du cyrillique, cela signifie presque toujours que les donnees ont ete « emballees » dans un encodage trop pauvre (ou que quelqu’un a decide que le monde se limitait au latin).

Ensuite, souvenez-vous que UTF‑8 est presque toujours le meilleur choix pour les echanges externes de texte : il est compact pour l’anglais, supporte tout Unicode et est compatible avec ASCII la ou c’est important (par exemple, en-tetes, cles, symboles structurels). Donc, si vous n’avez pas d’exigences particulieres, « se mettre d’accord sur UTF‑8 » est en general plus raisonnable que « laisser faire au hasard ».

Enfin, traitez UTF‑16 avec prudence : il n’est ni mauvais ni bon, il est juste different. Des octets de service apparaissent souvent au debut, et sur la JVM, de nombreuses choses au niveau de Char et length se comportent comme un « nombre de morceaux de 16 bits », pas comme un « nombre de caracteres visibles ». C’est normal ; il ne faut simplement pas ecrire du code qui suppose le contraire.

Si on rassemble tout cela en une courte « formule sans magie », on obtient :

Texte ≠ octets.
L’encodage est un accord sur les regles de conversion.
Accord rompu — caracteres illisibles.

6. Erreurs typiques lors de la comparaison des encodages

Erreur n°1 : penser que “1 caractere = 1 octet”.
Cela ne marche que dans le monde ASCII et seulement pour un ensemble limite de caracteres. Des que vous rencontrez du cyrillique, des diacritiques, des ideogrammes chinois ou des emojis, la taille en octets commence a varier. Si vous construisez une logique « je coupe la chaine a N octets » via substring(0, n), vous obtiendrez presque a coup sur un resultat incorrect.

Erreur n°2 : croire qu’ASCII et UTF‑8, c’est la meme chose.
Ils coincident effectivement sur de nombreux textes (par exemple Hello), ce qui induit en erreur. En pratique, ils ne sont identiques que sur l’intersection des caracteres ASCII. Des qu’apparait à — ASCII la transforme en ?, tandis que UTF‑8 l’encode tranquillement.

Erreur n°3 : attendre de String.length qu’il reponde a la question “combien de caracteres y a-t-il dans le texte”.
Sur la JVM, length compte le nombre de Char, c’est-a-dire d’unites de code sur 16 bits. Un seul caractere visuel peut donc occuper deux Char (exemple classique : certains emojis). Cela ne rend pas length inutile, mais rend dangereuse l’hypothese que length == le nombre de caracteres visibles.

Erreur n°4 : comparer les tailles des textes “a l’oeil” et en deduire l’encodage.
Il arrive qu’un developpeur voie qu’un fichier est « plus gros que prevu » et decide : « donc il y a des dechets ». En realite, « plus gros » peut simplement signifier que le fichier est dans un autre encodage (par exemple UTF‑16) ou qu’il contient beaucoup de caracteres non ASCII. La taille est un indice, pas une preuve.

Erreur n°5 : essayer de “reparer les caracteres illisibles” en remplaçant des symboles au lieu de corriger l’encodage.
Des remplacements comme text.replace("�", "") ressemblent a un correctif rapide, mais c’est souvent une tentative d’effacer les consequences au lieu de traiter la cause. La correction se trouve presque toujours a la frontiere « bytes ↔ string » : il faut decoder les octets d’origine avec le bon encodage, et non rafistoler un texte deja casse.

1
Mission
Kotlin SELF, niveau 44, leçon 1
Bloqué
Vérification de l'envoi
Vérification de l'envoi
1
Mission
Kotlin SELF, niveau 44, leçon 1
Bloqué
Corruption silencieuse
Corruption silencieuse
1
Mission
Kotlin SELF, niveau 44, leçon 1
Bloqué
Trois mesures
Trois mesures
1
Mission
Kotlin SELF, niveau 44, leçon 1
Bloqué
Deux dumps hexadécimaux
Deux dumps hexadécimaux
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION