En informatique, un Type Abstrait de Données (TAD) définit formellement (et indépendamment de toute implémentation) un type de données et les opérations sur les données de ce type.

D'ici la fin du semestre, le cours présentera les principaux Types Abstraits de Données "conteneur", c'est à dire permettant de grouper et manipuler plusieurs éléments de même type. Vous aprendrez également quel TAD choisir pour un problème donné, comment implanter ces TAD en C et comment les utiliser.

Cette fiche introduit la notion de TAD. Elle s'appuie pour cela sur un exemple très simple : un TAD "Rectangle".

1. Introduction

En programmation, un Type Abstrait de Données (TAD) est la spécification formelle :

  • d'un type de données que le programme devra manipuler
  • et des opérations que l'on devra pouvoir effectuer sur les données de ce type.

On dit qu'un TAD est "abstrait" car il est défini en termes mathématiques, indépendamment de toute implémentation dans un langage de programmation.

Un TAD se définit par :

  • son nom ;
  • les identifiants, signatures et contrats de chaque opération ;
  • les préconditions à respecter pour chaque opération ;
  • les axiomes, qui décrivent les relations entre les opérations.

Dans le cycle de développement logiciel, on distingue ainsi trois phases (ou points de vue) :

  • Phase de conception/spécification : le concepteur du type abstrait spécifie de façon non ambiguë le TAD, notamment les opérations du type abstrait. Ici, on pense les algorithmes d’une manière abstraite, sans connaître leur implémentation future.
  • Phase de développement ou d'implémentation : le programmeur choisit une représentation physique du TAD, au moyen d'un langage de programmation. Il déclare un type dans ce langage. Il écrit des fonctions manipulant les variables de ce type, pour donner corps aux opérations définies par le concepteur, en respectant préconditions et axiomes. Cette implémentation est appelée "structure de données".
  • Phase d'utilisation : l’utilisateur (en général un autre programmeur) utilise l'implémentation du type abstrait pour son application en n'exploitant que les fonctions définies par le concepteur et écrites par le programmeur.

Pour un TAD donné, il existe plusieurs implémentations possibles.

Lors de l'implémentation, le développeur prend garde à ce que les choix faits pour la structure de données réalisant le type abstrait conduise à des complexités aussi faibles que possible (coût en mémoire et coût en temps d'exécution).

C'est cette contrainte qui nous guidera notamment dans la suite du cours lorsque nous introduirons les implémentations standards de quelques types abstraits courants.

2. Exemple de définition d'un TAD : le TAD Rectangle

Prenons l'exemple d'un cycle de développement logiciel qui aurait besoin de manipuler des rectangles.

