Retour au cours

backend / python

Sérialisation binaire avancée : struct et memoryview

Leçon 371 exercice

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 structTypeTaille
Bentier non signé1 octet
Hentier non signé2 octets
Ientier non signé4 octets
fflottant simple précision4 octets
dflottant double précision8 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.

python
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, hauteur

Résumé

  • struct.pack/unpack convertissent entre valeurs Python et représentation binaire exacte (endianness, taille fixe).
  • memoryview accède à un buffer sans le copier : le slicing d'un memoryview est en O(1) contre O(n) pour bytes.
  • memoryview.cast() réinterprète le même buffer sous un autre type numérique, toujours sans copie.
  • array.array est bien plus compact qu'une list pour des données numériques homogènes, avec E/S binaires natives.

Exercices pratiques

1 disponible
1

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.

Résoudre l’exercice →