Utiliser des types opaques en C

 x0r   2
programmation c code opaque type types struct malloc

Cela fait déjà quelques temps que je montre le langage C à ma chère et tendre Nausicaa et que je la vois faire de plus en plus de choses avec. Je me suis donc dit que je pourrais peut-être faire une série d'articles afin de l'aider à mieux coder, et les partager ici pour que vous en profitiez tous.

N'importe qui ayant fait du C de manière à peu près sérieuse a déjà utilisé des structs pour créer des types de données composites, par exemple des listes chaînées ou plus ou moins ce qu'on appellerait des classes dans des langages (orientés) objet.

Cependant, lorsqu'un programme C est constitué d'une multitude de modules et qu'il touche à de nombreux types de données, on rentre vite dans des problèmes de dépendances circulaires : un module a a besoin de la déclaration d'une struct type_b qui est dans le module b (le header b.h); or le module b a besoin de la déclaration d'un struct type_c dans le module c (fichier c.h), et ce module c a besoin de connaître l'existence du type struct type_a dans le module a (a.h). La compilation échoue alors sur une erreur de la forme « type struct type_a inconnu » alors qu'il est pourtant déclaré correctement.

Nous allons d'abord expliquer pourquoi ces dépendances circulaires n'en sont pas vraiment en expliquant la différence entre déclaration et définition ; nous verrons ensuite ce que sont les fameux types opaques et comment ceux-ci permettront de casser la majorité des dépendances circulaires.

La différence entre déclaration et définition

Imaginons que nous voulions mettre en place une structure de liste chaînée, très simple, et tant qu'on y est, générique. Alors, on va avoir deux fichiers, liste.h et liste.c. Un programmeur C novice écrirait alors quelque chose qui ressemblerait au listing suivant :

liste.h :

#ifndef HEADER_LISTE
#define HEADER_LISTE

typedef struct liste liste;

struct liste {
    void * data;
    liste* suivant;
};

liste* creation_liste();
liste* insertion(liste* l, void* data);

#endif

liste.c :

#include <stdlib.h>
#include "liste.h"

liste* creation_liste() {
    return NULL;
}

liste* insertion(liste* l, void* data) {
    liste* e;
    liste* new_element = malloc(sizeof(struct liste));
    new_element->data = data;
    new_element->suivant = NULL;

    if (l == NULL)
        return new_element;

    for (e = l; e->suivant != NULL; e = e->suivant);

    e->suivant = new_element;
    return l;
}

En C, il est très important de connaître la différence entre déclarer et définir un objet (que ce soit une fonction, une variable, un struct, enum, union ou typedef) :

  • Déclarer un objet, c'est signaler au compilateur qu'il existe. Par exemple, les prototypes des fonctions dans le .h sont des déclarations : on dit au compilateur « tiens, il y a une fonction creation_liste() et une fonction insertion() ; ce qu'ils font exactement est écrit ailleurs mais sache qu'ils existent vraiment » ;
  • Définir un objet, c'est expliciter au compilateur ce qu'est exactement cet objet. Si liste.h contient les déclarations de deux fonctions, on les définit dans le .c. Dans le cas de fonctions, on dit aussi qu'elles sont déclarées dans le .h et implémentées dans le .c.

La subtilité, c'est que lorsqu'on définit un objet qui n'a pas encore été déclaré ailleurs, on le déclare également. Par exemple, l'instruction

int a = 42;

et les deux instructions

int a;
a = 42;

font tous les deux une déclaration suivie d'une définition de la variable a.

Ajoutons maintenant un fichier main.c :

main.c :

#include <stdio.h>
#include "liste.h"

int main(int argc, char* argv[]) {
    liste* l = creation_liste();
    liste* e;

    int a = 42;
    int b = 1337;

    /* 
     * Normalement, ça c'est mal, parce que &a et &b ne sont plus
     * des pointeurs valables lorsqu'on sort de la fonction
     * main(), mais c'est juste pour la démonstration.
     */
    l = insertion(l, (void*)(&a));
    l = insertion(l, (void*)(&b));

    printf("Voici le contenu de ma liste : \n");
    for (e = l; e != NULL; e = e->suivant) {
        printf(" * %d\n", *(int*)(e->data));
    }

    return 0;
}

