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 fonctioncreation_liste()
et une fonctioninsertion()
; 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 pointeurssuivant
alors que seules les fonctions dansliste.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