2024-05-31
On va sans doute penser que je fais du comique de répétition,
mais là je tiens quelque chose : j'ai directement embrayé sur la version suivante de :
qui implémente le maximum des plus récentes évolutions du langage C (alias C23).
Pour le coup, ça commence vraiment à devenir intéressant.
Ce dont je suis le plus fier est que le code compile désormais sans aucun warning avec la version "15.x staging" de GCC (installable ici pour Debian/Ubuntu, ou ici pour Fedora/RHEL). Cela dit, normalement tout est déjà dans GCC14. Qu'Arch Linux a sûrement déjà 😉.
Le composant lui-même, une liste hétérogène proche d'un std::vector<std::variant> en C++, peut se révéler très utile... le but reste cependant de "démontrer C23" au détriments de certaines optimisations (pas de hash table p.ex.).
Je vais ci-après lister toutes les fonctions C23 utilisées, plus 2-3 qui valent le coup d'oeil !
L'inférence de type "made in C++" fait son entrée sous une forme affaiblie.
auto v = (Value*) calloc(1, sizeof(Value)); // le compilateur déduit "Value*"
ou
auto L = list_create(0); // le compilateur déduit "List*" par la signature
On peut bien l'utiliser avec des pointeurs, comme ici "Value*" et "List*", contrairement à ce que laisse supposer la spécification ; c'est uniquement la syntaxe "auto* var" qui est interdite.
J'ai dit "affaiblie" car cela reste une déduction statique -> faite à la compilation, pas à l'exécution. Cela veut dire que ce genre de raffinement :
auto var = (list_get_Type(L, idx) == EINTEGER)
? list_grab_int(L, idx) // int
: ((list_get_Type(L, idx) == EBOOLEAN)
? list_grab_bool(L, idx) // bool
: ...);
avec un type de retour dynamique, reste impossible... pour l'instant !
Lié au précédent, et à la compilation également ;
pour éviter les répétitions ou ne pas exposer les détails d'implémentation, on peut faire :
for (typeof(val->idx) i = 0; i < (val->idx - 1); i++) { // val->idx: size_t
Tant qu'on sait que i est de la famille des entiers, on n'a pas besoin de connaître son type précis (size_t, celui de val->idx en borne haute) pour le définir comme itérateur de notre boucle.
Dans le cas des APIs publiques, j'ai ajouté une petite macro sympa - quoi qu'encore sous-exploitée - qui en démontre un usage possible :
for (TYPEOF(list_length) i = 0; i < list_length(L); i++)
et qui récupère le type de retour de toute fonction acceptant la syntaxe "func(nullptr, [...])" pour pouvoir l'utiliser, justement dans un itérateur par exemple !
Et le code de ladite macro, d'ailleurs, utilise...
Cette nouvelle macro plus générique, utilisée comme :
define TYPEOF(F, ...) typeof(F(nullptr __VA_OPT__(,) __VA_ARGS__))
remplace l'ancienne macro non-standard GCC "##VA_ARGS" ;
avec l'avantage d'omettre automatiquement la virgule "," si l'appel n'a qu'un argument (ex. : "TYPEOF(func)" -> "func(nullptr)").
NULL est historiquement défini comme "void()()" par GCC, mais "(int)0" par G++ et d'autres compilateurs.
Cela peut avoir des conséquences subtiles sur le code, notamment sur les macros _Generic apportées par C11 et dont je fais désormais un usage intensif.
"nullptr" conserve la propriété de cast universel qu'a "NULL", mais normalise sa définition sur celle de C++ : distince à la fois de "void*" et "int".
char* err = nullptr;
if (!err) { err = "test"; }
Avec les macros _Generic justement : il faut pas oublier de gérer son nouveau type propre "nullptr_t" :
#define list_set(L, I, V) _Generic((V), \
int: list_set_int, \ // 0
void*: list_set_void, \ // NULL
nullptr_t: list_set_nullptr)(L, I, V) // nullptr
Avec tous les warnings activés, GCC en émet un lorsqu'un "switch(){ case: [...]" n'est pas délimité par un break;. C'est effectivement en général une erreur... mais pas tout le temps !
Cette nouvelle annotation permet d'indiquer qu'il est bien dans l'intention du développeur d'enchaîner :
switch (res) {
case EINVAL: if (!err) err="Invalid index"; [[fallthrough]];
case EAGAIN: if (!err) err="List locked"; [[fallthrough]];
case EUNDEF: if (!err) err="Undefined value";
fprintf(stderr, "[ERR: %s]\n", err);
Imaginez une fonction "list_empty()" supposée être utilisée ainsi :
if (list_empty(L)) {
sauf que le stagiaire du mois a compris tout à fait autre chose :
list_empty(L); // clear list for re-use
Un warning indiquant de ne pas ignorer le retour, avec un message ajustable, peut être explicitement ajouté sur les signatures des fonctions :
[[nodiscard("Did not test the return value!")]]
bool list_empty(List* list);
Ces maladresses sont parfois bien pires : pensez à "list_create()" qui renvoie un pointeur alloué sur le tas, et qu'on est supposé libérer... !
C'est un point mineur... mais bool n'est désormais plus un alias pour 0 ou 1 ;
c'est un type intégré du langage, distinct de int et ne nécessitant plus d'inclure "stdbool.h".
L'avantage est entre autres une bonne gestion par les macros _Generic :
errno_t list_add_int(List* list, int i);
errno_t list_add_bool(List* list, bool b);
#define list_add(L, V) _Generic((V), \
int: list_add_int, \
bool: list_add_bool, \
Un paramètre de fonction peut être non-nommé dans une implémentation, ce qui ne produit plus de warning genre "Paramètre inutilisé" :
errno_t _value_get_Type(Value* v, void*) // "void*" pas utilisé
{
switch (v->t) {
case T_INTEGER: return EINTEGER;
case T_BOOLEAN: return EBOOLEAN;
case T_FLOAT : return EFLOAT;
case T_STRING : return ESTRING;
default : return EUNDEF;
}
}
On utilisait traditionnellement l'initialiseur {0} propre à GCC et Clang :
// does not compile with MSVC
#ifndef _MSC_VER
struct MyStruct s = {0}; // all bits set to 0
#endif
qui initalise tous les champs à 0 (y compris le padding rajouté selon l'architecture),
mais ne fonctionnait pas avec tous les compilateurs ni tous les niveaux d'optimisation - obligeant à recourir p.ex. à "memset(&s, 0, sizeof(s))".
Il est désormais normalisé sous la forme :
struct MyStruct s = {}; // all bits set to 0, on all compilers & levels
En lieu et place des traditionnelles macros à la :
#define ARRAY_SIZE 5
on peut désormais définir des expressions constantes déterminées à la compilation :
constexpr size_t ArraySize = 5;
dont l'avantage sur des const est le gain de temps à l'exécution (initialisation),
tout en restant de véritables constantes du langage, utilisables comme telles :
printf("Pointer address of 'ArraySize': %p\n", &ArraySize);
for(typeof(ArraySize) i; i < ArraySize; i++) { // i: size_t
Attention par contre, car il reste des subtilités 😉 ;
const int a = 5;
int arr_a[a]; // tableau de taille variable
memset(arr_a, 0, a*sizeof(int));
constexpr int b = 5;
int arr_b[b] = {}; // tableau de taille statique
Historiquement, le standard ne définit pas ce qu'il se passe lorsqu'on fait dépasser les bornes de son type à un nombre :
int i = 2147483647 + 1; // "-214748364" on GCC without "-fwrapv"
cela se contrôlait, mal, avec des flags propres aux compilateurs comme "-fwrapv".
En plus d'être sources d'erreur subtiles, cela ne faisait pas très sérieux pour un langage se vantant de sa pertinence en calculs mathématiques…
On peut désormais contrôler programmatiquement le comportement des dépassements de bornes :
#include <stdckdint.h>
uint32_t res;
if (ckd_add(&res, 2147483647, 1)) {
fprintf(stderr, "OVERFLOW! Limiting to upper limit...\n");
res = INT_MAX; // 2147483647
}
Il est facile, partant de là, de créer des fonctions du type "check_add()" effectuant des calculs sûrs.
Voilà !
Comme la dernière fois, j'ai également écrit une version bibliothèque du composant sous LGPLv3.