Ce fichier ajoute les entiers 42 et 1337 à une liste et les affiche aussitôt. Notre liste chaînée ne sait stocker que des pointeurs (les pointeurs void * sont des pointeurs génériques) ; il faut donc stocker les adresses de a et de b. Pour les afficher, on caste le void * en int * puis on déréférence le pointeur.

Passer aux types opaques

Ce genre de code fonctionne bien lorsqu'on a un petit projet. Cependant, il y a quelques problèmes potentiels :

  • si on décide de modifier la liste chaînée en liste doublement chaînée, ça casse les fonctions qui l'utilisent ;

  • si on décide de changer le nom des membres, ça casse toutes les fonctions qui les utilisent ;

  • chaque module utilisant liste.h peut casser les listes et peuvent par exemple modifier les pointeurs suivant alors que seules les fonctions dans liste.c ont besoin d'y toucher ;

  • enfin, chaque module utilisant notre liste chaînée a besoin de connaître la définition de la struct liste pour pouvoir fonctionner.

Le dernier point est le plus problématique, et c'est souvent la cause des dépendances circulaires. Heureusement, il est possible de séparer la déclaration et la définition de notre liste chaînée. Voilà comment on fait :

liste.h :

#ifndef HEADER_LISTE
#define HEADER_LISTE

typedef struct liste* liste_t;

liste_t creation_liste();
liste_t insertion(liste_t l, void* data);

#endif

La première modification majeure est qu'on a fait un typedef qui fait un alias vers un pointeur de struct liste. Ce typedef est parfaitement légal, car le compilateur connaît toujours la taille en mémoire d'un pointeur, indépendamment de ce vers quoi il pointe. Ainsi, un module qui inclut liste.h pour ensuite déclarer une liste_t maliste; quelque part dans une fonction ne fait pas d'erreurs à la compilation. De toute façon, nous ne faisions que manipuler des pointeurs vers ces structures tout au long.

On peut néanmoins se demander où est passée la définition de struct liste. Elle est dans le fichier .c correspondant :

liste.c :

#include <stdlib.h>
#include "liste.h"

struct liste {
    void * data;
    liste_t suivant;
};

liste_t creation_liste() {
    return NULL;
}

liste_t insertion(liste_t l, void* data) {
    liste* e;
    liste* new_element = malloc(sizeof(struct liste));
    new_element->data = data;
    new_element->suivant = NULL;

    if (l == NULL)
        return new_element;

    for (e = l; e->suivant != NULL; e = e->suivant);

    e->suivant = new_element;
    return l;
}

Là aussi on a remplacé toutes les occurrences de liste* par liste_t. À part ça, rien ne change.

Néanmoins, compiler le programme en l'état ne marchera pas immédiatement. gcc donnera des insultes du style Dereferencing pointer to incomplete type.

Ce que ça veut dire : en compilant main.c, on a inclus liste.h et le typedef struct liste* liste_t. Ainsi, gcc sait qu'un type liste_t existe et que c'est en fait un pointeur vers une struct liste. Mais comme on a caché la définition de la struct liste dans le .c, il ne connaît pas le contenu de la struct, ni sa taille, ni si le membre suivant existe et est bien du type liste_t, ni que pour accéder à ce membre suivant il faut en fait prendre les octets 4 à 7 de la struct (8 à 15 si vous êtes en 64 bits) et les interpréter comme un pointeur… bref, il ne connaît rien de tout cela.

Ce qui pose problème sont la troisième clause de la boucle for (donc e = e->suivant) et l'expression qui permet d'extraire une valeur de la liste, à savoir *(int*)(e->data).

La solution consiste tout simplement à remplacer ces clauses par deux fonctions : une fonction qui renvoie le membre element d'un liste_t et une autre fonction qui renvoie le membre data interprété en tant que int. Il suffit alors de les rajouter dans le header :

