Les structures

Dans la plupart des programmes non triviaux, les données manipulées ne sont pas des scalaires isolés mais des agrégats de valeurs sémantiquement liées. Vous avez certainement vue une notion similire en Java : Les Classes qui sont à l’origine, donc, des structures. La notion est senssiblement identique sur la sémantique, la seul différence notable est qu’en C il n’y a pas de comportement associé a ces données. On ne peu pas mettre de méthode dans le corp de la structure, donc liéer données et comportements.

Prenons l’exemple d’un répertoire d’utilisateurs : chaque ficher contient un nom, un prénom, une adresse et un numéro de téléphone. Manipuler quatre variables séparées pour chaque utilisateur serait non seulement fastidieux mais aussi source d’erreurs, car rien dans le code ne traduirait le lien logique entre ces données.

Le langage C résout ce problème par le mécanisme des structures : un moyen de regrouper des variables de types potentiellement différents sous un seul identificateur, créant de facto un nouveau type composite.

Il est essentiel de comprendre qu’une définition de structure ne réserve aucune mémoire. Elle se contente de décrire un gabarit : un plan qui spécifie combien de mémoire sera nécessaire et comment les octets seront organisés. C’est exactement comme le type int : on ne manipule pas le type lui-même, on déclare des variables de ce type. La réservation mémoire n’intervient qu’à la déclaration d’une variable ayant ce type structure.

struct complexe {
  double reel; // 8
  double imag; // 8
};

struct complexe x, y;  // deux variables, local ou global, chacune occupant 16 octets (2 × 8)

Dans cet exemple, on définit un type struct complexe contenant deux champs double, puis on déclare deux variables x et y de ce type. Chaque variable occupe un bloc de mémoire contigu suffisant pour stocker ses deux membres.

Accès aux membres et initialisation

On accède à un champ (ou membre) d’une variable de type structure via l’opérateur point (.). Cet opérateur effectue un calcul d’offset à la compilation : le compilateur connaît la position en octets de chaque membre par rapport au début de la structure et génère un accès mémoire à l’adresse base + offset. Notez la macro offsetof qui permet de connaitre explicitement les offsets générés. Notez églement le keyword sizeof(type) qui permet de connitre la taille total de la structure (et dans certains cas de la variable). Ici sizeof(struct complexe) = 16.

x.reel = 3.14; // base = 0x???? + offset = 0
x.imag = 2.71; // base = 0x???? + offset = 8

L’initialisation peut être réalisée au moment de la déclaration, de manière analogue à celle d’un tableau :

struct complexe x = {10, 5};  // reel = 10.0, imag = 5.0

Les valeurs sont affectées dans l’ordre de déclaration des membres. C99 introduit également les designated initializers ({.reel = 10, .imag = 5}) pour plus de clarté, mais la forme positionnelle reste la plus courante.

Opérations sur les structures

Le standard C autorise trois opérations sur une variable de type structure :

  • L’affectation globale : le compilateur génère une copie membre à membre (en réalité, souvent un memcpy optimisé sur la taille totale de la structure).
  • La récupération d’adresse via l’opérateur &, ce qui est la porte d’entrée vers les pointeurs de structures.
  • L’accès aux membres via ..

L’affectation mérite un commentaire approfondi. Contrairement à ce que l’on pourrait croire, le C ne se contente pas de copier les membres « logiquement » : il copie l’intégralité du bloc mémoire, y compris les octets de padding (cf. section ??). C’est une copie bit à bit, pas une copie sémantique.

struct complexe x = {1, 2}, z;
z = x;
// Le compilateur génère l'équivalent de :
// memcpy(&z, &x, sizeof(struct complexe));

Structures et fonctions

Une fonction peut recevoir et retourner une structure par valeur. Cela signifie que toute la structure est copiée sur la pile lors de l’appel. Pour des structures de petite taille (comme un nombre complexe), cette copie est négligeable. Mais pour des structures volumineuses, elle peut engendrer un coût temporel et spatial significatif, et l’on préfèrera alors le passage par pointeur (Dans ce cas la uniquement l’adresse mémoire est copié, c’est à dire 4 ou 8 octets).

struct complexe conjugue(struct complexe x) { // première copie
  struct complexe y; // nouvelle espace mémoire
  y.reel = x.reel;
  y.imag = -x.imag;
  return y; // seconde copie
}

struct complexe x = {3, 4}, z;
z = conjugue(x);  // x est copié dans le paramètre, y est copié dans z

Cette sémantique de copie implique que la fonction travaille sur une copie locale de l’argument. Modifier x dans la fonction n’affecte pas l’original : exactement comme pour un int. Ce comportement, bien que sûr, est l’une des raisons pour lesquelles les structures sont souvent manipulées par pointeur dans les programmes C idiomatiques. Grace aux pointeur, l’information initial peut être modifier et non plus uniquement la copie locale.

