CodeGym /Cours /Swift SELF /String ↔ Data via UTF‑8 et erreurs de conversion

String ↔ Data via UTF‑8 et erreurs de conversion

Swift SELF
Niveau 57, Leçon 1
Disponible

1. Parfois, il faut convertir String en Data et inversement

Quand on ecrit des programmes, il est tres facile de tomber dans l'illusion suivante : « une chaine, c'est du texte, donc elle est stockee comme du texte ». Mais la realite est plus terne et plus pragmatique : sur le disque, sur le reseau, dans la memoire d'un fichier, il y a presque toujours des octets. Et le « texte » n'apparait que lorsque nous avons convenu de la facon exacte d'interpretation de ces octets.

On peut se le representer ainsi : Data est une boite de pieces LEGO, et String est un modele assemble. A partir de la boite, on peut construire un bateau, un chateau ou meme quelque chose de spatial... mais seulement si l'on a la notice. En programmation, la notice s'appelle codage (encoding).

Dans notre cours, nous fixons une regle simple : quand nous parlons de « texte », nous entendons presque toujours UTF‑8. C'est le standard moderne de facto : pour les fichiers, les API reseau, les journaux et le code source. En outre, en Swift, a partir de la version 5, le modele de chaine est fortement centre sur un travail efficace avec UTF‑8.

Codage et UTF‑8

Le codage est une convention : « quels octets representent quels caracteres ». Et ici, il est important de ne pas confondre deux « comptages » differents.

String.count compte les caracteres (plus precisement, les Character, c'est-a-dire les « graphes visibles pour l'utilisateur »). Alors que Data.count compte les octets. Ce sont des mesures differentes. Un seul caractere peut occuper 1 octet, 2 octets, 3 octets, 4 octets... et parfois cela casse les attentes des debutants, un peu comme la premiere consultation de sa facture de mobile en roaming.

UTF‑8 est pratique car, pour les caracteres ASCII (lettres latines, chiffres, signes de base), il est economique : le plus souvent 1 octet par caractere. Et pour les caracteres Unicode plus « riches » (cyrillique, kana japonais, emojis), davantage d'octets sont necessaires. C'est normal et attendu ; il faut simplement cesser de penser « caractere = octet ».

Il faut garder a l'esprit que meme des « chaines qui se ressemblent » peuvent differer par leur taille et leur structure au niveau des octets, car UTF‑8 est un encodage de longueur variable.

2. String → Data : encoder le texte en octets

Quand nous voulons transformer une chaine en octets (par exemple pour l'ecrire dans un fichier comme donnees binaires, l'envoyer sur le reseau ou simplement la stocker dans un tampon Data), nous faisons de l'encodage :

import Foundation

let text = "Hello"
let data = text.data(using: .utf8)

print(data as Any)         // Optional(5 bytes)
print(data?.count as Any)  // Optional(5)

Apres cette premiere joie vient souvent la question : « pourquoi data est-il de type Data?, et pas simplement Data ? »

La reponse est tres terre-a-terre : parce que c'est un contrat d'API. La methode data(using:) renvoie un Optional, parce qu'en theorie le codage peut echouer. Dans la vraie vie, avec .utf8 pour une chaine Swift ordinaire, cela reussit presque toujours, mais Swift nous habitue a ceci : « ne fais pas semblant que les erreurs sont impossibles ».

Et c'est une bonne habitude : quand le travail avec des sources externes commencera (fichiers, reseau, donnees tierces), le « presque toujours » se transformera soudain en « de temps en temps, mais toujours un vendredi soir ».

Style sur : if let au lieu de !

Dans les exercices pedagogiques, on a envie d'ecrire text.data(using: .utf8)! et d'oublier le reste. Mais c'est exactement le cas ou Swift dit : « tu as signe un contrat indiquant que nil n'existera pas — et s'il existe, je vais te faire tomber ».

Faisons-le correctement : extrayons Data avec soin.

import Foundation

let title = "Le Maitre et Marguerite"