liste_t liste_get_suivant(liste_t element);
int liste_get_data_int(liste_t element);

et de les implémenter comme suit :

liste_t liste_get_suivant(liste_t element) {
    return element->suivant;
}

int liste_get_data_int(liste_t element) {
    return *(int*)(element->data);
}

Ce qui nous permet enfin de réécrire la boucle du main.c ainsi :

printf("Voici le contenu de ma liste : \n");
for (e = l; e != NULL; e = liste_get_suivant(e)) {
    printf(" * %d\n", liste_get_data_int(e));
}

Une petite macro pour finir

Si on ajoute dans list.h la macro suivante :

#define liste_foreach(iterator, list) \
    for ((iterator) = (list); \
        ((iterator) != NULL); \
        (iterator) = liste_get_suivant(iterator))

on peut alors écrire la boucle du main.c ainsi :

printf("Voici le contenu de ma liste : \n");

/* pour chaque élément e dans la liste l */
liste_foreach (e, l) {
    printf(" * %d\n", liste_get_data_int(e));
}

Élégant, n'est-ce pas ?

Vers de la programmation orientée objet en C

J'ai loin d'avoir montré toutes les subtilités des types opaques en C, mais je pense que ceux qui ont déjà programmé dans des langages (orientés) objet verront dans les fonctions liste_get_suivant et liste_get_data_int des sortes d'« accesseurs » (un « getter », plus exactement) pour les éléments de notre liste chaînée. Dans notre exemple, nous n'avions pas besoin de plus, mais il serait tout à fait aisé d'imaginer les « setters » et autres « méthodes ».

Si on voulait écraser une valeur dans un élément d'une liste, il suffirait d'écrire une fonction dont le prototype est :

void liste_set_data(liste_t element, void* data);

Ce que j'ai montré ici ressemble donc presque à une sorte de façon détournée de faire de la programmation orientée objet en C. Mais en fait, c'est plus ou moins la façon dont c'est implémenté : en C, il est d'usage que lorsqu'on code une fonction qui opère sur une structure, que le premier paramètre de cette fonction soit cette structure. De la même façon que toute méthode d'une classe reçoit systématiquement comme paramètre caché le pointeur this en C++.

J'en profite enfin pour faire un petit peu de publicité pour le blog de Nausicaa et son article « Le pessismisme français : radiographie d'une représentation ». Un post très intéressant que j'ai lu avec beaucoup de plaisir sur un blog qui cherche à démystifier toute un tas d'idées reçues plus ou moins en lien avec l'actualité.

Commentaires

Poster un commentaire

Edgar Bonet

Bonjour ! Voici un article accessible et utile sur la programmation structurée, mais on dirait que le code n'a pas été testé avant d'être posté. Je me permets quelques corrections :

– Dans insertion(), il ne faut pas oublier d'initialiser new_element->suivant à NULL (ou alors remplacer malloc() par calloc()), autrement on risque le segmentation fault aléatoire dès qu'on parcourt la liste. Le programme peut marcher si on a la chance que les octets en question soient déjà à zéro, mais ce n'est pas garanti.

– insertion() doit renvoyer liste* (première version) ou liste_t (deuxième version) plutôt que void.

– Dans la deuxième version de liste.c, garder le #include <stdlib.h> (déclaration de NULL) et remplacer liste* par liste_t.

– Dans la deuxième et troisième version de main.c, garder le \n dans le format de printf().

– Dans la macro liste_foreach(), il faut remplacer (iterator)->next par liste_get_suivant(iterator), autrement on a une erreur « dereferencing pointer to incomplete type » quand on l'utilise dans main.c.

P.S.: Je vous suggère d'effacer mon commentaire après avoir fait les corrections.

x0r

Oups ! J'avais en fait testé une partie du code, mais j'avais oublié de répercuter les modifications. Merci de m'avoir rappelé que je ne suis pas (encore) un Mozart du C ! :)

Poster un commentaire