En réalité vous faite la même chose en Java. Tous les Object sont passé par référence. Vous utilisé l’opérateur . pour accédé a un membre. En C il y a deux sémantique : . quand c’est une variable locale ou une lvalue et l’opérateur -> quand c’est un pointeur. Nous reviendrons dessus plus tard.

Types synonymes : typedef

Le mot-clé typedef ne crée pas un nouveau type au sens du système de types du compilateur. Il crée un alias, un synonyme pour un type existant. Le compilateur traite le nouveau nom exactement comme le type original : aucune vérification supplémentaire, aucune distinction au niveau de la sémantique.

typedef int longueur;
longueur m = 0;  // strictement identique à : int m;
int t = m; // Aucun soucis, même type, le compilateur ne distingue pas de différence

Attention : Un typedef ne crée pas de barrière de type. longueur et int sont interchangeables partout, y compris dans les conversions implicites. Ce n’est pas un mécanisme de typage fort.

L’intérêt est avant tout la lisibilité et la maintenabilité. Un typedef bien choisi documente l’intention du programmeur : longueur exprime une sémantique que int seul ne porte pas. Mais certaines bonne partique de programmation peuvent aussi déléguer la charge de la sémantique au nom de la variable …

L’application la plus courante concerne les structures. Sans typedef, on est obligé d’écrire struct complexe partout. Avec un alias, le code devient plus léger :

typedef struct complexe comp; // Un type struct 
typedef struct complexe *p_comp; // Un type pointeur vers un type struct complexe
typedef comp *p_comp; // Identique à la ligne du dessus

comp x = {1, 2};       // au lieu de : struct complexe x = {1, 2};
p_comp ptr = &x;        // au lieu de : struct complexe *ptr = &x;

Ce code est a titre illustratif, typedef struct complexe *p_comp; peut être considéré comme une mauvais pratique. Car cela masque visuelement que l’on manipule un pointeur. La sémantique p_ pour dire c’est un pointeur n’est pas nécéssairement plus lisible.

On peut combiner typedef et la définition de la structure en une seule déclaration. C’est la forme idiomatique la plus répandue dans les codes professionnels :

typedef struct {
  double reel;
  double imag;
} comp;

Pointeurs typés

Pour comprendre les pointeurs, il faut d’abord comprendre comment la mémoire fonctionne au niveau matériel. Lorsqu’un programme s’exécute, il est chargé en mémoire vive (RAM). Cette mémoire est conceptuellement un immense tableau d’octets, chaque octet étant identifié par un numéro unique appelé adresse.

Quand on déclare une variable, le compilateur réserve un certain nombre d’octets consécutifs en mémoire et associe le nom de la variable à l’adresse du premier de ces octets. La taille du bloc dépend du type : un char occupe 1 octet, un int typiquement 4 octets, un double 8 octets. Le nombre d’octet peut varié en fonction de la plateforme (88, 16, 32, 64 bit voir éxotique).

On peut accéder à cette variable de deux façons :

  • Directement, par son nom (x)
  • Indirectement, par l’adresse de son premier octet, stockée dans une autre variable : c’est le pointeur

Un pointeur est donc une variable d’un type spécial qui contient l’adresse d’une autre variable. Mais un pointeur n’est pas qu’un simple entier contenant une adresse : il est typé, c’est-à-dire qu’il sait quel type de données se trouve à l’adresse qu’il contient. Cette connaissance est porté par le language, après compilation la case mémoire ne contient que l’adresse. Cette information est cruciale car elle détermine combien d’octets constituent la variable pointée et comment interpréter ces octets.

Les deux règles fondamentales

Toute la mécanique des pointeurs repose sur deux opérateurs et deux règles symétriques :

  • Règle 1 : L’opérateur & (référencement) : Appliqué à une variable de type T, il produit une valeur de type T* (pointeur vers T). Il ajoute une étoile. Cela ajoute une indirection.
  • Règle 2 : L’opérateur * (déréférencement) : Appliqué à une variable de type T*, il produit une valeur de type T. Il retire une étoile. Cela permet de “supprimer” une indirection.

Ces deux règles se composent naturellement. Si x est un int, alors &x est un int*. Si ptr est un int*, alors &ptr est un int** (pointeur de pointeur). Et inversement, *ptr est un int, **pptr est un int (en traversant deux niveaux d’indirection) :

int x = 5;
int *ptr = &x;       // &x : int → int*        (règle 1)
int **pptr = &ptr;    // &ptr : int* → int**    (règle 1)

