grim7reaper

A Code Craftsman

Les pointeurs opaques

Cet article provient de mon ancien site Internet. Il a été rédigé le 17 septembre 2011.

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 :

lib.h
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 :

lib.c
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 :

main.c
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 :

1
2
3
4
5
[grim7reaper@chaos ~] % gcc -fpic -c lib.c
[grim7reaper@chaos ~] % gcc -shared -Wl,-soname,liblib.so lib.o -o liblib.so
[grim7reaper@chaos ~] % gcc main.c -o main -llib -L./
[grim7reaper@chaos ~] % LD_LIBRARY_PATH=./ ./main
(nil)

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.

lib.h v2
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
lib.c v2
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 :

1
2
3
4
5
[grim7reaper@chaos ~] % gcc -fpic -c lib.c
[grim7reaper@chaos ~] % gcc -shared -Wl,-soname,liblib.so lib.o -o liblib.so
[grim7reaper@chaos ~] % LD_LIBRARY_PATH=./ ./main
(nil)
2383424856

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 :

lib.h v3
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
lib.c v3
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 :

main.c v3
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 :

1
2
3
4
5
[grim7reaper@chaos ~] % gcc -fpic -c lib.c
[grim7reaper@chaos ~] % gcc -shared -Wl,-soname,liblib.so lib.o -o liblib.so
[grim7reaper@chaos ~] % gcc main.c -o main -llib -L./
[grim7reaper@chaos ~] % LD_LIBRARY_PATH=./ ./main
(nil)

Oui !

Appliquons maintenant nos modifications (on notera que cette fois, seul le fichier source lib.c est modifié) :

lib.c v4
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 ?

1
2
3
4
5
[grim7reaper@chaos ~] % gcc -fpic -c lib.c
[grim7reaper@chaos ~] % gcc -shared -Wl,-soname,liblib.so lib.o -o liblib.so
[grim7reaper@chaos ~] % LD_LIBRARY_PATH=./ ./main
(nil)
0

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) :

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 ;
  • 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 :

str.h
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 :

str.c
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.

example.h
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.

example.c
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.

main.c
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 car Example 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.