Après analyse du problème, le concepteur définit le type abstrait "Rectangle" :

  • le nom du TAD :
    Rectangle
  • les opérations (... les choix faits dans cet exemple ont un but pédagogique et sont critiquables ... ) :
    • RectangleCreer(Réel centre_x, Réel centre_y, Réel longueur, Réel hauteur) -> Rectangle :
      Crée et retourne un rectangle centré sur (centre_x, centre_y) et de taille (longueur x hauteur) ;
    • RectangleTranslater (Rectangle r, Réel dx, Réel dy) -> Rectangle :
      Retourne le rectangle translaté de dx, dy ;
    • RectangleAireEtPerimetre (Rectangle r) -> Réel, Réel :
      Retourne l'aire et le périmètre du rectangle ;
    • RectangleEgal (Rectangle r1, Rectangle r2) -> Booléen :
      Retourne vrai si r1 et r2 représentent le même rectangle du plan ;
    • RectangleComparerAire (Rectangle r1, Rectangle r2) -> Entier :
      Compare les aires des rectangles. Retourne 0 si elles sont égales, un nombre strictement négatif si l'aire de r1 est inférieure à l'aire de r2 et un nombre strictement positif si l'aire de r1 est supérieure à l'aire de r2 ;
    • RectangleAfficher (Rectangle r) -> Néant :
      Affiche le rectangle dans une représentation compréhensible par l'utilisateur.
    • etc. (on pourrait considérer d'autres opérations sur les rectangles.)
  • les préconditions :
    • RectangleCreer(Réel centre_x, Réel centre_y, Réel longueur, Réel hauteur) -> Rectangle :
      longueur et hauteur sont deux réels positifs ou nuls.
    • etc.

    L'ensemble des opérations que l'utilisateur pourra effectuer sur le TAD porte le nom d'API (pour Application Programming Interface), que l'on traduirait en français par "interface de programmation applicative", soit la manière dont le programmeur pourra programmer son application en utilisant les opérations du TAD.

    Il est courant de rappeler le nom du TAD dans une fonction afin de savoir à quel TAD elle se réfère. Imaginez que nous ajoutions le TAD Triangle. Comment savoir si l'opération Egal se rapporte à l'un ou l'autre ?

  • les axiomes :
    • Aire(CreerRectangle(x, y, 0., 0.)) == 0.
    • RectangleEgal(RectangleCreer(x, y, l, L), RectangleCreer(x, y, l, L)) == vrai
    • etc.

Pour les axiomes, il faut assurer la complétude (tous les comportements sont modélisés) et la consistance (pas de contradiction entre les axiomes).

3. Notion de précondition en programmation et fonction assert(expressionBooleenne) en C

Considérant une fonction C (ou une opération, si l'on est en train de définir un TAD), une précondition est une condition booléenne que doivent respecter les paramètres pour qu'il soit légitime d'appeler la fonction.

Les préconditions déterminent donc le domaine d'usage de la fonction (ou de l'opération, pour un TAD).

Si l'une des préconditions de la fonction n'est pas respectée lors de l'exécution de la fonction (c'est-à-dire si l'on sort de son domaine d'usage), alors la fonction se donne le droit de "faire n'importe quoi" : arrêter le programme, provoquer une erreur de segmentation, corrompre les données en mémoire, vous envoyer sur la lune, ou autre chose.

Les préconditions sont annoncées dans la documentation (le contrat) de la fonction. Par exemple, vous pourrez lire, en commentaire avant le prototype de la fonction dans le fichier .h, que si telle condition n'est pas vérifiée, alors le comportement de la fonction est imprédictible (unspecified).

Voici 2 exemples de préconditions sur 2 fonctions de la libc :

  • double sqrt(double x); : PRECONDITION : "x > 0"
  • void free(void *ptr);   : PRECONDITION : "ptr doit pointer une zone mémoire allouée dynamiquement."

Et un autre exemple, dans notre spécification du type abstrait Rectangle : on a décidé qu'il ne doit pas être possible de créer un rectangle de longueur ou hauteur négative. Ceci est spécifié par une précondition dans le contrat de l'opération l'opération RectangleCreer.

Par principe, une pré-condition est censée être garantie avant d’appeler la fonction, par le code appelant – et non pas dans la fonction elle même. Toutefois, en phase de développement (donc avant de livrer le programme), il est extrêmement utile de s’assurer que les préconditions d’une fonction sont bien vérifiée à chaque appel. Cela ralentit considérablement l’exécution du programme... mais permet de détecter au plus vite de nombreuses erreurs !

Pendant le développement, on pourrait écrire au début de chaque fonction une série de conditionnelles (if) qui chacune vérifie une pré-condition et qui affiche un message explicatif (par exemple « appel de la fonction invalide : pré-condition truc non vérifiée ») puis arrête brutalement le programme si elle n’est pas vérifiée en appelant exit(1).

Mais, idéalement, il faudrait que les préconditions soient vérifiées par la fonction pendant la phase de développement du programme. Puis, une fois que tout a été testé et que le programme est livré au client, on voudrait pour gagner du temps d'exécution qu'elles ne soient plus vérifiées par la fonction.

En C, c'est exactement le mécanisme d'assertion assert(expressionBooleenne)

