Liste chaînée en C : insérer, supprimer, libérer sans fuite
Une liste chaînée en C complète et sans fuite : struct node, insertion en tête et en queue, suppression d'un élément, libération, le tout vérifié par Valgrind.
Ta liste chaînée affiche les bons nombres, dans le bon ordre. Puis Valgrind annonce des blocs « definitely lost », ou le programme plante à la toute dernière ligne, au moment de tout libérer. Les listes chaînées ont trois endroits où l'on perd un pointeur, et on va les traverser un par un, sur un programme complet qui finit sans la moindre fuite.
En bref
Une liste chaînée en C est une suite de maillons alloués sur le tas, chacun contenant une valeur et un pointeur next vers le maillon suivant ; le dernier pointe sur NULL. On insère en tête en faisant pointer le nouveau maillon sur l'ancienne tête, en queue en parcourant la liste jusqu'au dernier maillon. Pour supprimer un élément, on raccroche le maillon précédent au suivant, puis on libère l'élément ; pour libérer toute la liste, on sauvegarde next avant chaque free. Les fonctions qui peuvent changer la tête reçoivent son adresse, un struct node **.
Le maillon : une structure qui pointe sur elle-même
Chaque élément de la liste est une structure qui contient une valeur et l'adresse de l'élément suivant.
struct node { int value; struct node *next;};Une structure ne peut pas contenir un membre de son propre type (elle serait infinie), mais elle peut contenir un pointeur vers son propre type : un pointeur a une taille fixe, 8 octets sur une machine 64 bits, quoi qu'il désigne. Sur ces machines, un struct node occupe 16 octets : 4 pour l'int, 4 octets de remplissage pour aligner le pointeur, 8 pour next. Valgrind le confirmera plus bas en parlant de blocs de 16 octets.
La liste elle-même n'est rien d'autre qu'un pointeur vers le premier maillon, head. Une liste vide, c'est head == NULL. Pas de taille, pas d'indices : pour atteindre le troisième élément, il faut suivre deux flèches.
Le programme complet, vérifié par Valgrind
Voici la liste entière : création d'un maillon, insertion en tête et en queue, affichage, suppression d'une valeur, libération. Les sections suivantes reprennent chaque fonction avec ce qui se passe en mémoire.
#include <stdio.h>#include <stdlib.h>
struct node { int value; struct node *next;};
struct node *new_node(int value){ struct node *n = malloc(sizeof *n);
if (n == NULL) return NULL; n->value = value; n->next = NULL; return n;}
int push_front(struct node **head, int value){ struct node *n = new_node(value);
if (n == NULL) return -1; n->next = *head; *head = n; return 0;}
int push_back(struct node **head, int value){ struct node *n = new_node(value); struct node *cur;
if (n == NULL) return -1; if (*head == NULL) { *head = n; return 0; } cur = *head; while (cur->next != NULL) cur = cur->next; cur->next = n; return 0;}
void print_list(const struct node *head){ for (const struct node *cur = head; cur != NULL; cur = cur->next) printf("%d -> ", cur->value); printf("NULL\n");}
void remove_value(struct node **head, int value){ struct node *prev = NULL; struct node *cur = *head;
while (cur != NULL && cur->value != value) { prev = cur; cur = cur->next; } if (cur == NULL) return; if (prev == NULL) *head = cur->next; else prev->next = cur->next; free(cur);}
void free_list(struct node *head){ while (head != NULL) { struct node *next = head->next;
free(head); head = next; }}
int main(void){ struct node *head = NULL;
push_front(&head, 2); push_front(&head, 1); push_back(&head, 3); push_back(&head, 4); print_list(head); remove_value(&head, 3); print_list(head); remove_value(&head, 1); print_list(head); free_list(head); return 0;}$ gcc -Wall -Wextra -g liste.c -o liste$ ./liste1 -> 2 -> 3 -> 4 -> NULL1 -> 2 -> 4 -> NULL2 -> 4 -> NULL$ valgrind ./liste==15== Memcheck, a memory error detector==15== Copyright (C) 2002-2024, and GNU GPL'd, by Julian Seward et al.==15== Using Valgrind-3.24.0 and LibVEX; rerun with -h for copyright info==15== Command: ./liste==15== 1 -> 2 -> 3 -> 4 -> NULL1 -> 2 -> 4 -> NULL2 -> 4 -> NULL==15== ==15== HEAP SUMMARY:==15== in use at exit: 0 bytes in 0 blocks==15== total heap usage: 5 allocs, 5 frees, 1,088 bytes allocated==15== ==15== All heap blocks were freed -- no leaks are possible==15== ==15== For lists of detected and suppressed errors, rerun with: -s==15== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)Les deux lignes à regarder : All heap blocks were freed -- no leaks are possible et ERROR SUMMARY: 0 errors. Il faut les deux, on verra plus bas un programme qui obtient la première sans la seconde.
Tu te demandes peut-être pourquoi 5 allocations pour 4 maillons. La cinquième vient de la bibliothèque C : le tampon de sortie qu'elle réserve au premier printf. Les 4 maillons font 64 octets, le tampon en fait 1 024 quand la sortie est un terminal. Redirige la sortie vers un fichier et le total change, preuve que ce bloc dépend de stdout et pas de ta liste :
$ valgrind ./liste > sortie.txt 2> rapport.txt; grep "total heap" rapport.txt==15== total heap usage: 5 allocs, 5 frees, 4,160 bytes allocatedRemarque aussi que main ne teste pas le retour de push_front et push_back. Si malloc échouait, la fonction renverrait -1 sans rien modifier : la liste serait plus courte, mais rien ne fuirait et rien ne planterait. Dans un vrai programme, on testerait ce retour.
Insérer en tête : pourquoi push_front prend un struct node **
Insérer en tête, c'est deux affectations : le nouveau maillon pointe sur l'ancienne tête, puis la tête devient le nouveau maillon. L'ordre compte. Dans l'autre sens, on écraserait head avant d'avoir sauvé l'adresse de l'ancien premier maillon, et le reste de la liste serait perdu.
Voici la mémoire pendant le deuxième appel, push_front(&head, 1), juste après n->next = *head; (ligne 26) et avant *head = n; :
Pile (stack)
- struct node *headhead
- n2
- struct node **head
- head
- intvalue
- 1
- struct node *n
- n1
Tas (heap)
- intvalue
- 1
- struct node *next
- n2
- intvalue
- 2
- struct node *next
- NULL
La ligne suivante, *head = n;, écrit dans la variable head de main, parce que le head de push_front contient son adresse. C'est tout l'intérêt du double pointeur. Si push_front recevait head par valeur, elle modifierait une copie, exactement comme le swap raté de l'article sur les pointeurs en C :
#include <stdio.h>#include <stdlib.h>
struct node { int value; struct node *next;};
void push_front(struct node *head, int value){ struct node *n = malloc(sizeof *n);
n->value = value; n->next = head; head = n;}
int main(void){ struct node *head = NULL;
push_front(head, 1); printf("head = %p\n", (void *)head); return 0;}$ gcc -Wall -Wextra -g tete.c -o tete$ ./tetehead = (nil)$ valgrind --leak-check=full ./tete==25== Memcheck, a memory error detector==25== Copyright (C) 2002-2024, and GNU GPL'd, by Julian Seward et al.==25== Using Valgrind-3.24.0 and LibVEX; rerun with -h for copyright info==25== Command: ./tete==25== head = (nil)==25== ==25== HEAP SUMMARY:==25== in use at exit: 16 bytes in 1 blocks==25== total heap usage: 2 allocs, 1 frees, 1,040 bytes allocated==25== ==25== 16 bytes in 1 blocks are definitely lost in loss record 1 of 1==25== at 0x488545C: malloc (vg_replace_malloc.c:446)==25== by 0x40069B: push_front (tete.c:11)==25== by 0x4006E3: main (tete.c:22)==25== ==25== LEAK SUMMARY:==25== definitely lost: 16 bytes in 1 blocks==25== indirectly lost: 0 bytes in 0 blocks==25== possibly lost: 0 bytes in 0 blocks==25== still reachable: 0 bytes in 0 blocks==25== suppressed: 0 bytes in 0 blocks==25== ==25== For lists of detected and suppressed errors, rerun with: -s==25== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)Aucun warning, aucun crash, et pourtant head vaut toujours NULL après l'appel. Le maillon alloué ligne 11 n'est plus désigné par personne quand push_front se termine : Valgrind le classe « definitely lost », 16 octets, la taille d'un struct node.
Insérer en queue : parcourir jusqu'au dernier maillon
Pour ajouter à la fin, il faut trouver le maillon dont next vaut NULL, puis y accrocher le nouveau. Il y a un cas particulier : la liste vide, où il n'existe aucun dernier maillon et où c'est head lui-même qu'il faut modifier (lignes 38 à 41). D'où le struct node ** ici aussi.
Allouer le maillon, avec
nextàNULLpuisqu'il sera le dernier.Si la liste est vide, faire de lui la tête, et c'est fini.
Sinon, avancer
curtant quecur->nextn'est pasNULL.Écrire l'adresse du nouveau maillon dans
cur->next.
Après les quatre insertions de main, la liste est complète :
Pile (stack)
- struct node *head
- n1
Tas (heap)
- intvalue
- 1
- struct node *next
- n2
- intvalue
- 2
- struct node *next
- n3
- intvalue
- 3
- struct node *next
- n4
- intvalue
- 4
- struct node *next
- NULL
L'insertion en queue parcourt toute la liste à chaque appel. Pour des milliers d'insertions, on garde en plus un pointeur vers le dernier maillon ; pour apprendre, la version simple suffit.
Parcourir une liste chaînée
Le parcours est toujours la même boucle : partir de la tête, traiter le maillon, passer à next, s'arrêter sur NULL. print_list l'écrit sous forme de for :
for (const struct node *cur = head; cur != NULL; cur = cur->next) printf("%d -> ", cur->value);Le const dit que la fonction ne modifie pas les maillons, et la boucle ne touche qu'à sa variable cur, une copie : le head de l'appelant ne bouge pas. Compter, chercher, additionner, appliquer une fonction à chaque valeur, tout part de ce squelette.
list_node_count
Compte les maillons d'une liste dont tu ne reçois que la tête : c'est la boucle de print_list, avec un compteur à la place du printf. Le maillon de l'exercice s'appelle ListNode et porte un void *data, et les tests passent aussi une liste vide (NULL) et une liste d'un seul maillon.
Supprimer un élément sans perdre de pointeur
Supprimer un maillon, c'est faire pointer son prédécesseur sur son successeur, puis le libérer. remove_value avance donc avec deux pointeurs, prev un cran derrière cur, pour avoir sous la main le maillon à raccrocher. Voici remove_value(&head, 3) juste après le raccrochage (ligne 70), avant le free :
Pile (stack)
- struct node *headhead
- n1
- struct node **head
- head
- struct node *prev
- n2
- struct node *cur
- n3
Tas (heap)
- intvalue
- 1
- struct node *next
- n2
- intvalue
- 2
- struct node *next
- n4
- intvalue
- 3
- struct node *next
- n4
- intvalue
- 4
- struct node *next
- NULL
À cet instant, cur est le seul pointeur qui mène encore à n3. Si la fonction se termine sans free(cur), cette adresse disparaît avec la variable locale, et le bloc est perdu pour de bon. C'est le pointeur perdu classique : la liste s'affiche parfaitement, seule la mémoire sait. Mets le free(cur); de liste.c en commentaire (/* free(cur); */, pour garder les mêmes numéros de ligne), enregistre le résultat sous oubli.c et relance Valgrind :
$ gcc -Wall -Wextra -g oubli.c -o oubli$ ./oubli1 -> 2 -> 3 -> 4 -> NULL1 -> 2 -> 4 -> NULL2 -> 4 -> NULL$ valgrind --leak-check=full ./oubli==35== Memcheck, a memory error detector==35== Copyright (C) 2002-2024, and GNU GPL'd, by Julian Seward et al.==35== Using Valgrind-3.24.0 and LibVEX; rerun with -h for copyright info==35== Command: ./oubli==35== 1 -> 2 -> 3 -> 4 -> NULL1 -> 2 -> 4 -> NULL2 -> 4 -> NULL==35== ==35== HEAP SUMMARY:==35== in use at exit: 32 bytes in 2 blocks==35== total heap usage: 5 allocs, 3 frees, 1,088 bytes allocated==35== ==35== 16 bytes in 1 blocks are definitely lost in loss record 1 of 2==35== at 0x488545C: malloc (vg_replace_malloc.c:446)==35== by 0x400717: new_node (oubli.c:11)==35== by 0x400767: push_front (oubli.c:22)==35== by 0x4009AB: main (oubli.c:89)==35== ==35== 16 bytes in 1 blocks are definitely lost in loss record 2 of 2==35== at 0x488545C: malloc (vg_replace_malloc.c:446)==35== by 0x400717: new_node (oubli.c:11)==35== by 0x4007BF: push_back (oubli.c:33)==35== by 0x4009B7: main (oubli.c:90)==35== ==35== LEAK SUMMARY:==35== definitely lost: 32 bytes in 2 blocks==35== indirectly lost: 0 bytes in 0 blocks==35== possibly lost: 0 bytes in 0 blocks==35== still reachable: 0 bytes in 0 blocks==35== suppressed: 0 bytes in 0 blocks==35== ==35== For lists of detected and suppressed errors, rerun with: -s==35== ERROR SUMMARY: 2 errors from 2 contexts (suppressed: 0 from 0)Deux blocs perdus, et Valgrind dit lesquels par la pile d'appels qui les a créés : celui du push_front de la ligne 89 (la valeur 1) et celui du push_back de la ligne 90 (la valeur 3). Ce sont exactement les deux maillons supprimés.
Le cas de la tête mérite un mot. Quand la valeur cherchée est dans le premier maillon, il n'y a pas de prédécesseur (prev vaut NULL) et c'est *head qu'il faut faire avancer : encore une raison de recevoir struct node **. On peut d'ailleurs supprimer ce cas particulier en parcourant la liste avec un pointeur vers le lien à modifier plutôt qu'avec prev :
void remove_value(struct node **head, int value){ struct node **link = head;
while (*link != NULL && (*link)->value != value) link = &(*link)->next; if (*link != NULL) { struct node *victim = *link;
*link = victim->next; free(victim); }}link désigne d'abord head, puis le champ next du maillon courant. Supprimer revient toujours à réécrire *link, que ce soit la tête ou un next. Cette version, substituée dans liste.c, donne la même sortie et passe Valgrind sans erreur. Choisis celle que tu relis le plus facilement.
list_filter
Supprime et libère tous les maillons dont la valeur correspond à une référence, en recevant ListNode **begin pour pouvoir changer la tête. C'est remove_value en boucle, avec une fonction de comparaison passée en paramètre ; les tests couvrent une tête qui correspond, plusieurs correspondances d'affilée et une liste entièrement vidée.
Libérer une liste chaînée sans lire après free
Pour libérer une liste, il faut libérer chaque maillon, et c'est là qu'on écrit le bug le plus tentant de tout l'article :
while (head != NULL) { free(head); head = head->next;}while (head != NULL) { struct node *next = head->next;
free(head); head = next;}Après free(head), le bloc n'appartient plus au programme : lire head->next est un comportement indéfini. La mémoire au moment fautif :
Pile (stack)
- struct node *head
- n2(libéré)
Tas (heap)
- intvalue
- 2
- struct node *next
- n4
- intvalue
- 4
- struct node *next
- NULL
Remplace la bonne boucle de liste.c par la mauvaise, et tu obtiens apres_free.c. Ici, GCC repère le bug dès la compilation (-Wuse-after-free fait partie de -Wall) et le programme plante à la fin. Mais lis bien la sortie de Valgrind :
$ gcc -Wall -Wextra -g apres_free.c -o apres_freeapres_free.c: In function 'free_list':apres_free.c:78:14: warning: pointer 'head' used after 'free' [-Wuse-after-free] 78 | head = head->next; | ~~~~~^~~~~~~~~~~~apres_free.c:77:9: note: call to 'free' here 77 | free(head); | ^~~~~~~~~~$ ./apres_free1 -> 2 -> 3 -> 4 -> NULL1 -> 2 -> 4 -> NULL2 -> 4 -> NULLSegmentation fault$ valgrind ./apres_free==45== Memcheck, a memory error detector==45== Copyright (C) 2002-2024, and GNU GPL'd, by Julian Seward et al.==45== Using Valgrind-3.24.0 and LibVEX; rerun with -h for copyright info==45== Command: ./apres_free==45== 1 -> 2 -> 3 -> 4 -> NULL1 -> 2 -> 4 -> NULL2 -> 4 -> NULL==45== Invalid read of size 8==45== at 0x400968: free_list (apres_free.c:78)==45== by 0x4009FF: main (apres_free.c:95)==45== Address 0x4a7f048 is 8 bytes inside a block of size 16 free'd==45== at 0x48884F8: free (vg_replace_malloc.c:989)==45== by 0x400963: free_list (apres_free.c:77)==45== by 0x4009FF: main (apres_free.c:95)==45== Block was alloc'd at==45== at 0x488545C: malloc (vg_replace_malloc.c:446)==45== by 0x400717: new_node (apres_free.c:11)==45== by 0x400767: push_front (apres_free.c:22)==45== by 0x4009A3: main (apres_free.c:86)==45== ==45== ==45== HEAP SUMMARY:==45== in use at exit: 0 bytes in 0 blocks==45== total heap usage: 5 allocs, 5 frees, 1,088 bytes allocated==45== ==45== All heap blocks were freed -- no leaks are possible==45== ==45== For lists of detected and suppressed errors, rerun with: -s==45== ERROR SUMMARY: 2 errors from 1 contexts (suppressed: 0 from 0)Le rapport se lit de haut en bas. Invalid read of size 8 : une lecture de 8 octets, la taille du pointeur next, ligne 78. 8 bytes inside a block of size 16 free'd : à 8 octets du début d'un bloc de 16 octets déjà libéré, soit exactement le champ next d'un struct node. Puis Valgrind dit où le bloc a été libéré (ligne 77) et où il avait été alloué.
Et en bas, All heap blocks were freed -- no leaks are possible. Tous les blocs ont bien été rendus, il n'y a pas de fuite, mais le programme a lu de la mémoire libérée en chemin. D'où la règle : regarde toujours ERROR SUMMARY, pas seulement le résumé des fuites. Sous Valgrind, le programme n'a même pas planté ; sans lui, sur une autre machine ou avec une autre version de la glibc, il aurait aussi pu terminer sans rien dire. Le détail de chaque message est dans Lire un rapport Valgrind.
Le parcours C sur les listes
Les listes chaînées concentrent tout ce qui rend les pointeurs difficiles : allocation, double indirection, ordre des affectations, libération. Le module du parcours C qui leur est consacré les reprend dans l'ordre, du simple parcours au raccrochage de maillons, avec des tests qui exécutent vraiment ton code.
Questions fréquentes
Pourquoi utiliser une liste chaînée plutôt qu'un tableau ?
Pour insérer ou retirer un élément sans déplacer les autres ni réallouer : en tête, c'est deux affectations, quelle que soit la longueur. En échange, on perd l'accès direct par indice (atteindre le millième élément demande de suivre mille flèches) et chaque maillon paie un pointeur supplémentaire. Pour une collection qu'on parcourt surtout par indice, un tableau dynamique reste le meilleur choix.
Comment savoir si ma liste boucle sur elle-même ?
Avec deux pointeurs qui avancent à des vitesses différentes : l'un d'un maillon par tour, l'autre de deux. S'il y a une boucle, le rapide finit par rattraper le lent ; sinon, il atteint NULL. C'est l'exercice detect_cycle.
Faut-il libérer la valeur en plus du maillon ?
Seulement si elle a été allouée à part. Ici value est un int rangé dans le maillon, un seul free suffit. Si le maillon contenait un char * obtenu par malloc ou strdup, il faudrait libérer la chaîne d'abord, puis le maillon.
Pourquoi mon programme plante sans Valgrind et pas sous Valgrind ?
Sous glibc, free écrit ses propres données de gestion dans le bloc libéré : pour un struct node, les octets 8 à 15, exactement le champ next, reçoivent une valeur aléatoire, et head->next devient une adresse absurde. Memcheck remplace malloc et free par les siens : il laisse le contenu du bloc intact, le marque inaccessible et le met en quarantaine pour repérer les accès après free. La lecture retrouve donc l'ancien pointeur et n'est plus fatale, mais elle est signalée par Invalid read. Le plantage disparaît, l'erreur reste dans ERROR SUMMARY.
Sources
