Comment écrire des structures de données génériques et sûres en type en C
(danielchasehooper.com)- Cet article explique une nouvelle méthode pour créer des structures de données génériques et sûres en type en C
- Il présente, à travers l’exemple d’une liste chaînée, une technique qui associe des informations de type à la structure de données à l’aide d’une
union - Il compare les différences avec les modèles génériques C existants (macros, pointeurs
void, Flexible Array Member) ainsi que les inconvénients de chaque approche - Le contrôle de type à la compilation permet d’empêcher à l’avance l’utilisation de types incorrects
- Cette nouvelle technique fournit une interface de fonctions et de structures de données claire et cohérente, comme
foo_list
Introduction
- Présentation d’une méthode pour créer en C des structures de données génériques avec sécurité de type
- Cette technique relie, à la compilation, les informations de type à la structure de données grâce à une
union - Elle peut s’appliquer à toutes sortes de structures de données, comme les maps, tableaux, arbres binaires, etc., et l’explication s’appuie sur l’implémentation d’une liste chaînée simple
- Comme beaucoup de développeurs pensent que les génériques sont impossibles en C, l’article propose une explication simple, étape par étape
Les génériques traditionnels basés sur les macros
- La méthode traditionnelle pour implémenter des structures de données génériques en C consiste à générer les noms de structures, de fonctions et les types à l’aide de macros
- L’approche est étendue en incluant plusieurs fois l’en-tête de la structure de données pour différents types
Exemples :
- Utilisation de macros (par ex.
CONCAT,NODE_TYPE,PREPEND_FUNC) pour générer les noms de structures et de fonctions adaptés au type - Des fonctions et structures distinctes sont générées pour chaque type :
int,Foo, etc. obtiennent chacun leur propre définition de structure de données
Inconvénients :
- Il est difficile de repérer où sont définis les types et les fonctions (comme ils sont générés par macros, leur traçage est compliqué)
- Les fonctionnalités d’autocomplétion sont difficiles à exploiter
- La génération de plusieurs versions d’une même fonction augmente la taille du binaire et le temps de build
- Les noms de fonctions nécessitent un préfixe de type (par ex.
Foo_list_prepend)
Génériques, étape 1 : l’approche par pointeur void
- Le type de données de la structure est rendu indépendant du type en utilisant
void * - Le champ
datade la liste chaînée est déclaré commevoid * - Comme aucun contrôle de type n’est possible, des erreurs de type peuvent survenir à l’exécution, avec une faible sécurité à la compilation
- Utilisation inefficace de la mémoire et du cache : les nœuds et les données sont alloués séparément, ce qui ajoute un surcoût inutile et augmente les cache misses
Génériques, étape 2 : stockage inline (Flexible Array Member)
- Utilisation d’un Flexible Array Member pour stocker directement les données dans le nœud au lieu d’un pointeur
- Une seule allocation par nœud suffit, et dans le cache les données sont placées à proximité du pointeur
next - Cette approche nécessite de transmettre des informations de taille, par exemple avec
memcpy, mais améliore les performances grâce à une disposition mémoire cohérente - La fonction
list_alloc_frontpermet d’initialiser directement la structure sansmemcpy
Génériques, étape 3 : implémenter le contrôle de type
- On ajoute des informations de type à la structure de données à la compilation en déclarant un pointeur de type paramétré dans le membre
payloadd’uneunion - Exemple :
#define List(type) union { ListNode *head; type *payload; } - Cela permet d’obtenir le type de la liste via
__typeof__(foo_list.payload) - Dans la macro
list_prepend, un transtypage du type de fonction permet de ne compiler que si le type est correct - En cas d’utilisation d’un mauvais type, une erreur est produite à la compilation
Exemple d’erreur :
- Si on ajoute un
intàfoo_list, le compilateur affiche un message d’erreur du type'incompatible integer to pointer conversion'
Prise en charge des compilateurs sans typeof
- Jusqu’à C23,
__typeof__n’était pas standard, donc certains compilateurs (par ex. d’anciennes versions de MSVC) ne le prennent pas en charge - Il est possible d’obtenir un effet proche par des méthodes de contournement, comme l’utilisation du membre
payloaddans unestruct
Passage de paramètres et typedef
- Même si deux
List(Foo)ont la même forme, le compilateur les considère comme des types différents - En utilisant
typedef, le passage de paramètres et les affectations deviennent plus fluides
Exemples :
typedef List(Foo) ListFoo;- Les variables
ListFoopeuvent ensuite être déclarées et utilisées comme paramètres de fonction
Conclusion et extension à diverses structures de données
- Cette technique peut aussi s’appliquer à des structures nécessitant plusieurs paramètres de type, comme les hash maps
- Grâce à la
union, il est possible de garantir séparément la sécurité de type des clés et des valeurs - Pour des exemples plus détaillés et l’implémentation des macros, voir le gist de code associé
Conclusion
- Cette nouvelle approche surmonte les limites des méthodes existantes (lisibilité, efficacité de build, maintenabilité) et fournit un schéma de nommage cohérent des fonctions ainsi qu’une sécurité de type
- Elle facilite la prise en charge de plusieurs structures de données et de paramètres de type multiples
- Le contrôle de type à la compilation permet d’assurer à la fois la sûreté et l’efficacité dans l’usage des structures de données génériques
Remerciements
- Cet article a été finalisé grâce aux retours et aux encouragements de Martin Fouilleul
2 commentaires
On peut se demander s’il ne suffit pas simplement d’utiliser Zig, non ?
Commentaires sur Hacker News
Dans le code de l’étape 2, l’usage de
uint64_t data[];est critiqué : il ne convient pas aux types dont les contraintes d’alignement sont supérieures à celles deuint64_t, et il est inutilement gaspilleur pour les types plus petits ; le problème est encore plus visible, par exemple, avec un ABI ilp32 sur architecture 64 bits. Pour le code de l’étape 3, il est indiqué qu’il faudrait écrireint main() { List(Foo) foo_list = {NULL};ainsi. Sanstypeof, on ne peut pas renvoyer la valeur de retour, et dans le code de remplacement, des erreurs liées àconstpeuvent survenir, problème mis en évidence par la symétrie de l’opérateur==. Si on enlèvepayload, il n’y a plus d’information de taille, donc ce n’est pas sûr ; par exemple, ajouter unint32_tàList(int64_t)peut sembler acceptable, mais on ne peut pas déterminer la taille réelle deint32_t. Il faudrait des améliorations pour rendre cela plus sûr. L’auteur estime qu’il existe deux grandes limites à l’usage des generics en C : d’abord, l’approche par délégation via vtable limite les fonctionnalités, car une structure ne peut pas inclure de macros ; ensuite, avec une délégation vers une vtable externe, il faut déclarer à l’avance tous les types qui seront utilisés. La meilleure méthode serait de ne déclarer que des fonctions statiques dans un en-tête contenant les déclarationstypedef, mais GCC et Clang n’émettent pas l’avertissement sur lesstaticnon définies au même moment. Enfin, il prend comme exemple la conception de fonctions recevant différentes structures de buffer pour souligner qu’il faut aussi gérer toutes les versionsconstÀ propos du problème de délégation vers une vtable externe, quelqu’un raconte avoir déjà dû aller jusqu’à créer un compilateur pour le résoudre dans un ancien projet. Au lancement du projet Apache Clownfish, ils ont commencé par parser des fichiers
.h, puis ont fini par créer leur propre format, les en-têtes Clownfish (.cfh). Il montre un exemple de code appelant la méthodeCloned’un objet réel, et explique qu’il fallait générer en masse ce type de code lourd pour fournir des bindings de langages dynamiques avec des fonctionnalités orientées objet. L’objectif de Clownfish était de fournir un modèle objet au plus petit dénominateur commun, et les types des langages liés étaient également générés depuis les.cfh. Il ajoute que, face à cette complexité, la plupart des gens finissent par renoncer à la sûreté de type et passer par des castvoid*. https://github.com/apache/lucy-clownfishÀ propos de
int main(), il est rappelé qu’en C,int main()signifie que le nombre d’arguments est indéterminé. Il faut déclarerint main(void)pour signifier qu’il n’y a pas d’arguments. Un point que beaucoup de développeurs C++ oublient souvent, souligne le commentaireQuelqu’un dit qu’il s’attendrait à ce qu’une
unionpuisse réellement s’unifier, c’est-à-dire qu’un type puisse se déclarer lui-même comme faisant partie de launiond’un autre typeIl est signalé qu’au moment du
malloc, à cause du padding interne, la taille calculée peut être inférieure à la taille réelle ; par exemple, avecmalloc(sizeof(*node) + data_size);, cela peut être risquéUn commentaire conteste le contenu de l’astuce n°0, expliquant l’avoir utilisée pour créer tout un dialecte de C. Il partage par exemple une implémentation de tas binaire générique : https://github.com/gritzko/librdx/blob/master/abc/HEAPx.h. La syntaxe est un peu lourde, mais au final on obtient une structure C ordinaire, avec de gros avantages en optimisation et en prévisibilité. Selon lui, les autres implémentations rendent inévitables
void*, le dimensionnement mémoire à l’exécution et les définitions par macrosL’auteur répond que le tas binaire et la liste chaînée n’ont pas le même objectif. Un tas binaire doit lire les données au moment du stockage, donc l’approche diffère, et les choix peuvent être différents lorsqu’on écrit un tas binaire générique. Il précise l’avoir aussi mentionné dans une note de bas de page du billet
Quelqu’un donne plusieurs raisons de préférer une implémentation dans le header. En débogage, il est plus facile de suivre le code et d’exploiter les informations de type qu’avec des fonctions macro. Le compilateur peut appliquer des optimisations monomorphisées pour chaque instance, sans coût d’exécution ni contrainte de taille variable. On peut placer des structures génériques sur la pile. Les deux problèmes mentionnés par l’auteur peuvent être contournés : on peut facilement changer les noms avec des macros sur les noms de fonctions, et utiliser des weak symbols pour fusionner automatiquement les doubles définitions à l’édition de liens. Les conteneurs génériques de types pointeurs ont un autre problème, mais il peut être résolu avec des
typedef, etc. Selon lui, les structures de données intrusives restent pratiques en C, même si elles sont difficiles à déboguerL’expression « le compilateur l’avale comme un donut » a beaucoup fait rire quelqu’un
Lors d’une conversion de type de fonction, on suppose par exemple que la représentation interne de
Foo*etvoid*est identique, mais ce n’est pas garanti par le C standard. En l’absence de compatibilité de type (« compatible »), de tels casts peuvent conduire à un comportement indéfini. Le compilateur peut aussi en être affecté dans son analyse d’aliasing, selon le commentaire (avec lien de référence) https://news.ycombinator.com/item?id=44421185Quelqu’un demande : « Pourquoi faire autant de contorsions pour avoir des generics en C ? Pourquoi ne pas simplement utiliser C++ ? »
Une réponse partage l’expérience selon laquelle, pour des raisons d’exigences de sûreté et autres contraintes, il est souvent impossible de migrer immédiatement vers C++ dans des projets legacy. Les nouveaux projets peuvent fixer des standards et adopter C++, mais les projets existants devront parfois rester en C encore un moment. Le commentaire souhaiterait un peu plus de compréhension contextuelle derrière le simple « utilisez juste C++ »
En pratique, dans les environnements qui utilisent C, passer à C++ peut être plus complexe et causer davantage de problèmes
À l’inverse, un autre avis estime qu’avec un peu d’effort on peut obtenir le même résultat en C, sans devoir aller jusqu’à C++
Quelqu’un présente une approche réellement utilisée dans le noyau Linux : inclure une
struct list_headcontenant les informations de liste dans une structure spécifique à chaque type. Lien de référence : https://kernelnewbies.org/FAQ/LinkedListsLIST_HEAD_INITetINIT_LIST_HEADdu noyau Linux ne paraissent pas très intuitifsDans le code de la section « typeof on old compilers »,
(list)->payload = (item);n’est en réalité pas un no-op : cela écrase la tête de liste avecitem, fait remarquer un commentaire. Si c’était bien le comportement recherché, il faudrait l’envelopper dansif(0)uniona été remplacée par unestruct, ce qui paraît également gaspilleur. Il vaudrait mieux, selon lui, le traiter dans unif(0)Quelqu’un montre qu’en D, une structure de liste générique de ce genre est bien plus simple, et compare le préprocesseur C à l’action d’enfoncer un clou avec un marteau sur son propre ongle, alors qu’un nail gun serait bien plus rapide et propre, pour souligner l’inconfort des macros C
L’idée d’utiliser
unionettypeof()est jugée intéressante. De son côté, quelqu’un dit qu’avec les structures de données intrusives, il finit malgré tout par avoir besoin d’un wrapper composé de grosses macros, et se demande si une telle implémentation est aussi possible avecunionettypeof. Il partage en exemple du code de wrapper pour table de hachage et sa documentation : https://github.com/FRRouting/frr/blob/master/lib/typesafe.h#L823-L971 https://docs.frrouting.org/projects/dev-guide/en/latest/lists.htmlQuelqu’un indique utiliser déjà cette technique dans une bibliothèque expérimentale : https://github.com/uecker/noplate/blob/main/src/list.h
Le point clé semble être l’utilisation du type des pointeurs de fonction pour garantir la sûreté de type, au lieu du type de handle souvent employé. Il est ajouté que la norme C23 a amélioré les problèmes de compatibilité de type, avec partage du document standard et de l’état du support dans GCC/Clang récents : https://www.open-std.org/jtc1/sc22/wg14/www/docs/n3037.pdf
union, et que le cast de fonction n’est qu’une possibilité parmi d’autres. Il précise en avoir discuté en détail dans les notes de bas de page et dans la section « typeof on old compilers »