Les pointeurs opaques
Cet article provient de mon ancien site Internet.
Présentation
Bien avant la démocratisation de la programmation orientée objet, les développeurs ont ressenti le besoin d’organiser leur code en modules et de découpler l’interface du module de son implémentation. Pour cela, ils ont mis en place un mécanisme d’encapsulation.
Avec les langages orienté objet c’est très simple à mettre en œuvre car la
plupart d’entre eux offrent un
mécanisme public
/private
(voire
protected
). Or, en C (car c’est de C dont il est question ici) ce
genre de choses n’existe pas. C’est là qu’interviennent nos amis les pointeurs
opaques.
L’usage de pointeurs opaques est une technique très répandue. Ils sont
largement utilisés dans les bibliothèques. On les retrouve entre autres dans
GTK+ et la zlib. On les retrouve même dans certaines implémentations de la
bibliothèque standard du C via le type FILE*
.
Il y a deux moyens d’implémenter les pointeurs opaques :
- Soit on se base sur le savoir-vivre de l’utilisateur : dans ce cas, s’il viole (volontairement ou pas) l’encapsulation le compilateur ne dira rien. C’est cette technique qui est utilisée dans les bibliothèques que j’ai précédemment cité ;
- Soit on met ce qu’il faut en œuvre pour que le compilateur nous aide à faire respecter l’encapsulation.
C’est cette dernière technique que je vais présenter plus en détail dans la suite de cet article. Mais avant cela, voyons quelques avantages apportés par l’utilisation des pointeurs opaques.
Apports des pointeurs opaques
Encapsulation
Un des gros avantages de l’encapsulation c’est que l’on peut modifier à volonté l’implémentation sans que cela n’impacte le code de l’utilisateur (dans une certaine mesure : il ne faut pas casser la compatibilité au niveau de l’interface proposée ni modifier le layout de la structure si celle-ci est directement manipulée). On a donc plus de liberté pour faire évoluer notre code.
Ici le rôle des pointeurs opaques est évident, l’utilisateur ne connaît pas
les champs, il ne peut donc pas y accéder (c’est comme si c’était
en private
). Il doit donc passer par les fonctions que nous avons
définies (l’interface).
On notera que le mécanisme des pointeurs opaques est moins souple que les
solutions basées sur public
/private
. En effet, ici
c’est toute la structure qui est opaque (et donc privée). Il est cependant
possible de ruser afin d’obtenir un contrôle plus fin (un exemple est présenté
à la fin de cet article).
On pourrait penser que l’arrivée des langages orienté objet et leurs mécanismes d’encapsulation intégrés avait signée le glas des pointeurs opaques. Que nenni ! On les croise encore en C++, certes pas pour faire de l’encapsulation mais pour le point suivant.
Compatibilité ascendante sans recompilation
Contexte
Ce dernier point concerne surtout le développement de bibliothèques dynamiques.
Quand on développe une bibliothèque un tant soit peu complexe, il y a de fortes chances pour que l’on utilise des structures. Or ces structures risquent d’évoluer (ajout ou retrait de champs) en même temps que notre bibliothèque. Et là, ça risque de poser un souci : l’utilisateur va devoir recompiler TOUS ses codes qui utilisent notre bibliothèque (ce qui annule l’un des avantages des bibliothèques dynamiques) sous peine d’emmerdes. Comme un exemple vaut mieux qu’un long discours, en voici un (un exemple, pas un long discours :p).
Pour cet exemple, nous allons développer une bibliothèque qui implémente une liste simplement chaînée. Tout d’abord, le fichier d’en-tête :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | #ifndef H_LIB_LS_20110917173256 #define H_LIB_LS_20110917173256 typedef struct node_ { struct node_* next; int i; } node; typedef struct { node* head; } list; list list_init(void); void dump(list l); #endif |
Ensuite, l’implémentation :
1 2 3 4 5 6 7 8 9 10 11 12 13 | #include <stdio.h> #include "lib.h" list list_init(void) { list l = { NULL }; return l; } void dump(list l) { printf("%p\n", l.head); } |
Et enfin, le programme principal :
1 2 3 4 5 6 7 8 | #include "lib.h" int main(void) { list l = list_init(); dump(l); return 0; } |
Bon, maintenant on compile et on exécute tout ça. On obtient :
Bon, et bien tout est OK :)
Maintenant on décide d’améliorer notre bibliothèque. Pour le moment, pour
avoir la taille de notre liste, on doit la parcourir entièrement et compter
les éléments. C’est un algorithme en O(N)
. On décide alors de
garder cette taille dans la tête de notre liste afin de pouvoir y accéder en
temps constant. On ajoute donc un champ à la structure list
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | #ifndef H_LIB_LS_20110917173256 #define H_LIB_LS_20110917173256 typedef struct node_ { struct node_* next; int i; } node; typedef struct { node* head; unsigned size; } list; list list_init(void); void dump(list l); #endif |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | #include <stdio.h> #include "lib.h" list list_init(void) { list l = { NULL, 0u }; return l; } void dump(list l) { printf("%p\n", l.head); printf("%u\n", l.size); } |
C’est une modification mineure de notre bibliothèque. L’interface offerte à
l’utilisateur ne change pas (les prototypes de list_init
et dump
sont identiques à ceux de la version précédente). Comme
c’est une modification mineure, notre nouvelle version est 100% compatible
avec l’ancienne. Donc les codes utilisant la version précédente peuvent
utiliser cette version sans aucun souci, sans recompilation. Ou pas…
Voyons le comportement de notre programme avec cette nouvelle version :
Ouch, le champ size
est dans les choux : 2383424856 (vous pouvez
obtenir une valeur différente) alors que la valeur attendue est 0 (la liste
est vide).
Explications
Alors, pourquoi ça déconne ?
Et bien en ajoutant/retirant un champ, on change la taille de notre structure.
D’ailleurs, rien qu’en changeant l’ordre des champs on peut modifier la taille
de la structure à cause du padding. Notre bibliothèque est
recompilée, donc la fonction dump
a un code correct pour
récupérer son argument sur la pile. Elle va dépiler un certain nombre de bytes
(je ne donne pas de valeur ici car elle va dépendre de l’architecture
sous-jacente) correspondant à la taille d’une structure composée d’un pointeur
et d’un entier non-signé. En revanche, la fonction main
du code
utilisateur n’est pas recompilée donc avant d’appeler
dump
elle va empiler une structure composée d’un unique pointeur.
Dès lors, on voit le problème : le code appelant empile une structure de
taille X et le code appelé dépile une structure de taille Y. Cela va très vite
provoquer des trucs foireux (comme dans notre cas où l’on dépile plus que ce
que l’on a empilé).
De même, si l’on fait un malloc
de la structure dans notre
programme principal on ne va pas allouer une taille suffisante pour contenir
le nouveau champ, ce qui aura des conséquences fâcheuses par la suite.
Solution
La solution est évidemment de passer par un pointeur opaque (c’est un peu le sujet de cet article en même temps). En effet, si l’on passe toujours par un pointeur pour manipuler notre structure on n’aura pas à se soucier de sa taille. Quand on la passe en paramètre, il faudra toujours empiler/dépiler un pointeur (quelque soit la taille de la structure) et les allocations de structures se feront dans le code de notre bibliothèque qui, lui, allouera toujours la bonne taille.
Il faut donc modifier notre bibliothèque pour qu’elle utilise un pointeur opaque. Cela nécessite un peu plus de code, mais on n’a rien sans rien. Voilà donc le résultat :
1 2 3 4 5 6 7 8 9 10 | #ifndef H_LIB_LS_20110917173256 #define H_LIB_LS_20110917173256 typedef struct list__* list; /* Le fameux pointeur opaque. */ list list_create(void); void dump(list l); void list_destroy(list* l); #endif |
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 | #include <stdlib.h> #include <stdio.h> #include "lib.h" typedef struct node_ { struct node_* next; int i; } node; struct list__ { node* head; }; list list_create(void) { list l = malloc(sizeof *l); if (l != NULL) l->head = NULL; return l; } void dump(list l) { printf("%p\n", l->head); } void list_destroy(list* l) { if (l && *l) { free(*l); *l = NULL; } } |
Notre programme principal subit lui aussi quelques modifications :
1 2 3 4 5 6 7 8 9 10 11 12 13 | #include <stddef.h> /* For NULL. */ #include "lib.h" int main(void) { list l = list_create(); if (l != NULL) { dump(l); list_destroy(&l); } return 0; } |
Voyons si l’exécution est identique à celle sans pointeur opaque :
Oui !
Appliquons maintenant nos modifications (on notera que cette fois, seul le
fichier source lib.c
est modifié) :
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 | #include <stdlib.h> #include <stdio.h> #include "lib.h" typedef struct node_ { struct node_* next; int i; } node; struct list__ { node* head; unsigned size; }; list list_create(void) { list l = malloc(sizeof *l); if (l != NULL) { l->head = NULL; l->size = 0; } return l; } void dump(list l) { printf("%p\n", l->head); printf("%u\n", l->size); } void list_destroy(list* l) { if (l && *l) { free(*l); *l = NULL; } } |
Alors, avons-nous résolu le problème de compatibilité binaire ?
Yes, it works! Les modifications internes de notre bibliothèque n’obligeront pas les utilisateurs à recompiler tout le code qui en dépends.
Utilisation
Comme vous pouvez le voir, cette technique est très utile. C’est d’ailleurs la raison pour laquelle on rencontre encore des pointeurs opaques dans certains langages objet. C’est même un idiome assez connu et on le retrouve en C++ sous le nom de « PIMPL idiom » (ou « handle classes », « Compiler firewall idiom » ou encore « Cheshire Cat »).
Pour ceux que cela intéresse, voici quelques liens à ce sujet (des articles d’Herb Sutter et un de Vladimir Batov) :
- Pimpls - Beauty Marks You Can Depend On ;
- The Joy of Pimpls (or, More About the Compiler-Firewall Idiom) ;
- Compilation Firewalls ;
- Making Pimpl Easy ;
- The Fast Pimpl Idiom.
On retrouve cette technique dans de très gros projets tels que Qt et KDE.
Comment ça fonctionne ?
Pour qu’un pointeur soit vraiment opaque (c’est‑à‑dire pour que le compilateur nous aide) il faut utiliser un type incomplet. Il faut savoir qu’en C les types sont classés en trois catégories (Cf. la norme, 6.2.5 Types, page 33) :
- Les types « fonction » : ce sont les types qui décrivent une fonction. Ils sont composés du type de retour et du nombre et du type des arguments ;
-
Les types incomplets : ce sont les types dont on ignore la taille. Cette
catégorie de type est elle-même divisée en trois parties :
-
Le type
void
: c’est le seul type incomplet qui ne peut pas être complété ; - Les tableaux dont la taille n’est pas spécifiée ;
- Les structures et les unions dont le contenu est inconnu ;
-
Le type
- Les types « objet » : ce sont les types qui décrivent totalement un objet. Ils rassemblent tout ce qui n’entre pas dans les deux catégories précédentes.
On s’assure de la coopération du compilateur en utilisant les types incomplets ET la manière dont les compilateurs C fonctionnent. En effet, les compilateurs C travaillent à partir d’unités de traduction (Translation Unit) et chacune de ces unités est compilée de manière totalement indépendante (c’est l’éditeur de liens qui se charge d’assembler les fichiers objets résultants pour former l’exécutable final). Une unité de traduction est composée d’un fichier source après le passage du préprocesseur (Cf. la norme, 5.1.1.1 Program structure, page 9) et elle est transformée en fichier objet lors du processus de compilation.
Et c’est là que réside toute l’astuce : l’utilisateur ne peut utiliser dans ses fichiers sources (qui formeront ses unités de traduction) que notre fichier d’en-tête (qui ne contiendra que la déclaration de notre structure) donc le compilateur, lors de la phase de compilation, n’aura aucune information sur la taille de notre structure (car la définition complète est située en dehors des unités de traduction du code utilisateur). Cela a plusieurs implications :
-
Impossible de déclarer des variables du type
T
(T
étant notre structure) car le compilateur, ignorant la taille, ne peut pas réserver l’espace nécessaire. La manipulation par pointeur est donc obligatoire ; -
Impossible de déréférencer un pointeur de type
T*
car le compilateur, ignorant la taille, ne peut pas générer le code correspondant au déréférencement (combien d’octets doit-il lire en mémoire ?).
Et voilà le travail :-), avec ça l’utilisateur est obligé de passer par notre interface pour manipuler nos structures et il ne peut pas les bidouiller à la main. Enfin si, s’il le veut vraiment il pourra en jouant à la main avec les adresses et l’arithmétique des pointeurs mais dans ce cas il devra connaître la composition de notre structure et son code ne sera pas portable (voire dépendant du compilateur…).
Exemple d’implémentation
L’astuce, comme je le disais dans la partie précédente, c’est d’utiliser un type incomplet. Concrètement, c’est très simple : dans le fichier d’en-tête on met uniquement la déclaration de notre structure et dans le fichier source correspondant on met sa définition.
Pour illustrer cela, rien de tel qu’un petit exemple. Ici, je ne me suis pas cassé la tête et j’ai pris une structure pour la gestion d’une chaîne de caractères.
D’abord, le fichier d’en-tête :
1 2 3 4 5 6 7 8 | #ifndef H_LS_STR_20101012213147 #define H_LS_STR_20101012213147 typedef struct Str__* Str; /* Many very interesting functions. */ #endif |
Ensuite, le fichier source correspondant :
1 2 3 4 5 6 7 8 9 10 | #include "str.h" struct Str__ { char* str; /* String. */ size_t slen; /* String length (in characters, without '\0'). */ size_t mlen; /* Size of buffer (in byte). */ }; /* Many very interesting functions. */ |
Une application possible : émulation de public/private
Dans le fichier d’en-tête on définit notre structure avec ses membres publics et on déclare un pointeur opaque vers sa partie privée. On définit également notre interface.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | #ifndef H_EXAMPLE_LS_20110915211258 #define H_EXAMPLE_LS_20110915211258 typedef struct private_example__* Private_example; /* Public attributes. */ typedef struct { int public_attr_1; Private_example priv_part; } Example; /* Interface definition. */ void public_function_1(Example* me); #endif |
Dans le fichier source, on définit la structure qui contient les attributs privés et on implémente notre interface et les fonctions privées.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | #include "example.h" /* Private attributes. */ struct private_example__ { int private_attr_1; }; /* Interface implementation. */ void public_function_1(Example* me) { /* do something. */ } /* Private functions. */ static void private_function_1(Example* me) { /* do something. */ } |
Le programme minimal qui suit illustre ce que l’on peut faire et ce que l’on ne peut pas faire.
1 2 3 4 5 6 7 8 9 10 11 12 | #include "example.h" int main(void) { Example e; e.public_attr_1 = 42; public_function_1(&e); e.priv_part->private_attr_1 = 42; private_function_1(&e); return 0; } |
Voyons maintenant plus en détail cette fonction main
:
-
Ligne 5 : on déclare une variable de type
Example
. Pas de problème ici carExample
n’est pas un type incomplet ; - Ligne 6 : on accède à un attribut public. Là encore, aucun soucis, c’est fait pour ;-) ;
- Ligne 7 : On appelle une fonction publique, une fois de plus c’est correct car on utilise l’interface fournie ;
-
Ligne 8 : là, ça se corse. On tente d’accéder à un attribut privé donc le
compilateur intervient :
error: dereferencing pointer to incomplete type
. C’est bien le comportement attendu :-) ; -
Ligne 9 : on essaye d’appeler une fonction privée. Cette fois le compilateur
laisse passer, mais il nous met en garde
warning: implicit declaration of function "private_function_1" [-Wimplicit-function-declaration]
. En revanche, l’éditeur de liens est intransigeant :undefined reference to `private_function'
. Encore gagné :-)
En fait, pour la ligne 9, l’éditeur de liens pourrait considérer le code comme
étant valide si l’utilisateur définissait aussi une fonction appelée
private_function_1
. Mais ce n’est pas grave car elle ne pourra
pas modifier la partie privée de notre structure Example
(pour
les même raisons que la ligne 8) donc l’encapsulation est préservée.