printf("%d\n", *ptr);    // *ptr : int* → int = 5       (règle 2)
printf("%d\n", **pptr);  // **pptr : int** → int* → int = 5 (règle 2 × 2)

Ce cadre conceptuel peut paraître abstrait, mais il permet de raisonner mécaniquement sur n’importe quelle expression impliquant des pointeurs. Dès que l’on doute, il suffit de compter les étoiles. Cela dis, la confusion majeure chez les étudiants porte généralement sur la sémantique, entre définition de pointeur et opérateur de déréférencement : int* est utilisé lors de la déclaration d’un pointeur, tandis que *variable sert à accéder (déréférencer) la valeur pointée par ce pointeur.

Pour illustré les propos précédents : Par exemple int *a; nous dis que a est un pointeur vers 4 octets et que ces derniers doivent être considéré comme un entier. MAIS nous pouvons aussi considérer le code suivant :

int i = 5; // définie 4 octets mémoires 0x??????0 - 0x???????4
int *p_i = &ai; // stocke l'adresse des 4 octets précédents. Contient 0x???????0
char *p_c = (char*)p_i; // considère l'espace précédent comme un char
*p_c; // Manipule uniquement 0x???????0

Déclaration et initialisation

La syntaxe de déclaration d’un pointeur est :

type *nom_du_pointeur;

Le symbole * dans la déclaration n’est pas l’opérateur de déréférencement. C’est un marqueur syntaxique qui indique au compilateur que la variable est un pointeur. Cette ambiguïté de notation est l’une des sources de confusion les plus fréquentes chez les débutants.

Un point critique : la déclaration d’un pointeur réserve de la mémoire uniquement pour le pointeur lui-même (typiquement 8 octets sur une architecture 64 bits), pas pour l’objet pointé ni de meta donnée décrivant les types. Au moment de la déclaration, le pointeur contient une valeur résiduelle : c’est-à-dire ce que contenait la case mémoire avant. Il pointe potentiellement vers n’importe quelle zone de la mémoire, y compris des zones appartenant au système d’exploitation. Un pointeur non initialisé est un danger.

Il est donc impératif d’initialiser tout pointeur avant utilisation. Deux options s’offrent au programmeur :

int a = 42;
int *p1 = &a;      // initialisation avec l'adresse d'une variable existante
int *p2 = NULL;     // initialisation à NULL (convention : ne pointe sur rien)

La macro NULL est définie comme ((void *)0) dans <stdlib.h>. Par convention, elle indique qu’un pointeur est invalide de manière intentionnelle. Tester if (ptr != NULL) avant un déréférencement est une pratique défensive fondamentale.

Déréférencement : accéder à la valeur pointée

L’opérateur * appliqué à un pointeur initialisé permet de lire ou de modifier la valeur stockée à l’adresse contenue dans le pointeur. Ce mécanisme est appelé indirection ou déréférencement :

int x = 5; // 0x???????0 - 0x???????5, 4 cases et chaque case contiendra par exemple [0, 0, 0, 5] (ou [5, 0, 0, 0] en fonction de l'endiant)
int *ptr = &x; // adresse de x, c'est a dire 8 cases mémoire contenent [??, ??, ??, ?0]

printf("%d\n", *ptr);  // lecture : affiche 5
*ptr = 10;             // écriture : modifie x, dit autrement modifie la valeur a l'adresse contenue dans ptr
printf("%d\n", x);     // affiche 10

L’expression *ptr et la variable x désignent le même emplacement mémoire, tous deux 0x???????0 dans ce cas. Toute modification via l’un est immédiatement visible via l’autre. C’est ce qu’on appelle l’aliasing : deux noms (ou expressions) pour la même zone mémoire. Ce phénomène est à la fois la grande force des pointeurs (manipulation indirecte, structures de données dynamiques) et leur principal danger (modifications non anticipées, difficiles à tracer dans le code).

Pointeurs et tableaux

La relation entre pointeurs et tableaux est l’une des particularités les plus profondes du C. Quand on déclare un tableau, le compilateur réserve un bloc de mémoire contigu pour les éléments et fournit un type légérement différent (int[] dans le code ci-dessous). C’est une forme de pointeur et contient aussi l’adresse du premier élément. Le nom du tableau, utilisé sans crochets, peut-être implicitement converti en adresse.

