Structures de données génériques en C
Contrairement à d’autres langages (tels que C++, Ada, Java, …) le C n’offre pas de réel support pour la programmation générique. Cela ne signifie pas que la programmation générique est impossible en C, par contre elle nécessite plus d’effort pour être mise en œuvre. Dans cet article, je vais présenter deux approches :
-
une approche à base de
void*
, que je nomme approche « traditionnelle ». - une approche intrusive, sur laquelle je m’attarderai un peu plus car elle me semble moins connue.
Dans la suite de cet article, je vais prendre l’exemple d’une liste simplement chaînée. En effet, c’est une des structures de données de bases et on la retrouve dans d’autres structures de données (certaines tables de hachage par exemple) ou algorithmes. Elle peut même servir de support à un langage (comme les dialectes Lisp où tout est liste, le programme lui-même y compris).
Approche « traditionnelle »
L’approche la plus courante (on la retrouve dans la
GLib
et les
EFL
entre autres) est l’utilisation d’un pointeurvoid*
. Pour
implémenter une liste simplement chaînée générique on pourrait donc avoir le
code suivant :
1 2 3 4 5 6 7 8 9 10 11 | /* Head of a singly-linked list. */ typedef struct slist_s { size_t length; /* List length. */ struct slist_node_s* first; /* List head. */ } slist; /* A node of a singly-linked list. */ typedef struct slist_node_s { struct slist_node_s* next; /* Next element. */ void* data; /* User data. */ } slist_node; |
Ici j’ai choisi d’avoir une structure à part pour la tête (ce qui permet
d’avoir accès à longueur de la liste sans avoir à la parcourir), mais ce n’est
qu’un détail d’implémentation. Chaque nœud de la liste contient un pointeur
vers le nœud suivant et un pointeur void*
sur les données.
Maintenant que la liste est définie, il faudrait stocker quelque chose dedans. Je vais partir sur une structure qui représente un labyrinthe :
1 2 3 4 5 6 7 8 9 10 11 | /* A cell of the maze. */ typedef unsigned char Cell; /* A Maze. */ typedef struct maze_s { Cell* maze; size_t m; /* Number of lines. */ size_t n; /* Number of columns. */ size_t in; /* Index of the starting point. */ size_t out; /* Index of the exit. */ } Maze; |
En combinant les deux fichiers précédents on peut donc créer une liste de labyrinthes. Cela peut être représenté graphiquement de la manière suivante :
L’avantage de cette approche est qu’elle est relativement naturelle et simple à comprendre. En revanche, elle comporte quelques inconvénients :
-
le fait d’utiliser un pointeur pour les données stockées implique qu’il faudra presque toujours allouer de la mémoire pour les données. On se retrouve donc avec deux allocations par élément de la liste : allocation de la donnée et allocation du nœud lui-même.
-
un autre souci, même s’il est probablement rarement ressenti dans la plupart des cas, est celui des performances : à chaque fois que l’on veut accéder à un élément de la liste à partir du nœud on doit déréférencer un pointeur. Ce n’est pas gratuit.
L’impact du point 1 peut être réduit en utilisant une stratégie d’allocation adaptée (la GLib propose quelque chose dans ce sens).
Si la perte de performance due au point 2 se fait vraiment sentir, il est toujours possible remplacer l’implémentation naïve de la liste par une liste « déroulée » afin d’augmenter la localité des données et de bénéficier de l’usage du cache.
Approche intrusive
Définition
Tout d’abord, il me semble utile de définir ce que l’on appelle une structure de données intrusive.
Dans le cas « normal », le conteneur et le contenu sont totalement
indépendants. Le conteneur n’a pas besoin de savoir s’il va servir à stocker
des int
ou une structure Maze
pour être défini. De
même, une structure Maze
n’a pas besoin de savoir si elle va être
stockée dans une liste pour être définie (souvenez vous de la première partie
de cet article : slist.h
et maze.h
sont totalement
indépendants).
En revanche, dans le cas d’une structure de données intrusive, la donnée
contenue (la structure Maze
dans mon exemple) devra être définie
en tenant compte du conteneur (dans notre exemple, cela signifie
que maze.h
dépendra de
slist.h
).
Implémentation
La liste est toujours indépendante de son contenu (comme dans l’approche « traditionnelle »).
1 2 3 4 5 6 7 8 9 10 | /* Head of a singly-linked list. */ typedef struct slist_s { size_t length; /* List length. */ struct slist_node_s* first; /* List head. */ } slist; /* A node of a singly-linked list. */ typedef struct slist_node_s { struct slist_node_s* next; /* Next element. */ } slist_node; |
Définition quasiment identique à la définition précédente. À un détail près :
chaque nœud de la liste ne contient qu’un pointeur vers le nœud suivant et
rien d’autre. Aucune référence à son contenu (le champ data
a
disparu).
En effet, le lien entre la liste et la donnée est maintenant embarqué dans la
donnée elle-même (on voit bien le côté intrusif) comme le prouve le nouveau
champ node
ajouté à la structure Maze
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | #include "slist.h" /* A cell of the maze. */ typedef unsigned char Cell; /* A Maze. */ typedef struct maze_s { Cell* maze; slist_node node; size_t m; /* Number of lines. */ size_t n; /* Number of columns. */ size_t in; /* Index of the starting point. */ size_t out; /* Index of the exit. */ } Maze; |
En combinant les deux fichiers précédents on peut donc créer une liste de labyrinthes. Cela peut être représenté graphiquement de la manière suivante (on voit bien la différence par rapport à l’approche « traditionnelle ») :
Avec cette approche, nous n’avons plus les inconvénients dus à l’utilisation
de void*
:
- plus besoin d’allouer le nœud ET la donnée : une seule allocation suffit.
- plus de déréférencement pour accéder à la donnée à partir du nœud.
Contrairement à ce que l’on pourrait penser, une liste intrusive n’est pas
plus difficile à utiliser qu’une liste « traditionnelle » et ne nécessite pas
plus de code. D’ailleurs, on retrouve ces structures intrusives dans le noyau
Linux
(liste doublement chaînée
et
arbre rouge-noir
par exemple), dans l’en-tête sys/queue.h
chez les
*BSD
(Linux en propose aussi
une implémentation)
et dans les
EFL
pour ne citer que quelques exemples connus.
Cependant, cette approche n’est pas parfaite non plus :
- on utilise de l’espace pour le nœud de la liste même si la donnée n’est pas stockée dans une liste.
- il faut avoir le contrôle sur la définition des données que l’on veut stocker afin pouvoir ajouter le membre nécessaire à l’utilisation de la liste.
- il faut avoir un moyen d’accéder à la donnée à partir d’une référence au nœud.
Les problèmes 1 et 2 peuvent être contournés en encapsulant la donnée dans une structure dédiée.
1 2 3 4 5 6 7 8 | struct Data { /* Interesting fields. */ }; struct data_wrapper { struct Data data; struct slist_node node; }; |
Cependant, on perd la facilité d’utilisation.
La solution au problème 3 sera abordée plus loin dans cet article.
Exemple d’utilisation
Voici un petit exemple d’utilisation : on crée une liste de 5 éléments, on l’affiche puis on libère la mémoire.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 | #include <stdio.h> #include <stdlib.h> #include "slist.h" typedef struct { unsigned to; slist_node node; unsigned from; } pair_list; int main(void) { slist list; slist_node* curr; slist_node* tmp; pair_list* elt; unsigned i; /* Initialize the list. */ slist_init(&list); /* Populate the list. */ for (i = 0; i < 5; ++i) { elt = malloc(sizeof *elt); if (elt == NULL) goto failure; elt->from = i; elt->to = i+1; slist_add(&list, &(elt->node)); } /* Display the list. */ slist_foreach(&list, curr) { elt = slist_elt(curr, pair_list, node); printf("from = %u | to = %u\n", elt->from, elt->to); } /* Free the list. */ slist_foreach_safe(&list, curr, tmp) { elt = slist_elt(curr, pair_list, node); slist_del(&list); free(elt); } return EXIT_SUCCESS; /* Error handling. */ failure: if (!slist_is_empty(&list)) { slist_foreach_safe(&list, curr, tmp) { elt = slist_elt(curr, pair_list, node); slist_del(&list); free(elt); } } return EXIT_FAILURE; } |
Lignes 6-10 on définit notre structure (qui pourrait représenter un
intervalle). On n’oublie pas d’y intégrer un membre slist_node
afin de pouvoir en faire une liste.
Ligne 14 on déclare notre liste puis on l’initialise ligne 20. S’en suit une
boucle (lignes 22-30) pour la peupler : on alloue 5 structures et on les
ajoute à la liste les unes à la suite des autres. On remarque que pour ajouter
un élément à la liste on ne passe pas la structure elle-même mais uniquement
son champ node
(ligne 29, second paramètre
de slist_add
).
La seconde boucle (lignes 32-35) affiche le contenu de notre liste.
slist_foreach
permet de parcourir la liste nœud par nœud. À
partir du nœud, on récupère notre structure et on l’affiche.
Enfin, la dernière boucle (lignes 37-41) libère la mémoire allouée. On
remarque que l’on supprime d’abord le nœud de la liste
via slist_del
avant de libérer la mémoire allouée à notre
structure (ce qui est logique étant donné que la structure contient le nœud,
si on la désalloue en premier on perd l’accès au nœud).
L’exécution de ce code nous donne :
Le code est relativement clair et concis, rien de bien compliqué. Cependant, la ligne 33 nous rappelle le problème 3 : comment peut-on récupérer la structure à partir du nœud ?
Dans l’approche traditionnelle, c’est très simple : étant donné que le nœud
contient un pointeur sur les données, il suffit de
faire node->data
pour accéder aux données. Mais dans l’approche
intrusive, c’est la donnée qui contient notre nœud : comment peut on accéder à
une structure à partir d’un de ses champs ?
Solution
Champ en première position
La norme (ISO/IEC 9899:TC3, 6.7.2.1 Structure and union specifiers § 13, page 103) nous dit :
Within a structure object, the non-bit-field members and the units in which bit-fields reside have addresses that increase in the order in which they are declared. A pointer to a structure object, suitably converted, points to its initial member (or if that member is a bit-field, then to the unit in which it resides), and vice versa. There may be unnamed padding within a structure object, but not at its beginning.
Cela signifie qu’il n’y a jamais de padding avant le premier membre d’une structure et que son adresse correspond donc à l’adresse de la structure elle-même.
Par conséquent, si le champ slist_node
est le premier de la
structure, il suffit d’un simple cast pour obtenir l’adresse de la
structure. Cette solution est très simple et très efficace (un cast
en lui-même ne coute rien à l’exécution) mais souffre de deux inconvénients :
-
manque de flexibilité : l’utilisateur doit faire en sorte que le champ
slist_node
soit le premier de la structure. -
il est impossible de stocker la structure dans plus d’une liste (comme
cette structure
qui peut à la fois être dans une liste de bus (champ
node
) et dans une liste de bus enfant (champchildren
)) car on ne peut avoir qu’un seulslist_node
en première position.
Utilisation d’offsetof
La solution pour être indépendant de la position du champ (et donc de pouvoir
avoir plusieurs champs slist_node
par structure) est d’utiliser
une macro méconnue mais pourtant fort utile : offsetof
(définie
dans stddef.h
).
Cette macro prend deux arguments :
- un nom de structure (
pair_list
par exemple). - un nom de champ (
node
par exemple).
Elle renvoie alors l’offset1 du
champ dans la structure, en byte2.
À partir de là, c’est très simple : il suffit de calculer l’offset via
offsetof
puis de soustraire cette valeur à l’adresse du champ qui
contient le nœud afin d’obtenir l’adresse de la structure.
Pour éviter de se retrouver avec une dépendance au nom du champ, on passe par la macro suivante :
1 2 3 4 5 6 7 8 9 10 11 12 13 | /** * Returns a pointer to the structure which contains the node. * * \param node a list node (slist_node*). * \param type type of the structure which contains the node. * \param fieldname name of the node (field name) in the structure. * * \pre `node` must be not NULL. * * \remarks Complexity: O(1) */ #define slist_elt(node, type, fieldname) \ ((type*)((char*)(node) - offsetof(type, fieldname))) |
Cette macro prend trois arguments :
-
l’adresse du nœud (l’adresse du champ
slist_node
). -
le type de la structure (
pair_list
par exemple) : nécessaire pour convertir le type de l’adresse retournée. -
le nom du champ
slist_node
utilisé comme premier argument.
Seul point un peu délicat : il faut absolument convertir node
en char*
sinon la soustraction ne va pas renvoyer l’adresse que
l’on souhaite. En effet, soit P
un pointeur T*
et N
un nombre entier : P-N
est équivalent
à P-N*sizeof(T)
(arithmétique des pointeurs de base). Or,
l’offset renvoyé par offsetof
est exprimé en byte, il faut donc
faire la soustraction avec un pointeur sur un type T
vérifiant sizeof(T)==1
. Sachant que
la norme
(ISO/IEC 9899:TC3, 6.5.3.4 The sizeof operator § 3, page 80) nous
dit :
When applied to an operand that has type char, unsigned char, or signed char, (or a qualified version thereof) the result is 1.
sizeof(char)
renvoyant toujours 1 par définition, il suffit donc
de convertir node
en char*
avant la soustraction
pour obtenir le résultat souhaité.
Cependant, cette solution a un gros problème3 : elle contient un comportement indéfini ! En effet, l’arithmétique des pointeurs n’est pas aussi simple qu’elle en à l’air. Il y a un certains nombre de règles à respecter, parmi lesquelles ISO/IEC 9899:TC3, 6.5.6 Additive operator § 7, page 83 :
For the purposes of these operators, a pointer to an object that is not an element of an array behaves the same as a pointer to the first element of an array of length one with the type of the object as its element type.
et ISO/IEC 9899:TC3, 6.5.6 Additive operator § 8, page 83 :
[…]
If both the pointer operand and the result point to elements of the same array object, or one past the last element of the array object, the evaluation shall not produce an overflow; otherwise, the behavior is undefined.
[…]
Qu’est ce que cela implique dans notre cas ?
On applique une soustraction à un pointeur qui pointe sur le
champ slist_node
d’une structure. De manière évidente, ce
pointeur ne pointe pas sur un élément contenu dans un tableau donc la première
règle que j’ai cité va être appliquée et on va considérer node
comme un pointeur sur le premier élément d’un tableau de char
(et
oui, on convertit node
en char*
avant d’appliquer la
soustraction) de taille 1. Jusque là, on est sauf.
Ensuite, on applique notre soustraction. On va donc soustraire un certain
nombre de byte à l’adresse contenue dans le pointeur : le résultat pointera
donc à une adresse située avant node
. Et là, la seconde règle va
nous frapper : le pointeur node
et l’adresse résultante ne pointe
pas sur le même tableau (étant donné que l’adresse résultante pointe avant le
tableau), on a donc un undefined behavior (comportement
indéfini). GAME OVER !
Conclusion
En conclusion, le choix entre les deux approches dépend principalement de vos objectifs, de vos contraintes et de vos données.
Si les données peuvent exister de manière indépendante, en dehors de toute structure, la première approche est probablement plus logique. De la même manière, si ce sont des données que vous ne pouvez pas modifier pour y ajouter les champs nécessaires, il est probablement plus pratique d’utiliser l’approche « traditionnelle ».
D’un autre côté, si les données font nécessairement partie d’une liste, on
peut envisager l’approche intrusive (en plaçant le champ requis en premier
dans la structure). En revanche, si les données doivent pouvoir être stockées
dans plusieurs conteneurs à la fois il faudra revenir à la première approche
ou utiliser l’approche à base d’offsetof
(tout en étant conscient
que cela se base sur un comportement indéfini et qu’un compilateur pourrait
« casser » le code en l’optimisant).
D’autres critères peuvent bien sûr entrer en compte (si les ajouts/suppressions sont très frèquents, une approche intrusive peut être plus efficace). Il n’y a pas de règle absolue en la matière.
Téléchargement
Pour ceux que cela intéresse, le code complet de la liste simplement chaînée (version intrusive) est disponible ici.
Attention : Ce code ayant été écrit longtemps avant la rédaction de cet
article, il est donc basé sur la méthode offsetof
qui contient
un comportement indéfini (dont j’ignorai l’existence au moment de l’écriture
du code).
Le code est sous licence BSD-3. Il est écrit en C89 et aucune dépendance particulière n’est requise (hormis Check pour les tests unitaires). La compilation se fait via CMake, une documentation peut être générée par Doxygen. Deux exemples sont disponibles dans le dossier examples.
-
« décalage » en bon français.↩
-
« multiplet » en bon français. J’en profite pour signaler qu’un byte n’est pas forcément égal à un octet (même si, de nos jours, c’est le cas le plus répandu) et que ce n’est pas non plus un synonyme de bit (Cf. Wikipédia pour plus d’information).↩
-
problème que j’ai remarqué lors de la rédaction de cet article.↩