if let bytes = title.data(using: .utf8) {
    print(bytes.count) // par exemple, 31 (cela depend des caracteres)
    print(Array(bytes.prefix(8)))
} else {
    print("Impossible d'encoder la chaine en UTF-8")
}

Ici, il est utile de saisir l'idee suivante : meme si vous vous attendez a ce que nil n'apparaisse pas, vous ecrivez quand meme une branche else. C'est comme attacher sa ceinture en voiture : on ne planifie pas un accident, on planifie sa vie.

Defaut rapide via ?? et pourquoi il est dangereux

Parfois, il n'est pas important de distinguer « echec » et « succes vide », et l'on veut une valeur par defaut :

import Foundation

let input = "OK"
let bytes = input.data(using: .utf8) ?? Data()

print(bytes.count) // 2

Mais voici un detail important : Data() correspond a des octets vides. Si vous utilisez cette valeur par defaut lors de l'ecriture d'un fichier, vous risquez d'enregistrer accidentellement un resultat vide sans meme vous en rendre compte. C'est pourquoi ?? avec Data() convient pour le « code de demo » et les petits utilitaires rapides, mais pour le stockage de donnees il vaut souvent mieux traiter explicitement nil et afficher une erreur.

4. Data → String : decoder des octets en texte

Passons a l'operation inverse : nous avons des octets et nous voulons obtenir une chaine. Par exemple, nous avons lu un fichier comme Data, puis nous voulons afficher une partie de son contenu en texte.

Le decodage ressemble a ceci :

import Foundation

let bytes = Data([72, 105, 33]) // "Hi!"
let text = String(data: bytes, encoding: .utf8)

print(text as Any) // Optional("Hi!")

Et encore un Optional. Mais ici, Optional n'est plus « au cas ou », mais parce que la situation peut reellement etre mauvaise : tous les octets ne sont pas un UTF‑8 correct.

Exemple d'octets « casses »

Faisons un petit exemple qui montre un cas typique : les octets ne constituent pas un UTF‑8 valide, et la chaine ne peut pas etre obtenue.

import Foundation

let invalid = Data([255]) // 0xFF
let text = String(data: invalid, encoding: .utf8)

print(text ?? "<invalid utf8>") // <invalid utf8>

L'idee centrale : Data n'est pas tenue d'etre du texte. Data, ce ne sont que des octets. Si vous essayez d'interpreter n'importe quelle donnee binaire comme de l'UTF‑8, vous obtiendrez nil, et c'est le comportement correct.

Dans l'ecosysteme Swift, l'idee du decodage « validant » est tres importante : si la sequence d'octets ne correspond pas au codage attendu, il vaut mieux refuser de construire une chaine plutot que de « reparer » silencieusement les octets et d'obtenir du bruit.

5. Ou la conversion casse

On pourrait croire que le « UTF‑8 casse » est quelque chose d'exotique. Mais en pratique, les problemes ne viennent pas seulement d'un « hacker malveillant », mais de choses tout a fait quotidiennes : mauvais codage de fichier, morceau de donnees tronque, lecture du mauvais fichier, melange de formats texte et binaire, ou simplement erreur dans la logique « prenons les N premiers octets ».

Vous avez coupe une chaine par octets — vous avez casse l'UTF‑8

UTF‑8 est un codage de longueur variable. Cela signifie qu'un caractere peut occuper plusieurs octets. Si vous prenez Data.prefix(1) ou prefix(2) d'une chaine dont le premier caractere occupe 2 octets, vous pouvez couper « la moitie d'un caractere », et au decodage vous obtiendrez nil.

Montrons-le sur un exemple avec du cyrillique (souvent 2 octets par lettre en UTF‑8) :

import Foundation

let text = "Ya" // en general 2 octets en UTF-8
let data = text.data(using: .utf8) ?? Data()

let cut = data.prefix(1) // on a coupe la moitie de la sequence UTF-8
let decoded = String(data: cut, encoding: .utf8)

print(Array(data))           // par exemple, [208, 175]
print(decoded ?? "<broken>") // <broken>