En language C, la macro assert(expressionBooleenne), dont le prototype est défini dans assert.h, arrête le programme avec un message d’erreur explicite (nom du fichier, numéro de ligne, etc.) si l’expression expressionBooleenne est fausse.

Lorsque le programme a été bien testé et débuggé, et qu'on s'apprête à le livrer au client, il est possible de désactiver assert en passant -DNDEBUG au compilateur.

Ainsi, donc si on compile avec :

                   gcc -DNDEBUG monFichier.c
                 

alors assert(expressionBooleenne) n’est tout simplement pas exécuté : c’est alors comme si l’appel n’était pas dans le code.

Par exemple, voici comment vérifier la précondition au début de la fonction rectangle_create() avec assert() :


            // fichier rectangle.c
            ...
            #include <assert.h>
            ...

            rectangle_t rectangle_create(double x, double y, double width, double height) {
              rectangle_t r;

              // vérification des préconditions dans la fonction avec assert
              assert(width >=0 &&  height >= 0) ;

              ...
            }
          

En résumé, pour un code plus clair et plus robuste :

  • Dans les fichier header .h, indiquez en commentaire les préconditions qui doivent être vérifiées avant l'appel de la fonction.
  • Dans votre code, pensez à utiliser assert(expressionBooleenne) pour vérifier les préconditions au début de vos fonctions.

Une fiche plus détaillée sur les contrats de fonction et sur assert() est disponible sur le site du cours.

4. Exemple d'implémentation d'un TAD : une structure de données Rectangle

Pour implémenter un TAD nommé "MonTAD" en C dans une structure de données, voici comment nous procéderons dans le cadre de ce module :

  • Le développeur écrit un module en C pour le TAD : un couple de fichiers montad.h et montad.c.
  • Le développeur définit d'abord le fichier d'en-tête montad.h :
    • Définition du type en C.
    • Traduction de la signature de chaque opération en prototype de fonction C. L'ensemble de ces prototypes constitue donc l'API du type abstrait.
    • Ajout d'un commentaire avant chaque prototype, qui précise le contrat de la fonction. Le contrat précise notamment les préconditions à respecter avant l'appel de la fonction.
  • Le développeur écrit ensuite le fichier source montad.c :
    • Il prend garde à respecter les préconditions et les axiomes du TAD.
    • En C, il vérifie les préconditions au début de la fonction à l'aide la macro void assert(expressionBooléenne);.
  • Par convention :
    • On utilisera l'anglais dans le code source ;
    • Le type C sera nommé montad_t ;
    • les noms des fonctions seront préfixés par montad_.
  • Le développeur teste tout cela proprement : axiomes, préconditions...
  • Cela fait, le développeur peut fournir son implémentation du TAD à l'utilisateur :
    • Le fichier d'en-tête montad.h
    • Et :
      • soit le fichier source montad.c, auquel cas l'utilisateur devra compiler ce fichier avec le reste de son programme ;
      • soit uniquement le fichier binaire montad.o, résultat de la compilation du fichier source montad.c. En effet, l'utilisateur n'est pas censé avoir besoin de modifier ces sources.

Parfois, dans le fichier source montad.c, il est utile d'insérer des fonctions supplémentaires qui facilitent l'écriture des fonctions de l'API. Ces fonctions ne seront utilisées que dans montad.c.

On distingue alors :

  • les fonctions qui sont utiles et visibles de l'utilisateur. Elles définissent l'API du module. Elles sont dites "publiques". Leur prototype est donné dans montad.h. Par défaut, une fonction en C est publique ;
  • celles qui ne sont utiles qu'au programmeur du TAD et doivent être invisibles de l'utilisateur. Elles sont dites "privées". Elles ne font pas partie de l'API du module. Leur prototype n'est pas donné dans montad.h. Enfin, en C, elles sont déclarées avec le mot-clef static.

En langage C, il est possible de définir des types qualifiés d'opaques.

Un type opaque n'est défini que dans le fichier source. Dans le fichier d'en-tête, n'apparaît alors que le nom du type, et (surtout) pas sa structure.

