Dans la fiche précédente, nous avons introduit le type C pour la réalisation du type abstrait Liste appelée "listes chaînées dynamiques", puis quelques algorithmes de parcours de listes.

Dans cette fiche, on complète pas à pas les points essentiels de l'implémentation, avec les fonctions d'ajout et de suppression en tête de liste.

La fiche suivante présentera in extenso l'implantation de référence des listes chaînées dynamiques qui vous accompagnera tout le semestre et servira de base aux implantations des autres TADs fonctionnant sur le même principe.

1. Rappels de la fiche précédente

Comme dans la fiche précédente, on s'intéresse à une liste chaînée dynamique contenant des éléments de type double, dont voici une représentation après l'ajout de 2 éléments :

Une liste chaînée dynamique contenant 2 éléments, donc avec 2 maillons.

Le type C list_t introduit dans la fiche précédente est donc défini ainsi :


            // Fichier list.h
            #ifndef _LIST_H
            #define _LIST_H
            // définition des types maillon link_t et liste list_t
            typedef struct _link {
              double val;          /* un élément de la liste*/
              struct _link * next; /* un pointeur sur le maillon suivant de la liste */
            } link_t, * list_t;
            #endif
          

On connait déjà le code des trois fonctions suivantes, que nous avons construites dans la fiche précédente :


            // retourne une liste vide
            list_t list_new() {
              return NULL;
            }

            // predicat : retourne VRAI si la liste l est vide
            int list_is_empty( list_t l ) {
              return NULL == l;
            }

            // Affiche les éléments de la liste - Version finale
            void list_print( list_t l ) {
              link_t * p = l;

              // traitement du cas particulier "liste vide"
              if(list_is_empty(p)) {
                printf("La liste est vide\n");
                return ; // plus rien à faire : on arrête la fonction
              }

              // cas général
              while ( NULL != p ) {       // "tant que p pointe un maillon"
                printf(" %lf ;", p->val); // on affiche la valeur stockée dans le maillon
                p  = p->next;             // on affecte à p l'adresse du maillon suivant
              }
              printf("\n"); // fin de la liste. Retour à la ligne pour terminer proprement
            }
          

On construit maintenant les fonctions d'ajout en tête et de suppression en tête de liste.

2. Ajout en tête d'un élément

Maintenant qu'on sait parcourir une liste, voyons comment ajouter un nouvel élément en tête de la liste. On rappelle que le contrat et le prototype de la fonction à écrire est :


          // Ajoute l'élément e en tête de la liste l et retourne la nouvelle liste
          list_t list_add_first(element_t e, list_t l);
        

2.1 Principe de l'ajout en tête d'une liste chaînée dynamique

Pour réfléchir, on suppose qu'on a une liste l contenant déjà un maillon, qui contient l'élement ( 9.0 ) :

La liste initiale ( 9.0 )

Pour ajouter l'élement 8.0 en tête de la liste contenant 9.0, il faut :

  1. Créer et allouer un nouveau maillon, appelé p ;
  2. Mettre la valeur 8.0 dans ce maillon ;
  3. Insérer le nouveau maillon p au bon endroit (devant tous les autres), i.e. indiquer à ce nouveau maillon que son suivant est l'ancienne tête de liste, c'est à dire le maillon qui contient 9.0 et qui est actuellement pointé par l ;
  4. Indiquer que le nouveau maillon p est la nouvelle tête de la liste, i.e. mettre à jour la variable liste.
  5. La nouvelle liste ( 8.0 9.0 ).

2.2 Vers la fonction d'ajout en tête list_t list_add_first( element_t e, list_t l )

Dans cette section, c'est vous qui faites le cours !

Vos profs préférés ont préparé chacun une version de la fonction list_t list_add_first( element_t e, list_t l ), qu'ils croient en situation de révolutionner le Monde des Listes Chaînées.

Le(s)quel(s) de vos profs devrai(en)t retourner à l'école ?

