Mes conseils pratiques en C🔗
- Introduction
- Du danger du C
- Structures/structs
- Initialisations
- Sources
- Chaîne de caractères/strings
- Qualificateurs
- Boucles
- Conditions
- Overflow et gestion des nombres
- Allocation mémoire
- Taille des tableaux
- Tableaux à taille variable
- Pour les chaînes de caractères
- malloc et realloc dans une boucle
- Fonctions variadiques
- Le nombre de paramètres est toujours inconnu d'une va_list
- Sous GNU libc, utiliser va_copy pour utiliser une va_list plusieurs fois
- Déclarations complexes
- Réseau
- Optimisations
Introduction
Je ne suis pas un expert en C, mais j'aimerais bien m'améliorer. Voilà ce que j'ai appris ces quelques années. Ce document n'est pas une référence, ni un tutoriel, mais un recueil de mes pratiques. Il s'adresse à des gens qui maitrisent la grammaire du langage C.
Comme à mon habitude, je sais que je ne suis pas le fils de Brian Kernighan ni de Denis Ritchie donc il est possible que j'écrive des conneries.
Du danger du C
Le langage C est réputé pour être assez simple à comprendre:
- sa grammaire semble assez simple, de prime abord (ça paraît simple mais ça nel'est pas toujours)
- le nombre de mots réservés est faible (une trentaine)
- les concepts du langage sont assez basiques
- les règles de base semblent accessibles.
Néanmoins, cette simplicité est également à l'origine de problèmes pas si évidents que ça à gérer, comme:
- la comparaison entre entiers signés ou non signés
- la gestion des chaînes de caractères (quand il n'y a pas de caractère de terminaison)
- les débordements mémoires
- les situations non gérées (undefined behavior).
J'ai tendance à dire que le C, c'est un peu comme la loi des robots d'Isaac Asimov: elle se base sur 3 points très simples que tout le monde peut comprendre. Mais, en pratique, ces points simples doivent être finalement corrigés et étoffés, car ils arrivent à se contredirent eux-même dans certaines situations. Par exemple, la loi n°1 s'intitule d'abord: "Un robot ne peut porter atteinte à un être humain.". Mais à un moment, un humain meurt suite à l'inaction d'un robot face à une situation de danger. LA loi doit être complétée comme suit: "Un robot ne peut porter atteinte à un être humain ni, restant passif, laisser cet être humain exposé au danger."
Structures/structs
typedef ou pas ?
J'ai longuement hésité à utiliser un typedef sur des structs. Au début, j'avais envie de laisser le struct partout pour me faire rappeler que c'était une structure et pas un type de données de base. Mais avec un brin plus d'expérience, ça finit par encombrer le code de struct partout pour rien. Et puis, en C, les types de données de base sont: int, char, float, double, void (y en a plus mais y en n'a pas pléthore non plus). Donc, tout ce qui sort de l'ordinaire comme précision du type de données c'est à 90% une struct. Donc, pas la peine de le rappeler tout le temps.
Donc, je fais comme recommendé dans modernC, à savoir, déclarer un typedef en amont de la déclaration de la struct:
typedef struct stuff stuff; struct stuff { size_t volume; /**< Sound level of the channel */ bool muted; /**< should the sound be muted or not? */ char *path; /**< Filepath to sound file */ char *name; /**< Name of the channel */ Mix_Chunk *music; /**< An SDL_Mixer Chunk pointer */ };
Ensuite, il suffit d'utiliser le type stuff
partout. On sait bien que c'est une struct parce que ce n'est pas un type de base.
Dès qu'on a besoin de beaucoup de variables, je fous tout dans une struct
Quand il faut que je passe plein de paramètres à une fonction, c'est généralement le signe d'une utilisation d'une espèce d'objet qui regrouperait tout ces paramètres. En C, une espèce d'objet, ça s'appelle une struct.
Initialisations
Pour les pointeurs
J'utilise toujours une initialisation à 0, en même temps qu'une déclaration. Par exemple, je ne fais jamais:
int *stuff; *stuff = 10;
Je fais toujours:
int *stuff = 0; *stuff = 10;
Je n'utilise jamais NULL, sauf lorsque c'est pas évident. Pourquoi utiliser 0 au lieu de NULL ? Tout simplement parce que ça a le mérite de rappeler que 0, c'est false.
Pour les structures
Pour les structures discrètes dans la stack, j'utilise très souvent {0}
, ça a le mérite de tout foutre à 0 par défaut:
- Tout ce qui est pointeur pointe vers 0 soit NULL.
- Les chiffres sont tous à 0.
- Les booléens sont tous à false.
Sinon, j'utilise des compound literals:
struct stuff a = { .name = "SDF", .velocity=50.0, .active = true };
Pour le structs dans la heap (allocation dynamique), je déclare une struct par défaut avec un compound literal comme au dessus et puis, je fais un memcpy après l'allocation:
struct stuff default_stuff = { .name = "SDF", .velocity = 50.0, .active = true }; struct stuff *multiple_stuff = malloc(10 * sizeof struct stuff); for (size_t i = 0; i < 10; ++i) { memcpy(multiple_stuff + i, &default_stuff, 1); }
Pour les tableaux, je reprends le même principe mais, pas besoin de memcpy (oui, c'est un tableau, on accède directement au membre comme un objet à part entière, pas besoin de déréférencer):
struct stuff default_stuff = { .name = "SDF", .velocity=50.0, .active = true }; struct stuff multiple_stuff[10] = {0}; for (size_t i = 0; i < (sizeof multiple_stuff) / (sizeof multiple_stuff[0]); ++i) { multiple_stuff[i] = default_stuff; }
Sources
Commentaires
Bon, c'est trivial mais j'aime bien utiliser les commentaires originels du C, c'est à dire : /*
et */
, jamais //
. Je ne sais pas pourquoi mais je trouve ça plus élégant. Ça prend plus de place (en Ko de texte), mais on n'est pas en Python et de toute manière, le C est plus verbeux à l'usage pour beaucoup de choses (faut vérifier plein de trucs, tout le temps gérer l'allocation de mémoire, faire des calculs arithmétiques de pointeurs).
J'avoue utiliser //
uniquement en phase de développement pour commenter du code en l'invalidant. Ça m'aide à savoir ce qui est temporaire et doit être supprimé ou recodé par rapport à ce qui relève d'un vrai commentaire.
Formattage
Pour le texte du source, j'utilise UTF-8 partout, on est dans la décénie 2020. Par contre, je suis resté fidèle aux TABS pures comme indicateur d'alignement. Je les configure pour que ça fasse 2 caractères dans mon éditeur.
Pour les variables comme les fonctions, je suis resté au snake_case, ça fait plus un style K&R.
Chaînes de caractères litérales
Attention, un espace entre deux chaînes de caractères litérales les concatène. C'est assez pratique et je l'utilise souvent, par exemple:
#define STUFF "définition" const char *fixed = "Un commentaire avec une " STUFF; printf("fixed = %s\n", fixed); /* fixed = Un commentaire avec une définition */
Par contre, quand on remplit un tableau de chaînes de caractères litérales, si on oublie la virgule terminale, on a un membre de moins dans le tableau:
char *fixed[] = { "1ère chaîne litérale", "2ème chaîne litérale", "3ème chaîne litérale" /* manque la virgule ici */ "4ème chaîne litérale", "5ème chaîne litérale", } for (size_t i = 0; i < sizeof fixed / sizeof fixed[0]; ++i) { printf("Chaîne %zu: %s.\n", i, fixed[i]); } /* * Chaîne 0: 1ère chaîne litérale. * Chaîne 1: 2ème chaîne litérale. * Chaîne 2: 3ème chaîne litérale4èmechaîne litérale. * Chaîne 4: 5ème chaîne litérale. */
Nommage et recouvrement de l'espace de nom
Il est possible de nommer une fonction comme une fonction C de la lib standard. Par contre, il faut veiller à la mettre static
pour limiter le scope et ne pas faire en sorte que tout appel à la fonction, y compris tout appel interne depuis la libc n'appelle votre fonction (forcément codée avec les pieds).
Bon, en règle générale, on évite mais parfois c'est dommage de se priver d'un nom explicite court déjà pris pour quelque-chose qui fait la même chose qu'une fonction de la libc.
Chaîne de caractères/strings
Typage des strings
Une différence notable entre le C et les autres langages majeurs (C++/Java/Python/etc.), c'est qu'en C, les chaînes de caractères sont des structures de données et pas des types à part entière. En effet, une chaîne de caractère en C, c'est un espace mémoire (tableau ou pointeur vers une structure mémoire linéaire) de char
ET qui DOIT avoir un caractère de terminaison explicite ('\0').
De fait, la gestion des chaînes c'est en fait un ensemble de fonctions qui agissent sur un tableau ou un espace mémoire alloué de char (donc pas des int ou même unsigned char). Ces fonctions s'attendent toutes à ce qu'il y ait un caractère de terminaison. Si ce n'est pas le cas, alors la donnée traitée n'est PAS une chaîne de caractères en C. Et donc, forcément, ça ne se passe généralement pas très bien, comme quand on essaye de remplacer des choux par des carottes.
strnlen
Je n'utilise jamais strnlen parce qu'il y a plus de chances que je déborde du cadre mémoire alloué (tableau dans la stack ou pointeur dans la heap) que je ne tombe sur une (fausse) string sans le caractère '\0' terminal. Si j'ai besoin de travailler avec des entrées qui peuvent poser problème, je code une fonction ad-hoc.
strncpy
N'oubliez pas que strncpy n'ajoute systématiquement un caractère de terminaison ('\0') dans la chaîne en retour. Si on n'est pas sûr que ce qu'il y a à copier est bien une vraie string C, on doit l'ajouter à la main:
char *stuff = "un contenu"; char *result = do_the_fuck_with_the_string(stuff);
On peut donc:
- Renvoyer un pointeur vers une chaîne litérale (mais bof et limité à des cas prévus).
- Utiliser une variable globale (typiquement un tableau). C'est souvent pertinent mais ça gère mal les threads.
- Utiliser une variable statique de la fonction. Ça permet de faire comme le point ci-dessus mais avec une variable partagée dans une fonction.
- Faire un malloc et renvoyer le pointeur. C'est ce que strdup fait. Par contre, c'est pratiquement assuré qu'on va oublier le free juste après parce que le malloc n'est pas directement visible.
- Gérer le malloc et la taille avant la fonction et utiliser le pointeur et la taille comme paramètres de la fonction. Gérer le free en dehors de la fonction.
Donc, souvent la bonne solution à utiliser, c'est le dernier cas:
- la gestion de l'allocation mémoire est visible dans le code.
- c'est bien de la responsabilité du développeur de la gérer (pas au mec qui a fait la fonction, même si c'est le même développeur).
L'inconvénient, c'est que ça fait souvent assez verbeux (faut gérer le malloc, l'appel à la fonction et le free) et pour des choses triviales, ça peut faire touffu. Mais c'est plus safe en termes de pratique.
Conversion de chiffres en string
Toutes les fonctions de conversion de chaînes de caractères en nombre du style atof/atoi ne gèrent pas les erreurs: si la chaîne contient autre-chose qu'un nombre, c'est le bordel. Il faut utiliser les fonctions strtof/strtol à la place. A noter qu'il n'y a pas de strtoi, il faut utiliser strtol et voir si ça rentre dans un int de base (attention aux overflow ou aux types).
Pour convertir rapidement un chiffre int en char affichable à partir d'un seul chiffre, il suffit de lui ajouter le caractère '0' (48). Ça ne marche que si on utilise les chiffres de la table ASCII (donc ça marche aussi en UTF-8).
Caractères wide: wchar
De ce que j'ai compris des wchar, c'est surtout utile si on veut gérer les caractères sur plusieurs octets (UTF-8 > 128 et UTF-16/32) comme des caractères uniques. Pour l'affichage basique, sans calculs, ça ne sert pas à grand chose.
Le truc le plus flagrant, c'est strlen qui renvoie le nombre d'octets de la chaîne de caractères alors que wcslen renvoie bien le nombre de caractères (sans le terminator).
Qualificateurs
Ce sont les petits mots qu'on indiquent avant le type: const/static/register/volatile.
Static
Pour ma part, je fous en static:
- Toutes les fonctions privées d'un fichier source. Comme ça, je sais qu'elles ne sont pas exportées ailleurs. Je les déclare systématiquement dans les débuts du fichier, après les includes, car ça permet de ne pas gérer leur ordre de définition dans le code.
- Idem pour les variables "globales" privées du fichier source.
Mais surtout, j'utilise static pour indiquer si je veux utiliser un pointeur non null comme paramètre d'une fonction. En effet, en C, un paramètre de fonction de type tableau est transformé à la volée en pointeur. Toutefois un pointeur, ça peut être NULL, ce qui correspond à un tableau avec zéro entrée. Pour s'assurer que le pointeur est non-null, il suffit de dire qu'on veut un tableau de la chose mais avec obligatoirement une valeur. Et c'est là qu'on utilise le qualificateur static avant le nombre requis, par exemple: static 1 pour indiquer qu'on veut au moins un élément:
void function(int stuff[static 1]) { /* Code de la fonction... */ } int i = 57; int *p = &i; fonction(p)
Ici, le pointeur est transformé en tableau lors du passage à la fonction et on s'assure qu'il n'est pas vide.
Ça marche bien évidemment pour les structs:
typedef struct stuff stuff; struct stuff { size_t volume; /**< Sound level of the channel */ bool muted; /**< should the sound be muted or not? */ char *path; /**< Filepath to sound file */ char *name; /**< Name of the channel */ Mix_Chunk *music; /**< An SDL_Mixer Chunk pointer */ }; void function(stuff[static 1]) { /* Code de la fonction... */ }
Bien entendu, ça marche aussi avec les tableaux. Mais il ne faut pas oublier que tout ça, c'est vérifié uniquement à la compilation, pas en dynamique. De plus, c'est le nombre minimum de membres du tableau, pas le maximum. Mais, ça a l'avantage de:
- indiquer combien de membres dans un tableau sont nécessaires.
- de gérer les erreurs statiques dans le code (compile-time), c'est déjà ça.
- ça fait la doc en même temps.
J'ai trouvé ça dans modernC et depuis, je l'utilise à peu près partout. Donc, dans mon code, il y a souvent des tableaux en static 1 dans les prototypes de fonction.
Déclaration const et pointeurs
Pour le const:
const int *p
: un pointeur vers un contenu de type const int.int *const p
: un pointeur constant vers un contenu de type int (donc variable).int const*const p
: un pointeur constant vers un contenu de type const int (rien n'est variable ici).
Pour rendre un pointeur constant (on ne peut pas changer l'adresse pointée), il faut le déclarer comme *const
. Attention, int const *p
c'est la même chose que const int p
. Il faut vraiment que le pointeur () soit accolé à const pour que ça marche. Pour aller plus loin, et en conséquence, int const*const p c'est un pointeur constant vers une constante de type int.
Const, pointeur de pointeur et réduction
On peut faire "const char *p = char f;" mais pas const char *p = char **p; Globalement, le const, ça marche pour les pointeurs directs, pour les pointeurs de pointeurs, ça n'est plus valable.
Restrict
restrict ça permet d'indiquer que deux (ou plus) paramètres pointeurs d'une fonction ne peuvent pas être les mêmes dans la fonction. Ça sert à indiquer qu'on ne veut pas d'overlapping, c'est-à-dire à utiliser tout (cas où on utilise deux fois la même variable ou des pointeurs vers un emplacement commun) ou partie d'espaces mémoires qui se recouvrent.
Mais attention, rien n'oblige le compilateur à l'assurer en amont. Ce n'est qu'une indication pour le programmeur et c'est à lui de s'en assurer.
Register
Un qualifieur register sur une variable propose que la variable en question soit dans un registre (donc, c'est forcément un chiffre). En conséquence, et comme ce qui est dans un registre n'a pas d'adresse (normal, c'est dans le CPU): on ne peut pas avoir de pointeur vers une variable qualifiée en register.
Boucles
Toujours penser for au lieu de while
Avec le temps, je me rends compte que la boucle while, dans la plupart des cas, c'est une boucle for, mais en moins bien. Dans la boucle for, on défini trois choses en amont de l'exécution:
- Les conditions de base, avec l'avantage de pouvoir y effectuer la déclaration de variables complètement opaques à l'extérieur de la boucle.
- Les conditions de sortie de la boucle qui sont claires.
- Les conditions de progression.
Dans une boucle while, il faut gérer tout ça dans des endroits différents:
- Les conditions de base sont souvent faites en amont (donc en dehors de la boucle).
- Les conditions de sortie, peuvent être dans la condition de la boucle mais aussi dans des break dans le corps de la boucle.
- Les conditions de progression sont aussi dans le corps de la boucle.
Donc au lieu de faire:
for (size_t i = 0; i < max; ++i) { /* Do stuff */ }
Conditions
Priorité des opérateurs
Dès que tu as plus de deux opérateurs dans une expression, tu mets les parenthèses en fonction de ce que tu veux dire. Les parenthèses, c'est forcément prioritaire sur la majorité des opérateurs. Aussi, utiliser des espaces entre les opérateurs permet d'éviter l'effet division qui se transforme en caractère d'échappement: *a/*b
⇒ *a '/*(b)'
. *a / *b
marche par contre.
Switch
Ne pas oublier de foutre break dans tous les case de switch ! Ne pas oublier que le break, ça sert dans 99% des cas: on a 1% de chance d'avoir deux valeurs de condition qui font la même chose.
Attention, un break en dehors d'une boucle ou d'un switch, ça n'existe pas !
Et puis, ne pas oublier que ce qu'il y a dans le case, c'est de l'integer constant, car c'est géré au moment de la compilation.
Goto
Je ne l'utilise pas souvent mais une utilisation légitime, c'est quand on utilise des boucles de boucles et qu'on doit sortir en dehors de la boucle en cours (genre, on passe un cycle sur la boucle supérieure d'appel): utiliser break ou continue se fait sur la boucle en cours. Avec goto, on peut passer directement à la fin de la boucle principale et recommencer un cycle. C'est plus clair et moins compliqué à écrire.
Overflow et gestion des nombres
Comparaisons
Toujours faire attention quand on compare des valeurs: par précaution, mieux vaut toujours tout caster sur ce qu'on veut.
Attention au signed et unsigned de chaque côté d'un opérateur, particulièrement si on utilise des valeurs directes (-1 ou -10 par exemple). Dans ces conditions, un cast s'avère indispensable.
Comparaison explicite ou pas ?
Au début je comparais systématiquement par rapport à 0 ou à NULL. Maintenant, avec le temps, je me suis habitué à utiliser les valeurs implicites. Pour un test à false ou à NULL, je fais:
if (var) { /* trucs si la valeur vaut true... */ }
Pour les pointeurs, je teste si on a un pointeur NULL avec:
if (!pointeur) { /* trucs à faire si le pointeur est NULL */ }
A force, ça rentre dans la tête que "True is non zero!"
Par contre, dès que c'est plus complexe, je mets les parenthèses partout.
Signed/unsigned
La conséquence des overflows ou des conversions implicites de comparaison, c'est que si tu n'as pas besoin de performances au top ou s'il n y'a pas besoin de minimiser l'empreinte mémoire, il vaut mieux utiliser des entiers signés partout. Ça évite généralement des surprises.
Mais comme toujours en C, aucune règle n'est parfaite: ça ne marche pas si on utilise du size_t
pour un indice de tableau (dont c'est le réel usage et la bonne pratique): -2 convertit en size_t
c'est 18446744073709551614 (ce qui équivaut à SIZE_MAX - 3) et pas 2 ou 0.
Allocation mémoire
Taille des tableaux
Toujours utiliser l'idiome: sizeof array / sizeof array[0]
pour la taille d'un tableau. Ça permet de s'assurer qu'on gère bien la taille du tableau en nombre d'éléments, peu importe le type du tableau.
Pour sizeof, on met les parenthèses uniquement quand on a besoin de la taille d'un type. Quand c'est une variable ou un objet, on ne met PAS les parenthèses.
Tableaux à taille variable
Personnellement, j'ai appris à faire sans:
- ça fait des tableaux dans la stack (et pas dans la heap). Donc si c'est tropgros, ça ne passe pas.
- c'est plus lent à gérer, on n'est jamais sûr des indices.
- en règle générale, on peut s'en sortir avec des tableaux fixes dans le cas courant (on reste dans la stack).
Pour les chaînes de caractères
Un principe simple à retenir: malloc et strlen, ça signifie '+1':
char *stuff = malloc(strlen(s) + 1);
malloc et realloc dans une boucle
realloc(NULL, nouvelle_taille)
c'est comme malloc(nouvelle_taille)
. Ça sert lorsqu'on est dans une boucle qui fait de la réallocation et qu'on ne veut pas gérer le cas d'initialisation avec malloc en dehors. On défini un pointeur à NULL et on a juste à l'utiliser dans une boucle où il y a realloc(pointeur, nouvelle_taille). C'est pas con du tout !
Fonctions variadiques
Ce sont les 4 fonctions dans stdarg.h qui permettent de faire passer un nombre indéfini de paramètres dans une fonction.
Le nombre de paramètres est toujours inconnu d'une va_list
Que tu le veuilles ou non, on ne sait jamais le nombre d'éléments dans une va_list à moins de le passer en paramètre de la fonction ou avec une astuce.
Par exemple, dans printf, l'astuce consiste à utiliser le premier élément comme une chaîne de caractères permettant de savoir combien il y a d'éléments.
Une autre astuce est de signifier la fin des paramètres par la valeur NULL ou la valeur 0 (ou toute autre valeur arbitraire de fin).
Sous GNU libc, utiliser va_copy pour utiliser une va_list plusieurs fois
Je ne sais pas pour les autres environnements/bibliothèques mais dans la glibc, va_arg
modifie le contenu de la va_list
et on ne peut plus l'utiliser après l'avoir parcourue. Donc, il faut la copier forcément en amont.
Déclarations complexes
Un petit outil pour lire les déclarations complexes et savoir de quoi on parle (trouvé dans Expert C programming "The precedence rule"" ):
- On commence par lire le nom de ce qui est décrit.
- Ensuite, on applique les priorités qui suivent, dans l'ordre:
- Les parenthèses qui regroupent plusieurs parties de la déclaration.
- Les parenthèses extérieures () qui indiquent une fonction.
- Les crochets qui indiquent un tableau.
- L'astérisque (*) qui indique un pointeur vers.
- Si un qualificateur (const/volatile) est situé près (droite ou gauche) d'unspécificateur de type (int/long/float/etc.), il s'applique à ce spécificateur detype, sinon, il s'applique à l'astérisque de pointeur immédiatement à sa gauche.
Un exemple décomposé:
char* const *(*next)();
- On commence par le nom:
next
: "next est". - On isole ensuite ce qui est dans la parenthèse de regroupement:
(*next)
. - Dans la parenthèse de regroupement, on retrouve un astérisque, donc c'est un pointeur vers: "next est un pointeur vers".
- On passe ensuite à ce qui est en dehors de la parenthèse de regroupement. On trouve
()
qui indique une fonction: "next est un pointeur vers une fonction qui renvoie". - On repasse au dessus du niveau, on trouve un astérisque: "next est un pointeur vers une fonction qui renvoie un pointeur vers".
- On passe ensuite à
const
. Il est situé près d'une déclaration de type (char* ) donc il s'applique auchar*
et pas à notre pointeur vers. - On continue, sur le
char*
qui est un pointeur vers un char. Mais le const s'applique dessus donc on a un pointeur constant vers un char (et non un pointeur vers un char constant). - On a enfin notre déclaration complète: "next est un pointeur vers une fonction qui renvoie un pointeur vers un pointeur constant vers un char".
Réseau
Récupérer des adresses IP à partir d'un nom
J'utilise getaddrinfo à la place de gethostbyname. Ça gère les adresses IPv6 de manière plus simple et plus complète.
Optimisations
Les évaluations de boucle
Si on place une fonction qui prend du temps dans les conditions d'évaluation de boucle, ça peut prendre du temps parce que c'est exécuté à chaque itération. Par exemple, dans:
for (size_t i = 0; i < strlen(s); ++i) { /* ... code ... */ }
On exécute strlen(s) à chaque itération. Si la longueur de la chaîne de caractères ne change pas dans le code de la boucle for, ça prend du temps pour rien. À la place, on peut plutôt faire ça:
int l = strlen(s) for (size_t i = 0; i < l; ++i) { /* ... code ... */ }
Ça marche pareil pour la condition d'évaluation dans une boucle while ou do-while.