L’intéret de cette notation c’est que sizeof(tab), UNIQUEMENT DANS CE CONTEXT. permet de connaitre la taille total réservé. On peut donc calculer le nomber d’élément via sizeof(tab)/sizeof(tab[0]). C’EST GLOBALEMENT UNE MAUVAISE PRATIQUE, pour les étudiants. Car l’utilisation de int[] en paramètre d’une fonction (exemple : void fonction(int[] data)) est syntaxiquement valide mais considéré comme int*. Donc dans ce context sizeof(data) = sizeof(int*). La sémantique est différente !! Ainsi on ne peut pas connaitre la taille, une variable complémentaire dois être donnée ! (void fonction(int[] data, unsigned int size))

int tab[5] = {10, 20, 30, 40, 50};
int *ptr = tab;

L’indexation tab[i] n’est en réalité qu’un sucre syntaxique pour *(tab + i). Le compilateur transforme systématiquement l’une en l’autre. C’est pourquoi les expressions suivantes sont toutes équivalentes :

tab[i]  ≡  *(tab + i)  ≡  *(i + tab)  ≡  i[tab]

La dernière forme (i[tab]) est parfaitement légale en C, bien que personne ne l’utilise en pratique. Elle illustre le fait que [] est un opérateur commutatif dans l’algèbre du C.

La différence fondamentale entre un tableau et un pointeur est que le tableau est une constante : on ne peut pas écrire tab = ptr ou tab++. Le pointeur, en revanche, est une variable qui peut être réaffectée à volonté.

Bonne pratique : Préférer sizeof(*ptr) à sizeof(type). Si le type du pointeur change lors d’un refactoring, l’expression reste correcte automatiquement

Arithmétique des pointeurs

L’arithmétique des pointeurs est l’un des mécanismes les plus puissants : et les plus dangereux : du C. Elle repose sur un principe simple : toute opération arithmétique sur un pointeur tient compte de la taille du type pointé.

Concrètement, quand on écrit ptr + nptr est de type T*, le compilateur ne calcule pas ptr + n en octets. Il calcule ptr + n * sizeof(T). Autrement dit, ptr + 1 ne se déplace pas d’un octet, mais d’un élément du type pointé :

int tab[] = {10, 20, 30, 40, 50};  // adresse de base : 0x7ffe4fd43130
int *ptr = tab;

ptr++;       // avance de sizeof(int) = 4 octets : pointe vers tab = 20
ptr += 2;    // avance de 2 * 4 = 8 octets : pointe vers tab = 40
ptr--;       // recule de 4 octets : pointe vers tab = 30

Ce mécanisme fonctionne pour tout type. Avec un char*, l’incrément est de 1 octet. Avec un double*, il est de 8 octets. Avec un type struct complexe*, il sera de sizeof(struct complexe) octets. C’est cette propriété qui rend l’arithmétique des pointeurs si adaptée au parcours de tableaux et, par extension, aux structures de données contiguës en mémoire.

Pour illustrer la différence selon le type :

char *cptr = (char *)1000;   // pointeur vers char (1 octet)
int  *iptr = (int  *)1000;   // pointeur vers int (4 octets)

char *c1 = cptr + 1;  // adresse 1001
int  *i1 = iptr + 1;  // adresse 1004

Soustraction de pointeurs : La soustraction de deux pointeurs du même type produit un entier signé de type ptrdiff_t qui exprime la distance en nombre d’éléments, pas en octets. Le compilateur divise (optimisé par décallage de bit) automatiquement la différence d’adresses par sizeof(T) :

int tab[] = {10, 20, 30, 40, 50};
int *p1 = tab;
int *p2 = &tab[4]; // même chose que tab+4
ptrdiff_t diff = p2 - p1;  // (0x...40 - 0x...34) = 12 octets / 4 = 3 éléments

Attention, si les types sont différents, donc ce cas la différence est en nombre d’octets

Comparaison de pointeurs : Les pointeurs, étant des adresses numériques, peuvent être comparés avec les opérateurs relationnels (<, <=, >, >=, ==, !=). Cela permet l’idiome classique du parcours par pointeur begin/end, similaire a un foreach dans d’autres languages :

int tab[] = {10, 20, 30};
int *begin = tab;
int *end = tab + 3;  // pointe juste après le dernier élément (ne surtout pas déréférencé)

for (int *p = begin; p < end; p++) {
  printf("%d\n", *p);
}

Il est légal de créer un pointeur vers “un après le dernier élément” à des fins de comparaison. En revanche, déréférencer ce pointeur est un comportement indéfini (Undefined Behavior).

Que se passe-t-il concrètement en cas d’accès hors bornes ? Le standard dit simplement undefined behavior. En pratique, selon le layout de la stack frame, on pourrait écraser des octets de padding, ou pire, modifier une variable locale adjacente :

c *(tab + 3) = 45; // modifie potentiellement le padding *(tab + 4) = 45; // modifie potentiellement les premiers octets de 'begin'
Ou encore accéder a une zone mémoire ne vous appartenent pas (Le syteme vous dis SEGFAULT).