Quelle est la bonne version (si il y en a une)?

A vous de jouer !

Ajout en tete de liste - la version de Nicolas

Pour les listes de double, on considère le code suivant pour la fonction d'ajout en tête de liste :


            // Dans le fichier list.c
            list_t list_add_first(double val, list_t l) {
              list_t newPtr ;    // On link_t * newPtr, puisque le type liste est défini comme "pointeur sur maillon"
              newPtr->val = val; // On stocke la valeur dans le maillon
              newPtr->suiv = l;  // On pointe sur l'ancien premier maillon
              return newPtr;     // On retourne l'adresse du nouveau maillon.
                                 // newPtr est bien de type "adresse de maillon" : tout va bien !
            }
            // Dans le fichier test.c
            int main() {
              list_t mylist = list_new();
              mylist = list_add_first(9.0, mylist);
              mylist = list_add_first(8.0, mylist);
              list_print(maliste);
            }
            

Que fait le code ?

Erreur à la compilation Non, le code est syntaxiquement correct. Comportement aléatoire du programme : parfois il semble s'exécuter sans erreur, parfois il affiche n'importe quoi, le plus souvent il provoque une erreur de segmentation mémoire (segfault). Le code affiche la liste 8.0 ; 9.0 Le code affiche la liste 9.0 ; 8.0 On ajoute en tête de liste : les éléments sont donc stockés dans la liste dans l'ordre inverse de l'ajout ! Le code n'affiche rien

Attention, important !

Dans cette fonction, on a bien déclarer une variable locale pointeur newPtr pour pointer le nouveau maillon.

Mais :

  • que vaut ce pointeur ? Il n'est pas initialisé ! Sa valeur (une adresse) est aléatoire !
  • Où pointe-t-il ? N'importe où en mémoire !
  • A-t-on le droit d'accéder, par exemple, à new->val ? Non !

En découle un comportement hératique (aléatoire) du programme.

En fait, ce code n'a jamais créé en mémoire le nouveau maillon !

Rationnel 1 : il est nécessaire, dans la fonction d'ajout en tête, de créer en mémoire le nouveau maillon !

Rationnel 2 : Nicolas, tu sors.

Ajout en tete de liste - la version de François

On considère le code suivant :


              // Dans le fichier list.c
              list_t list_add_first(double val, list_t l) {
                link_t m ;    // le nouveau maillon.
                              // Notez bien l'usage de link_t :
                              // cette fois ci, m n'est pas un pointeur, mais bien une structure maillon
                m.val = val;  // on stocke la valeur dans le maillon
                m.suiv = l;   // on pointe sur l'ancien premier maillon
                return &m;    // on retourne l'adresse du nouveau maillon.
                              // &m est bien de type "adresse de maillon",
                              // comme le type de retour de la fonction. Tout va bien... apparemment...
              }

              // Dans le fichier test.c
              int main() {
                list_t mylist;
                mylist = list_new();
                mylist = list_add_first(9.0, mylist);
                mylist = list_add_first(8.0, mylist);
                list_print(maliste);
              }
            
Que fait le code ?
Erreur à la compilation Non, le code est syntaxiquement correct. Notamment, la fonction attend qu'on retourne une "liste", c'est à dire l'adresse d'un maillon ; et &m est bien de type "adresse d'un maillon". Ceci dit, notez que, si l'option -Wall était passée au compilateur, le compilateur afficherait un warning "Attention : retour de l'adresse d'une variable locale". Comportement aléatoire du programme : parfois il semble s'exécuter sans erreur, parfois il provoque une erreur de segmentation, parfois il affiche n'importe quoi... Le code affiche la liste 8.0 ; 9.0 Le code affiche la liste 9.0 ; 8.0 On ajoute en tête de liste : les éléments sont donc stockés dans la liste dans l'ordre inverse de l'ajout ! Le code n'affiche rien

Attention, important !

