Ce que le langage C devrait évidemment faire
(digitalmars.com)- Même si le C standard a continué d’évoluer jusqu’à C23, Walter Bright estime que d’anciennes sources de friction comme l’évaluation des expressions constantes, les références anticipées et la dépendance aux headers perturbent encore le flux de développement
- ImportC, le compilateur C intégré au compilateur D, tire parti de son statut de nouvelle implémentation pour exécuter à la compilation des fonctions qui respectent certaines conditions aux emplacements de
constant-expression - Si le CTFE devient possible, des tests unitaires comme
_Static_assert(sum(3, 4) == 7,...)peuvent être exécutés à chaque compilation sans binaire séparé - Les contraintes du C standard sur l’ordre des déclarations entraînent des répétitions de déclarations anticipées et une organisation du code à rebours, mais ImportC adopte une approche moins liée à l’ordre global des déclarations
- Avec
__import dex;, on peut importer directement les déclarations d’un fichier.c, ce qui réduit le besoin de fichiers.hséparés et allège aussi la charge de débogage causée par les écarts entre header et implémentation
Inconvénients persistants du C standard
- Le C standard a été amélioré régulièrement jusqu’à C23, mais Walter Bright considère qu’il reste encore des points non corrigés
- La communauté Dlang a intégré un compilateur C dans le compilateur du langage D, et ce compilateur s’appelle ImportC
- ImportC a été conçu entièrement de zéro, ce qui lui a laissé la possibilité de traiter les lacunes du C avec des techniques modernes de compilation
- L’article aborde quatre points
- l’évaluation de
constant-expression - les tests unitaires à la compilation
- les références anticipées dans les déclarations
- l’import de déclarations
- l’évaluation de
Exécution de fonctions à la compilation et évaluation des expressions constantes
- Le C peut calculer à la compilation des expressions simples grâce au constant folding, mais le C standard ne permet pas d’exécuter des fonctions à la compilation
- Si l’on compile avec gcc l’exemple
enum E { A = 3, B = 4, C = sum(5, 6) };, une erreur indique que la valeur de l’énumérateurCn’est pas une constante entière - ImportC peut compiler le même code
- L’idée est que le compilateur devrait pouvoir exécuter des fonctions à la compilation partout où apparaît
constant-expressiondans la grammaire du C - En revanche, la fonction concernée ne doit pas effectuer d’opérations comme des E/S, l’accès à des variables globales modifiables ou des appels système
Des tests unitaires exécutés à chaque compilation
- Si l’évaluation de fonctions à la compilation, ou CTFE, devient possible, la manière de faire des tests unitaires peut aussi changer
- Si les tests unitaires sont rares en C, c’est en grande partie à cause de la contrainte de devoir créer puis exécuter une cible de build et un binaire séparés
- Si l’on compile avec gcc l’exemple
_Static_assert(sum(3, 4) == 7, "test #1");, une erreur se produit car l’expression dans l’assertion statique n’est pas une constante - ImportC peut compiler ce code
- Cette approche simplifie fortement les tests unitaires des fonctions exécutables à la compilation
- aucun build séparé n’est nécessaire
- aucun travail supplémentaire n’est nécessaire
- les tests s’exécutent à chaque compilation du code
- La suite de tests d’ImportC utilise aussi largement cette méthode
Les répétitions de déclarations causées par les références anticipées
- En C standard, le compilateur ne connaît que les déclarations apparues plus haut dans le code sur le plan lexical, donc les références anticipées ne sont pas autorisées
- Dans l’exemple, si
floo()appelle d’aborddex(), définie plus bas, gcc signale une erreur indiquant un conflit entre la déclaration implicite dedexet le type de sa définition réelle - Si l’on inverse l’ordre de
flooetdex, le même code se compile normalement - ImportC peut accepter des déclarations globales quel que soit leur ordre d’apparition
- Sans références anticipées, il faut ajouter une déclaration séparée pour chaque définition utilisée plus loin
- écrire d’abord une déclaration comme
char dex(char *s, int i); - puis réécrire ensuite la définition de la fonction
- écrire d’abord une déclaration comme
- Cette méthode constitue une répétition inutile, et pousse les programmeurs à placer les fonctions feuille en haut et les fonctions d’interface globale en bas, donc à organiser le code à rebours
- L’auteur critique cette disposition en disant qu’elle revient à lire un article de journal de bas en haut, ce qui n’a pas de sens
Importer des déclarations sans fichier header
- En C, pour utiliser un module externe, on crée en général des déclarations dans un fichier
.h, puis on lesincludedans le fichier.c - La structure d’exemple est la suivante
floo.cinclutdex.het appelledex()dex.hdéclarechar dex(char *s, int i);dex.cinclutdex.het définitdex()
- Créer un fichier
.hpour chaque module externe augmente le travail répétitif - Si le fichier
.hne correspond pas exactement au fichier.c, il peut falloir beaucoup de temps pour trouver ce qui ne va pas - L’approche proposée consiste à importer directement
dex.c- dans
floo.c, on utilise__import dex; - dans
dex.c, on ne garde qu’une définition commechar dexx(char *s, int i) { return s[i]; }
- dans
- Avec cette méthode, il n’est plus nécessaire d’écrire de fichier
.h - ImportC prend aussi en charge cette approche
Documents liés
- ImportC : documentation d’ImportC
- D Language : documentation du langage D
1 commentaires
Avis sur Hacker News
L’une des choses qui me manquent le plus quand j’utilise un langage autre que C, ce sont les fichiers d’en-tête.
J’aime particulièrement, dans le code C, la séparation très claire entre public/privé et interface/implémentation. J’apprécie de pouvoir parcourir simplement le fichier
.hd’une bibliothèque pour comprendre comment l’utiliser ; en général, le.hcontient la documentation d’utilisation et ne duplique pas le.c. On peut aussi mettre la documentation dans le.c, mais du point de vue de l’utilisateur, lire l’interface devient beaucoup moins agréable.Par exemple, en Rust, pour voir l’interface et le mode d’emploi d’une bibliothèque,
cargo doc --opensuffit. Toute l’API publique est générée automatiquement sous une forme consultable, sans avoir à dupliquer du code entre en-têtes et sources ni à le maintenir à la main.Ils ne fonctionnent correctement que si l’on respecte les conventions, et font pâle figure face à des langages comme Ada, où les spécifications d’interface et d’implémentation sont bien conçues et ne nécessitent pas de reparsing répété. J’aime utiliser C, mais cette partie aurait dû être mieux conçue.
Les fichiers d’en-tête de C se contentent d’insérer du texte tel quel, donc on est au niveau du « ça a l’air de marcher à peu près ». À l’inverse, Ada avait les notions de paquetage et de corps de paquetage : le paquetage correspondait au fichier d’en-tête, et le corps du paquetage à l’implémentation. Quand j’utilisais Ada il y a longtemps, tout le monde pouvait compiler en se basant sur les paquetages même si l’implémentation du corps n’était pas encore prête, ce qui permettait d’aligner les interfaces avant l’implémentation. Dans une autre direction, j’aime aussi le rôle de « fichier d’en-tête » que joue l’
importde Python, parce qu’il se mappe naturellement sur le système de fichiers et évite de gérer la sémantique des include de C.Avec des fichiers d’en-tête, on en vient à considérer l’interface comme quelque chose de distinct de l’implémentation. C’est pourquoi les personnes habituées à cette approche sont mal à l’aise avec l’idée que l’interface soit générée par des outils. L’interface n’est pas un sous-produit de l’implémentation : elle est conçue séparément et intentionnellement, et pour certains elle est plus importante que l’implémentation. À l’inverse, les personnes habituées à la documentation générée automatiquement sont gênées quand l’interface n’est pas produite à partir de l’unique source de vérité qu’est le code d’implémentation. Après avoir longtemps utilisé des langages avec fichier d’interface séparé ou sans, on se fige dans son camp et on oublie la sensation de penser selon l’autre approche.
Soit avec des fichiers séparés, comme Modula-2, Modula-3, Ada, Standard ML, Caml Light, OCaml, F# ou D, soit via une génération par des outils textuels ou graphiques, comme Object Pascal, D, Haskell, Java, C#, F#, Swift, Go ou Rust. Tous disposent d’un typage plus fort, d’une compilation plus rapide et de vrais espaces de noms. Les toolchains Rust et Swift ont encore des points à améliorer, mais les outils C ont toujours été primitifs par rapport à ce qui se faisait en dehors de Bell Labs. Si AT&T avait pu les exploiter commercialement, l’histoire aurait peut-être été différente ; au final, on a reçu des citrons gratuits au lieu d’oranges bien mûres. Cela dit, on a bien créé quelque chose comme TypeScript pour C, et aujourd’hui cela prend aussi en charge des types de collections avec vérification des bornes et de vrais modules.
L’auteur ayant manifestement beaucoup plus de connaissances et d’expérience dans ce domaine, je me demande comment il résoudrait les problèmes suivants.
L’évaluation des expressions constantes devient simple si elle se limite à l’unité de traduction, mais cela restreint fortement ce qu’on peut faire sans répéter du code. Les tests unitaires à la compilation sont possibles dans une certaine mesure si les tests peuvent être exprimés sous forme de macros, et deviennent plus faciles avec le point précédent. Les références en avant dans les déclarations peuvent susciter une forte opposition, car le compilateur passe alors d’un modèle en une passe à un modèle en deux passes, avec un impact sur les performances. Pour ceux qui compilent d’énormes bases de code tout en parallélisant les unités de traduction, cela peut être difficile à accepter. L’importation de déclarations est une modification qui casse la compatibilité. J’ai déjà implémenté une sorte de template en C en définissant une variable, puis en important un fichier
.c, en modifiant la variable, puis en important à nouveau le même.c. Il m’est aussi arrivé de placer plusieurs définitions avant SQLite C Amalgamation et d’ajouter des fonctions pour exposer des fonctions internes ; tous ces usages semblent voués à casser. Je me demande s’il existe une solution à ces problèmes.En C, cela se fait avec
#include, et en D, en important le module qui contient le code nécessaire. Exprimer les tests sous forme de macros n’est pas adapté quand on veut tester des fonctions. L’exemple était volontairement simple pour faciliter la compréhension ; les usages réels peuvent être beaucoup plus complexes. Côté performances, D se compile plus vite qu’un compilateur C, principalement parce que le préprocesseur C est une structure fossile qui exige plusieurs passes, tandis que D utilise import au lieu de#includeet ne recompile donc pas sans cesse les fichiers.h. La stratégie de D consiste à séparer le parsing de l’analyse sémantique ; cela peut être un peu plus lent, mais il n’y a pas le coût de recompiler des déclarations dupliquées puis de les fusionner. L’exécution de fonctions à la compilation peut devenir un goulot d’étranglement si elle est beaucoup utilisée, mais avec un usage léger, les performances restent correctes. Si vous implémentez des templates en C par des hacks, vous avez déjà dépassé les limites du langage et il vous faut un langage plus puissant. D dispose d’une métaprogrammation de tout premier ordre, et les autres langages à templates suivent souvent la voie ouverte par D.On ne peut plus croire que le graphe de dépendances est disposé dans l’ordre d’un tri topologique.
Je me demande aussi si les compilateurs C modernes sont réellement encore en une seule passe.
L’exemple d’évaluation d’expressions constantes dans l’article est assez simple, mais dans des cas plus complexes, la vitesse du compilateur et l’utilisation mémoire risquent de se dégrader fortement, et il faudrait probablement une machine virtuelle pour en tirer parti.
Je comprends donc qu’on ait pu juger cela « trop complexe » pour l’intégrer à la norme. J’aurais préféré que C++ ou C s’oriente vers l’import de déclarations, plutôt que vers l’étrange mélange défini dans C++20. Par exemple, importer un module sous un symbole avec quelque chose comme
#import "string.c" as str, puis rendre tous les symboles non statiques du fichier accessibles sous la formestr.trim(" Hello World ");. À part ça, je n’aime pas les formes où le chemin du fichier n’est pas explicite, comme__import dex;. Dans ce cas, impossible de savoir si l’on importedex.doudex.c.Si on l’utilise beaucoup, cela consomme évidemment du temps de compilation et de la mémoire. Quant à la machine virtuelle, le repliement de constantes est déjà en soi une machine virtuelle, à laquelle on étendrait le traitement des appels de fonction. La sémantique de C est simple, donc ce ne serait pas si mauvais. Le mode d’import que tu proposes ressemble beaucoup à ce que fait l’import en D : https://dlang.org/spec/module.html#import-declaration. Le problème de savoir s’il s’agit de
dex.dou dedex.cse pose réellement, et la réponse est la configuration des chemins d’import. C’est similaire aux chemins d’include d’un compilateur C.C possède déjà des expressions constantes. Le plus gros obstacle est que le compilateur doit avoir accès au code source de la fonction, donc cela serait sans doute limité aux fonctions de la même unité de traduction. Et il y a aussi le problème humain, potentiellement bien plus important : un comité réunissant des représentants de plusieurs compilateurs devrait se mettre d’accord sur la sémantique de cette évaluation constante.
constexprde C++.Comme presque tous les compilateurs C sont déjà aussi des compilateurs C++, je me demande si prendre en charge les fonctions et l’évaluation
constexpren C pourrait vraiment être si problématique.J’écris toujours des tests unitaires pour le code C.
Avec un bon système de build et en acceptant un peu de boilerplate, ce n’est pas difficile. Les tests de la bibliothèque
npyappellentnpy_load("tests/npy/uint8.npy")danstest_load_uint8(), vérifient les dimensions, la taille et le type avecassert, puis appellentnpy_free, et sont exécutés dansmainavec quelque chose commePRINT_RUN(test_load_uint8);. On pourrait aussi générer une partie des tests avec le préprocesseur, mais je préfère garder les choses simples.Les exemples de tests unitaires du compilateur ImportC ressemblent à
_Static_assert(sizeof(struct S22079){1,2,3} == sizeof(int)*3, "ok");et_Static_assert(sizeof(struct S22079){1,2,3}.a == sizeof(int), "ok");. Comme la sémantique est vérifiée à la compilation, il n’est pas nécessaire de lier ni d’exécuter. Plus il y a de tests, plus cette approche devient rapide, et plus la suite de tests tourne vite, plus la productivité augmente.J’ai autrefois exploré en profondeur des frameworks de tests unitaires sophistiqués, mais j’ai fini par comprendre qu’ils n’apportaient pas grand-chose, et je me suis stabilisé sur une forme presque identique. Ce que d’autres pourraient voir d’un mauvais œil, c’est que je suis prêt à
#includele fichier.ctesté pour appeler des fonctions statiques. Je n’inclus qu’un seul fichier.c. Je fais aussi un petit traitement de préprocesseur avant le#include, afin queNDEBUGne soit pas défini, pour éviter qu’une personne construise en « mode release » et désactive lesassert.J’utilise quelque chose de similaire : https://github.com/ensisoft/detonator/blob/master/base/test_.... Je me suis beaucoup inspiré de
boost.test.minimal; à l’origine c’était un unique header, mais avec le temps il a fallu ajouter une unité de traduction séparée. Ma conclusion est que si l’on maintient la base de code dans un état où les tests passent toujours, on a beaucoup moins besoin de complexité dans les outils de test, comme le reporting d’erreurs ou la tolérance aux défauts.Les tests unitaires à la compilation sont une idée aussi mauvaise que transformer les « imports/variables/résultats inutilisés » en erreurs plutôt qu’en avertissements.
C’est une « fonction nounou » qui retire le contrôle au développeur et finit par l’obliger à suivre des procédures bureaucratiques pour terminer le travail. Ce genre de test qui fait échouer le build convient aux builds de type « ça a l’air fini », mais pas aux builds « encore en cours », qui représentent 99 % des builds. C’est un peu comme dire : « tu ne peux pas utiliser la scie sur table tant que tu n’as pas rangé la perceuse ».
Si tu essayais d’exprimer une idée et qu’un test à la compilation te dit qu’elle est fausse, il se peut en réalité que cette idée soit encore incomplète, ou que tu n’aies pas suffisamment réfléchi à toutes les conséquences de cette expression. C’est comparable à la vérification de types de Haskell. Un programme qui ne passe pas la vérification de types ne peut pas être compilé, ce qui force le programmeur à n’exprimer que des pensées toujours complètes. En théorie, cela peut mener à des programmes mieux réfléchis. En revanche, l’écriture devient plus difficile, car cela force aussi le programmeur à découvrir même les recoins dont il sait qu’ils sont « invalides mais sans importance ».
static_assert, et ils ont beaucoup de valeur pour détecter des utilisations incompatibles de fonctions de bibliothèque.Je trouve que c’est une assez bonne idée.
« Les fonctions leaf d’abord, les fonctions d’interface globale en dernier », c’est l’inverse pour moi
Pour plusieurs raisons, je préfère écrire le code dans un ordre topologique. Cela ressemble à la façon dont on écrit le code à l’intérieur d’une fonction, cela rend clair l’endroit où placer les fonctions dans un module et, surtout, cela rend très visibles les dépendances cycliques entre fragments de code internes au module. Je n’aime pas vraiment les dépendances cycliques, car elles rendent une base de code plus entremêlée et un module plus difficile à comprendre comme unité indépendante. En Python, elles peuvent aussi créer des problèmes qui ne se révèlent qu’à l’exécution[0], et les imports cycliques sont tellement courants que les vérificateurs de types actuels semblent désactiver ce diagnostic par défaut[1]. Les langages qui ne prennent pas en charge les références en avant, comme C, OCaml ou SML, permettent d’appliquer le « principe de moindre surprise » aux dépendances cycliques. OCaml interdit même les dépendances récursives entre fonctions sauf si on les déclare avec quelque chose comme
let rec fn1 = .. and fn2 = ..; c’est un peu pénible à l’écriture, mais cela devient une information importante à la lecture[0]: https://gist.github.com/Mark24Code/2073470277437f2241033c200...
[1]: https://microsoft.github.io/pyright/#/configuration?id=type-... (voir
reportImportCycles)Il y a l’explication selon laquelle, comme le compilateur ne connaît que ce qui précède lexicalement, on obtient un ordre « de bas en haut », avec les fonctions leaf d’abord et les fonctions d’interface globale en dernier ; mais cet ordre est fréquent même dans des langages qui autorisent les références en avant, comme Python[0]
Je me demande si c’est un vestige des langages qui n’autorisent pas les références en avant, ou si c’est réellement plus logique pour certains types de code
[0] https://stackoverflow.com/a/73131538
C’est juste que les identifiants dans le corps d’une fonction ne sont résolus qu’au moment où la fonction elle-même est exécutée, et qu’à ce moment-là tout ce qui se trouve dans la portée du module a été défini. On peut le vérifier directement en mettant n’importe quel nom dans le corps d’une fonction puis en chargeant le module
Il n’y a pas de règle stricte, mais je trouve que c’est plus propre et plus logique à lire comme ça. Surtout quand l’implémentation est volumineuse, je n’ai pas envie de devoir tout faire défiler avant de voir ce que le code fait fondamentalement. Je suis curieux de savoir comment les autres font
Parmi les « choses évidentes » que C devrait faire, il y a la prise en charge d’un type slice contenant un pointeur et une longueur, des versions réentrantes et si possible thread-safe des API qui utilisent de l’état global, la standardisation de quelque chose comme
deferen Go ou Zig, ou l’attributcleanupde GCC, ainsi qu’une prise en charge portable d’Unicode et d’UTF-8L’idée que, partout où la syntaxe C attend une expression constante, le compilateur devrait pouvoir exécuter une fonction à la compilation tant que celle-ci ne fait pas d’I/O, n’accède pas à des variables globales mutables, n’effectue pas d’appels système, etc., peut facilement poser problème
Il suffit de choisir une fonction pure qui tourne très longtemps. Par exemple, si
int busybeaver(int n) {...}est une fonction pure qui renvoie la durée de vie maximale d’une machine de castor affairé à n états, alors du code commeint x = busybeaver(99);devient problématiqueC23 a
constexpr, mais on ne peut pas encore l’appliquer aux fonctionsIl existe toutefois une proposition : https://www.open-std.org/jtc1/sc22/wg14/www/docs/n2976.pdf