Constantes et pointeurs

Le mot-clé const interagit avec les pointeurs de manière subtile. Selon sa position, il protège soit la valeur pointée, soit le pointeur lui-même, soit les deux. Il faut lire la déclaration de droite à gauche pour en comprendre le sens :

int x = 5, y = 10;

const int *ptr1 = &x;       // ptr1 est un pointeur vers un int constant
// *ptr1 = 20;              // ERREUR : la valeur est protégée
ptr1 = &y;                  // OK : le pointeur peut changer de cible

int *const ptr2 = &x;       // ptr2 est un pointeur constant vers un int
*ptr2 = 20;                 // OK : la valeur est modifiable
// ptr2 = &y;               // ERREUR : le pointeur est figé

const int *const ptr3 = &x; // ptr3 est un pointeur constant vers un int constant
// *ptr3 = 20;              // ERREUR
// ptr3 = &y;               // ERREUR

L’utilisation de const est une forme de contrat entre le programmeur et le compilateur. En marquant un paramètre comme const int *, une fonction garantit à l’appelant qu’elle ne modifiera pas la donnée pointée. Le compilateur peut également exploiter cette information pour certaines optimisations (reordering, caching).

Passage de paramètres à une fonction

En C, tout passage de paramètre se fait par valeur. Cela signifie que la fonction reçoit une copie de l’argument. Modifier cette copie à l’intérieur de la fonction n’a aucun effet sur l’original dans l’appelant. C’est un comportement sûr par défaut, mais très limitant lorsque l’on souhaite qu’une fonction modifie ses arguments.

L’exemple classique est la permutation de deux entiers. Le code suivant ne fonctionne pas :

void permutation_v1(int a, int b) {
  int c = a;
  a = b;
  b = c;  // on permute des copies locales : aucun effet sur l'appelant
}

int x = 3, y = 7;
permutation_v1(x, y);  // x vaut toujours 3, y vaut toujours 7

La raison est limpide une fois qu’on comprend le mécanisme de la pile : a et b sont des variables locales à la fonction, allouées dans une nouvelle stack frame. Elles sont initialisées avec les valeurs de x et y, mais elles occupent des emplacements mémoire distincts.

La solution consiste à passer non pas les valeurs mais les adresses des variables. Les paramètres de la fonction deviennent des pointeurs, et on utilise le déréférencement pour accéder aux valeurs originales :

void permutation_v2(int *p_a, int *p_b) {
  int c = *p_a;
  *p_a = *p_b;
  *p_b = c;
}

int x = 3, y = 7;
permutation_v2(&x, &y);  // x vaut 7, y vaut 3

Intérêt des pointeurs pour les structures de données

Les pointeurs ne sont pas qu’un outil de bas niveau pour manipuler la mémoire. Ils sont le fondement des structures de données dynamiques. Les tableaux statiques imposent une taille fixée à la compilation, ce qui est inadapté à la plupart des situations réelles. Les pointeurs permettent de :

  • Manipuler efficacement des données volumineuses sans les copier (on passe un pointeur au lieu de la donnée entière)
  • Créer des tableaux dynamiques dont la taille varie à l’exécution
  • Construire des structures chaînées (listes, piles, files, arbres) où chaque élément contient un pointeur vers le suivant

C’est précisément ce dernier point que nous exploiterons dans les chapitres suivants.

Pointeurs génériques : void*

Un pointeur void* effectue un effacement de type : il peut stocker l’adresse de n’importe quelle variable, quel que soit son type. C’est le mécanisme de “généricité “du C.

La contrepartie est que le compilateur ne sait plus quel type de donnée se trouve à l’adresse stockée. Il est donc impossible de déréférencer un void* directement. Toute lecture ou écriture nécessite un cast explicite vers le bon type :

int x = 42;              // 4 octets
void *ptr = &x;          // effacement du type

// *ptr = 8;             // ERREUR : le compilateur ne sait pas quelle taille lire
*(int *)ptr = -1;        // OK : on restaure le type avant le déréférencement

Un point subtil mérite d’être souligné : on peut aussi caster vers un type différent de l’original. Par exemple, *(char *)ptr = 8 est syntaxiquement valide et modifiera uniquement le premier octet de la zone mémoire. Sur une machine little-endian, si x valait -1 (tous les bits à 1, soit 0xFFFFFFFF), après l’opération on obtiendrait 0xFFFFFF08 : une valeur qui n’a plus grand-chose à voir avec l’intention initiale. C’est à la charge du programmeur de garantir la cohérence des types.