Dans cette fonction, on crée bien une variable tructurée de type link_t pour le nouveau maillon.

Mais il s'agit d'une variable locale. Lorsque la fonction se termine, toutes variables localessont détruites sur la pile. Ce maillon est donc détruit. Et la fonction retourne l'adresse d'une variable (le maillon) qui n'existe plus !

Conséquence : dans la suite du programme principal, on manipule par adresse une case mémoire qu'on a plus le droit de manipuler. En découle un comportement hératique, d'aparence aléatoire, parfois provoquant une erreur de segmentation (seg fault).

Rationnel 1 : comme on veut que le maillon reste alloué après l'exécution de la fonction, il faut avoir recours à l'allocation dynamique.

Rationnel 2 : François, dehors !

Des deux QCM précédents, on retiendra (outre le fait que vos profs devraient retourner à l'école... ) :

Pour toutes les fonction d'ajout d'un élément à une liste...

Comme pour les variables classiques de type pointeur, déclarer list_t l; définit une variable qui contient une adresse.

Mais cela ne réserve PAS la mémoire pour la structure pointée, en l'occurence le maillon qu'on veut créer.

Pour ajouter un élément à une liste, il faut créer un maillon en mémoire.

De plus, le maillon créé doit continuer à exister après la fin de la fonction.

On doit donc avoir recours à l'allocation dynamique pour allouer le nouveau maillon.

Ajout en tete de liste - la version de Michel

On considère le code suivant :


              // Dans le fichier list.c
              list_t list_add_first_v3(double val, list_t l) {
                list_t p;     // pour pointer le nouveau maillon
                // on alloue dyamiquement le nouveau maillon
                p = calloc(1,sizeof(*p));
                if (p==NULL) {
                  // une allocation dynamique peut échouer : on gère ce cas d'erreur !
                  printf("Allocation de mémoire impossible. Aborts.\n");
                  exit(1);
                }
                p->val = val; // on stocke la valeur dans le maillon
                p->suiv = l;  // le maillon suivant le nouveau est l'ancien premier maillon : on pointe dessus
                return p;     // on retourne l'adresse du nouveau maillon alloué dynamiquement
              }

              // Dans le fichier test.c
              int main() {
                list_t mylist;
                mylist = list_new();
                list_add_first_v3(9.0, mylist);
                list_add_first_v3(8.0, mylist);
                list_print(maliste);
              }
            
Que fait ce code ?
Erreur à la compilation Non, le code est syntaxiquement correct Le code affiche la liste 8.0 ; 9.0 Le code affiche la liste 9.0 ; 98.0 Impossible avec la sémantique de ajout en tete Le code affiche la liste est vide L'affichage est imprévisible

Le code de la fonction est correct. Mais l'appel de la fonction ne l'est pas !

En C, les paramètres sont passés par valeur, c'est à dire qu'une fonction ne peut jamais modifier ses paramètres d'appel.

Lorsqu'on appelle la fonction avec list_add_first(9.0, mylist);, on ne passe pas à la fonction la variable mylist, mais la valeur de cette variable (l'adresse du premier maillon, donc).

Donc, la variable mylist des appels de list_add_first, ligne 20 et 21 ne peut pas etre modifiée par ces appels. Comme elle est initialisée à NULL par la ligne 19 list_add_first, elle aura toujours la meme valeur apres la ligne 20 (appel à ajoutTeteListeDyn). mylist valant NULL, c'est une liste vide.

Dans ces conditions, l'execution de la ligne 22 afficheListeDyn(maliste); affiche une liste vide !

En fait : dans le main(), le développeur à oublié d'utiliser la valeur retournée par la fonction d'ajout en tête !.

Il eut fallut écrire :

mylist = list_add_first(9.0, mylist); 

Avec cette modication, tout ce code devient correct.

Du QCM précédent, on retiendra (outre le fait que Michel doit aussi retourner en cours... ) :