Seul le fichier source implémentant le TAD peut manipuler directement le contenu des variables de ce type. Dans les autres fichiers du programme, on n'a pas d'autre choix que d'utiliser les fonctions du module.

Le type FILE* est un exemple de type opaque : vous ne saurez pas comment il est défini, et vous ne connaissez que la liste des fonctions vous permettant de manipuler des fichiers (fopen, fclose, etc.)

Mettre en oeuvre un type opaque permet de séparer plus proprement l'implémentation (les choix faits pour le type) et le type abstrait (accessible alors uniquement par les opérations).

La notion de type opaque n'est pas au programme de ce cours. Pour en savoir plus, consulter la section ** du poly de Fr Cayre**.

4.1 API de la structure de données Rectangle

Pour le fichier d'en-tête rectangle.h de notre TAD "Rectangle", la définition du type C est simplissime : le type rectangle_t sera un type structuré à 4 champs double, pour la position (x, y) et la taille (longueur, hauteur).

La définition des prototypes des fonctions de l'API est également simple.

Seul le prototype traduisant l'opération RectangleAireEtPerimetre (Rectangle r) -> Réel, Réel nécessite un peu de réflexion.

En effet :

  • Cette opération retourne 2 valeurs. Or, en C, une fonction ne peut retourner qu'une seule valeur avec return !
  • Il est donc nécessaire ici d'avoir recours au passage par adresse pour au moins l'une des deux valeurs à retourner.
  • Comme il n'y a pas de raison de privilégier l'aire ou le périmètre, on choisira plutôt dans ce cas d'avoir recours à deux paramètres pointeurs, pour l'aire et le périmètre — et la fonction ne retournera rien au sens de return.

Après cette intense réflexion, on peut écrire le contenu du fichier d'en-tête montad.h.

