- Même en C, on peut créer des structures de données génériques sûres en types en combinant des macros,
void *, des flexible array members et des union. L’exemple montre une implémentation pas à pas avec une liste chaînée.
- La méthode consistant à inclure plusieurs fois un en-tête par type est sûre, mais le code généré par macros peut rendre le suivi des définitions et l’autocomplétion difficiles, tout en augmentant la taille du binaire et le temps de compilation.
- Une liste basée sur
void * est polyvalente, mais n’empêche pas les erreurs de type, et si le nœud et les données sont alloués séparément, cela peut entraîner deux allocations par nœud ainsi que des défauts de cache.
- En stockant les données dans le nœud via un flexible array member et en enveloppant
List(type) dans une union, on peut attacher des informations de type à la compilation sans coût à l’exécution.
- La macro
list_prepend utilise l’opérateur ternaire pour faire correspondre le type de la valeur transmise avec celui de payload et provoquer une erreur de compilation en cas d’incompatibilité, tandis que __typeof__() peut servir à typer le pointeur de retour.
Point de départ d’une implémentation générique en C
- L’objectif est de pouvoir déclarer en C des listes typées comme
List(int) ou List(Foo), et de faire en sorte qu’un type incorrect ne compile pas.
- Dans l’exemple, on peut insérer une valeur de type
Foo dans List(Foo), mais du code comme list_prepend(&foo_list, 7) ne compile pas si le type ne correspond pas.
- À l’intérieur de
list_for(item, &foo_list), item peut être manipulé comme un Foo *.
Niveau 0 : la méthode de l’en-tête générique
- Une première approche consiste à écrire la structure de données dans un en-tête, puis à exécuter plusieurs
#include en changeant à chaque fois la macro de type T.
list.h génère à partir de T des types et fonctions comme FooListNode ou Foo_list_prepend via des macros.
- Cette approche est générique et sûre en types, mais son ergonomie laisse à désirer.
- Les types et fonctions étant définis par macros, il peut être difficile de retrouver l’emplacement des définitions.
- L’autocomplétion peut mal fonctionner.
- Une copie de la même fonction est produite pour chaque type, ce qui augmente la taille du binaire et le temps de compilation.
- Au lieu d’un unique
list_prepend(), il faut utiliser des fonctions préfixées par type comme Foo_list_prepend() ou int_list_prepend().
- Cette méthode peut toutefois être plus adaptée aux fonctions génériques qui nécessitent une génération de code spécifique à chaque type.
Niveau 1 : liste basée sur void *
- Si
ListNode contient un void *data, il peut stocker des données de types variés.
list_prepend(ListNode **head, void *data) se contente de stocker tel quel le pointeur vers les données, ce qui rend l’implémentation simple.
- Le problème est que cette structure n’est pas sûre en types.
- Si le nœud et les données sont alloués séparément, le coût mémoire et les performances se dégradent aussi.
- Deux allocations sont nécessaires par nœud.
- Le pointeur
data consomme lui-même de la mémoire supplémentaire.
- Lors du parcours de la liste, l’accès au nœud suivant et l’accès aux données peuvent chacun provoquer des défauts de cache.
- Le code d’exemple utilise
malloc par habitude, mais en pratique l’auteur recommande d’utiliser une Arena et renvoie vers cette vidéo et cet article.
Niveau 2 : stocker les données dans le nœud
- À la place de
void *data, on peut utiliser un Flexible Array Member pour stocker les données directement dans le nœud.
struct ListNode contient alors ListNode *next et char data[], et l’allocation réserve en une seule fois sizeof(* node) + data_size.
list_prepend reçoit les données et leur taille, puis les copie dans node->data avec memcpy.
- Cette approche place
next et les données réelles à proximité en mémoire, ce qui réduit les surcoûts d’allocation et les problèmes de cache du modèle void *.
- En contrepartie, l’appelant doit fournir
data_size.
- Si l’on veut éviter
memcpy, list_alloc_front peut renvoyer un pointeur vers la zone de données du nœud pour que l’appelant initialise lui-même cette mémoire.
- Les questions d’alignement du membre
data, de padding et de calcul de taille constituent un sujet à part entière et ne sont pas détaillées dans l’exemple.
Niveau 3 : attacher des informations de type avec union
- La technique clé consiste à définir
List(type) comme une union contenant à la fois la tête réelle de la liste et un pointeur servant d’information de type.
#define List(type) union { \
ListNode *head; \
type *payload; \
}
payload n’est pas utilisé à l’exécution et sert uniquement à fournir une information de type à la compilation.
- Comme il s’agit d’une
union, payload ne consomme pas de mémoire supplémentaire.
- On peut ainsi créer des listes typées comme
List(Foo) foo_list ou List(int) int_list.
Vérifier les types avec l’opérateur ternaire
- La macro
list_prepend appelle une fonction interne _list_prepend en utilisant l’opérateur ternaire pour faire correspondre le type de item avec celui de (list)->payload.
#define list_prepend(list, item) \
_list_prepend(&((list)->head), \
(1 ? (item) : (list)->payload), \
sizeof(*(list)->payload))
- Si les deux types candidats de l’opérateur ternaire ne correspondent pas, le compilateur signale une erreur d’incompatibilité de type.
- Par exemple, si l’on passe un
Bar * à List(Foo), Clang signalera une incompatibilité entre les types de pointeurs Foo * et Bar *.
- La même macro transmet aussi automatiquement la taille du type stocké via
sizeof(*(list)->payload).
- Le vrai travail est effectué par une fonction interne générique du type
_list_prepend(ListNode **head, void *data, size_t data_size).
Utiliser __typeof__() pour le type de retour
- Lorsqu’une fonction générique doit renvoyer un pointeur vers les données internes,
__typeof__() permet de caster une valeur de retour en void * vers le type de payload.
#define list_alloc_front(list) \
(__typeof__((list)->payload))_list_alloc_front(&(list)->head, sizeof(*(list)->payload))
__typeof__() est pris en charge par Clang, GCC et MSVC 19.39 ou version ultérieure.
- Avant son inclusion dans la norme C23,
__typeof__() était une extension optionnelle.
- Sur les compilateurs ne prenant pas en charge
__typeof__(), comme MSVC avant la version 19.39, la vérification de type via l’opérateur ternaire reste possible.
- Un retour sûr en types peut aussi être obtenu via une approche d’allocation utilisant
payload, mais les détails de mise en œuvre sont omis ici.
Ancienne méthode et précautions de définition
- L’ancienne approche appelait
_list_prepend après transtypage vers un type de pointeur de fonction incluant __typeof__((list)->payload).
- Appeler un pointeur de fonction après transtypage relève techniquement d’un comportement non défini, même si dans la pratique cela ne pose pas de problème sur les compilateurs et plateformes modernes.
- L’approche actuelle ne caste plus de pointeur de fonction et provoque les erreurs via la correspondance de types dans l’opérateur ternaire.
Le problème quand on passe List(Foo) en argument
- Le compilateur C peut ne pas considérer deux définitions
List(Foo) ayant pourtant la même structure comme étant du même type.
List(Foo) a;
List(Foo) b = a; // error
- Même si l’on définit une fonction comme
void my_function(List(Foo) list) puis qu’on appelle my_function(a), on peut obtenir une erreur de types incompatibles.
- La solution consiste à donner un nom au type avec
typedef.
typedef List(Foo) ListFoo;
ListFoo a;
ListFoo b = a; // ok
void my_function(ListFoo list);
my_function(a); // ok
- Pour les variables locales, on peut continuer à utiliser une forme comme
List(Foo) local_foo_list.
- Avec GCC 15 et Clang vers la fin 2025, un changement de règle devrait faire en sorte que des types structurellement identiques portant le même nom de tag soient considérés comme le même type.
Application à d’autres structures de données que les listes
- La même technique peut s’appliquer non seulement aux listes, mais aussi à d’autres structures de données comme les maps, tableaux ou arbres binaires.
- Elle peut également être étendue aux structures nécessitant plusieurs types associés.
- Par exemple, une table de hachage peut regrouper dans une même
union sa structure interne, le type de clé et le type de valeur.
#define Map(key_type, value_type) union { \
MapInternal map; \
key_type *key; \
value_type *value; \
}
- stb_ds.h est lui aussi un exemple de structure de données générique sûre en types, mais comme ses tableaux et maps utilisent des tableaux C, certaines erreurs de type ne sont détectées qu’au moment de l’affectation au tableau, et non lors du passage de la valeur.
2 commentaires
On peut se demander s’il ne suffit pas simplement d’utiliser Zig, non ?
Avis sur Hacker News
Dans le code de niveau 2,
uint64_t data[];est incorrect pour les types dont les exigences d’alignement sont supérieures à celles deuint64_t, et c’est du gaspillage pour les types plus petits. C’est le cas, par exemple, de l’ABI ilp32 sur les architectures 64 bitsLe code de niveau 3 devrait être
int main() { List(Foo) foo_list = {NULL};Comme il n’y a pas
typeof, le contournement ne peut rien renvoyer, et comme==est symétrique, ce contournement laisse aussi passer des erreurs liées àconstOn ne peut pas non plus omettre
payloaden toute sécurité, car il est nécessaire pour connaître la bonne taille. Il devrait être possible d’ajouter unint32_tà unList(int64_t), mais on ne peut pas connaître lesizeofde cetint32_t. Il manque encore pas mal d’éléments pour que ce code fonctionne correctementLes génériques en C aujourd’hui ont deux grandes limites. Premièrement, l’approche consistant à déléguer à une vtable est limitée, car une struct ne peut pas contenir de macros, seulement des fonctions. Deuxièmement, pour éviter l’overhead, il faut déléguer à une vtable externe, ce qui impose de faire une déclaration anticipée de tous les types qui utiliseront la vtable
Le mieux que j’aie trouvé jusqu’ici consistait à déclarer, sans les définir, des fonctions
staticdans un en-tête d’anticipation qui déclare les typedefs. En pratique, le moment où un avertissement « undefined static » apparaît quand on n’inclut pas l’en-tête d’un type donné dans une unité de traduction diffère entre GCC et ClangOn peut par exemple penser à une fonction qui accepte
struct SizedBuffer {void *p; size_t len;};oustruct BoundedBuffer {void *begin; void *end;};, venus d’en-têtes différents, ainsi que leurs versionsconstrespectivesÀ cause de ce problème qui oblige à déclarer à l’avance tous les types susceptibles d’utiliser la vtable pour déléguer à une vtable externe, le projet Apache Clownfish auquel j’ai participé autrefois avait même créé un compilateur pour cela
Au début, il analysait les fichiers
.h, mais nous avons fini par juger préférable de créer un petit langage d’en-têtes, les.cfh« Clownfish Header »Pour appeler la version
CharBufde la méthodeClonedéfinie dans la classe parenteObj, il générait ce genre de codetypedef cfish_CharBuf*(*CFISH_CharBuf_Clone_t)(cfish_CharBuf* self);extern uint32_t CFISH_CharBuf_Clone_OFFSET;static inline cfish_CharBuf*CFISH_CharBuf_Clone(cfish_CharBuf* self) {const CFISH_CharBuf_Clone_t method= (CFISH_CharBuf_Clone_t)cfish_obj_method(self,CFISH_CharBuf_Clone_OFFSET);return method(self);}L’utilisation ressemblait à ceci
cfish_CharBuf *charbuf = cfish_CharBuf_new();cfish_CharBuf *clone = CFISH_CharBuf_Clone(charbuf);L’objectif de Clownfish était de fournir un modèle objet plus petit dénominateur commun pour plusieurs bindings de langages dynamiques, et les fichiers
.cfhservaient aussi à dériver les types pour les langages de binding. Malgré tout, la quantité de code passe-partout générée pour éviter le problème signalé était franchement absurdeC’est pourquoi presque tout le monde renonce à la sécurité de typage et se contente de caster la cible de l’appel en
void*https://github.com/apache/lucy-clownfish
En C,
int main()ne signifie pas que la fonction ne prend pas d’arguments, mais qu’elle prend un nombre inconnu d’arguments. Pour dire qu’elle ne prend pas d’arguments, il faut écrireint main(void). C’est un fait que les personnes venant du C++ oublient souventCe serait bien que les
unionpuissent être étendues de manière fédérative : qu’un type puisse se déclarer lui-même comme faisant partie de la même union qu’un autre type, sans avoir à déclarer au même endroit tous les types possibles à l’avancemalloc(sizeof(*node) + data_size);peut aussi poser problème à cause du padding. La taille calculée peut être trop petiteJe ne suis pas d’accord
J’ai déjà créé tout un dialecte de C avec le trick#0 décrit dans l’article. Par exemple, un tas binaire générique se trouve ici : https://github.com/gritzko/librdx/blob/master/abc/HEAPx.h
La syntaxe est un peu lourde, mais le gros avantage est qu’au bout du compte on obtient une struct C ordinaire, simple, prévisible et facile à optimiser. C’est du code que le compilateur avale comme un beignet
Les autres approches finissent par nécessiter des
void*et des calculs de taille mémoire à l’exécution, et il faut de toute façon aussi définir des macrosSi j’avais utilisé un tas binaire générique, j’aurais peut-être évalué les options autrement. Je le mentionne aussi dans une note de bas de page
Comme chaque instance est monomorphisée, le compilateur a aussi davantage de possibilités d’optimisation, et il n’y a pas de coût à l’exécution dû aux tailles variables. Comme la taille est fixe, on peut aussi placer la struct générique sur la pile
Au moins deux des problèmes mentionnés par l’auteur peuvent être contournés. Les noms peuvent être transformés avec une simple macro de name mangling, de
Bar_func(args…)enfunc(Bar)(args…). Le gonflement du binaire peut être en partie réduit en utilisant des symboles faibles, afin que les fonctions partagées entre unités de traduction soient dédupliquées à l’édition de liensLes conteneurs génériques de types pointeurs posent d’autres problèmes, mais on peut les contourner avec des typedefs ou des alias de type
En C, les structures de données intrusives restent plus pratiques, mais elles sont pénibles à manipuler dans un débogueur
Le cast de type de fonction suppose que le type de pointeur vers l’élément, par exemple
Foo*, a la même représentation quevoid*, ce que la norme C ne garantit pas. Dans la terminologie de la norme, les deux types ne sont pas « compatibles ».Appeler une fonction avec le type converti relève donc d’un comportement indéfini. Même si la représentation des pointeurs se trouve être identique par hasard, cela affecte aussi l’analyse d’aliasing du compilateur. À ce sujet, [0] mérite aussi d’être consulté.
Caster une fonction avec des types d’arguments différents semble être au cœur de la sûreté de type des appels génériques, mais je ne sais pas si c’est un problème qu’on peut corriger.
https://news.ycombinator.com/item?id=44421185
Si l’on veut du « C avec des génériques », pourquoi ne pas simplement utiliser C++ plutôt que de faire autant de détours ?
En revanche, pour les nouveaux projets, on peut fixer comme standard et comme attente d’utiliser C++, et c’est bien ce que nous faisons, en ciblant un
stdprécis.Je vois assez souvent ce genre d’attitude sur Hacker News, et ça ressemble un peu à un « montez en compétence ». À mon avis, il faut beaucoup plus de contexte que ça.
Après le regain d’intérêt de Microsoft pour Linux et les logiciels libres et open source, il a été vraiment décevant de les voir abandonner l’idée que « C++ est l’avenir ».
https://herbsutter.com/2012/05/03/reader-qa-what-about-vc-an...
https://devblogs.microsoft.com/cppblog/c11-and-c17-standard-...
Aujourd’hui, avec les nouvelles politiques de Microsoft sur C et C++ liées aux pouvoirs publics et aux réglementations cyber, ce n’est plus très important.
https://azure.microsoft.com/en-us/blog/microsoft-azure-secur...
https://blogs.windows.com/windowsexperience/2024/11/19/windo...
Joli tour de passe-passe. Je l’utilise déjà dans ma bibliothèque expérimentale : https://github.com/uecker/noplate/blob/main/src/list.h
C’est-à-dire mettre la structure de nœud dans les données, plutôt que les données dans le nœud comme ici, avec l’avantage annexe qu’un même objet peut appartenir à plusieurs conteneurs.
Il faut faire attention à l’affirmation selon laquelle « les types structurellement identiques sont considérés comme le même type dans GCC 15 et dans Clang fin 2025 grâce à un changement de règles ».
Dans les nouvelles règles, seuls les unions avec tag sont considérés comme le même type, et il faut qu’ils aient la même structure ainsi que le même tag.
La macro
List(T)devrait être modifiée pour générer un tag différent pour chaqueT. C’est facile avec##pour un type simple en un seul mot, mais impossible dès que ça devient un peu plus complexe, comme un pointeur verschar, c’est-à-dire une chaîne.Bien sûr, on peut imposer que tous les types soient définis avec typedef avant d’être utilisés avec
List, mais cela réduit fortement la généricité.typedef char *str;List(str) my_list_of_str;List(str) tokenize(str input) {...}Le terme courant pour un « membre qui ne fait rien et ne sert qu’à porter un type » me semble être type witness. Cela dit, il y a beaucoup moins de littérature sur les type witnesses que je ne l’aurais cru.
Je l’ai surtout vu en Haskell, et je m’en suis aussi servi en Scala pour simuler une hiérarchie de types qui n’existe pas dans le système de types réel.
D’une certaine manière, cette astuce avec une union ressemble aussi à un phantom type, puisque le type auxiliaire n’est jamais réellement utilisé.
Il y a aussi l’approche utilisée dans le noyau Linux : embarquer
struct list_head, qui contient les informations de liste, dans une structure propre à chaque type.https://kernelnewbies.org/FAQ/LinkedLists
LIST_HEAD_INITetINIT_LIST_HEADprêtent à confusion.Si je dois en arriver là, autant utiliser directement les templates C++.
En D, on fait comme ça :
struct ListNode(T) {ListNode* next;T data;}T!int node;Pourquoi se donner du mal avec le préprocesseur C ? Utiliser des macros du préprocesseur, c’est comme utiliser un marteau au lieu d’un cloueur pneumatique pour des travaux de finition en menuiserie. Le cloueur est 10 fois plus rapide, enfonce les clous précisément à chaque fois et ne laisse pas de marques en demi-lune sur la pièce.