Pensez à récupérer les valeurs de retour de la fonction list_add_first() quand vous l'appelez !

2.3 Version finale de la fonction list_t list_add_first( double e, list_t l )

Vous devriez avoir compris les points clés permettant d'écrire la fonction list_t list_add_first( double e, list_t l ). En voici la version finale

Liste dynamique : version finale de la foncton d'ajout en tête

              // dans le fichier list.c
              list_t list_add_first( double val, list_t l ) {
                list_t p;

                p = calloc( 1, sizeof( *p ) );
                if ( NULL == p ) {
                  return l;
                  // En cas d'echec de calloc(),
                  // plutôt que de sortir violemment du programme avec exit()
                  // on décide de retourner la liste non modifiée.
                  // Cela permet à la fonction appelante de tester ce cas d'erreur.
                }

                p->val  = val;
                p->next = l;

                return p;
              }

              // dans le fichier test.c
              int main() {
                  list_t maliste;
                  maliste = list_new();
                  maliste = list_add_first(9.0,maliste);
                  maliste = list_add_first(8.0,maliste);
                }
              

Appel de la fonction pour ajouter le second élément.

Déclaration d'un pointeur p, pour le nouveau maillon :

  • Allouer dynamiquement un nouveau maillon, pointé par p ;
  • Vérifier que l'allocation dynamique s'est bien passée ;
  • Mettre 8.0 dans le nouveau maillon.

Le pointeur next de p doit indiquer l'ancienne tête de liste

La tête de la nouvelle liste le nouveau maillon, pointé par p.

On retourne cette nouvelle liste, repérée par p.

Retour au main.

Penser à mettre à a jour la variable liste (ici maliste) pour qu'elle pointe le nouveau maillon !

Il vous appartient de bien comprendre le code de cette fonction, de vous assurer qu'elle se comporte bien dans tous les cas (tracer l'algorithme dans le cas général, le cas liste à 1 élément et le cas liste vide) et de le retenir.

3. Suppression de l'élément de tête

La suppression de l'élément de tête consiste simplement à enlever le premier maillon et retourner le reste de la liste. On rappelle que le contrat et le prototype de la fonction à écrire est :


          // Supprime le maillon en tête de liste et retourne la nouvelle liste
          // PRECONDITION : liste non vide
          list_t list_del_first(list_t l);
        

3.1. Entrainons-nous sur 2 exemples comportant des erreurs courantes

Un erreur courante... Une erreur courante pour la fontion de suppression en tête :

            list_t list_del_first( list_t l ) {
              if(NULL == l) {
                // cas liste vide : Erreur
                fprintf(stderr, "Erreur : liste vide\n");
                exit(EXIT_FAILURE);
              }
              return l->next; // il suffit de retourner l'adresse du maillon suivant, nouveau 1er maillon !
            }
          
Que dire de ce code ?
Erreur à la compilation Ce code compile et semble fonctionner. On a pas libéré la mémoire du maillon de tête. C'est une fuite mémoire. Ce maillon a été alloué avec calloc dans la fonction d'ajout. Il faut le libérer avec free. Sin on ne le fait pas, la mémoire restera allouée, alors qu'on ne l'utilisera plus jamais... C'est une "fuite mémoire"
Un autre erreur courante ...

            list_t list_del_first( list_t l ) {
              free( l );
              return l->next;
            }
          
Quel(s) sont les problèmes de ce code ?
Erreur à la compilation On ne considère pas le cas "liste vide". Si liste est vide, il se produit une erreur de segmentation. Ligne 3, on accède à une case mémoire qui vient d'être libérée. Aie ! Ca pique ! Dès lors qu'on a appelé la fonction free() pour libérer une une zone mémoire allouée précédemment allouée dynamiquement, il est interdit d'accéder à cette zone mémoire.
Or, la ligne 3 accède au champ next du maillon qu'on vient de libérer !
Si la liste n'est pas vide, le comportement est aléatoire ; ca peut sembler marcher... jusqu'à ce qu'une segfault ait lieu. En effet, en C, accéder à une zone mémoire qui vient d'être libérée, comme cela est fait ligne 3, conduit à un comportement aléatoire : on peut avoir l'impression que ça marche... jusqu'à ce que le code "plante" parce que la zone mémoire vient d'être utilisée pour autre chose !