*(char*)ptr = 5;         // Aucun soucis : cela correspond a manipuler le premier octet
*(float*)ptr = 1.0;     // Aucun soucis : vous manipuler la zone mémoire comme vous-voulez
*(double*)ptr = 1.0;     // Aie, ça compile mis cela ne correspond pas aux bon nombre d'octets

Copie de zones mémoire : memcpy

Puisque le déréférencement est interdit sur void*, comment copier des données entre deux zones désignées par des pointeurs génériques ? La solution est la fonction memcpy de <string.h>, qui copie n octets bruts d’une adresse source vers une adresse destination :

void *memcpy(void *dest, const void *src, size_t n);

Cette fonction ne connaît pas les types : elle copie aveuglément des octets. La responsabilité de passer la bonne taille incombe au programmeur.

Fonction générique de permutation

L’intérêt pratique des pointeurs génériques éclate dans l’écriture de fonctions indépendantes du type. Sans void*, il faudrait écrire autant de versions de permutation qu’il y a de types : permut_int, permut_float, permut_double, permut_comp, etc. Avec void* et memcpy, une seule implémentation suffit :

#include <stdlib.h>
#include <string.h>

void permut(void *p_a, void *p_b, int taille) {
  void *p_c = malloc(taille);
  memcpy(p_c, p_a, taille);    // *p_c = *p_a n'est pas autorisé
  memcpy(p_a, p_b, taille);
  memcpy(p_b, p_c, taille);
  free(p_c);
}

L’appelant fournit la taille via sizeof, et la fonction opère sans connaître le type :

int i = 2, j = 3;
permut(&i, &j, sizeof(i));

float x = 3.4, y = 6.5;
permut(&x, &y, sizeof(x));

comp cx = {1, 2}, cy = {3, 4};
permut(&cx, &cy, sizeof(cx));   // fonctionne aussi avec les structures

Ce patron de conception (pointeur générique + taille + memcpy) est exactement celui qu’utilise la fonction standard qsort de la bibliothèque C. Il est à la base de toute programmation générique en C.

En C++, les templates permettent de réaliser quelque chose de similaire. Le principe est d’écrire un code générique et le compilateur génère les varintes pour tous les type utilisé dns le programme à la compilation. Donc en C++ on peu ce passé de donné la taille explicite que les variantes de chaque type existent.

Allocation dynamique de mémoire

Les variables locales vivent sur la pile (stack), dans la stack frame de la fonction qui les déclare. Leur durée de vie est strictement limitée au bloc englobant, et leur taille doit être connue à la compilation (à l’exception des VLA, qui sont déconseillés, exemple printf). Les variables globales vivent dans les segments de données, mais leur taille est elle aussi figée.

Or, dans de très nombreuses applications, le nombre de données n’est pas connu à l’avance. Un éditeur de texte ne connaît pas la taille du fichier qui sera ouvert. Un répertoire de contact ne connaît pas le nombre de contacts qui seront ajoutés. Les structures de données dynamiques (listes chaînées, arbres, tables de hachage) n’ont par définition pas de taille préétablie.

Il faut donc un mécanisme pour réserver et libérer de la mémoire pendant l’exécution. C’est le rôle de l’allocation dynamique, qui opère sur le tas (heap).

Quand un programme C est chargé en mémoire, le système d’exploitation organise l’espace d’adressage du processus en plusieurs segments :

┌────────────────────┐  Adresses hautes (virtuel)
│       Stack        │  variables locales, paramètres, adresses de retour
│         ↓          │    (croît vers le bas)
│                    │
│         ↑          │
│        Heap        │  mémoire allouée par malloc/calloc/realloc
│                    │    (croît vers le haut)
├────────────────────┤
│   BSS (non init.)  │  variables globales/statiques non initialisées (= 0)
├────────────────────┤
│   Data (init.)     │  variables globales/statiques initialisées
├────────────────────┤
│       Text         │  code machine (read-only, partageable)
└────────────────────┘  Adresses basses (virtuel)

Le segment Text contient les instructions machine compilées. Il est généralement en lecture seule et peut être partagé entre plusieurs processus exécutant le même programme. Le segment Data contient les variables globales et statiques explicitement initialisées, tandis que le segment BSS (Block Started by Symbol) contient celles qui ne le sont pas (initialisées à zéro par le système).

La pile (stack) gère les appels de fonctions selon le modèle LIFO. Chaque appel crée une stack frame contenant les variables locales, les paramètres et l’adresse de retour. Elle croît vers les adresses basses.

Le tas (heap) est la zone gérée par les fonctions malloc/free. Il croît en direction opposée à la pile, et sa gestion est entièrement manuelle : le programmeur est responsable de libérer chaque bloc qu’il a alloué.

malloc

