backend / python
Sérialisation binaire avancée : struct et memoryview
Explication
Ce que vous allez apprendre
- Comprendre quand un format binaire devient nécessaire face aux limites de JSON
- Convertir entre valeurs Python et octets bruts avec
struct.pack/struct.unpack - Éviter le piège de l'ordre des octets (little-endian vs big-endian) entre systèmes différents
- Manipuler de gros volumes de données binaires sans copie avec
memoryview - Relier la représentation binaire au profiling mémoire vu à la leçon précédente
Dans quel contexte ?
Une application IoT reçoit des milliers de paquets par seconde depuis des capteurs embarqués, chacun encodé dans un format binaire compact (version, type, longueur, valeurs) pour économiser la bande passante d'un réseau à faible débit. Parser ce flux avec du JSON serait bien trop lourd, et copier chaque paquet en mémoire à chaque étape de traitement créerait une pression mémoire inutile sur un volume aussi élevé. Cette leçon montre les outils struct et memoryview adaptés à ce genre de contrainte.
Au-delà de JSON : quand le format binaire devient nécessaire
JSON est pratique et lisible, mais il a un coût : chaque nombre est du texte à parser, chaque virgule et accolade occupe de la place. Pour un protocole réseau performant, un format de fichier compact, ou l'interopérabilité avec du code C/C++, on manipule directement des octets bruts organisés selon une structure binaire précise — exactement comme le font les protocoles réseau bas niveau ou les formats de fichiers scientifiques.
struct : le pont entre types Python et octets
Le module struct traduit entre valeurs Python (entiers, flottants, chaînes) et leur représentation en octets, selon un format que vous décrivez avec de petits codes (I pour un entier non signé sur 4 octets, f pour un flottant simple précision, etc.). C'est l'inverse exact l'un de l'autre : pack transforme des valeurs Python en octets, unpack fait le chemin inverse à partir d'octets reçus.
Code struct | Type | Taille |
|---|---|---|
B | entier non signé | 1 octet |
H | entier non signé | 2 octets |
I | entier non signé | 4 octets |
f | flottant simple précision | 4 octets |
d | flottant double précision | 8 octets |
L'ordre des octets, un détail qui casse tout si on l'oublie
Un même nombre peut être stocké octet par octet dans deux ordres différents selon le matériel (little-endian ou big-endian). Un format comme ! (network byte order, big-endian) impose un ordre fixe et universel — indispensable dès que les octets peuvent être échangés entre systèmes différents, par exemple sur un réseau.
Piège fréquent
Empaqueter des données avec struct.pack("I", ...) (sans préfixe d'ordre) utilise l'ordre natif de la machine, qui peut différer entre l'émetteur et le récepteur d'un protocole réseau. Utilisez toujours ! (network byte order) pour tout format binaire destiné à être échangé entre systèmes potentiellement différents.
memoryview : manipuler sans copier
Sur de gros volumes de données binaires, copier des octets à chaque opération (slicing, passage à une fonction) coûte cher en mémoire et en temps. memoryview permet de manipuler une "fenêtre" sur les octets existants sans les dupliquer — un gain de performance important quand on traite des buffers volumineux (fichiers, flux réseau).
Le fil conducteur avec les leçons précédentes
Cette leçon complète naturellement le profiling mémoire (35) : comprendre la représentation binaire aide à raisonner sur la taille réelle des données en mémoire, et memoryview est un outil direct pour réduire la pression mémoire identifiée par tracemalloc.
Commandes & code
Sérialisation binaire avancée
Manipuler des octets bruts sans copie inutile : formats réseau, fichiers binaires, protocoles custom.
import struct
import array
# --- struct.pack / unpack : convertir entre types Python et representation binaire ---
# Format : '<' little-endian, '>' big-endian, '!' network (big-endian standard)
# I=uint32, i=int32, H=uint16, h=int16, B=uint8, f=float32, d=float64, s=chaine
# Empaqueter un en-tete de paquet reseau custom : version(1o) + type(1o) + longueur(4o)
en_tete = struct.pack("!BBI", 1, 42, 1024)
print(en_tete) # b'\x01\x2a\x00\x00\x04\x00'
print(len(en_tete)) # 6 octets exactement
version, type_paquet, longueur = struct.unpack("!BBI", en_tete)
print(version, type_paquet, longueur)
# calcsize : connaitre la taille exacte d'un format AVANT de le lire
print(struct.calcsize("!BBI")) # 6
# --- Formats composes : plusieurs valeurs, y compris des chaines de taille fixe ---
FORMAT_ENREGISTREMENT = "!I16sfB" # id, nom(16 octets), score, actif
enregistrement = struct.pack(
FORMAT_ENREGISTREMENT, 1001, b"Alice".ljust(16, b"\x00"), 98.5, 1
)
id_, nom_brut, score, actif = struct.unpack(FORMAT_ENREGISTREMENT, enregistrement)
nom = nom_brut.rstrip(b"\x00").decode("utf-8")
print(id_, nom, score, bool(actif))
# --- Lire un fichier binaire structure (ex : format proprietaire simplifie) ---
def lire_enregistrements(chemin, format_struct):
taille = struct.calcsize(format_struct)
enregistrements = []
with open(chemin, "rb") as f:
while chunk := f.read(taille):
if len(chunk) < taille:
break # fichier tronque : dernier enregistrement incomplet ignore
enregistrements.append(struct.unpack(format_struct, chunk))
return enregistrements
# --- struct.iter_unpack : lire en flux un buffer contenant N enregistrements identiques ---
buffer_multiple = enregistrement * 3 # simule 3 enregistrements concatenes
for tuple_valeurs in struct.iter_unpack(FORMAT_ENREGISTREMENT, buffer_multiple):
print(tuple_valeurs)
# --- memoryview : acceder a un buffer SANS EN COPIER LE CONTENU ---
gros_buffer = bytearray(1_000_000)
vue = memoryview(gros_buffer)
# Slicing d'un memoryview : ZERO copie, contrairement au slicing d'un bytes/bytearray classique
morceau = vue[100:200] # reference dans le meme buffer, pas de nouvelle allocation
morceau[0] = 255 # modifie DIRECTEMENT gros_buffer, car memoryview est une vue
print(gros_buffer[100]) # 255
# Demonstration de l'economie memoire : slicing bytes vs memoryview
import sys
gros_bytes = bytes(1_000_000)
copie_bytes = gros_bytes[1000:2000] # COPIE 1000 octets, nouvelle allocation
vue_sans_copie = memoryview(gros_bytes)[1000:2000] # AUCUNE copie, juste des offsets
# --- cast() : reinterpreter le meme buffer sous un autre type, toujours sans copie ---
buffer_octets = bytearray(struct.pack("<4f", 1.0, 2.0, 3.0, 4.0))
vue_flottants = memoryview(buffer_octets).cast("f") # vue sur 4 float32
print(list(vue_flottants)) # [1.0, 2.0, 3.0, 4.0]
vue_flottants[0] = 99.0
print(struct.unpack("<f", buffer_octets[0:4])) # (99.0,) -- le buffer original est modifie
# --- array : tableau homogene compact, plus efficace qu'une liste pour du numerique pur ---
tableau_entiers = array.array("i", [1, 2, 3, 4, 5]) # 'i' = int signe 32 bits
print(tableau_entiers.itemsize, len(tableau_entiers)) # 4 octets par element
print(sys.getsizeof(tableau_entiers) < sys.getsizeof([1, 2, 3, 4, 5])) # True, plus compact
# Ecrire/lire un array directement en binaire (tres rapide, pas de parsing element par element)
with open("donnees.bin", "wb") as f:
tableau_entiers.tofile(f)
tableau_relu = array.array("i")
with open("donnees.bin", "rb") as f:
tableau_relu.fromfile(f, 5) # lit exactement 5 elements
print(tableau_relu)
# --- Protocole buffer : n'importe quel objet exposant memoryview peut etre lu sans copie ---
def calculer_checksum(donnees) -> int:
# fonctionne indifferemment avec bytes, bytearray, memoryview, array.array...
vue = memoryview(donnees)
return sum(vue) % 256
print(calculer_checksum(b"hello"))
print(calculer_checksum(bytearray(b"hello")))
# --- Cas d'usage reel : parser un en-tete PNG (magic bytes + IHDR) ---
def lire_dimensions_png(chemin_fichier) -> tuple[int, int]:
with open(chemin_fichier, "rb") as f:
signature = f.read(8)
if signature != b"\x89PNG\r\n\x1a\n":
raise ValueError("Pas un fichier PNG valide")
f.read(4) # longueur du chunk IHDR (toujours 13)
f.read(4) # type du chunk : b'IHDR'
largeur, hauteur = struct.unpack("!II", f.read(8))
return largeur, hauteurRésumé
struct.pack/unpackconvertissent entre valeurs Python et représentation binaire exacte (endianness, taille fixe).memoryviewaccède à un buffer sans le copier : le slicing d'unmemoryviewest en O(1) contre O(n) pourbytes.memoryview.cast()réinterprète le même buffer sous un autre type numérique, toujours sans copie.array.arrayest bien plus compact qu'unelistpour des données numériques homogènes, avec E/S binaires natives.
Exercices pratiques
Mission : sauver une passerelle IoT saturée par le parsing JSON
Objectif : Remplacer un parsing JSON coûteux par un format binaire compact avec struct, et éliminer une copie mémoire inutile avec memoryview.
Contexte
Une passerelle IoT reçoit des milliers de paquets binaires par seconde depuis des capteurs embarqués, chacun encodé comme version(1 octet) + type(1 octet) + longueur(4 octets). Un développeur avait initialement prévu de convertir chaque paquet en JSON avant traitement, mais le CPU sature immédiatement sous la charge réelle du réseau.
Tu dois décoder cet en-tête binaire directement avec struct, choisir le bon ordre d'octets pour un protocole réseau, puis éviter une copie mémoire inutile lors du traitement d'un gros buffer de paquets concaténés.