2 points par GN⁺ 2025-01-13 | 1 commentaires | Partager sur WhatsApp
  • 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 .h sé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

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érateur C n’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-expression dans 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’abord dex(), définie plus bas, gcc signale une erreur indiquant un conflit entre la déclaration implicite de dex et le type de sa définition réelle
  • Si l’on inverse l’ordre de floo et dex, 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
  • 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 les include dans le fichier .c
  • La structure d’exemple est la suivante
    • floo.c inclut dex.h et appelle dex()
    • dex.h déclare char dex(char *s, int i);
    • dex.c inclut dex.h et définit dex()
  • Créer un fichier .h pour chaque module externe augmente le travail répétitif
  • Si le fichier .h ne 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 comme char dexx(char *s, int i) { return s[i]; }
  • 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

1 commentaires

 
GN⁺ 2025-01-13
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 .h d’une bibliothèque pour comprendre comment l’utiliser ; en général, le .h contient 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.

    • Cet argument m’a toujours paru étrange : les autres langages résolvent ce problème avec des outils, et je trouve cela préférable.
      Par exemple, en Rust, pour voir l’interface et le mode d’emploi d’une bibliothèque, cargo doc --open suffit. 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.
    • Je vois plutôt les fichiers d’en-tête comme un bricolage fragile destiné à gérer les plateformes aux ressources limitées des années 70.
      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.
    • Je suis d’accord et pas d’accord à la fois.
      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’import de 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.
    • Il me semble qu’il y a là une différence de façon de penser.
      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.
    • La plupart des langages modulaires compilés le permettent déjà.
      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.

    • Il est exact que le compilateur doit pouvoir voir le code source de la fonction évaluée.
      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 #include et 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.
    • Personnellement, je n’aime pas les références en avant, car elles rendent le code plus difficile à lire.
      On ne peut plus croire que le graphe de dépendances est disposé dans l’ordre d’un tri topologique.
    • Les autres langages semblent très bien fonctionner sans fichiers d’en-tête ni déclarations anticipées ; j’ai donc du mal à comprendre d’où vient l’opposition.
      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 forme str.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 importe dex.d ou dex.c.

    • D’autres langages populaires savent aussi le faire, et en D, l’exécution de fonctions à la compilation est une fonctionnalité très populaire et utile.
      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.d ou de dex.c se 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.
    • La plupart des compilateurs C réels font déjà probablement une certaine évaluation à la compilation pour l’optimisation.
      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.
    • En réalité, cela ressemble presque trait pour trait à la fonctionnalité constexpr de 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 constexpr en 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 npy appellent npy_load("tests/npy/uint8.npy") dans test_load_uint8(), vérifient les dimensions, la taille et le type avec assert, puis appellent npy_free, et sont exécutés dans main avec quelque chose comme PRINT_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.

    • Cette fonction semble faire de l’I/O, donc elle ne fonctionnera pas comme test à la compilation.
      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.
    • Je fais presque pareil.
      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 à #include le fichier .c testé 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 que NDEBUG ne soit pas défini, pour éviter qu’une personne construise en « mode release » et désactive les assert.
    • Rendre les tests aussi faciles que possible à exécuter aide vraiment beaucoup à aboutir à une base de code qui a effectivement des tests.
      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 ».

    • J’ai du mal à être d’accord avec cette partie.
      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 ».
    • Ces tests à la compilation peuvent être plus proches de 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

    • Python n’a pas de références en avant
      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
    • Dans mon code, l’interface publique a toujours tendance à remonter en haut, et les détails d’implémentation à aller en fin de fichier
      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 defer en Go ou Zig, ou l’attribut cleanup de GCC, ainsi qu’une prise en charge portable d’Unicode et d’UTF-8

    • La plupart de ces points ne sont-ils pas plutôt des fonctionnalités souhaitées dans la bibliothèque standard, et non des choses que le langage lui-même devrait faire ?
  • L’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 comme int x = busybeaver(99); devient problématique

  • C23 a constexpr, mais on ne peut pas encore l’appliquer aux fonctions
    Il existe toutefois une proposition : https://www.open-std.org/jtc1/sc22/wg14/www/docs/n2976.pdf