La fonction malloc réserve un bloc de taille octets contigus sur la Heap et retourne un pointeur void* vers le début du bloc. Si l’allocation échoue (mémoire insuffisante), elle retourne NULL.

void *malloc(size_t taille);

La mémoire allouée par malloc n’est pas initialisée. Elle contient les résidus de ce qui occupait précédemment ces octets : des valeurs imprévisibles. C’est un comportement voulu pour des raisons de performance : initialiser coûte du temps, et dans beaucoup de cas le programmeur va écraser ces valeurs immédiatement.

En pratique, on combine malloc avec l’opérateur sizeof et un cast pour obtenir un pointeur du type désiré :

int *entier = (int *)malloc(sizeof(int));
struct complexe *tabcomp = (struct complexe *)malloc(5 * sizeof(struct complexe));

Vérification obligatoire : Il faut systématiquement tester le retour de malloc avant d’utiliser le pointeur. Un déréférencement de NULL provoque un segmentation fault :

int *tab = (int *)malloc(n * sizeof(int));
if (tab == NULL) {
  fprintf(stderr, "Erreur : allocation de %d octets échouée\n", n * (int)sizeof(int));
  exit(EXIT_FAILURE);
}

realloc

realloc modifie la taille d’un bloc mémoire précédemment alloué. Les données existantes sont conservées (jusqu’à la taille minimale entre l’ancienne et la nouvelle). Si le bloc ne peut pas être étendu sur place, realloc alloue un nouveau bloc, copie les données, et libère l’ancien :

void *realloc(void *ptr, size_t new_size);

Piège classique et critique : Ne jamais écrire p = realloc(p, new_size). Si realloc échoue, il retourne NULL mais ne libère pas l’ancien bloc. En écrasant p avec NULL, on perd la seule référence vers l’ancien bloc, c’est donc une fuite mémoire irréversible :

// DANGEREUX :
tab = realloc(tab, new_size);  // si échec, ancien bloc perdu !

// CORRECT :
int *tmp = realloc(tab, 20 * sizeof(int));
if (tmp == NULL) {
  fprintf(stderr, "realloc échoué\n");
  free(tab);         // on peut encore libérer l'ancien bloc
  exit(EXIT_FAILURE); // fermeture du programme
}
tab = tmp;             // mise à jour uniquement en cas de succès

free

free restitue un bloc mémoire au system. Après l’appel, le pointeur contient toujours l’adresse de l’ancien bloc, mais cette adresse n’est plus valide :

void free(void *ptr);
free(tabcomp);
tabcomp = NULL;   // bonne pratique : empêche le déréférencement accidentel

free(NULL) est garanti sans effet par le standard. C’est pourquoi la bonne pratique de mettre un pointeur à NULL après free constitue une double protection : elle empêche à la fois le dangling pointer et le double free.

Padding des structures

sizeof est un opérateur (pas une fonction) évalué à la compilation. Il retourne la taille en octets d’un type ou d’une expression. C’est l’outil indispensable pour écrire du code portable, car la taille des types varie selon les architectures :

printf("char   : %zu\n", sizeof(char));    // toujours 1
printf("int    : %zu\n", sizeof(int));     // souvent 4
printf("double : %zu\n", sizeof(double));  // souvent 8
printf("int*   : %zu\n", sizeof(int *));   // 4 (32 bits) ou 8 (64 bits)

Le phénomène du padding

La taille d’une structure n’est presque jamais la simple somme des tailles de ses membres. Le processeur accède à la mémoire le plus efficacement lorsque les données sont alignées sur des adresses multiples de leur taille. Un int de 4 octets sera lu en un seul cycle s’il est à une adresse multiple de 4, mais pourrait nécessiter deux cycles s’il est mal aligné, ou pire.

Pour garantir cet alignement, le compilateur insère des octets de padding (remplissage) entre les membres et potentiellement à la fin de la structure. Ces octets ne contiennent aucune donnée utile mais assurent que chaque membre commence à une adresse correctement alignée.

Prenons un exemple concret :

struct Exemple1 {
  char  a;   // 1 octet, offset 0
  int   b;   // 4 octets, mais doit commencer à un offset multiple de 4
  char  c;   // 1 octet
};

Le layout mémoire est :

Offset :  0    1    2    3    4    5    6    7    8    9   10   11
Contenu: [a ] [pad] [pad] [pad] [b          b          b          b  ] [c ] [pad] [pad] [pad]

Pourquoi 12 octets ? Après a (offset 0), le membre b doit être aligné sur un multiple de 4, donc le compilateur insère 3 octets de padding (offsets 1-3). b occupe les offsets 4-7. c occupe l’offset 8. Puis, pour que la structure elle-même soit alignée sur un multiple de 4 (taille de son plus grand membre), 3 octets de padding sont ajoutés en fin (offsets 9-11). Total : 12 octets.

