Partie 1 - Bases de C¶
Support de présentationCette partie a pour objectif de rafraîchir vos connaissances en C et d’aborder des notions utiles pour la suite du cours ou déjà vues dans d’autres cours (allocation de la mémoire dans le stack (pile) ou la heap (tas), représentation des variables en binaire, etc.). Ce n’est ni un cours complet ni un cours pour débutants en informatique : nous couvrirons les bases du langage pour les exploiter dans le cours de programmation système. Je considère, ici et dans le reste du cours, que vous avez des bases en algorithmique et en programmation (peu importe le langage) et une pratique minimale du terminal.
Voir les ressources pour des cours et liens/indications vers la documentation.
Voir les outils que nous utiliserons dans ce cours.
Le glossaire rappelle le sens (et souvent l’origine anglaise) des termes et abréviations du cours.
Qu’est-ce que le C ?¶
Le C est un langage de programmation impératif et compilé, inventé dans les années 1970 par Dennis Ritchie. Il est considéré aujourd’hui comme un langage de bas niveau, comparé aux langages modernes, et de haut niveau comparé à l’assembleur.
Dennis Ritchie – concepteur du C |
Brian Kernighan – a contribué à la formalisation et à la diffusion du C (coauteur de The C Programming Language) |
Malgré ses quelque 50 ans, il reste très utilisé grâce à sa vitesse d’exécution et à sa portabilité, et le restera très probablement pendant de nombreuses années. Zig et Rust se présentent comme des alternatives modernes au C. Rust est d’ailleurs le deuxième langage accepté dans le noyau Linux, après le C.
Le C est notamment employé pour le développement de systèmes d’exploitation, de pilotes ou de systèmes embarqués.
Pour aller plus loin : normes du C
Le langage C a connu plusieurs normes qui ont permis d’améliorer le langage et de donner une direction dans l’implémentation des compilateurs. Un certain niveau de rétrocompatibilité avec les versions précédentes est maintenu dans la plupart des cas.
C K&R (1978)
Première version non standardisée, présentée dans le livre The C Programming Language de Brian Kernighan et Dennis Ritchie.
ANSI C / C89 (1989) – ISO C / C90 (1990)
Première normalisation officielle par l’ANSI. Ajout des prototypes de fonctions et de nouveaux mots-clés, meilleure portabilité du code. Présentée dans le livre The C Programming Language, 2nd edition.
C99 (1999)
Mise à jour majeure avec, entre autres, les commentaires
//, les déclarations de variables n’importe où dans un bloc (dontfor (int i = 0; …)), les types entiers de taille fixe (<stdint.h>), les tableaux de taille variable (VLA), les initialisations désignées, et une meilleure prise en charge du standard IEEE pour les flottants.C11 (2011)
Mise à jour majeure avec un support standardisé des threads (
<threads.h>), des opérations atomiques (<stdatomic.h>), de nouveaux mots-clés et des améliorations de sécurité.C17/C18 (2018)
Révision mineure de C11, corrections sans nouvelles fonctionnalités.
C23/C2x (finalisé en 2023, publié en 2024)
Modernisation du langage et meilleure compatibilité avec le C++.
C2y (202?)
En cours de développement avec pour objectifs d’améliorer la sécurité et l’interopérabilité.
Comme pour le C++, le comité de standardisation du langage C définit les évolutions du langage. Les développeurs de compilateurs (GCC, Clang, MSVC, etc.) doivent ensuite implémenter ces spécifications. Il peut donc exister un décalage de plusieurs années entre la publication d’une norme et sa prise en charge complète dans les compilateurs (voir C compiler support et C++ compiler support).
Hello World!¶
Le premier programme classique pour débuter dans un langage est d’afficher Hello World! dans le terminal.
Voici un exemple en C :
1 #include <stdio.h>
2 #include <stdlib.h>
3
4 int main(void) {
5 printf("Hello World!\n");
6 return EXIT_SUCCESS;
7 }
Le même programme, commenté ligne par ligne :
1// déclare printf
2// (stdio → standard input output, entrées-sorties standard)
3#include <stdio.h>
4// définit EXIT_SUCCESS (0 → fin normale)
5// et EXIT_FAILURE (1/autre → erreur)
6// (stdlib → standard library, bibliothèque standard)
7#include <stdlib.h>
8
9// le point d'entrée du programme :
10// la première fonction appelée à l'exécution
11int main(void) {
12 // écrit un message sur la sortie standard (stdout)
13 printf("Hello World!\n");
14
15 // fin du programme
16 // retourne un code de succès au système d'exploitation
17 return EXIT_SUCCESS;
18}
Le C étant un langage compilé, le code source écrit par le programmeur doit être traduit en langage machine avant de pouvoir être exécuté.
Ce processus est effectué par le compilateur : le préprocesseur traite les #include, puis le compilateur traduit le C en assembleur, l’assembleur produit un fichier objet (binaire intermédiaire), puis l’éditeur de liens produit un exécutable prêt à être lancé par le système d’exploitation.
Voir la partie Compilation de la page Outils pour plus de détails.
Pour compiler cet exemple avec GCC (GNU Compiler Collection) :
gcc -std=c2x -Wall -Wextra -pedantic -g hello.c -o hello
-std=c2x: indique au compilateur d’utiliser la norme C23-Wall: active les avertissements classiques (erreurs de base, variables inutilisées, etc.)-Wextra: active encore plus d’avertissements utiles-pedantic: signale les constructions et extensions non conformes à la norme choisie-g: ajoute les informations de debug (utiles pourvalgrindetgdb, voir la page Outils)-o: change le nom du programme créé (a.outpar défaut)
Les options -W… et -pedantic rendent le compilateur plus bavard : elles ne modifient pas le comportement du programme, mais aident à détecter des erreurs ou mauvaises pratiques dès la compilation.
Une fois le programme compilé, nous pouvons l’exécuter avec :
$ ./hello
Hello World!
Les variables¶
Une variable se compose de quatre éléments :
un type (qui détermine la nature et la taille de la donnée)
un nom (l’identifiant que vous choisissez)
un espace mémoire réservé (dont la taille dépend du type) identifié grâce à son adresse
une valeur (optionnelle à l’initialisation, mais obligatoire avant toute utilisation) stockée dans l’espace mémoire réservé
Les variables sont déclarées sous la forme :
// Déclaration
type nom_variable;
// Déclaration et initialisation (fortement recommandé)
type nom_variable = valeur;
Pour le nom de la variable :
il doit commencer par une lettre ou un
_(underscore)il ne peut contenir que des lettres, chiffres et underscores
il ne doit pas être un mot-clé du langage (par exemple
int,return,if)
Par convention, on utilise souvent le snake_case (ma_variable) en C (convention utilisée dans ce cours).
Pour la valeur :
elle doit correspondre au type déclaré
utiliser une variable non initialisée peut conduire à un comportement indéfini
la valeur peut être modifiée si la variable n’est pas déclarée constante avec le mot-clé
const
Une variable déclarée dans une fonction est rangée dans le stack (allocation automatique) : elle disparaît à la fin du bloc où elle est déclarée (voir Allocation dynamique).
Types et sizeof¶
Pour le type, voici un sous-ensemble des principaux types en C que nous utiliserons dans ce cours (voir liste plus complète sur Wikipédia) :
1#include <stdbool.h> // pour bool (inutile en C23)
2#include <stdio.h>
3#include <stdlib.h>
4
5int main(void) {
6
7 // booléen
8 bool b = true;
9 printf("bool b = %d\t(taille : %zu octet(s))\n", b, sizeof(b));
10
11 // caractère : signé ou non selon la plateforme,
12 // utiliser signed char / unsigned char pour l'imposer
13 // (attention : utiliser des apostrophes simples 'A' et non "A")
14 char c = 'A';
15 printf("char c = '%c'\t(taille : %zu octet(s))\n", c, sizeof(c));
16
17 // entier signé
18 // ou non signé avec unsigned
19 int i = 42;
20 printf("int i = %d\t(taille : %zu octet(s))\n", i, sizeof(i));
21
22 // entier non signé pour les tailles et indices
23 // ou signé avec ssize_t
24 // (type POSIX, retourné par read/write)
25 size_t s = sizeof(int);
26 printf("size_t s = %zu\t(taille : %zu octet(s))\n", s, sizeof(s));
27
28 // entier non signé d'au moins 64 bits
29 // (compteurs ou sommes qui dépassent la capacité d'un int)
30 unsigned long long grand = 10000000000ULL;
31 printf("unsigned long long grand = %llu\t(taille : %zu octet(s))\n", grand, sizeof(grand));
32
33 // nombre à virgule flottante
34 // float et double
35 double d = 3.1415926535;
36 printf("double d = %.2f\t(taille : %zu octet(s))\n", d, sizeof(d));
37
38 // pointeur vers entier (voir la section Pointeurs)
39 int *p = &i;
40 printf("int *p = %p\t(pointe vers i = %d)\n", (void *)p, *p);
41
42 // pointeur générique (void *, voir la section Pointeurs)
43 void *vp = &c; // peut pointer vers n'importe quoi
44 printf("void *vp = %p\t(pointe vers char c)\n", vp);
45
46 return EXIT_SUCCESS;
47}
L’opérateur sizeof retourne la taille en mémoire, en octets (bytes ; 1 octet = 8 bits), de la variable ou du type donné.
Pour une variable, on peut écrire sizeof x ou sizeof(x) ; pour un type, les parenthèses sont obligatoires : sizeof(int).
Sortie (dépendant de l’architecture de votre machine, 32 bits ou 64 bits ; les adresses changent à chaque exécution) :
bool b = 1 (taille : 1 octet(s))
char c = 'A' (taille : 1 octet(s))
int i = 42 (taille : 4 octet(s))
size_t s = 4 (taille : 8 octet(s))
unsigned long long grand = 10000000000 (taille : 8 octet(s))
double d = 3.14 (taille : 8 octet(s))
int *p = 0x7ffdcec93f54 (pointe vers i = 42)
void *vp = 0x7ffdcec93f52 (pointe vers char c)
Constantes¶
Avec le mot-clé const, une variable devient une constante et ne peut plus être modifiée :
const int mon_entier = 42;
mon_entier = 4; // Erreur : assignment of read-only variable 'mon_entier'
Les octets¶
La mémoire est un ensemble de blocs de 8 bits appelés octets et repérés par une adresse.
Il existe plusieurs manières de représenter les octets :
en binaire :
0000 0010, avec des bits valant 0 ou 1en hexadécimal :
0x02, avec un chiffre de 0 à F pour chaque groupe de 4 bits (appelé nibble)en décimal :
2, avec des chiffres de 0 à 9
Les adresses (et plus généralement le contenu de la mémoire) sont représentées en hexadécimal car chaque octet correspond exactement à 2 chiffres hexadécimaux, ce qui rend la lecture des octets directe :
en décimal, un octet va de
0à255, soit 1 à 3 chiffresen hexadécimal, un octet va de
0x00à0xFF, soit toujours 2 chiffres
Les variables en mémoire¶
Sur l’image ci-dessus, chaque ligne représente 8 octets consécutifs : on retrouve les octets de chaque variable, écrits en hexadécimal.
Les bool et char occupent 1 octet en mémoire, les int 4 octets contigus et les double 8 octets.
Ordre des octets (endianness) : sur nos machines (x86_64), l’octet de poids faible est rangé en premier, à l’adresse la plus basse (little-endian) :
int i = 256(0x00000100) est rangé00 01 00 00Alignement : un
intcommence à une adresse multiple de 4, undoubleà une adresse multiple de 8. Le compilateur ajoute si besoin des octets de remplissage (padding), comme l’octet hachuré de l’image
Un pointeur contient une adresse : en pratique, sur les machines utilisées, il occupe donc la même taille quel que soit le type pointé, que ce soit un pointeur sur un int ou sur un bool (8 octets sur une machine 64 bits, 4 en 32 bits).
Pour aller plus loin : contenu de la mémoire octet par octet
La sortie suivante montre les adresses et valeurs en mémoire pour les variables déclarées ci-dessous. Le programme qui l’affiche n’est pas donné, seules les déclarations le sont. Les adresses et l’ordre des variables en mémoire dépendent du compilateur et de ses options :
// bool : mot-clé du C23 (avant C23 : #include <stdbool.h>)
// #include <stdint.h> pour int32_t et int64_t
bool b = true;
unsigned char uc = 200;
int i1 = 255;
int i2 = 256;
int i3 = 2147483647;
int32_t i32 = 1500;
int64_t i64 = 345623132;
float f = 3.1415926f;
double d = -12345.6789;
bool b = true (base=0x7ffe6c04df72, taille=1 octet)
Idx Offset Adresse Hex Dec Binaire
--- ------ ------------------ ----- ----- ---------
0 +0 0x7ffe6c04df72 0x01 1 0000 0001
unsigned char uc = 200 (base=0x7ffe6c04df73, taille=1 octet)
Idx Offset Adresse Hex Dec Binaire
--- ------ ------------------ ----- ----- ---------
0 +0 0x7ffe6c04df73 0xC8 200 1100 1000
int i1 = 255 (base=0x7ffe6c04df74, taille=4 octets)
Idx Offset Adresse Hex Dec Binaire
--- ------ ------------------ ----- ----- ---------
0 +0 0x7ffe6c04df74 0xFF 255 1111 1111
1 +1 0x7ffe6c04df75 0x00 0 0000 0000
2 +2 0x7ffe6c04df76 0x00 0 0000 0000
3 +3 0x7ffe6c04df77 0x00 0 0000 0000
int i2 = 256 (base=0x7ffe6c04df78, taille=4 octets)
Idx Offset Adresse Hex Dec Binaire
--- ------ ------------------ ----- ----- ---------
0 +0 0x7ffe6c04df78 0x00 0 0000 0000
1 +1 0x7ffe6c04df79 0x01 1 0000 0001
2 +2 0x7ffe6c04df7a 0x00 0 0000 0000
3 +3 0x7ffe6c04df7b 0x00 0 0000 0000
int i3 = 2147483647 (base=0x7ffe6c04df7c, taille=4 octets)
Idx Offset Adresse Hex Dec Binaire
--- ------ ------------------ ----- ----- ---------
0 +0 0x7ffe6c04df7c 0xFF 255 1111 1111
1 +1 0x7ffe6c04df7d 0xFF 255 1111 1111
2 +2 0x7ffe6c04df7e 0xFF 255 1111 1111
3 +3 0x7ffe6c04df7f 0x7F 127 0111 1111
int32_t i32 = 1500 (base=0x7ffe6c04df80, taille=4 octets)
Idx Offset Adresse Hex Dec Binaire
--- ------ ------------------ ----- ----- ---------
0 +0 0x7ffe6c04df80 0xDC 220 1101 1100
1 +1 0x7ffe6c04df81 0x05 5 0000 0101
2 +2 0x7ffe6c04df82 0x00 0 0000 0000
3 +3 0x7ffe6c04df83 0x00 0 0000 0000
int64_t i64 = 345623132 (base=0x7ffe6c04df88, taille=8 octets)
Idx Offset Adresse Hex Dec Binaire
--- ------ ------------------ ----- ----- ---------
0 +0 0x7ffe6c04df88 0x5C 92 0101 1100
1 +1 0x7ffe6c04df89 0xCA 202 1100 1010
2 +2 0x7ffe6c04df8a 0x99 153 1001 1001
3 +3 0x7ffe6c04df8b 0x14 20 0001 0100
4 +4 0x7ffe6c04df8c 0x00 0 0000 0000
5 +5 0x7ffe6c04df8d 0x00 0 0000 0000
6 +6 0x7ffe6c04df8e 0x00 0 0000 0000
7 +7 0x7ffe6c04df8f 0x00 0 0000 0000
float f = 3.1415926f (base=0x7ffe6c04df84, taille=4 octets)
Idx Offset Adresse Hex Dec Binaire
--- ------ ------------------ ----- ----- ---------
0 +0 0x7ffe6c04df84 0xDA 218 1101 1010
1 +1 0x7ffe6c04df85 0x0F 15 0000 1111
2 +2 0x7ffe6c04df86 0x49 73 0100 1001
3 +3 0x7ffe6c04df87 0x40 64 0100 0000
double d = -12345.6789 (base=0x7ffe6c04df90, taille=8 octets)
Idx Offset Adresse Hex Dec Binaire
--- ------ ------------------ ----- ----- ---------
0 +0 0x7ffe6c04df90 0xA1 161 1010 0001
1 +1 0x7ffe6c04df91 0xF8 248 1111 1000
2 +2 0x7ffe6c04df92 0x31 49 0011 0001
3 +3 0x7ffe6c04df93 0xE6 230 1110 0110
4 +4 0x7ffe6c04df94 0xD6 214 1101 0110
5 +5 0x7ffe6c04df95 0x1C 28 0001 1100
6 +6 0x7ffe6c04df96 0xC8 200 1100 1000
7 +7 0x7ffe6c04df97 0xC0 192 1100 0000
Les colonnes Hex/Dec/Binaire sont des vues du même octet ; l’ordre des octets dans la mémoire (indices 0, 1, 2, 3…) dépend de l’endianness (voir plus haut).
Les entiers signés sont en complément à deux (d’où INT_MAX = 0x7FFFFFFF).
Les float/double suivent IEEE-754 : val = (-1)^s × (1+mantisse) × 2^(exp-bias) (hors cas spéciaux).
L’alignement explique les frontières d’adresses « propres » (multiples de 4 pour int/float, multiples de 8 pour int64_t/double).
LSB (Least Significant Bit/Byte) : poids faible, le bit (ou l’octet) qui compte le moins, côté droit.
MSB (Most Significant Bit/Byte) : poids fort, le bit (ou l’octet) qui compte le plus, côté gauche.
Endianness : on voit partout que l’octet d’indice 0 (adresse la plus basse) contient le poids faible (LSB) de la valeur :
i1 = 255 → FF 00 00 00;i2 = 256 → 00 01 00 00. La machine est donc en little-endian. En big-endian, on aurait00 00 00 FFet00 00 01 00, soit l’ordre le plus « naturel » pour un humainUnités d’allocation : chaque ligne (un octet) a une adresse qui augmente de 1 d’une ligne à l’autre. Les types de plus d’un octet occupent un bloc contigu (4 pour
int, 8 pourint64_t/double, etc.)Alignement : les adresses de début (
base) respectent les alignements usuels de x86_64 :int/floatà des adresses multiples de 4 (…74, …78, …7C, …80, …84)int64_t/doubleà des adresses multiples de 8 (…88, …90)
Le compilateur peut insérer du padding (octets de remplissage) ou réordonner légèrement les variables pour respecter ces contraintes. C’est le cas du
float f, placé aprèsint32_t i32et avantint64_t i64alors qu’il est déclaré après ce dernier
Entrées-sorties¶
Sorties¶
Comme vu précédemment, printf permet d’afficher des informations dans le terminal (la sortie standard ou stdout).
La fonction printf et sa variante fprintf, qui écrit sur le flux de son choix (voir les flux standard ci-dessous), sont déclarées dans l’en-tête <stdio.h> de la bibliothèque standard (doc, man 3 printf).
int printf(const char *format, ...);
int fprintf(FILE *stream, const char *format, ...);
stream: pourfprintf, flux sur lequel écrire (stdout,stderr, ou un fichier ouvert)format: chaîne de caractères à formater...: éléments à afficher, dans l’ordre d’apparition des spécificateurs de format dans la chaîneformat
Retourne le nombre de caractères affichés, ou une valeur négative en cas d’erreur (errno indique la cause).
La chaîne format peut contenir des spécificateurs de format (%d par exemple), remplacés dans l’affichage par les valeurs qui la suivent.
Voici quelques spécificateurs de format courants :
%c:char%d:int%u:unsigned int%ld:long%llu:unsigned long long%f:float/double%lf:double(obligatoire pourscanf, accepté parprintf)%s:char *%zu:size_t%p:void *(il faut convertir le pointeur en(void *), voir Conversions explicites (cast))
On peut préciser une largeur minimale : %04d complète avec des 0 jusqu’à 4 caractères, %4d avec des espaces (voir l’exemple de la doc).
C’est une largeur minimale : une valeur plus longue est affichée en entier.
Pour les nombres à virgule, on peut aussi préciser le nombre de chiffres après la virgule : %.2f en affiche 2 (3.14 pour 3.1415926535).
Dans les chaînes, certains caractères s’écrivent avec une séquence d’échappement : \n (retour à la ligne), \t (tabulation), \\ (antislash), \" (guillemet) et \0 (caractère nul, fin de chaîne).
Pour afficher une chaîne suivie d’un retour à la ligne, puts (<stdio.h>) est plus simple que printf :
int puts(const char *s);
s: chaîne à afficher surstdout(putsajoute le\n)
Retourne un nombre positif, ou EOF en cas d’erreur (errno indique la cause).
Flux standard et buffer¶
Un programme dispose dès son lancement de trois flux : l’entrée standard (stdin, le clavier par défaut), la sortie standard (stdout) et la sortie d’erreur (stderr), ces deux dernières affichées par défaut dans le terminal.
printf écrit sur stdout ; les messages d’erreur s’écrivent sur stderr avec fprintf(stderr, ...).
Les erreurs restent ainsi séparées des résultats du programme : si la sortie standard est redirigée vers un fichier (./prog > resultats.txt), les erreurs s’affichent toujours dans le terminal.
La sortie standard passe par un buffer (tampon en français) : quand elle est affichée dans un terminal, le texte n’est souvent affiché qu’à la fin d’une ligne (\n).
Redirigée vers un fichier, elle n’est vidée que lorsque le buffer est plein ou à la fin du programme (le rôle du buffer est détaillé dans la partie Fichiers, voir Bufferisation).
fflush force l’écriture immédiate du contenu du buffer :
int fflush(FILE *stream);
stream: flux dont le buffer doit être vidé (NULLvide tous les flux ouverts en écriture)
Retourne 0, ou EOF en cas d’erreur (errno indique la cause).
fflush(stdout) est utile quand un message sans \n doit s’afficher tout de suite, par exemple avant un calcul long ou un sleep (avant une lecture au clavier, la libc vide déjà stdout).
Conséquence pour le debug : un message encore dans le buffer est perdu si le programme plante (ici à cause d’un accès invalide, voir Les pointeurs).
printf("avant"); // pas de \n : le texte reste dans le buffer
int *p = NULL;
*p = 1; // accès invalide (voir les pointeurs) : le programme plante
// et "avant" n'est jamais affiché
Pour un affichage de debug, terminez le message par \n ou écrivez sur stderr (fprintf(stderr, ...)), qui n’a pas de buffer.
Entrées¶
Pour récupérer des données de l’utilisateur depuis le clavier (entrée standard ou stdin), nous pouvons utiliser, entre autres, scanf (doc, man 3 scanf).
int scanf(const char *format, ...);
format: format des éléments à lire...: pointeurs vers les éléments à modifier
Retourne le nombre d’éléments lus et modifiés, ou EOF (End Of File) si la fin de l’entrée est atteinte ou si une erreur survient avant la première conversion (errno indique la cause en cas d’erreur).
Elle récupère l’entrée utilisateur grâce au format demandé et à l’adresse de la variable où stocker la valeur lue.
L’adresse d’une variable est donnée avec & (par exemple, &age) et permet à la fonction scanf d’aller directement modifier l’espace mémoire de la variable avec l’entrée utilisateur (voir Les pointeurs).
int age;
printf("Quel est votre âge ? ");
scanf("%d", &age);
printf("Vous avez %d ans.\n", age);
Les spécificateurs de format sont les mêmes que pour printf, à quelques exceptions près : dans scanf, %f attend un float * et %lf un double *.
scanf peut être suffisant pour lire des entiers ou flottants simples, mais il présente plusieurs limites que nous verrons avec les chaînes de caractères.
Les opérateurs¶
Voici les principaux opérateurs du C (la notation a @ b indique où se placent les opérandes, @ représentant l’opérateur) :
Opérateurs arithmétiques (
a @ b), pour effectuer des opérations mathématiques :addition :
+soustraction :
-multiplication :
*division :
/modulo :
%(reste de la division entière)
int a = 7; int b = 3; printf("%d\n", a + b); // 10 printf("%d\n", a / b); // 2 : entre deux entiers, / est une division entière printf("%d\n", a % b); // 1
Opérateurs de comparaison (
a @ b), qui retournent unintvalant 0 (faux) ou 1 (vrai) :inférieur à :
<inférieur ou égal à :
<=supérieur à :
>supérieur ou égal à :
>=égal à :
==différent de :
!=
Opérateurs logiques (
a @ b), pour manipuler des valeurs booléennes vrai/faux (le résultat est unintvalant 0 ou 1) :non logique :
!(!a, inverse la valeur booléenne)et logique :
&&ou logique :
||
Opérateurs d’affectation (
a @ b), pour modifier la valeur d’une variable :affectation simple :
=addition puis affectation :
+=soustraction puis affectation :
-=multiplication puis affectation :
*=division puis affectation :
/=modulo puis affectation :
%=
Incrémentation / décrémentation (
@apour préfixe,a@pour postfixe) :incrémente de 1 :
++décrémente de 1 :
--
int i = 1; printf("%d\n", ++i); // affiche 2, puis i vaut 2 printf("%d\n", i++); // affiche 2, puis i vaut 3
Opérateurs bit à bit (
a @ b), pour manipuler les bits individuels dans les variables entières (utilisés en programmation bas niveau) :ou bit à bit :
|et bit à bit :
&ou exclusif (XOR) :
^non bit à bit :
~(~a)décalage à gauche :
<<décalage à droite :
>>
unsigned int x = 5; // binaire 0101 unsigned int y = x << 1; // décalage à gauche : 1010 (10 en décimal)
Opérateur conditionnel (ternaire)
condition ? a : b: vautasi la condition est vraie,bsinonint x = -4; int valeur_absolue = (x >= 0) ? x : -x; // 4
Comme en mathématiques, les opérateurs ont des priorités.
Les parenthèses peuvent être utilisées pour forcer l’ordre d’évaluation.
Pour aller plus loin : ordre d’évaluation
La priorité et l’associativité (ordre entre opérateurs de même priorité, par exemple de gauche à droite pour a - b - c) sont fixées par la norme.
En revanche, l’ordre d’évaluation des opérandes n’est pas spécifié (dans f() + g(), f ou g peut être appelée en premier), et modifier deux fois une variable dans une même expression (i++ + i++) est un comportement indéfini.
Avec les nombres flottants, l’ordre des opérations compte : (a + b) + c et a + (b + c) peuvent donner des résultats différents à cause des arrondis.
Conversions explicites (cast)¶
L’opérateur de conversion (cast) (type) expression convertit une valeur vers un autre type :
int a = 7;
int b = 2;
double moyenne = (double)a / b; // 3.5 (sans le cast : division entière, 3)
size_t taille = 10;
int taille_int = (int)taille; // size_t → int
printf("%p\n", (void *)&a); // conversion en void * pour %p
Une conversion peut perdre de l’information (partie décimale, valeur trop grande pour le type cible) : écrire le cast explicitement indique au compilateur, et au lecteur, que ce choix est volontaire.
Autre moyen de fixer un type : un littéral entier peut porter un suffixe, 10u est un unsigned int, 10L un long.
Structures de contrôle¶
Le contrôle du flux permet de diriger l’exécution du programme selon des conditions.
if, else if, else¶
Les instructions if, else if et else permettent d’exécuter des blocs de code en fonction de conditions.
if (condition1) {
// instructions si condition1 vraie
} else if (condition2) {
// instructions si condition2 vraie
} else {
// instructions si aucune condition vraie
}
Exemple :
int a = 5;
if (a != 8) {
printf("a n'est pas égal à 8\n");
}
if (a % 2 == 0) {
printf("a est pair\n");
} else {
printf("a est impair\n");
}
if (a > 10) {
printf("a est plus grand que 10\n");
} else if (a > 3) {
printf("a est entre 4 et 10\n");
} else {
printf("a est 3 ou moins\n");
}
switch¶
La structure switch permet de sélectionner une action parmi plusieurs selon la valeur d’une expression entière (int, char…).
switch (variable) {
case valeur1:
// instructions
break;
case valeur2:
// instructions
break;
...
default:
// instructions par défaut
}
Sans break, l’exécution se poursuit dans les case suivants (fall-through) jusqu’au prochain break ou à la fin du switch.
Exemple :
char note = 'B';
switch (note) {
case 'A':
printf("Excellent\n");
break;
case 'B':
// pas de break
// donc affiche le Bien du cas 'C'
case 'C':
printf("Bien\n");
break;
case 'D':
printf("Passable\n");
break;
default:
printf("Note inconnue\n");
}
Les boucles¶
Les boucles permettent de répéter une série d’instructions tant qu’une condition est remplie.
Le langage C propose trois types principaux de boucles : for, while et do...while.
for¶
La boucle for est souvent utilisée quand le nombre d’itérations est connu à l’avance.
Elle se compose de trois parties :
initialisation
condition (test à chaque tour)
mise à jour (à chaque fin d’itération)
for (initialisation; condition; mise à jour) {
// instructions à répéter
}
Exemple :
for (int i = 0; i < 5; i++) {
printf("i vaut %d\n", i);
}
while¶
La boucle while teste la condition avant chaque itération. Si la condition est fausse dès le départ, le bloc ne sera jamais exécuté.
while (condition) {
// instructions à répéter
}
Exemple :
int i = 0;
while (i < 5) {
printf("i vaut %d\n", i);
i++;
}
do…``while``¶
La boucle do...while est similaire à while, mais la condition est testée après l’exécution du bloc.
Le corps de la boucle est donc exécuté au moins une fois, même si la condition est fausse.
do {
// instructions à répéter
} while (condition);
Exemple :
int i = 0;
do {
printf("i vaut %d\n", i);
i++;
} while (i < 5);
break et continue¶
Une boucle peut être interrompue (break) ou une itération sautée (continue) :
breaksort immédiatement de la boucle :for (int i = 0; i < 10; i++) { if (i == 5) { break; } printf("%d ", i); } // affiche : 0 1 2 3 4
continuesaute à l’itération suivante (ignore les instructions restantes du bloc courant) :for (int i = 0; i < 5; i++) { if (i == 2) { continue; } printf("%d ", i); } // affiche : 0 1 3 4
Les fonctions¶
Une fonction permet de regrouper un ensemble d’instructions sous un même nom afin de pouvoir les réutiliser facilement.
En C, une fonction se compose :
d’un type de retour (le type de la valeur retournée)
d’un nom
de paramètres (éventuellement)
d’un corps (le bloc d’instructions à exécuter)
Déclaration et définition¶
La déclaration (ou prototype) indique au compilateur l’existence de la fonction :
void bonjour(void);
int donner_nombre(void);
int addition(int a, int b);
La définition fournit le corps de la fonction :
void bonjour(void) {
printf("Bonjour !\n");
}
int donner_nombre(void) {
return 42;
}
int addition(int a, int b) {
return a + b;
}
Appel¶
Une fonction doit être déclarée avant son appel (sinon, erreur de compilation : implicit declaration of function, voir l’encadré ci-dessous).
Pour appeler une fonction, on écrit son nom suivi des arguments entre parenthèses :
bonjour();
int nombre = donner_nombre();
printf("Numéro : %d\n", nombre);
int resultat = addition(3, 4);
printf("Résultat : %d\n", resultat);
Note
Avant C23, sans le void dans la déclaration des paramètres, un appel comme bonjour("coucou") compile sans erreur.
Il est donc fortement recommandé de toujours écrire (void) pour une fonction sans paramètre.
On place souvent les prototypes de fonction en haut du fichier .c ou dans un fichier .h.
#include <stdio.h>
#include <stdlib.h>
// Déclaration/Prototype
int carre(int n);
int main(void) {
int x = 5;
printf("Le carré de %d est %d\n", x, carre(x));
return EXIT_SUCCESS;
}
// Définition
int carre(int n) {
return n * n;
}
Erreur fréquente : fonction utilisée avant d’être déclarée
error: implicit declaration of function 'carre' [-Wimplicit-function-declaration]
La fonction est appelée (ici dans main) avant que le compilateur ait vu son prototype ou sa définition.
Ajoutez le prototype en haut du fichier, avant main.
La fonction main¶
La première fonction de votre programme appelée à l’exécution est la fonction spéciale main (le code de démarrage de la libc s’exécute avant et l’appelle).
Elle constitue le point d’entrée du programme.
Cette fonction peut être définie de plusieurs manières :
// sans arguments
int main(void)
int main() // préférer la forme explicite avec void
// ou
// avec argc et argv comme arguments
int main(int argc, char **argv)
int main(int argc, char *argv[])
int main(int argc, char *argv[argc + 1])
Arguments de la ligne de commande : argc et argv¶
Les paramètres du main correspondent aux arguments passés par la ligne de commande :
argc(argument count) : un entier qui représente le nombre d’arguments passés en ligne de commande, y compris le nom du programme lui-mêmeargv(argument vector) : un tableau de chaînes de caractères (pointeurs verschar, voir Les pointeurs) contenant chacun des arguments passés au programme
La forme char *argv[argc + 1] indique que le tableau contient argc + 1 éléments (indication pour le lecteur, le compilateur ne la vérifie pas) : la norme garantit que argv[argc] vaut NULL, ce qui marque la fin des arguments.
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char *argv[argc + 1]) {
printf("Nombre d'arguments : %d\n", argc);
for (int i = 0; i < argc; i++) {
printf("Argument %d : %s\n", i, argv[i]);
}
return EXIT_SUCCESS;
}
$ gcc -std=c2x -Wall -Wextra -pedantic -g exemple.c -o exemple
$ ./exemple abc 123 a1b2c3 3.14
Nombre d'arguments : 5
Argument 0 : ./exemple
Argument 1 : abc
Argument 2 : 123
Argument 3 : a1b2c3
Argument 4 : 3.14
En mémoire, pour ./exemple abc 123 (argc vaut 3), argv pointe vers un tableau de argc + 1 pointeurs, chacun pointant vers une chaîne de caractères (voir Les chaînes de caractères et Les pointeurs) :
Valeur de retour¶
La fonction main a pour type de retour int ; la valeur retournée est transmise au système d’exploitation :
return EXIT_SUCCESS;(oureturn 0;) indique que le programme s’est terminé normalementreturn EXIT_FAILURE;(ou un autre entier non nul) signale une erreur
Depuis C99, en l’absence de return à la fin de main, le programme retourne 0.
Les tableaux¶
Un tableau en C est un ensemble de valeurs de même type, rangées de façon contiguë en mémoire.
Dans la figure ci-dessous, la partie haute montre un int sur 4 octets, aux adresses 0x7f...00 à 0x7f...03, avec la valeur de chaque octet en binaire (octet de poids faible en premier, en little-endian).
À droite, on en donne une représentation simplifiée : une seule case, qui commence à l’adresse 0x7f...00 (et occupe les 3 octets contigus suivants) et contient 9.
Pour représenter un tableau de 5 int (partie basse), nous réservons 5 × 4 octets en mémoire (soit 20 octets), qui commencent ici à l’adresse 0x7f...00 ; le dernier int commence à l’adresse 0x7f...10 et finit donc à l’adresse 0x7f...13.
Déclaration¶
Déclaration d’un tableau d’entiers en C :
// tableau de 5 entiers
int tableau[5];
// avec initialisation
int notes[5] = {10, 12, 15, 9, 14};
int zeros[3] = {0}; // les autres éléments valent aussi 0
int valeurs[6] = {
[1] = 12,
[3] = 45,
[5] = 78,
}; // les autres valeurs sont initialisées à 0
// en laissant le compilateur trouver la taille
int autres[] = {1, 2, 3, 4}; // taille = 4
La taille du tableau doit être connue à la compilation (sinon c’est un VLA, voir ci-dessous) : elle doit donc être donnée directement ou définie avec une constante du préprocesseur, #define TAILLE 5 (voir Préprocesseur).
Pour un tableau dont la taille n’est connue qu’à l’exécution (saisie de l’utilisateur, lecture d’un fichier…), on utilisera l’allocation dynamique avec malloc (voir Allocation dynamique).
Pour aller plus loin : tableaux de taille variable (VLA)
Les VLA (Variable Length Arrays) ont été introduits en C99, puis rendus optionnels en C11 : les compilateurs ne sont plus obligés de les implémenter (MSVC, sous Windows, ne le fait pas).
C23 rend de nouveau obligatoires les types de taille variable (utilisés par exemple dans les paramètres, comme char *argv[argc + 1]) : seuls les VLA déclarés dans le stack restent optionnels.
Ils permettent d’allouer des tableaux dans le stack avec une taille inconnue à la compilation (variable, entrée utilisateur, lecture dans un fichier, etc.). Ils peuvent donc causer des stack overflows (dépassements du stack, voir gestion mémoire). Ils sont peu portables et ne peuvent pas être utilisés comme champs de structures.
Accès aux éléments¶
On accède aux éléments d’un tableau par leur indice, en commençant à 0 :
printf("Première note : %d\n", notes[0]);
notes[2] = 16; // modifier la 3e note
for (int i = 0; i < 5; i++) {
printf("Note %d : %d\n", i, notes[i]);
}
Avertissement
Il n’y a pas de vérification des limites du tableau. L’accès hors limites provoque un comportement indéfini.
Tableaux à plusieurs dimensions¶
On peut déclarer des tableaux de tableaux avec plusieurs dimensions :
// tableau 1D
// [indice]
int tab1d[5] = {10, 12, 15, 9, 14};
// tableau 2D
// [lignes][colonnes]
int matrice[3][2] = {
{1, 2},
{3, 4},
{5, 6},
};
printf("%d\n", matrice[1][0]); // affiche 3
Pour aller plus loin : 3 dimensions et plus
// tableau 3D
// [profondeur][lignes][colonnes]
int cube[2][3][4] = {
{
{1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12},
},
{
{13, 14, 15, 16},
{17, 18, 19, 20},
{21, 22, 23, 24},
},
};
printf("%d\n", cube[1][2][3]); // affiche 24
// indices : profondeur=1, ligne=2, colonne=3
// tableau 4D
// [t][z][y][x] (x = dimension la plus à droite)
int hyper[2][2][2][3] = {
{
// t = 0
{{1, 2, 3}, {4, 5, 6}}, // z=0 : y=0 puis y=1
{{7, 8, 9}, {10, 11, 12}}, // z=1
},
{
// t = 1
{{13, 14, 15}, {16, 17, 18}},
{{19, 20, 21}, {22, 23, 24}},
},
};
printf("%d\n", hyper[1][1][1][2]); // affiche 24 (t=1,z=1,y=1,x=2)
Le passage d’un tableau en paramètre d’une fonction est présenté dans la section Tableaux et fonctions.
Les pointeurs¶
Un pointeur est une variable qui contient l’adresse mémoire d’une autre variable.
Pour déclarer un pointeur vers un type T, on écrit T *mon_pointeur.
Pour obtenir l’adresse d’une variable, on utilise & : mon_pointeur = &ma_variable donne à mon_pointeur l’adresse de ma_variable.
On peut ensuite modifier la valeur à l’adresse pointée avec l’opérateur de déréférencement * : *mon_pointeur = 5.
int x = 42;
// p pointe vers x
int *p = &x;
// %p pour afficher une adresse avec le cast en (void *)
printf("Adresse de x : %p\n", (void *)&x);
printf("Adresse contenue dans p : %p\n", (void *)p);
printf("Valeur pointée par p : %d\n", *p);
printf("Modification de *p\n");
*p = 5;
printf("Valeur pointée par p : %d\n", *p);
printf("Valeur de x : %d\n", x);
Sortie :
Adresse de x : 0x7fff907fa4dc
Adresse contenue dans p : 0x7fff907fa4dc
Valeur pointée par p : 42
Modification de *p
Valeur pointée par p : 5
Valeur de x : 5
Dans la figure ci-dessous, x est stocké à l’adresse 0x7f...00 et p, à l’adresse 0x7f...08, contient cette adresse.
Nous utilisons principalement deux opérateurs avec les pointeurs :
&: donne l’adresse d’une variable*: utilisé dans deux cas :à la déclaration d’une variable : il indique que la variable est un pointeur vers un type
devant une variable pointeur (hors déclaration) : il permet de déréférencer le pointeur, c’est-à-dire d’accéder à la valeur stockée à l’adresse pointée
Passage par valeur, passage par adresse¶
En C, une fonction reçoit une copie de la valeur de chaque argument : on parle de passage par valeur (ou passage par copie). Modifier le paramètre dans la fonction ne modifie donc pas la variable de l’appelant :
void incrementer(int x) {
x = x + 1; // modifie la copie seulement
}
int main(void) {
int a = 10;
incrementer(a);
printf("%d\n", a); // affiche 10
return EXIT_SUCCESS;
}
Pour qu’une fonction modifie une variable de l’appelant, on lui donne l’adresse de la variable : c’est le passage par adresse.
Cela sert par exemple à produire plusieurs résultats, ou à réserver la valeur de retour à un code d’erreur.
L’adresse est elle aussi copiée dans le paramètre, mais elle permet d’atteindre la variable d’origine avec * :
void incrementer(int *x) {
*x = *x + 1; // modifie la variable dont l'adresse est dans x
}
int main(void) {
int a = 10;
incrementer(&a);
printf("%d\n", a); // affiche 11
return EXIT_SUCCESS;
}
Chaque appel de fonction réserve dans le stack (pile) une zone pour ses paramètres et ses variables locales, appelée stack frame ; la stack frame disparaît au return.
Récapitulatif : passer de la version par valeur à la version par adresse change trois endroits.
void incrementer(int *x) { // 1. le type du paramètre prend une * : int devient int *
*x = *x + 1; // 2. dans la fonction, *x désigne la variable de l'appelant
}
incrementer(&a); // 3. à l'appel, on donne l'adresse de la variable : &a
Ce qu’on passe dépend de ce que la fonction doit modifier :
une variable (
int, une structure, et même un pointeur) : on passe son adresse avec&, et le paramètre a une*de plus que le type de la variableles cases d’un tableau : on passe le tableau directement, car son nom est déjà l’adresse de sa première case (voir Tableaux et fonctions)
Un exemple où la fonction modifie un pointeur de l’appelant (paramètre int **) est donné avec l’allocation dynamique (voir Allouer dans une fonction).
Erreur fréquente : oubli de & ou de *
warning: passing argument 1 of 'incrementer' makes pointer from integer without a cast [-Wint-conversion]
warning: assignment to 'int *' from 'int' makes pointer from integer without a cast [-Wint-conversion]
« makes pointer from integer » : un entier est utilisé là où un pointeur est attendu.
Le premier message signale un & oublié à l’appel (incrementer(a) au lieu de incrementer(&a)), le second une * oubliée dans la fonction (x = 5; au lieu de *x = 5;).
Pointeurs et tableaux¶
Dans la plupart des expressions, le nom d’un tableau est converti implicitement en pointeur vers son premier élément :
int tab[3] = {1, 2, 3};
int *p = tab; // équivalent à &tab[0]
printf("%d\n", *(p + 1)); // affiche 2
printf("%d\n", tab[1]); // équivalent
*(p + 1) utilise l’arithmétique des pointeurs : p + 1 désigne l’élément suivant, sizeof(int) octets (4) plus loin que p.
Le compilateur connaît le type pointé : il calcule lui-même le décalage en octets.
p[i] est équivalent à *(p + i).
printf("sizeof(tab) = %zu\n", sizeof(tab)); // 12 : 3 int de 4 octets
printf("sizeof(p) = %zu\n", sizeof(p)); // 8 : la taille d'une adresse
printf("%p %p\n", (void *)p, (void *)(p + 1));
// affiche par exemple : 0x7f...20 0x7f...24 (4 octets d'écart)
On peut donc parcourir un tableau avec un pointeur :
for (int *ptr = tab; ptr < tab + 3; ptr++) {
printf("%p %d\n", (void *)ptr, *ptr);
}
Sortie :
0x7f...20 1
0x7f...24 2
0x7f...28 3
Tableaux et fonctions¶
Un tableau passé en paramètre d’une fonction est converti en pointeur vers son premier élément : la fonction ne connaît donc pas sa taille, qu’il faut passer en paramètre. Les deux écritures suivantes sont équivalentes :
void inverser_signes(int *tab, size_t taille);
// ou :
void inverser_signes(int tab[], size_t taille);
void inverser_signes(int *tab, size_t taille) {
for (size_t i = 0; i < taille; ++i) {
tab[i] = -tab[i];
}
}
int tab[4] = {1, -2, 3, -4};
inverser_signes(tab, 4); // tab vaut {-1, 2, -3, 4}
Comme le tableau n’est pas copié (passage par adresse), la fonction modifie directement le tableau de l’appelant.
Bonnes pratiques : utiliser size_t pour les tailles et const si la fonction ne modifie pas le tableau.
Avec const int *tab, la fonction s’engage à ne pas modifier les cases : le compilateur refuse tab[0] = 1;.
C’est aussi pour cela que les fonctions sur les chaînes (strlen, strcpy, fgets…, voir Les chaînes de caractères plus bas) prennent un char * (ou const char * en lecture) : une chaîne est un tableau de char, converti en pointeur vers son premier caractère.
Pour lire une chaîne, le \0 indique où elle s’arrête ; pour écrire dans un tableau (fgets), il faut en donner la taille.
Pour un tableau 2D, il faut préciser la taille de la 2e dimension (le nombre de colonnes), pour que le compilateur sache calculer l’adresse de matrice[i][j] :
void afficher_matrice(int matrice[][2], size_t nb_lignes);
// ou (équivalent) : pointeur vers des tableaux de 2 int
void afficher_matrice(int (*matrice)[2], size_t nb_lignes);
int matrice[3][2] = {{1, 2}, {3, 4}, {5, 6}};
afficher_matrice(matrice, 3);
Pointeurs de pointeurs¶
Un pointeur peut aussi pointer vers un pointeur :
int x = 5;
int *p = &x;
int **pp = &p;
printf("%d\n", **pp); // affiche 5
Un pointeur peut ne pointer vers rien : il vaut alors NULL (ou nullptr, à partir de C23) :
int *p1 = NULL;
int *p2 = nullptr; // à partir de C23
Il est prudent de tester qu’un pointeur n’est pas NULL avant de l’utiliser :
if (p1 != NULL) {
printf("%d\n", *p1);
}
On trouve souvent la forme courte if (!p1), équivalente à if (p1 == NULL) (un pointeur NULL est considéré comme faux).
Pointeurs de fonctions¶
Il est possible de créer des pointeurs sur fonction avec la syntaxe type_retour (*nom_pointeur)(types_parametres) :
int addition(int a, int b) {
return a + b;
}
int soustraction(int a, int b) {
return a - b;
}
int operation(int a, int b, int (*op)(int, int)) {
return op(a, b);
}
int main(void) {
// affiche : 2 + 3 = 5
printf("2 + 3 = %d\n", operation(2, 3, addition));
// affiche : 2 - 3 = -1
printf("2 - 3 = %d\n", operation(2, 3, soustraction));
return EXIT_SUCCESS;
}
Le nom d’une fonction, sans parenthèses (addition), désigne son adresse : on la donne en argument comme n’importe quelle valeur, et la fonction qui la reçoit l’appelle quand elle en a besoin.
Nous nous en servirons pour indiquer la fonction à exécuter à la réception d’un signal (sigaction, partie Processus) et la fonction exécutée par un nouveau thread (pthread_create, partie Threads).
Pointeur générique¶
void * est un pointeur générique vers un objet (mémoire de données) de n’importe quel type.
Beaucoup de fonctions prennent ou retournent un void *.
Par exemple, malloc et free, présentés plus loin dans la section Allocation dynamique :
mallocprend en entrée une taille en octets et retourne un pointeur vers la mémoire allouée. Pour éviter de créer une fonctionmallocpour chaque type imaginable, elle retourne unvoid *, qui peut être converti vers n’importe quel pointeur sur une donnéefree, de la même manière, prend en entrée unvoid *, donc un pointeur vers n’importe quel type, pour libérer la mémoire pointée
Les structures¶
Une structure permet de regrouper plusieurs variables (de types éventuellement différents) sous un même nom. C’est un moyen de créer ses propres types de données. Les variables d’une structure sont appelées champs ou membres de la structure.
Déclaration¶
On utilise le mot-clé struct :
struct Position {
int x;
int y;
};
struct Position pos1; // le type s'appelle struct Position
Cela déclare un nouveau type struct Position avec deux champs, x et y.
Pour ne pas répéter struct à chaque utilisation, on lui donne un nom plus court avec typedef (c’est la forme utilisée dans la suite du cours) :
typedef struct {
int x;
int y;
} Position;
Position pos1; // on écrit simplement Position
Pour aller plus loin : structure anonyme et étiquette
Ici, la structure est anonyme : le type s’appelle uniquement Position et struct Position n’existe pas.
Pour pouvoir utiliser les deux noms (nécessaire pour une structure qui contient un pointeur vers elle-même, voir l’exemple du nœud), on donne aussi une étiquette (tag) à la structure :
typedef struct Position {
int x;
int y;
} Position;
struct Position pos1 = {1, 2}; // les deux écritures
Position pos2 = {3, 4}; // désignent le même type
Initialisation et accès aux champs¶
On accède aux champs avec l’opérateur . :
Position pos1;
pos1.x = 3;
pos1.y = 4;
// ou initialisation directe
Position pos2 = {5, 6};
// ou initialisation avec les noms de champs :
Position pos3 = {.x = 5, .y = 4};
// accès
printf("x : %d, y : %d\n", pos2.x, pos2.y);
Copie d’une structure¶
L’affectation = copie tous les champs : on obtient deux structures indépendantes.
Position a = {3, 4};
Position b = a; // copie des champs x et y
b.x = 99; // a.x vaut toujours 3
De la même manière, lorsqu’une structure est passée à une fonction, elle est copiée :
// copie p à chaque appel
void afficher_position(Position p) {
printf("x = %d, y = %d\n", p.x, p.y);
}
Pour une petite structure comme Position (8 octets), cette copie ne coûte presque rien.
Pointeur vers une structure¶
Pour les structures volumineuses, ou pour les modifier dans une fonction, on passe plutôt un pointeur (const si on ne modifie pas la structure), pour éviter de copier toute la structure :
// passage par adresse, seul le pointeur est copié
void afficher_position_ptr(const Position *p) {
printf("x = %d, y = %d\n", p->x, p->y);
}
void deplacer_position(Position *p) {
p->x += 1;
p->y += 1;
}
Position a = {3, 4};
deplacer_position(&a); // a vaut {4, 5}
Avec un pointeur sur une structure, on n’utilise plus . pour accéder à un champ mais -> : p->x est équivalent à (*p).x.
Règle simple : . sur une variable de type structure, -> sur un pointeur vers une structure.
Les champs d’une structure sont rangés les uns après les autres en mémoire (ici a, sa copie b et un pointeur p vers a) :
Structures imbriquées et tableaux de structures¶
Une structure peut contenir d’autres structures :
typedef struct {
Position debut;
Position fin;
} Segment;
Segment s = {{0, 0}, {10, 5}};
printf("%d\n", s.fin.x); // affiche 10
On peut créer des tableaux de structures :
Position positions[3] = {{0, 0}, {1, 2}, {2, 4}};
for (int i = 0; i < 3; i++) {
printf("(%d, %d)\n", positions[i].x, positions[i].y);
}
Une structure peut aussi contenir un pointeur vers une structure du même type.
C’est le cas d’un nœud de liste chaînée, qui contient une valeur et l’adresse du nœud suivant (NULL pour le dernier) :
typedef struct Noeud {
int valeur;
struct Noeud *suivant; // adresse du nœud suivant
} Noeud;
Noeud n3 = {3, NULL}; // dernier nœud
Noeud n2 = {2, &n3};
Noeud n1 = {1, &n2};
// parcours de la liste à partir du premier nœud
Noeud *courant = &n1;
while (courant != NULL) {
printf("%d ", courant->valeur);
courant = courant->suivant;
}
// affiche : 1 2 3
À l’intérieur de la structure, le nom Noeud donné par typedef n’existe pas encore (il n’est défini qu’après l’accolade fermante) : on donne donc aussi une étiquette à la structure (struct Noeud) pour pouvoir écrire le type du champ suivant.
Les chaînes de caractères¶
Une chaîne de caractères est un tableau de char terminé par un caractère nul (\0).
Déclaration et longueur¶
Déclaration d’une chaîne :
// utilisation similaire à celle d'un tableau
char prenom[6] = {'A', 'l', 'i', 'c', 'e', '\0'};
// utilisation plus concise et recommandée
char nom[] = "Bob"; // équivalent à {'B','o','b','\0'}
printf("Nom : %s\n", nom);
// affiche :
// Nom : Bob
printf("%zu %zu\n", strlen(nom), sizeof(nom));
// affiche : 3 4
// strlen (<string.h>) compte les caractères avant le \0
// sizeof donne la taille du tableau, \0 compris
Sans \0 final, les fonctions qui parcourent la chaîne (printf("%s"), strlen…) ne savent pas où elle s’arrête et continuent à lire la mémoire qui suit.
Pour parcourir une chaîne caractère par caractère, on s’arrête au \0 :
for (size_t i = 0; nom[i] != '\0'; ++i) {
printf("%c ", nom[i]);
}
// affiche : B o b
Fonctions de <string.h>¶
La bibliothèque standard (<string.h>) propose plusieurs fonctions pour manipuler les chaînes (voir man 3 nom_fonction) :
size_t strlen(const char *s);
char *strcpy(char *dest, const char *src);
char *strncpy(char *dest, const char *src, size_t n);
char *strcat(char *dest, const char *src);
int strcmp(const char *s1, const char *s2);
size_t strcspn(const char *s, const char *reject);
s,s1,s2: chaînes lues (terminées par\0)dest: tableau de destination, assez grand pour contenir le résultat,\0comprissrc: chaîne copiée dansdest(strcpy,strncpy) ou ajoutée à la fin de la chaînedest(strcat)n: nombre maximal de caractères copiés parstrncpyreject: caractères recherchés parstrcspn
strlen retourne le nombre de caractères avant le \0.
strcpy, strncpy et strcat retournent dest.
strcmp retourne 0 si les deux chaînes sont égales, une valeur négative si s1 est avant s2 dans l’ordre lexicographique (comparaison des codes des caractères), une valeur positive sinon.
strcspn retourne l’indice du premier caractère de s qui fait partie de reject, ou la longueur de s s’il n’y en a aucun.
strncpy copie au plus n caractères : si src compte moins de n caractères, la fin de dest est complétée par des \0 ; sinon, dest n’est pas terminée par \0.
Exemple :
#include <string.h>
char nom[20] = "Alice";
char nom_complet[40];
strcpy(nom_complet, nom); // nom_complet : "Alice"
strcat(nom_complet, " Dupont"); // nom_complet : "Alice Dupont"
size_t longueur = strlen(nom); // 5
if (strcmp(nom, "Alice") == 0) {
printf("C'est bien Alice !\n");
}
Attention : la taille du tableau doit inclure le \0 et toute chaîne doit finir par \0.
strcpy et strcat ne vérifient pas la place disponible : si la destination est trop petite, elles écrivent au-delà du tableau (comportement indéfini).
Avec strncpy (voir ci-dessus), on ajoute soi-même le \0 final :
char court[4];
strncpy(court, nom_complet, sizeof(court) - 1); // copie au plus 3 caractères
court[sizeof(court) - 1] = '\0'; // court : "Ali"
Les opérateurs = et == ne copient ni ne comparent les caractères d’une chaîne :
char copie[20];
// copie = nom; // erreur de compilation : on ne peut pas affecter un tableau
strcpy(copie, nom); // correct : copie les caractères
// if (copie == "Alice") { } // compare des adresses, pas les caractères
if (strcmp(copie, "Alice") == 0) { // correct : compare les caractères
printf("Identiques\n");
}
<string.h> contient aussi memset, qui travaille sur des octets plutôt que sur des chaînes :
void *memset(void *s, int c, size_t n);
s: adresse de la zone à remplirc: valeur donnée à chaque octet (convertie enunsigned char)n: nombre d’octets à remplir
Retourne s.
memset sert surtout à mettre une zone à zéro, par exemple memset(buffer, 0, sizeof(buffer)).
Comme elle remplit octet par octet, memset(tab, 1, sizeof(tab)) sur un tableau d”int ne met pas chaque case à 1 (chaque int vaut 0x01010101).
Lire une chaîne au clavier¶
Comme annoncé plus haut, scanf présente plusieurs limites :
%ss’arrête au premier blanc (espace, tabulation, retour à la ligne) et, sans largeur maximale (%99s), peut déborder du tableauil peut laisser des caractères non lus dans le buffer d’entrée (
stdin), comme le\naprès unscanf("%d", ...), ce qui peut perturber les lectures suivantesil est difficile à utiliser pour des saisies plus complexes ou robustes (détection d’erreurs, chaînes multi-mots, etc.)
Pour des lectures plus sécurisées et robustes, on peut utiliser fgets, pour lire une ligne entière depuis l’entrée utilisateur (jusqu’au retour à la ligne).
char *fgets(char *s, int size, FILE *stream);
s: tableau où stocker la ligne luesize: taille du tableaustream: flux à lire (stdinpour l’entrée standard)
Retourne s si la lecture réussit, ou NULL si la fin de fichier est atteinte avant toute lecture ou en cas d’erreur (errno indique la cause) ; le contenu de s ne doit alors pas être utilisé.
fgets s’arrête après size - 1 caractères ou après le \n, qu’elle conserve, et ajoute le \0 final.
char nom[100];
printf("Entrez votre nom : ");
if (fgets(nom, sizeof(nom), stdin) != NULL) {
printf("Bonjour %s !", nom); // Attention, le \n est conservé !
}
Pour supprimer le retour à la ligne :
nom[strcspn(nom, "\n")] = '\0';
strcspn retourne ici l’indice du premier \n (ou la longueur de la chaîne s’il n’y en a pas) : on y place le \0 de fin.
Exemple d’entrées-sorties (Input/Output ou IO en anglais) qui lit une chaîne avec fgets et un nombre avec scanf, et utilise puts (retours de fgets et scanf non vérifiés pour raccourcir) :
1#include <stdio.h>
2#include <stdlib.h>
3#include <string.h>
4
5int main(void) {
6 char nom[100];
7 puts("Votre nom ?");
8 fgets(nom, sizeof(nom), stdin); // fgets pour chaînes
9 nom[strcspn(nom, "\n")] = '\0'; // retire le \n
10
11 int age;
12 puts("Quel est votre âge ?");
13 scanf("%d", &age); // scanf pour nombres
14
15 printf("Bonjour %s, %d ans.\n", nom, age);
16 return EXIT_SUCCESS;
17}
Pour convertir une chaîne en entier, on peut utiliser atoi (<stdlib.h>, man 3 atoi) :
int atoi(const char *nptr);
nptr: chaîne à convertir (les blancs du début sont ignorés, puis un signe+ou-facultatif et des chiffres sont lus)
Retourne l’entier lu au début de nptr, ou 0 si nptr ne commence pas par un nombre. Le comportement est indéfini si la valeur ne tient pas dans un int.
La lecture s’arrête au premier caractère qui n’est pas un chiffre : atoi("42abc") retourne 42.
atoi ne signale aucune erreur : atoi("0") et atoi("abc") retournent tous les deux 0.
strtol fait la même chose mais permet de détecter les erreurs (voir man 3 strtol).
Allocation dynamique¶
L’allocation de mémoire dynamique permet de demander de la mémoire au système pendant l’exécution, quand sa taille n’est pas connue à la compilation. Par exemple, le nombre de livres d’une bibliothèque n’est pas connu au moment de la compilation du programme de gestion de l’inventaire et pourra varier au cours du temps.
Stack et heap¶
Jusqu’ici, les variables déclarées dans le main ou dans les fonctions, par exemple int mon_int = 4;, utilisent l’allocation automatique dans le stack (pile) : leur taille est connue à la compilation et elles disparaissent à la fin du bloc où elles sont déclarées.
Avec malloc, on accède à l’allocation dynamique dans la heap (tas) : la taille est choisie à l’exécution et la zone existe de l’appel à malloc jusqu’au free, même après la fin de la fonction qui l’a allouée.
Le pointeur lui-même reste une variable locale comme les autres : il est dans le stack, qu’il désigne une zone du stack ou de la heap. C’est seulement la zone pointée qui est allouée dans la heap.
Fonctions d’allocation¶
On inclut l’en-tête <stdlib.h>, qui déclare ces fonctions.
malloc : alloue un bloc de mémoire non initialisé¶
void *malloc(size_t size);
size: nombre d’octets à demander au système
Retourne un pointeur vers la zone allouée dans la heap, ou NULL en cas d’erreur (errno indique la cause).
int *tab = malloc(5 * sizeof(*tab));
// ou :
// int *tab = malloc(5 * sizeof(int));
// on demande alors un espace mémoire de taille 5 * la taille d'un int
// (ou de la taille d'un élément pointé par tab, donc un int)
// si malloc retourne NULL, alors l'allocation ne s'est pas bien passée
if (tab == NULL) { // ou if (!tab)
// dans ce cas malloc écrit la raison de l'erreur dans errno (sous POSIX)
// et nous pouvons l'afficher avec perror puis quitter avec exit
// (voir la section Gestion des erreurs)
perror("Erreur malloc");
exit(EXIT_FAILURE);
}
// on peut ensuite accéder aux éléments comme avec un tableau classique
for (int i = 0; i < 5; i++) {
tab[i] = i * 10;
}
// lorsque l'on n'a plus besoin de la mémoire allouée
// on l'indique au système qui va la libérer
// avec free
free(tab);
// tab pointe alors sur une partie de la mémoire qui ne lui appartient plus
// il est préférable de le faire pointer sur NULL
// pour éviter un dangling pointer
// (pointeur qui référence une zone invalide)
tab = NULL;
Déréférencer NULL est un comportement indéfini qui provoque en pratique une erreur de segmentation sous Linux : après tab = NULL, une utilisation par erreur de *tab se voit tout de suite. Sans tab = NULL, l’accès à la zone libérée est silencieux et imprévisible.
La taille peut être passée en indiquant le type, taille * sizeof(int) : on demande alors taille blocs de 4 octets (taille d’un int sur les plateformes utilisées dans le cours). Si taille = 5, on demande 5 * 4 = 20 octets consécutifs en mémoire.
sizeof(*tab) ne lit pas la mémoire : il donne seulement la taille du type pointé (ici int), même si tab n’est pas encore initialisé. Cette écriture reste juste si on change le type de tab.
malloc n’initialise pas la mémoire (son contenu est indéterminé) : il faut donc initialiser les valeurs avant de les utiliser.
Les appels à perror et exit sont détaillés dans la section Gestion des erreurs.
calloc : alloue et initialise à zéro¶
void *calloc(size_t nmemb, size_t size);
nmemb: nombre d’éléments dans le tableausize: taille des éléments en octets (taille d’un seul élément)
Retourne un pointeur vers la zone allouée dans la heap et initialisée à zéro, ou NULL en cas d’erreur (errno indique la cause).
int *zeros = calloc(5, sizeof(*zeros)); // 5 entiers à 0
Tous les bits de la zone sont à zéro.
realloc : réalloue un bloc (agrandir/réduire)¶
void *realloc(void *ptr, size_t size);
ptr: adresse de la zone à redimensionner (allouée parmalloc,callocourealloc)size: nouvelle taille de la zone pointée parptr
Retourne un pointeur vers la zone redimensionnée, éventuellement déplacée (realloc copie alors lui-même les données), ou NULL en cas d’erreur (errno indique la cause).
int *tab = malloc(5 * sizeof(*tab));
// ... on travaille avec tab et on a besoin de plus de place
// tmp reçoit le résultat : tab n'est modifié que si realloc réussit
int *tmp = realloc(tab, 10 * sizeof(*tab));
// si la réallocation a échoué, tmp contient NULL
if (tmp == NULL) {
// mais tab possède toujours la mémoire allouée de 5 int
// il faut donc la libérer avant de terminer le programme
free(tab);
tab = NULL;
exit(EXIT_FAILURE);
}
// si tmp != NULL alors la réallocation s'est bien passée
// on peut alors faire pointer tab vers tmp
tab = tmp;
// pour éviter un futur dangling pointer
tmp = NULL;
Dans le cas où on veut agrandir l’espace mémoire, la réallocation peut :
réussir à agrandir l’espace précédent en gardant la mémoire contiguë, sans avoir à le déplacer
ne pas pouvoir agrandir le bloc sur place : les données sont alors copiées dans un nouveau bloc assez grand (opération plus coûteuse) et l’ancien bloc est libéré
échouer et retourner
NULL(plus assez de place en mémoire, par exemple) : l’ancien bloc reste valide, d’où le pointeurtmp(tab = realloc(tab, ...)perdrait son adresse en cas d’échec)
Dans les deux premiers cas, les anciennes valeurs sont conservées et les nouvelles cases ne sont pas initialisées.
free : libère une zone précédemment allouée¶
void free(void *ptr);
ptr: adresse de la zone à libérer (allouée parmalloc,callocourealloc), ouNULL
Libère la zone mémoire pointée par ptr. Si free(ptr) a déjà été appelé sur la mémoire, le comportement est indéfini. free(NULL) ne fait rien.
Important : toujours libérer avec free quand on n’a plus besoin de la mémoire.
Attention cependant à ne pas la libérer deux fois (comportement indéfini).
Mettre ensuite le pointeur à NULL est une bonne pratique pour bien indiquer que le pointeur ne pointe plus sur rien.
Une fuite mémoire se produit lorsqu’un bloc alloué n’est pas libéré avec free.
Cela peut épuiser la mémoire à long terme.
Voir l’utilisation de valgrind ou AddressSanitizer pour la détection de fuites ou d’erreurs de manipulation de la mémoire.
free(tab);
tab = NULL;
Ne pas retourner l’adresse d’une variable locale¶
Avertissement
Une fonction ne doit jamais retourner l’adresse d’une de ses variables locales : la variable est détruite à la sortie de la fonction et le pointeur retourné désigne une zone invalide (dangling pointer).
// À ne jamais faire !
int *mauvais(void) {
int x = 42; // x est dans la stack frame de mauvais
return &x; // x n'existe plus après le return
}
// Correct : la zone allouée dans la heap survit à la fonction
int *init_tab_dynamique(size_t taille) {
int *tab = malloc(taille * sizeof(*tab));
if (!tab) {
return NULL;
}
for (size_t i = 0; i < taille; ++i) {
tab[i] = (int)i;
}
return tab; // le free sera à faire par la fonction appelante
}
Allouer dans une fonction¶
Plutôt que de retourner l’adresse allouée, une fonction peut la ranger dans une variable pointeur de l’appelant.
Comme pour toute variable modifiée par une fonction (voir Passage par valeur, passage par adresse), on lui passe l’adresse de ce pointeur : le paramètre a une * de plus (int ** pour un int *).
// modifie les cases du tableau : reçoit l'adresse de la première case
void remplir(int *tab, size_t taille) {
for (size_t i = 0; i < taille; ++i) {
tab[i] = 0;
}
}
// modifie la variable tab de l'appelant (un int *) : reçoit l'adresse de tab
void allouer(int **adresse_tab, size_t taille) {
*adresse_tab = malloc(taille * sizeof(**adresse_tab)); // range l'adresse retournée par malloc dans tab
}
int main(void) {
int notes[5];
remplir(notes, 5); // notes est un tableau : pas de &
int *tab = NULL;
allouer(&tab, 5); // on modifie la variable tab elle-même : &tab (type int **)
if (tab == NULL) {
return EXIT_FAILURE;
}
remplir(tab, 5); // tab contient déjà l'adresse des cases : pas de &
free(tab);
return EXIT_SUCCESS;
}
Propriété de la mémoire¶
Toute allocation a un propriétaire unique, responsable du free (on parle aussi d”ownership).
Une fonction qui retourne de la mémoire allouée transfère cette responsabilité à l’appelant : il faut le documenter.
Une fonction qui reçoit un pointeur pour l’utiliser ou le modifier ne le libère pas.
Pour une structure allouée dynamiquement, on écrit souvent une fonction qui alloue et initialise (une fabrique) et une fonction qui libère :
// Position : la structure vue plus haut (champs x et y)
// alloue et initialise : la propriété est transférée à l'appelant
Position *position_creer(int x, int y) {
Position *p = malloc(sizeof(*p));
if (!p) {
return NULL;
}
p->x = x;
p->y = y;
return p;
}
// utilise la position sans en prendre la propriété
void position_afficher(const Position *p) {
printf("(%d, %d)\n", p->x, p->y);
}
// libère la position
void position_detruire(Position *p) {
free(p);
}
int main(void) {
Position *p = position_creer(3, 4);
if (!p) {
perror("position_creer");
return EXIT_FAILURE;
}
position_afficher(p); // (3, 4)
position_detruire(p);
p = NULL;
return EXIT_SUCCESS;
}
Pour aller plus loin : mettre le pointeur de l’appelant à NULL dans la fonction
position_detruire reçoit une copie de p : elle ne peut pas mettre à NULL la variable p du main.
Pour cela, il faut lui passer l’adresse de p, donc un Position ** (voir le récapitulatif du passage par adresse) :
void position_detruire(Position **pp) {
if (pp && *pp) {
free(*pp);
*pp = NULL; // modifie la variable p de l'appelant
}
}
// dans le main :
position_detruire(&p); // p vaut NULL au retour
Tableau de structures¶
On peut aussi allouer dynamiquement un tableau de structures :
typedef struct {
int hauteur;
int largeur;
} Rectangle;
size_t nb_rectangles = 3;
Rectangle *rectangles = malloc(nb_rectangles * sizeof(*rectangles));
if (!rectangles) {
perror("malloc");
return EXIT_FAILURE;
}
for (size_t i = 0; i < nb_rectangles; ++i) {
rectangles[i].hauteur = (int)i + 1; // rectangles[i] est un Rectangle : on utilise .
rectangles[i].largeur = 2;
}
Rectangle *r = &rectangles[1]; // r est un pointeur : on utilise ->
printf("%d x %d\n", r->hauteur, r->largeur); // affiche : 2 x 2
free(rectangles);
rectangles = NULL;
Tableau à deux dimensions¶
Pour les tableaux dynamiques à deux dimensions (ou plus), on a deux choix :
avoir un tableau de pointeurs vers des tableaux qui ne sont pas forcément contigus en mémoire
avoir un pointeur sur un espace mémoire que l’on traite comme un tableau à plusieurs dimensions
Dans le cas 1, on alloue d’abord un tableau de nb_lignes pointeurs (int **), un par ligne, puis, pour chaque ligne, un tableau de nb_colonnes entiers :
#include <stdio.h>
#include <stdlib.h>
int main(void) {
size_t nb_lignes = 5;
size_t nb_colonnes = 10;
// matrice[ligne] pointera vers les cases de la ligne
int **matrice = malloc(nb_lignes * sizeof(*matrice));
if (matrice == NULL) {
perror("malloc (lignes)");
exit(EXIT_FAILURE);
}
for (size_t ligne = 0; ligne < nb_lignes; ++ligne) {
matrice[ligne] = malloc(nb_colonnes * sizeof(*matrice[ligne]));
if (matrice[ligne] == NULL) {
perror("malloc (colonnes)");
// libérer toute la mémoire déjà allouée
for (size_t i = 0; i < ligne; ++i) { free(matrice[i]); }
free(matrice);
exit(EXIT_FAILURE);
}
}
// on peut ensuite utiliser matrice[ligne][colonne]
for (size_t ligne = 0; ligne < nb_lignes; ++ligne) {
for (size_t colonne = 0; colonne < nb_colonnes; ++colonne) {
matrice[ligne][colonne] = (int)(colonne + ligne * nb_colonnes);
}
}
// libérer chaque ligne, puis le tableau de pointeurs sur les lignes
for (size_t ligne = 0; ligne < nb_lignes; ++ligne) { free(matrice[ligne]); }
free(matrice);
return EXIT_SUCCESS;
}
On obtient la configuration suivante (représentée avec 4 colonnes au lieu de 10 pour rester lisible) : un premier tableau de pointeurs dont chaque élément pointe vers un tableau d”int, chaque ligne étant un bloc séparé en mémoire :
La libération se fait dans l’ordre inverse de l’allocation : d’abord chaque ligne (free(matrice[ligne])), puis le tableau des lignes (free(matrice)).
Si on libère matrice en premier, les adresses des lignes sont perdues et les lignes ne peuvent plus être libérées (fuite mémoire).
Pour aller plus loin : matrice plate
Dans le second cas, on utilise une matrice plate : on alloue un seul bloc mémoire et on le parcourt en utilisant le nombre de colonnes (longueur d’une ligne) :
#include <stdio.h>
#include <stdlib.h>
int main(void) {
size_t nb_lignes = 5;
size_t nb_colonnes = 10;
// un seul bloc pour toutes les cases de la matrice
int *matrice_plate = malloc(nb_colonnes * nb_lignes * sizeof(*matrice_plate));
if (matrice_plate == NULL) {
perror("malloc");
exit(EXIT_FAILURE);
}
for (size_t ligne = 0; ligne < nb_lignes; ++ligne) {
for (size_t colonne = 0; colonne < nb_colonnes; ++colonne) {
matrice_plate[colonne + ligne * nb_colonnes] = (int)(colonne + ligne * nb_colonnes);
}
}
free(matrice_plate);
matrice_plate = NULL;
return EXIT_SUCCESS;
}
On obtient cette configuration (ici aussi avec 4 colonnes), où tout le tableau est contigu en mémoire :
Dans le cas de la matrice plate, l’équivalent de matrice[ligne][colonne] est matrice_plate[colonne + ligne * nb_colonnes].
On se décale de ligne fois le nombre de colonnes : quand ligne = 3, on se décale de 3 × 10 = 30, ce qui place au début de la ligne 3 ; on ajoute ensuite l’indice de colonne (quand colonne = 7, on avance de 7 à partir de l’indice 30 et on trouve 37).
Tableau de chaînes¶
Un tableau de chaînes de taille variable se construit de la même manière, avec des char à la place des int : un tableau de pointeurs (char **), puis une zone par chaîne.
Chaque zone doit contenir les caractères et le \0 final, soit strlen(chaine) + 1 octets.
Exemple : copie de 3 mots dans un tableau de chaînes alloué dynamiquement.
char source[3][10] = {"chat", "chien", "lapin"};
size_t nb_mots = 3;
char **mots = malloc(nb_mots * sizeof(*mots)); // nb_mots pointeurs
if (!mots) {
perror("malloc");
exit(EXIT_FAILURE);
}
for (size_t i = 0; i < nb_mots; ++i) {
mots[i] = malloc(strlen(source[i]) + 1); // + 1 pour le \0
if (!mots[i]) {
perror("malloc");
for (size_t j = 0; j < i; ++j) {
free(mots[j]);
}
free(mots);
exit(EXIT_FAILURE);
}
strcpy(mots[i], source[i]); // copie des caractères
}
// ... utilisation de mots[0], mots[1], mots[2]
for (size_t i = 0; i < nb_mots; ++i) {
free(mots[i]); // chaque chaîne
}
free(mots); // puis le tableau de pointeurs
mots = NULL;
mots[i] = source[i]; ne copierait pas les caractères : on copierait seulement une adresse, et les deux tableaux partageraient les mêmes caractères.
Pour obtenir une copie indépendante, il faut allouer une zone et y copier la chaîne avec strcpy.
Portée et durée de vie des variables¶
En C, chaque variable possède une portée (scope) et une durée de vie (lifetime) qui déterminent où elle peut être utilisée et combien de temps elle existe en mémoire.
Portée (scope)¶
La portée d’une variable définit la région du code où elle est accessible.
Variables locales, ou de bloc (dans le stack, allocation automatique) :
Déclarées à l’intérieur d’un bloc (corps d’une fonction, boucle, if, etc.), elles ne sont accessibles que dans ce bloc.
void ma_fonction(void) {
int x = 5; // x n'existe que dans ma_fonction
printf("%d\n", x);
}
int main(void) {
ma_fonction();
// printf("%d\n", x); // Erreur : x n'existe pas ici
return EXIT_SUCCESS;
}
Depuis C99, on peut déclarer des variables n’importe où dans un bloc, y compris dans l’en-tête d’une boucle for : leur portée est alors limitée à ce bloc.
for (int i = 0; i < 5; i++) {
int temp = i * 2; // temp n'existe que dans cette itération
printf("%d ", temp);
}
// i et temp n'existent plus ici
Variables globales (stockées dans le segment data si elles sont initialisées à une valeur non nulle, BSS sinon ; allocation statique ; ces segments sont présentés dans la partie gestion mémoire) :
Déclarées en dehors de toute fonction, elles sont accessibles dans tout le fichier, à partir de leur déclaration (et dans d’autres fichiers avec extern).
int compteur = 0; // variable globale (initialisée à 0 : segment BSS)
void incrementer_compteur(void) {
compteur++; // accessible ici
}
int main(void) {
compteur = 10; // et ici aussi
return EXIT_SUCCESS;
}
Avertissement
Les variables globales peuvent rendre le code difficile à maintenir. Privilégiez les variables locales quand c’est possible.
Durée de vie (lifetime)¶
La durée de vie définit combien de temps une variable existe en mémoire.
Durée de vie automatique (stack) :
Par défaut, les variables locales ont une durée de vie automatique : elles sont créées à l’entrée du bloc et détruites à sa sortie.
void fonction(void) {
int a = 42; // a est créée ici
printf("%d\n", a);
} // a est détruite ici
Durée de vie statique :
Avec le mot-clé static, une variable locale conserve sa valeur entre les appels de fonction. Elle est initialisée une seule fois.
void compteur(void) {
static int nb_appels = 0; // initialisée une seule fois
nb_appels++;
printf("Appel n°%d\n", nb_appels);
}
int main(void) {
compteur(); // affiche : Appel n°1
compteur(); // affiche : Appel n°2
compteur(); // affiche : Appel n°3
return EXIT_SUCCESS;
}
Les variables globales ont aussi une durée de vie statique : elles existent pendant toute l’exécution du programme.
Durée de vie dynamique (heap) :
Les zones allouées avec malloc existent jusqu’à ce qu’elles soient libérées avec free.
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int *p = malloc(sizeof(*p)); // création dans la heap
if (!p) {
perror("malloc");
return EXIT_FAILURE;
}
*p = 42;
// p est une variable locale (dans le stack)
// la zone pointée par p existe jusqu'à free(p)
free(p); // libération explicite
return EXIT_SUCCESS;
}
Type |
Portée |
Durée de vie |
Stockage |
|---|---|---|---|
Variable locale |
Bloc |
Automatique |
Stack (pile) |
Variable locale |
Bloc |
Statique (programme) |
Segment data/BSS |
Variable globale |
Fichier (ou programme avec |
Statique (programme) |
Segment data/BSS |
Variable dynamique |
Aucune (accessible via un pointeur) |
Contrôlée ( |
Heap (tas) |
Préprocesseur¶
Le préprocesseur C agit avant la compilation proprement dite. Il traite toutes les directives commençant par #.
Il travaille sur le texte du programme : #include recopie le contenu du fichier inclus, et #define remplace chaque occurrence du nom par le texte donné.
On y retrouve :
#include: insère le contenu d’un fichier, que ce soit un en-tête d’une bibliothèque (standard ou externe) ou l’un de vos fichiers d’en-tête (.h)#define: définit des constantes ou des macros#ifdef,#ifndef,#if,#else,#endif: compilation conditionnelle, par exemple pour éviter les inclusions multiples d’un fichier
La directive #pragma once évite aussi les inclusions multiples, en une seule ligne ; elle est acceptée par la majorité des compilateurs.
calculs.h :
1// soit utiliser pragma once :
2// #pragma once
3// soit utiliser ifndef (pas les deux) :
4#ifndef CALCULS_H
5#define CALCULS_H
6
7#define NOMBRE_MAGIQUE 42
8#define CARRE(x) ((x) * (x))
9
10/**
11 * @brief Calcule la somme des carrés de deux nombres
12 *
13 * @param a premier nombre
14 * @param b second nombre
15 * @return int a^2 + b^2
16 */
17int somme_carres(int a, int b);
18
19/**
20 * @brief Calcule la somme des carrés de deux nombres et y ajoute
21 * une pincée de nombre magique
22 *
23 * @param a premier nombre
24 * @param b second nombre
25 * @return int a^2 + b^2 + NOMBRE_MAGIQUE
26 */
27int somme_carres_magique(int a, int b);
28
29#endif // CALCULS_H
calculs.c :
1#include "calculs.h"
2
3int somme_carres(int a, int b) {
4 return CARRE(a) + CARRE(b);
5}
6
7int somme_carres_magique(int a, int b) {
8 return somme_carres(a, b) + NOMBRE_MAGIQUE;
9}
main.c :
1#include <stdio.h>
2#include <stdlib.h>
3
4#include "calculs.h"
5
6int main(void) {
7 int a = 5;
8 int b = 8;
9 int resultat1 = somme_carres(a, b); // 25 + 64 = 89
10 int resultat2 = somme_carres_magique(resultat1, b); // 7921 + 64 + 42
11
12 printf("resultat = %d\n", resultat2);
13
14 return EXIT_SUCCESS;
15}
Comme le remplacement est textuel, les parenthèses de CARRE sont nécessaires : sans elles, #define CARRE(x) x * x transformerait CARRE(a + 1) en a + 1 * a + 1.
On compile en donnant tous les fichiers .c (les .h sont inclus par le préprocesseur) :
$ gcc -std=c2x -Wall -Wextra -pedantic -g main.c calculs.c -o main
$ ./main
resultat = 8027
Gestion des erreurs¶
En C, il n’existe pas de mécanisme intégré d’exceptions (comme try/catch en C++ ou en Java).
La gestion des erreurs repose sur les principes suivants.
Valeurs de retour¶
La plupart des fonctions de la libc retournent une valeur spéciale en cas d’erreur (souvent -1 ou NULL).
Exemple avec fopen, qui ouvre un fichier et retourne un FILE * (les fichiers sont détaillés dans la partie Fichiers) :
1FILE *f = fopen("fichier.txt", "r");
2if (f == NULL) {
3 perror("fopen");
4 exit(EXIT_FAILURE);
5}
Ici, fopen retourne NULL en cas d’échec (par exemple si le fichier n’existe pas).
perror affiche un message d’erreur basé sur errno, et exit termine le programme avec le code donné (ces deux fonctions sont détaillées ci-dessous).
errno¶
errno (défini dans <errno.h>) contient le code de la dernière erreur, positionné par les fonctions système et de nombreuses fonctions de la libc en cas d’erreur.
Il se manipule comme une variable globale, mais c’est en pratique une macro qui désigne une valeur propre à chaque thread.
Exemple :
1#include <stdio.h>
2#include <stdlib.h>
3#include <errno.h>
4#include <string.h>
5
6int main(void) {
7 FILE *f = fopen("inexistant.txt", "r");
8 if (f == NULL) {
9 fprintf(stderr, "Erreur %d : %s\n", errno, strerror(errno));
10 // proche de perror("Erreur"), qui affiche "Erreur: message"
11 return EXIT_FAILURE;
12 }
13 fclose(f); // ferme le fichier
14 return EXIT_SUCCESS;
15}
Deux fonctions donnent la description d’un code d’erreur : perror (<stdio.h>) l’affiche, strerror (<string.h>) la retourne.
void perror(const char *s);
char *strerror(int errnum);
s: message affiché avant la description de l’erreur (en général le nom de la fonction qui a échoué)errnum: code d’erreur à décrire (souventerrno)
perror ne retourne rien : elle écrit sur stderr le message s, ": ", puis la description de l’erreur correspondant à la valeur courante de errno.
strerror retourne une chaîne décrivant l’erreur errnum (chaîne à ne pas modifier).
strerror sert aussi pour les fonctions pthread, qui retournent leur code d’erreur au lieu de le placer dans errno (voir pthread_create).
Toute fonction appelée (y compris printf ou fprintf) peut modifier errno, même quand elle réussit : si on veut utiliser la valeur plus tard, il faut la sauvegarder immédiatement (int err = errno;).
perror n’a de sens que si la fonction qui a échoué positionne errno : c’est indiqué dans les sections RETURN VALUE et ERRORS de sa page de manuel (malloc, fopen, les appels système…).
Pour une erreur détectée par votre programme (saisie invalide, valeur hors limites, scanf qui ne lit rien…), errno n’est pas positionné : utilisez fprintf(stderr, "...").
Pour aller plus loin : positionner errno soi-même
errno est modifiable : une fonction peut faire comme les appels système et positionner errno avant de retourner -1.
L’appelant peut alors utiliser perror :
#include <errno.h>
int pourcentage(int partie, int total, int *resultat) {
if (total <= 0) {
errno = EINVAL; // argument invalide
return -1;
}
*resultat = partie * 100 / total;
return 0;
}
// à l'appel
if (pourcentage(3, 0, &resultat) == -1) {
perror("pourcentage"); // affiche "pourcentage: Invalid argument"
}
Quelques règles :
utiliser un code existant de
<errno.h>(EINVALpour un argument invalide,EDOMpour une valeur hors du domaine mathématique), sans inventer de valeurpositionner
errnouniquement en cas d’échec, juste avant lereturn -1(tout appel intermédiaire peut le modifier)ne jamais remettre
errnoà0en cas de succès : les fonctions de la libc ne le font pas non plus
La liste des codes et leur signification est donnée par man 3 errno.
La commande errno -l (paquet moreutils) affiche la même liste sous forme de tableau, avec pour chaque code son nom, sa valeur numérique et son message ; les codes surlignés sont ceux que vous rencontrerez le plus souvent dans ce module :
$ errno -l
EPERM 1 Opération non permise
ENOENT 2 Aucun fichier ou dossier de ce nom
ESRCH 3 Aucun processus ayant ce numéro
EINTR 4 Appel système interrompu
EIO 5 Erreur d'entrée/sortie
ENXIO 6 Aucun périphérique ou adresse
E2BIG 7 Liste d'arguments trop longue
ENOEXEC 8 Erreur de format pour exec()
EBADF 9 Mauvais descripteur de fichier
ECHILD 10 Aucun processus enfant
EAGAIN 11 Ressource temporairement non disponible
ENOMEM 12 Ne peut allouer de la mémoire
EACCES 13 Permission non accordée
EFAULT 14 Mauvaise adresse
ENOTBLK 15 Bloc de périphérique requis
EBUSY 16 Périphérique ou ressource occupé
EEXIST 17 Le fichier existe
EXDEV 18 Lien physique inter-périphérique invalide
ENODEV 19 Aucun périphérique de ce type
ENOTDIR 20 N'est pas un dossier
EISDIR 21 est un dossier
EINVAL 22 Argument invalide
ENFILE 23 Trop de fichiers ouverts dans le système
EMFILE 24 Trop de fichiers ouverts
ENOTTY 25 Ioctl() inapproprié pour un périphérique
ETXTBSY 26 Fichier texte occupé
EFBIG 27 Fichier trop gros
ENOSPC 28 Aucun espace disponible sur le périphérique
ESPIPE 29 Repérage non permis
EROFS 30 Système de fichiers accessible en lecture seulement
EMLINK 31 Trop de liens
EPIPE 32 Relais brisé (pipe)
EDOM 33 L'argument numérique est hors du domaine
ERANGE 34 Le résultat numérique est en dehors de l'intervalle
EDEADLK 35 Interblocage évité des accès à la ressource
ENAMETOOLONG 36 Nom de fichier trop long
ENOLCK 37 Aucun verrou disponible
ENOSYS 38 Fonction non implantée
ENOTEMPTY 39 Le dossier n'est pas vide
ELOOP 40 Trop de niveaux de liens symboliques
EWOULDBLOCK 11 Ressource temporairement non disponible
ENOMSG 42 Aucun message du type désiré
EIDRM 43 Identificateur éliminé
ECHRNG 44 Numéro de canal en dehors des limites
EL2NSYNC 45 Niveau 2 non synchronisé
EL3HLT 46 Niveau 3 en halte
EL3RST 47 Niveau 3 réinitialisé
ELNRNG 48 Numéro du lien hors intervalle
EUNATCH 49 Pilote du protocole n'est pas attaché
ENOCSI 50 Aucune structure CSI disponible
EL2HLT 51 Niveau 2 en halte
EBADE 52 Échange invalide
EBADR 53 Descripteur de requête invalide
EXFULL 54 L'échangeur est plein
ENOANO 55 Aucune « anode » disponible
EBADRQC 56 Code de requête invalide
EBADSLT 57 Dalot invalide
EDEADLOCK 35 Interblocage évité des accès à la ressource
EBFONT 59 Mauvais format du fichier de fontes
ENOSTR 60 Le périphérique n'est pas de type « stream »
ENODATA 61 Aucune donnée disponible
ETIME 62 Expiration de la minuterie
ENOSR 63 Aucune autre ressource de type streams disponible
ENONET 64 La machine cible n'est pas sur le réseau
ENOPKG 65 Le paquetage n'est pas installé
EREMOTE 66 L'objet est télé-accessible
ENOLINK 67 Le lien a été endommagé
EADV 68 Erreur d'annonce
ESRMNT 69 Erreur srmount()
ECOMM 70 Erreur de communication lors de la transmission
EPROTO 71 Erreur de protocole
EMULTIHOP 72 Tentative de connexion par de multiples noeuds de relais
EDOTDOT 73 Erreur spécifique à « RFS »
EBADMSG 74 Message invalide
EOVERFLOW 75 Valeur trop grande pour le type défini de données
ENOTUNIQ 76 Le nom n'est pas unique sur le réseau
EBADFD 77 Le descripteur du fichier est dans un mauvais état
EREMCHG 78 L'adresse de l'hôte cible a été modifiée
ELIBACC 79 Ne peut accéder à la librairie partagée demandée
ELIBBAD 80 Accès d'une librairie partagée qui est corrompue
ELIBSCN 81 La section .lib dans a.out est corrompue
ELIBMAX 82 Tentative d'édition de liens à partir de trop de librairies partagées
ELIBEXEC 83 Ne peut exécuter une librairie partagée directement
EILSEQ 84 Chaîne multi-octets ou étendue de caractères invalide ou incomplète
ERESTART 85 Appel système interrompu, il aurait dû être relancé
ESTRPIPE 86 Erreur de relais de type streams
EUSERS 87 Trop d'usagers
ENOTSOCK 88 Opération de type socket sur un type non socket
EDESTADDRREQ 89 Adresse de destination requise
EMSGSIZE 90 Message trop long
EPROTOTYPE 91 Mauvais type de protocole pour un socket
ENOPROTOOPT 92 Protocole non disponible
EPROTONOSUPPORT 93 Protocole non supporté
ESOCKTNOSUPPORT 94 Type de socket non supporté
EOPNOTSUPP 95 Opération non supportée
EPFNOSUPPORT 96 Famille de protocoles non supportée
EAFNOSUPPORT 97 Famille d'adresses non supportée par le protocole
EADDRINUSE 98 Adresse déjà utilisée
EADDRNOTAVAIL 99 Ne peut attribuer l'adresse demandée
ENETDOWN 100 Le réseau ne fonctionne pas
ENETUNREACH 101 Le réseau n'est pas accessible
ENETRESET 102 Le réseau a rompu la connexion lors de la réinitialisation
ECONNABORTED 103 Le logiciel a provoqué l'abandon de la connexion
ECONNRESET 104 Connexion ré-initialisée par le correspondant
ENOBUFS 105 Aucun espace tampon disponible
EISCONN 106 Noeud final de transport déjà connecté
ENOTCONN 107 Noeud final de transport n'est pas connecté
ESHUTDOWN 108 Ne peut transmettre suite à la fermeture du noeud final de transport
ETOOMANYREFS 109 Trop de références : ne peut segmenter
ETIMEDOUT 110 Connexion terminée par expiration du délai d'attente
ECONNREFUSED 111 Connexion refusée
EHOSTDOWN 112 L'hôte cible est arrêté ou en panne
EHOSTUNREACH 113 Aucun chemin d'accès pour atteindre l'hôte cible
EALREADY 114 Opération déjà en cours
EINPROGRESS 115 Opération maintenant en cours
ESTALE 116 Panne d'accès au fichier
EUCLEAN 117 La structure a besoin d'un nettoyage
ENOTNAM 118 Aucun fichier de type « XENIX named »
ENAVAIL 119 Aucun sémaphore XENIX disponible
EISNAM 120 est un type de fichier nommé (named)
EREMOTEIO 121 Erreur d'entrée/sortie sur l'hôte cible
EDQUOT 122 Débordement du quota d'espace disque
ENOMEDIUM 123 Aucun support trouvé
EMEDIUMTYPE 124 Mauvais type de support
ECANCELED 125 Opération annulée
ENOKEY 126 Clé requise non disponible
EKEYEXPIRED 127 Expiration de la clé
EKEYREVOKED 128 La clé a été révoquée
EKEYREJECTED 129 La clé a été rejetée par le service
EOWNERDEAD 130 Propriétaire mort
ENOTRECOVERABLE 131 Etat non récupérable
ERFKILL 132 Opération impossible du fait de RF-kill
EHWPOISON 133 La page mémoire a une erreur matériel
ENOTSUP 95 Opération non supportée
Pratique pour décoder un message du type Erreur 2 : ... affiché avec la valeur numérique de errno.
Codes retour du programme¶
Un programme en C retourne un entier à sa fin via return dans main ou exit :
EXIT_SUCCESS(0) → exécution correcteEXIT_FAILURE(1 sous Linux) → échec
La fonction exit (<stdlib.h>, man 3 exit) termine le programme depuis n’importe quelle fonction :
void exit(int status);
status: code de retour du programme, transmis au système d’exploitation comme la valeur retournée parmain(EXIT_SUCCESSouEXIT_FAILURE)
Ne retourne pas : le programme se termine, après avoir vidé les buffers des flux ouverts (stdout, fichiers ouverts avec fopen…).
Dans main, return EXIT_FAILURE; et exit(EXIT_FAILURE); sont équivalents ; dans une autre fonction, return revient à l’appelant alors que exit arrête tout le programme.
Exemple :
if (ptr == NULL) {
perror("Erreur malloc");
exit(EXIT_FAILURE);
}
Bonnes pratiques¶
toujours vérifier la valeur de retour des fonctions critiques (
malloc,fopen, etc.)utiliser
perroroustrerrorpour des messages d’erreur explicitesécrire les erreurs sur
stderrplutôt questdout(fait automatiquement avecperror)libérer les ressources (fichiers, mémoire) avant de quitter sur erreur
Aléatoire¶
La fonction rand (standard C, <stdlib.h>, man 3 rand) génère un nombre pseudo-aléatoire.
Sa qualité est limitée, mais elle est disponible partout où le langage C est pris en charge.
int rand(void);
Retourne un entier pseudo-aléatoire compris entre 0 et RAND_MAX (au minimum 32767, valeur dépendante de l’implémentation).
Avant utilisation, il est conseillé d’initialiser le générateur avec srand :
void srand(unsigned int seed);
seed: graine pour une nouvelle séquence du générateur pseudo-aléatoire
Avec une même seed donnée à srand, rand donnera la même séquence de nombres pseudo-aléatoires (sur une même libc : la séquence dépend de l’implémentation).
Si rand est appelé sans srand, la séquence est la même qu’avec srand(1) : on obtient toujours la même suite.
Pour toujours avoir le même résultat à l’exécution :
srand(42);
Pour avoir des résultats différents à chaque exécution, vous pouvez utiliser comme graine l’heure courante, donnée par
time(<time.h>,man 2 time) :time_t time(time_t *tloc);
tloc: si ce n’est pasNULL, adresse où ranger aussi le résultat ; on appelle en généraltime(NULL)
Retourne le nombre de secondes écoulées depuis 1970-01-01 00:00:00 +0000 (UTC), ou
(time_t)-1en cas d’erreur (errnoindique la cause).
Exemple :
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
int main(void) {
unsigned int seed = (unsigned int)time(NULL);
srand(seed);
// Nombre pseudo-aléatoire entre 0 et 99
int tirage = rand() % 100;
printf("%d\n", tirage);
return EXIT_SUCCESS;
}
srand s’appelle une seule fois, au début du main : rappeler srand(time(NULL)) avant chaque tirage (dans une boucle ou dans une fonction de tirage) réinitialise la séquence avec la même graine pendant toute la seconde en cours, et donne donc les mêmes valeurs.
Autres mots-clés¶
En plus des mots-clés comme if, while, etc., le langage C propose des mots-clés spécifiques qui influencent la durée de vie, la portée ou le comportement mémoire des variables.
static¶
dans une fonction, une variable
staticconserve sa valeur entre les appels (voir l’exemple de la section Durée de vie (lifetime))utilisé hors d’une fonction (devant une variable globale ou une fonction),
staticlimite la portée à un fichier source
extern¶
Permet de déclarer une variable ou une fonction définie dans un autre fichier. Il est souvent utilisé pour partager des variables globales entre fichiers.
// dans global.h
extern int compteur_global;
// dans main.c
#include "global.h"
int compteur_global = 0;
// dans autre.c
#include "global.h"
void incrementer_compteur(void) {
compteur_global++;
}
Pour aller plus loin : volatile
Le mot-clé volatile signale au compilateur qu’une variable peut changer à tout moment, sans que le code courant l’indique explicitement. Cela désactive certaines optimisations (ex. conservation de la valeur dans un registre). Il est utilisé pour les interactions avec le matériel (registres de périphériques) ou pour les variables modifiées par un gestionnaire de signal (volatile sig_atomic_t, voir la partie sur les processus).
volatile ne suffit pas à synchroniser des threads (il ne garantit ni l’atomicité ni l’ordre des accès mémoire) : il faut pour cela utiliser _Atomic ou des mutex (voir la partie sur les threads).
L’exemple ci-dessous utilise un gestionnaire de signal, présenté dans la partie Processus : vous pourrez y revenir à ce moment-là.
volatile sig_atomic_t flag = 0;
void gestionnaire(int sig) {
(void)sig;
flag = 1; // modifié par un gestionnaire de signal
}
const¶
Déjà abordé dans la section sur les variables, const signifie que la valeur ne peut pas être modifiée.
Avec un pointeur, la position de const compte : il porte sur ce qui est écrit juste à sa droite.
int a = 5;
int b = 6;
const int *p = &a; // const int : la valeur pointée ne peut pas être modifiée via p
// *p = 7; // erreur
p = &b; // possible : p peut pointer ailleurs
int *const q = &a; // const q : le pointeur ne peut pas changer de cible
*q = 7; // possible : a vaut 7
// q = &b; // erreur
La forme const T * est la plus courante : c’est celle des paramètres qu’une fonction lit sans les modifier (const char * dans strlen, const int *tab…).
Pour aller plus loin : restrict
Vous verrez restrict dans les signatures de la documentation (int printf(const char *restrict format, ...)) : c’est une promesse faite au compilateur que la zone pointée n’est accessible que par ce pointeur, ce qui lui permet d’optimiser. Vous pouvez l’ignorer à la lecture : les signatures données dans ce cours l’omettent.