3.2. Construisons la fonction list_t list_del_first( list_t l )

On considère une liste contenant 2 éléments ( 8.0 9.0 ) :

Pour supprimer le maillon situé en tête de liste, qui contient l'élément 8.0, il faut :

  1. Sauvegarder un pointeur sur le reste de la liste ;
  2. Libérer la mémoire allouée pour le premier maillon contenant 8.0. Comme la mémoire a été allouée dynamiquement, il faut appeler free() pour la libérer.
  3. Retourner la nouvelle tête de liste.

3.3. Version finale de la fonction list_t list_del_first( list_t l )

Voici le code final de la fonction de suppression en tête de liste :

Liste dynamique : version finale de la fonction de suppression en tête

                // dans le fichier list.c
                list_t list_del_first( list_t l ) {
                  list_t p;
                  // Vérification de la précondition "liste non vide", avec une assertion
                  assert ( ! list_is_empty( l ) ) ;

                  // cas général
                  p = l->next;
                  free( l );
                  return p;
                }

                // dans le fichier test.c
                int main() {
                    list_t maliste;
                    maliste = list_new();
                    maliste = list_add_first(9.0,maliste);
                    maliste = list_add_first(8.0,maliste);
                    maliste = list_del_first(maliste); // On supprime le maillon de tête
                    list_print(maliste);
                }
              

La liste contient 2 éléments ; on appelle la fonction de suppression en tête.

Si la liste est vide, on ne peut pas supprimer le maillon de tête bien sûr. En fait, il est absurde de "supprimer en tête" d'une liste vide. Le contrat de la fonction le précise bien, avec la précondition la liste ne doit pas être vide.

Puisqu'il s'agit d'une précondition, on préfère traiter ce cas particulier absurde avec un appel à assert(), plutôt qu'avec un if( liste vide) et un exit(EXIT_FAILURE); .

Si vous ne savez plus ce qu'est assert(), reportez vous à la fiche Notion de Type Abstrait de Données !

Notre liste n'est pas vide, donc on arrive ici.

On stocke le reste de la liste au moyen d'un pointeur p.

A cette étape, p vaut l'adresse du deuxième maillon ou NULL si on est en fin de liste.

Libération de la mémoire du premier maillon

Pour rappel, il ne faut plus jamais accéder l'adresse pointée par l après l'appel de free() ! Ca tombe bien, on ne le fait pas : on a pris garde mettre de côté l'adresse du 2ème maillon avant de supprimer le premier.

La tête de la nouvelle liste est l'ancien deuxième maillon, ou NULL si jamais la liste n'avait qu'un seul maillon.

On retourne donc l'adresse de la nouvelle tête de liste, c'est à dire la valeur du pointeur p.

Fin de la fonction et retour au main.

Penser à récupérer la valeur de retour de la fonction pour changer l'adresse pointée par la variable liste !

Il vous appartient de bien comprendre le code de cette fonction, de vous assurer qu'elle se comporte bien dans tous les cas (tracer l'algorithme dans le cas général, le cas liste à 1 élément et le cas liste vide) et de la retenir.

4. En conclusion

Cette fiche et la précédente ont progressivement mis en place la structure de donnée "liste chaînée dynamique" : la définition des types C supportant cete structure de donnée, et les principales fonctions réalisant les principales opérations sur les listes.

Nous avons ce faisant expliqué comment travailler pour y parvenir.

La fiche suivante donne in extenso l'implantation de référence des listes chaînées dynamiques, qui vous accompagnera durant tout le semestre et au delà. A bien connaître !