En réordonnant les membres par taille décroissante, on réduit drastiquement le gaspillage :

struct Exemple2 {
  int   b;   // 4 octets, offset 0
  char  a;   // 1 octet, offset 4
  char  c;   // 1 octet, offset 5
};
// Padding de fin : 2 octets (pour aligner sur 4)
// Total : 8 octets au lieu de 12

Règle pratique : Trier les membres par taille décroissante minimise le padding et donc la consommation mémoire. C’est particulièrement important pour les structures allouées en grande quantité (nœuds de liste, d’arbre, etc.).

Désactiver le padding

Il est aussi possible de dire explicitement au compilateur de désactivé le padding. On gagne en place mais on perd en vitesse de lecture/écriture. Ce qui augmente drastiquement le nombre de cycle CPU. Cela peut par contre présenté un avantage pour les communications réseaux.

#pragma pack(1)
struct Packet {
  char id;
  int instance;
  double position;
};
#pragma pack()

Dans ce cas de figure, la structure est optimisée en espace au détriment des performances d’accès mémoire. Une pratique courante consiste alors à définir une seconde structure, naturellement alignée, utilisée pour les traitements internes du programme. Le programmeur effectue ensuite des copies explicites entre la structure compacte (destinée au transport réseau ou au stockage) et la structure alignée (destinée au calcul), afin de bénéficier à la fois d’un format binaire stable et minimal en taille, et de performances optimales lors des opérations en mémoire.

Avec l’amélioration des micro-architectures processeur (gestion plus efficace des accès non alignés, pipelines plus profonds, caches plus performants) ainsi que l’augmentation des débits réseaux, cette pratique tend à devenir moins systématique qu’auparavant. Les bénéfices peuvent être significatifs, en particulier lorsqu’on compare avec des technologies web reposant sur des formats textuels comme JSON : surcharge structurelle (clés textuelles), empreinte mémoire accrue, et coût non négligeable de sérialisation/désérialisation. Dans ces scénarios, un format binaire compact réduit à la fois la bande passante consommée, le volume de stockage et le temps CPU consacré au parsing. Indispenssable pour du jeux vidéo.

Pointeurs de fonctions

En C, une fonction n’est rien d’autre qu’une adresse de début d’instructions dans le segment Text du programme chargé en mémoire. Quand on écrit un appel de fonction, le compilateur génère une instruction qui sauvegarde l’adresse de retour, déplace le pointeur d’instruction (instruction pointer) vers l’adresse de la fonction, et le reste s’effectue (allocation de la stack frame, etc.).

Un pointeur de fonction est une variable qui stocke cette adresse, permettant d’appeler une fonction de manière indirecte. La syntaxe de déclaration reprend celle d’un prototype de fonction, mais l’identificateur est remplacé par (*nom_pointeur) :

int (*p_fonction)(int, int);
// déclare un pointeur vers une fonction prenant deux int et retournant un int

Le nom d’une fonction, comme le nom d’un tableau, se convertit automatiquement en pointeur. Les deux formes d’affectation sont donc équivalentes :

p_fonction = ajouter;    // implicite
p_fonction = &ajouter;   // explicite (identique)

De même, les deux formes d’appel sont équivalentes :

p_fonction(5, 3);       // syntaxe simplifiée
(*p_fonction)(5, 3);    // syntaxe explicite (identique)

Callbacks

L’intérêt principal des pointeurs de fonctions est de pouvoir paramétrer le comportement d’une fonction. C’est le patron de conception du callback : on passe à une fonction générique un pointeur vers une fonction spécifique qui sera appelée au moment opportun.

Exemple complet : recherche du minimum d’une fonction mono-variable entre deux bornes :

float carre(float x)    { return x * x; }
float parabole(float x) { return x * x - 4 * x + 2; }

float min_fct(float a, float b, float (*pF)(float)) {
  float pas = (b - a) / 100;
  float vmin = pF(a);
  for (int i = 1; i <= 100; i++) {
    float valeur = pF(a + i * pas);
    if (valeur < vmin)
      vmin = valeur;
  }
  return vmin;
}

int main(void) {
  printf("%f\n", min_fct(-3.0, 3.0, carre));
  printf("%f\n", min_fct(-3.0, 3.0, parabole));
  return 0;
}

La fonction min_fct ne connaît pas la fonction qu’elle évalue : elle reçoit un pointeur et l’appelle. On peut lui passer n’importe quelle fonction ayant la bonne signature. Ce mécanisme est omniprésent en C : qsort, bsearch, pthread_create, etc.