C'est une erreur logique tres frequente : une personne voit Data, se dit « bon, c'est presque un tableau d'octets », le coupe comme elle veut — puis s'etonne que le texte ne sorte pas. En realite, tout est honnete : vous avez essaye de construire un caractere avec la moitie des pieces.

6. String.count vs Data.count

Beaucoup de bugs dans les applications ne viennent pas d'« algorithmes complexes », mais d'une mauvaise metrique. Si, quelque part, vous limitez la longueur de saisie en comptant les « caracteres », alors qu'en realite le stockage ou le protocole impose une limite en « octets », vous obtiendrez des effets etranges sur du texte non ASCII.

Comparons sur des exemples simples :

import Foundation

let a = "Hello"
let b = "Privet"

print(a.count)                     // 5
print(a.data(using: .utf8)!.count) // 5

print(b.count)                     // 6
print(b.data(using: .utf8)!.count) // 12 (souvent 2 octets par lettre)

Notez que ! est utilise ici, mais seulement parce que l'exemple est pedagogique et que nous sommes certains que .utf8 va fonctionner. Dans du vrai code pour des donnees externes, on ne doit pas faire cela.

Pour une memorisation visuelle, on peut retenir un tableau simple :

Chaine String.count (caracteres) Octets UTF‑8 (Data.count)
"A"
1 1
"Ya"
1 2
"🙂"
1 4

Oui, les emojis sont souvent plus « chers » en octets. Un sourire dans le texte est gratuit, mais en memoire, c'est presque comme un mini-Int.

7. Mini-utilitaires CLI : conversion stricte

Il ne suffit pas de comprendre l'API ; il faut aussi l'integrer dans le style habituel de notre application d'apprentissage (CLI). Jusqu'a present, nous avons deja ecrit des fichiers texte via String.write(...). Parfois, cela suffit. Mais il existe des situations ou il faut travailler directement avec des octets, et ou le texte n'est qu'une partie des donnees.

Imaginons que notre LibraryCLI ecrive un fichier diagnostique « brut » : chaque ligne de journal est constituee d'octets UTF‑8 termines par un retour a la ligne \n. Nous voulons :

  1. convertir la chaine du message en Data
  2. ajouter l'octet \n
  3. ecrire/ajouter cela dans un fichier

Helper encodeUTF8Line(_:)

Pour ne pas repeter if let ... else ... a chaque fois, il est pratique de creer un petit helper :

import Foundation

func encodeUTF8Line(_ text: String) -> Data? {
    guard var data = text.data(using: .utf8) else { return nil }
    data.append(10) // '\n' en ASCII/UTF-8
    return data
}

Ici, il y a tout de suite deux idees. D'abord, nous renvoyons un Data?, parce que la conversion peut echouer (contrat). Ensuite, nous ajoutons l'octet de retour a la ligne sous forme du nombre 10.

Utilisation de l'helper

import Foundation

let message = "Ajoute le livre : Dune"

if let line = encodeUTF8Line(message) {
    print(Array(line.suffix(3))) // par exemple, [110, 101, 10] -> "ne\n"
} else {
    print("Impossible d'encoder le message du journal en UTF-8")
}

Oui, ce n'est pas une ecriture dans un fichier — mais au niveau de cette lecon, ce qui compte le plus, c'est la conversion « propre » et la comprehension de Optional. Ecrire Data dans un fichier et lire Data depuis un fichier, c'est l'etape suivante, celle ou ces octets vont vraiment etre envoyes sur le disque.

Decodage « souple » pour la diagnostic

Quand nous diagnostiquons un fichier ou un morceau de donnees, il faut souvent « essayer » de decoder. Si cela fonctionne, on affiche le texte a la personne. Sinon, on dit honnetement « ce n'est pas du texte ».

import Foundation

func decodeUTF8(_ data: Data) -> String {
    return String(data: data, encoding: .utf8) ?? "<not utf8 text>"
}

Utilisation :

import Foundation

print(decodeUTF8(Data([72, 105, 33]))) // Hi!
print(decodeUTF8(Data([255])))         // <not utf8 text>