Ci-dessous, repérez :

  • La garde (#ifndef _RECTANGLE_H_, #define _RECTANGLE_H_ ... #endif _RECTANGLE_H_), comme dans tout fichier d'en-tête .h ;
  • Les conventions de nommage et l'usage de l'anglais ;
  • Les commentaires, qui précisent le contrat de chaque fonction de l'API ;
  • Dans ces commentaires, la mention des préconditions (e.g. fonction rectangle_create()) ;
  • L'usage de pointeurs pour la fonction rectangle_get_area_and_perimeter().


      // fichier rectangle.h pour le TAD Rectangle
      #ifndef _RECTANGLE_H_
      #define _RECTANGLE_H_

      // 1. declaration du type rectangle_t
      typedef struct {
        double x;
        double y;
        double width;
        double height;
      } rectangle_t;

      // 2. prototype des fonctions de l'API du TAD Rectangle

      // Retourne un rectangle de centre (x, y) et de taille (width x height)
      // PRECONDITION : width > 0
      // PRECONDITION : height > 0
      rectangle_t rectangle_create(double x, double y, double width, double height);

      // Retourne le rectangle r translaté de (dx, dy)
      rectangle_t rectangle_translated(rectangle_t r, double dx, double dy);

      // Stocke l'aire et le périmètre de r
      // repectivement dans *parea et *pperimeter
      // PRECONDITION : parea et pperimeter pointent bien 2 variables de type double
      void rectangle_get_area_and_perimeter(rectangle_t r, double *parea, double *pperimeter);

      // Retourne 1 si r1 et r2 ont même position et taille, 0 sinon
      int rectangle_equal(rectangle_t r1, rectangle_t r2);

      // Retourne :
      //    0 si r1 et r2 ont la même aire
      //    un nombre >0 si r1 est plus grand que r2 (au sens de l'aire)
      //    un nombre <0 si r1 est plus petit que r2 (au sens de l'aire)
      int rectangle_compare_areas(rectangle_t r1, rectangle_t r2);

      // Affiche r dans le Terminal
      void rectangle_print(rectangle_t r);

      #endif
      

4.2 Fichier source de la structure de données Rectangle

Pour les curieux, on donne ci-dessous in extenso le code du fichier rectangle.c.


    // fichier rectangle.c
    #include <stdlib.h>
    #include <stdio.h>
    #include <math.h>

    // Rq : on inclut toujours le fichier d'en-tete du module
    #include "rectangle.h"

    // PROTOTYPE DES FONCTIONS PRIVEES
    static double rectangle_area_private(rectangle_t r);
    static double rectangle_perimeter_private(rectangle_t r);


    rectangle_t rectangle_create(double x, double y, double width, double height) {
      rectangle_t r;
      // gestion des préconditions de la fonction avec assert
      assert(width >=0 &&  height >= 0) ;

      r.x = x;
      r.y = y;
      r.width = width;
      r.height = height;
      return r;
    }

    rectangle_t rectangle_translate(rectangle_t r, double dx, double dy) {
      rectangle result = r;
      result.x += dx;
      result.y += dy;
      return result;
    }

    void rectangle_get_area_and_perimeter(rectangle_t r, double *parea, double *pperimeter) {
      *parea      = rectangle_area_private(r);
      *pperimeter = rectangle_perimeter_private(r);
    }

    int rectangle_equal(rectangle_t r1, rectangle_t r2) {
      return r1.x == r2.x
              && r1.y == r2.y
              && r1.width == r2.width
              && r1.height == r2.height;
    }

    int rectangle_compare_areas(rectangle_t r1, rectangle_t r2) {
      double area1 = rectangle_area_private(r1);
      double area2 = rectangle_area_private(r2);
      if(area1 == area2) {
        return 0;
      }
      if(area1 > area2){
        return 1;
      }
      return -1;
    }


    void rectangle_print(rectangle_t r) {
      printf("Rectangle (x=%lf, y=%lf) (l=%lf ; w=%lf)\n", r.x, r.y, r.width, r.length);
    }

    ////////////////////////////////////////
    // code des fonctions privées du module
    ////////////////////////////////////////
    static double rectangle_area_private(rectangle_t r) {
      return r.width * r.height;
    }

    static double rectangle_perimeter_private(rectangle_t r) {
      // rq : le module assure que width et height sont toujours > 0
      return 2. * (r.width + r.height) ;
    }

    

Mais ce code n'est pas très intéressant... Pour les plus pressés d'entre vous, voici les quelques extraits qui appellent des commentaires.

  • Le début du fichier rectangle.c :

    Dans notre exemple, pour faciliter l'écriture des fonctions de l'API, on a jugé utile de définir ainsi 2 fonctions utilitaires privées, qui ne font pas partie de l'API.

    Notez qu'on liste les prototypes de ces fonctions utilitaires privées au début du fichier source, et notez que ces fonctions sont déclarées avec le qualifieur static.

    
          // fichier rectangle.c
          #include <stdlib.h>
          #include <stdio.h>
          #include <math.h>
    
          // Rq : on inclut toujours le fichier d'en-tete du module
          #include "rectangle.h"
    
          // PROTOTYPE DES FONCTIONS PRIVEES
          static double rectangle_area_private(rectangle_t r);
          static double rectangle_perimeter_private(rectangle_t r);
    
          // ... le code de ces fonctions sera donné plus loin dans ce fichier ...
    
          ...
          
  • Exemple de gestion de précondition :

    Si une précondition n'est pas respectée, la fonction "a le droit de faire n'importe quoi".

    On pourrait par exemple tester la précondition au début de la fonction et, si elle n'est pas vérifiée, afficher un message d'erreur (dans le flux d'erreur standard stderr) puis quitter violemment le programme avec exit(1);

    Beaucoup mieux : vérifier la précondition au début de la fonction avec assert(...);

    Voici un exemple :

    
          // fichier rectangle.c
          #include <assert.h>
          ...
    
          rectangle_t rectangle_create(double x, double y, double width, double height) {
            // vérification des préconditions de la fonction avec assert
            assert(width >=0 &&  height >= 0) ;
            .... ici, la suite du code de la fonction ...
          }
    
          ...
    
          
  • Exemples d'usage des fonctions privées :
    
        // fichier rectangle.c
    
        ...
    
        void rectangle_get_area_and_perimeter(rectangle_t r, double *parea, double *pperimeter) {
          *parea      = rectangle_area_private(r);
          *pperimeter = rectangle_perimeter_private(r);
        }
    
        int rectangle_compare_areas(rectangle_t r1, rectangle_t r2) {
          double area1 = rectangle_area_private(r1);
          double area2 = rectangle_area_private(r2);
          if(area1 == area2) {
            return 0;
          }
          if(area1 > area2){
            return 1;
          }
          return -1;
        }
    
        ...
        

    Remarquez que :

    • Le comparateur rectangle_compare_areas() retourne bien un nombre nul, négatif ou positif, suivant les valeurs des aires ;
    • Dans la fonction rectangle_get_area_and_perimeter(), il n'a pas été nécessaire cette fois-ci de vérifier explicitement la précondition "les pointeurs pointent bien 2 variables de type double". En effet, le compilateur émettra un message d'erreur si l'on passe des adresses d'entiers, et si jamais ils pointent n'importe où, la fonction fera d'elle-même "n'importe quoi" — en l'occurrence très probablement une erreur de segmentation lors de l'exécution.

  • 5. TAD conteneurs : stocker et manipuler plusieurs éléments

    Notre exemple "TAD Rectangle" est fort simple (simpliste, même). Au-delà de cet exemple introductif, en fait :

    La notion de type abstrait prend une grande importance lorsqu'il s'agit de stocker et manipuler plusieurs éléments de même type.

    Pour faire cela, vous ne connaissez pour le moment que les tableaux en C. Ils s'avèrent rapidement insuffisants. On a besoin, en programmation, de nombreuses autres manières d'organiser et manipuler plusieurs éléments.

    L'histoire de l'Informatique a donc conduit à définir formellement plusieurs TAD permettant de stocker et manipuler plusieurs éléments.

    On appelle parfois ces TAD les "TAD conteneurs" (pour "conteneurs d'éléments").

    Les principaux TAD conteneurs pour stocker et manipuler plusieurs éléments sont :

    • les tableaux (déjà vus dans ce cours, bien sûr !)
    • les tableaux redimensionnables
    • les listes (vues dans ce cours)
    • les piles (LIFO) (vues dans ce cours)
    • les files (FIFO) (vues dans ce cours)
    • les tables d’association ou dictionnaires (vues dans ce cours)
    • les ensembles, au sens de Cantor.
    • les files de priorité
    • les arbres et leurs variantes (abordés dans ce cours)
    • les graphes et leurs variantes (abordés dans ce cours)
    • (...)

    Le choix d'un TAD conteneur, puis l'implémentation de ce TAD au moyen d'un langage de programmation, ont un impact majeur sur ce qu'on pourra faire avec le "paquet d'éléments" et sur l'efficacité du programme.

    Il est donc important de connaître ces principaux TAD et leur implémentation-type.

    Dans la suite de ce cours, les TAD décrits seront les principaux TAD conteneurs permettant de stocker et manipuler "plusieurs éléments". Leur conception est acceptée de longue date : vous aurez donc uniquement le rôle de programmeur et d'utilisateur.

    Néanmoins, vous vous rendrez vite compte à quel point vous aurez intérêt à programmer toutes vos structures de données comme des TAD, conteneurs ou pas : en procédant ainsi que nous avons vu, vous produirez naturellement et sans même vous en rendre compte un code à la fois pérenne, robuste et évolutif. Pour le dire autrement : vous appliquerez sans le savoir quelques préceptes fondamentaux du génie logiciel, l'ensemble des techniques permettant de garantir qu'un code sera à la fois efficace et aisé à maintenir et à utiliser — et dont le coût de développement sera donc davantage maîtrisable.