Mes conseils pratiques en C đź”—

🗓 In kb/snippets/

#code #C

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 ne l'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:

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 *dest = malloc(10);
strncpy(dest, src, 10);
dest[9] = '\0';

Encore une fois, strncpy ne met pas de \0 à la fin de la copie. Ce n'est pas une erreur mais bien une fonctionnalité: strncpy(chaine, autre_chaine, 5) permet de copier uniquement les 5 premiers caractères de autre_chaine dans chaine, sans ajouter de \0 qui aurait comme effet de tronquer la chaîne.

snprintf

Par contre, snprintf ajoute systématiquement un caractère de terminaison '\0', même si l'ensemble des sources n'en dispose pas où que le résultat est tronqué (taille de destination pas assez grande).

Un usage de snprintf qui semble intéressant, c'est la conversion d'un chiffre (un seul) en chaîne de caractères. Un float, ça peut prendre beaucoup de place, sans doute plus que ce qu'on a en stock dans ce qui a été alloué au pointeur (ou à la taille du tableau). Donc, pour être certain de récupérer une valeur adéquate qui ne déborde pas, on peut utiliser snprintf. A priori snprintf gère bien la taille maximale: quoiqu'il arrive, le dernier caractère sera toujours le terminateur.

Renvoyer une chaîne de caractères après manipulation

Il existe au moins ces méthodes pour renvoyer une chaîne de caractère après manipulation dans une fonction, typiquement un truc du genre:

char *stuff = "un contenu";
char *result = do_the_fuck_with_the_string(stuff);

On peut donc:

Donc, souvent la bonne solution Ă  utiliser, c'est le dernier cas:

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:

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:

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:

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 constconst 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:

Dans une boucle while, il faut gérer tout ça dans des endroits différents:

Donc au lieu de faire:

size_t i = 0;
while (i < max) {
    /* do stuff */
    ++i;
}

Je fais:

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:

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"" ):

  1. On commence par lire le nom de ce qui est décrit.
  2. Ensuite, on applique les priorités qui suivent, dans l'ordre:
  3. Les parenthèses qui regroupent plusieurs parties de la déclaration.
  4. Les parenthèses extérieures () qui indiquent une fonction.
  5. Les crochets qui indiquent un tableau.
  6. L'astérisque (*) qui indique un pointeur vers.
  7. Si un qualificateur (const/volatile) est situé près (droite ou gauche) d'un spécificateur de type (int/long/float/etc.), il s'applique à ce spécificateur de type, sinon, il s'applique à l'astérisque de pointeur immédiatement à sa gauche.

Un exemple décomposé:

char* const *(*next)();

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.