C'est un bon patron pour la sortie de diagnostic, mais un mauvais pour la logique metier, ou il est important de distinguer les vraies erreurs. Dans un vrai traitement de donnees, il vaut mieux renvoyer un Optional ou une erreur, et non pas une « chaine de remplacement », car cette derniere peut s'echapper plus loin dans le programme et devenir, de maniere inattendue, « le vrai nom du livre ».

8. Schema du flux de donnees et Optional

Il est utile de tout reunir dans un seul schema pour que le cerveau cesse de percevoir Optional comme « Swift se moque de moi » et commence a le voir comme un contrat honnete : « l'operation peut echouer ».

flowchart LR
    A[String] -->|"encode: data(using: .utf8)"| B[Data?]
    B -->|write/read bytes| C[Data]
    C -->|"decode: String(data:encoding: .utf8)"| D[String?]

Le principe est simple : a la frontiere « texte ↔ octets », nous devons soit traiter nil, soit signer explicitement le contrat ! (ce qui est presque toujours une mauvaise idee pour des donnees externes).

9. Erreurs typiques lors de String ↔ Data et UTF‑8

Erreur n°1 : utiliser ! lors du decodage de String(data:encoding:)!.
Cela ressemble a : « bon, je sais bien que c'est du texte », mais la realite prouve souvent le contraire : le fichier peut etre vide, binaire, tronque ou dans un autre codage. Au final, le programme plante au moment le plus innocent — lors de la tentative d'affichage d'un message a l'utilisateur. Il est beaucoup plus sur de faire if let/guard let et d'afficher un diagnostic clair.

Erreur n°2 : supposer que Data.count et String.count sont la meme chose.
Les debutants definissent souvent des limites du genre « titre de livre jusqu'a 20 caracteres », puis se heurtent de maniere inattendue a des problemes avec le cyrillique ou les emojis, parce que la vraie limite du stockage ou du protocole est en octets. Si la limite est en octets, il faut verifier data(using: .utf8)?.count, et non text.count.

Erreur n°3 : couper Data par octets et s'attendre a un UTF‑8 toujours valide.
Le decoupage avec prefix(N) parait anodin, mais l'UTF‑8 est de longueur variable : on peut couper « la moitie d'un caractere », et le decodage renverra nil. Pour le diagnostic, c'est normal (vous verrez "<broken>"), mais pour une logique du type « lire les N premiers octets comme une chaine », c'est une source de bugs etranges.

Erreur n°4 : essayer d'interpreter n'importe quel fichier comme du texte.
Apres le theme des fichiers texte, on a envie de tout lire comme String, mais des qu'on rencontre des formats binaires, des images, des archives ou simplement « le mauvais fichier », le decodage se transforme en nil. La bonne habitude : d'abord decider si, sur le plan du sens, on a du texte ou des octets. Si ce sont des octets, on travaille avec Data, et on ne fabrique une chaine que la ou c'est vraiment du texte UTF‑8.

Erreur n°5 : utiliser ?? Data() comme « bouee de sauvetage universelle » lors de l'encodage.
Un Data() vide, ce n'est pas « sur », c'est « nous avons perdu des donnees en silence ». Pour le diagnostic, un tel defaut peut parfois convenir, mais pour ecrire dans des fichiers/stockages, il vaut mieux traiter explicitement nil et ne pas poursuivre l'operation, sinon vous creez un resultat vide ou endommage, puis vous chercherez longtemps qui a vole vos donnees (spoiler : c'etait votre ?? Data()).

1
Mission
Swift SELF, niveau 57, leçon 1
Bloqué
Octets du message
Octets du message
1
Mission
Swift SELF, niveau 57, leçon 1
Bloqué
Reconstruction du texte
Reconstruction du texte
1
Mission
Swift SELF, niveau 57, leçon 1
Bloqué
Caractères et octets
Caractères et octets
1
Mission
Swift SELF, niveau 57, leçon 1
Bloqué
Texte tronqué
Texte tronqué
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION