Le langage C
Cours complet · informatique (MP2I/MPI), chapitre 1 · MP2I et MPI
Travailler ce chapitre sur Adloun Exercices corrigés de ce chapitre
1.1 Pourquoi commencer par C
Vous arrivez du lycée avec Python. Python vous a laissé écrire x = 3 puis x = "trois" sans broncher, il a fait grandir vos listes tout seul, il a récupéré la mémoire que vous n'utilisiez plus sans vous en parler. C ne fera rien de tout cela. C vous demandera de déclarer le type de chaque variable, de dire combien de cases vous voulez, et de rendre la mémoire que vous avez prise.
Ce n'est pas une brimade. Le programme officiel le dit : C est « un langage dit de bas niveau d'abstraction utilisé entre autres pour écrire tous les systèmes d'exploitation », et il « permet une gestion explicite de la mémoire et des ressources de la machine ». Autrement dit : ce que Python vous cachait, C vous l'apprend. Quand vous saurez pourquoi un tableau de dix cases n'accepte pas d'onzième valeur — et surtout, ce qui arrive quand on essaie quand même —, vous aurez compris quelque chose sur les machines, pas seulement sur un langage.
L'annexe A du B.O. « liste limitativement les éléments du langage C (norme C99 ou plus récente) dont la connaissance … est exigible ». Elle distingue deux niveaux, et ce chapitre les distingue aussi :
- ce qui doit être compris et utilisé sans rappel, y compris sans ordinateur — c'est le corps du chapitre ;
- ce qui doit être utilisable après rappel, documentation fournie — c'est la section sec:c-apres-rappel.
Le reste du langage C — et il y en a beaucoup — n'est pas au programme. On ne fait notamment pas d'arithmétique des pointeurs, et on n'utilise pas les opérateurs d'incrémentation ++ et –.
1.2 Un premier programme, et ce qu'il révèle
#include <stdio.h>
int main(void) {
printf("Bonjour\n");
return 0;
}
Quatre choses s'y lisent déjà, et aucune n'existait en Python :
#include <stdio.h>amène les déclarations d'entrées-sorties. Sans elle,printfest inconnu.int main(void): l'exécution commence toujours par la fonctionmain, et cette fonction a un type de retour,int.- Les accolades délimitent la portée. Les retours à la ligne et l'indentation, eux, ne sont pas signifiants : ils ne servent qu'à la lisibilité — mais ils y servent, et on les soigne.
return 0rend un code de sortie au système : zéro veut dire « tout s'est bien passé ».
1.2.1 Compilé, et non interprété
Python interprète : un programme lit votre texte et l'exécute au fil de la lecture. C compile : un programme lit votre texte en entier et fabrique un fichier exécutable, que la machine exécutera ensuite sans plus rien connaître de votre source.
bonjour.c --[compilation]--> bonjour.o --[édition de liens]--> bonjour
(source) (objet) (exécutable)
En pratique, une seule commande enchaîne les deux étapes :
gcc -Wall -Wextra -std=c99 -o bonjour bonjour.c
./bonjour
Bonne pratique (Toujours compiler avec les avertissements)
-Wall -Wextra demande au compilateur de signaler tout ce qui lui paraît douteux. Un programme C qui compile sans avertissement n'est pas forcément juste ; un programme qui en produit est presque toujours fautif. C'est le premier filet de sécurité, et il est gratuit.
Le programme demande de distinguer les deux. Un fichier .h (l'en-tête) déclare ce qu'une fonction fait — son nom, ses paramètres, son type de retour ; un fichier .c définit comment elle le fait. On inclut le premier là où l'on veut appeler la fonction ; on compile le second une fois. C'est la modularité de C, et c'est ce qui permet d'utiliser printf sans jamais en avoir lu le code.
1.3 Types de base, et la vérité sur les entiers
1.3.1 Les entiers ont une taille, et elle déborde
C distingue les entiers signés et non signés, et fixe leur taille en bits. L'annexe retient :
| Signés | Non signés | Bits |
|---|---|---|
| `int8_t` | `uint8_t` | 8 |
| `int32_t` | `uint32_t` | 32 |
| `int64_t` | `uint64_t` | 64 |
Quand la taille exacte n'apporte rien, on écrit simplement int et unsigned int. Les opérations sont +, -, , /, et % entre opérandes positifs*.
Un int8_t vit entre et . Que vaut ?
int8_t x = 127;
x = x + 1; /* x vaut maintenant -128, sans le moindre message */
Python vous aurait donné : ses entiers sont de taille illimitée. C, lui, vous donne un nombre faux en silence. C'est la différence la plus dangereuse entre les deux langages, et c'est aussi pourquoi le programme insiste sur la programmation défensive : c'est à vous de vérifier qu'une somme ne débordera pas, personne ne le fera à votre place.
int a = 7 / 2; /* a vaut 3 : la division de deux entiers est entière */
double b = 7.0 / 2; /* b vaut 3.5 : dès qu'un opérande est flottant, tout l'est */
int c = 7 % 2; /* c vaut 1 : le reste */
La première ligne est le piège classique : rien n'indique que l'on a perdu la moitié.
1.3.2 Flottants, caractères, booléens
double: un flottant, que l'on considère sur 64 bits. Opérations+,-,*,/.char: exclusivement un caractère codé sur un octet. La notation'\0'désigne le caractère nul, qui jouera un rôle décisif pour les chaînes.bool: les constantestrueetfalse, les opérateurs!,&&,||.
Le C historique permettait d'écrire if (n) pour « si est non nul ». L'annexe l'interdit explicitement : « les entiers ne doivent pas être utilisés comme booléens, ni l'inverse ». On écrit if (n != 0). La raison est de lisibilité : if (n) ne dit pas si l'on teste une quantité ou une condition.
&& et || n'évaluent leur second opérande que si c'est nécessaire : si le premier de && est faux, le résultat est faux, et l'on s'arrête là. Ce n'est pas une optimisation : c'est un outil.
if (i < n && t[i] == 0) { ... } /* t[i] n'est lu que si i < n : l'ordre PROTÈGE */
Écrite dans l'autre sens, cette condition lirait une case hors du tableau. On y reviendra.
Bonne pratique (Les constantes s'écrivent `const`)
const int TAILLE_MAX = 100;
L'annexe précise : « on n'utilise pas la directive du préprocesseur #define à cette fin ». Une constante const a un type ; une macro #define n'est qu'un remplacement de texte, sans type et sans portée.
1.4 Structures de contrôle
if (c) { instructionsSiVrai; }
if (c) { instructionsSiVrai; } else { instructionsSiFaux; }
while (c) { corps; }
for (int i = 0; i < n; i = i + 1) { corps; }
Le for de C n'est pas le for de Python : ce n'est pas un parcours d'objet, c'est un while qui a rangé son initialisation, sa condition d'arrêt et son incrément sur une même ligne. On peut définir la variable de boucle dans l'initialisation, et sa portée est alors limitée à la boucle. break sort de la boucle la plus proche.
/* Somme des n premiers termes de t. Précondition : n <= longueur de t. */
int somme(const int t[], int n) {
int s = 0;
for (int i = 0; i < n; i = i + 1) {
s = s + t[i];
}
return s;
}
Remarquez la ligne de commentaire au-dessus : c'est la spécification, et le programme la rend obligatoire — « on entraîne les étudiants à accompagner leurs programmes et leurs fonctions d'une spécification ». Elle dit ce qu'on attend en entrée et ce qu'on rend en sortie. Aucune fonction de ce livre n'en sera dépourvue.
1.5 Fonctions, et le passage par valeur
Une fonction se déclare (on annonce sa signature) et se définit (on donne son corps) :
int maximum(int a, int b); /* déclaration : la signature seule */
int maximum(int a, int b) { /* définition */
if (a > b) { return a; }
return b;
}
Le nombre de paramètres est toujours fixé — les fonctions à nombre variable d'arguments sont hors programme.
void rate(int x) { x = 42; } /* ne modifie RIEN chez l'appelant */
int n = 7;
rate(n); /* n vaut toujours 7 */
L'appel a copié la valeur de n dans un nouveau x, et c'est la copie qui a changé. Pour modifier une variable de l'appelant, il faut lui passer son adresse — c'est tout l'objet des pointeurs, section suivante.
Méthode : Simuler plusieurs valeurs de retour
Une fonction C ne rend qu'une valeur. Quand il en faut deux, on passe des pointeurs vers les cases à remplir :
/* Range dans *mini le plus petit et dans *maxi le plus grand des n premiers
termes de t. Précondition : n >= 1, mini et maxi non nuls. */
void extremes(const int t[], int n, int* mini, int* maxi) {
assert(n >= 1 && mini != NULL && maxi != NULL);
*mini = t[0];
*maxi = t[0];
for (int i = 1; i < n; i = i + 1) {
if (t[i] < *mini) { *mini = t[i]; }
if (t[i] > *maxi) { *maxi = t[i]; }
}
}
1.6 Tableaux et chaînes
1.6.1 Tableaux statiques
type T[s] déclare un tableau de s cases, où s est une constante littérale entière. On lit et écrit la case d'indice i par T[i], les indices allant de à .
int t[5]; /* 5 cases, contenu INDÉTERMINÉ */
int u[5] = {3, 1, 4, 1, 5}; /* initialisateur (utilisable après rappel) */
int m[3][4]; /* tableau à deux dimensions : 3 lignes, 4 colonnes */
C'est la phrase exacte de l'annexe, et c'est la plus lourde de conséquences du chapitre.
int t[5];
t[7] = 12; /* AUCUNE erreur à la compilation, AUCUNE à l'exécution.
On vient d'écrire dans une mémoire qui ne nous appartient pas. */
Python aurait levé IndexError. C écrit, et continue. Le programme sera peut-être faux mille instructions plus loin, peut-être jamais, peut-être seulement chez l'examinateur. Le programme officiel relie explicitement ce point à la sécurité : « les défauts, les bogues et les failles de logique constituent systématiquement la cause première des vulnérabilités des logiciels exploitées de façon malveillante ».
La parade est double, et elle est de méthode : un tableau voyage toujours accompagné de sa taille, et l'on écrit assert là où l'on doute.
1.6.2 Chaînes de caractères
En C, il n'existe pas de type « chaîne ». Une chaîne est un char[] dont la fin est marquée par le caractère '\0'.
char mot[6] = "chien"; /* 5 lettres + le '\0' final : SIX cases, pas cinq */
Trois fonctions sont au programme : strlen (la longueur, sans compter la sentinelle), strcpy (la copie), strcat (la concaténation).
strlen ne connaît pas la longueur : il la cherche, en parcourant jusqu'à la sentinelle. Écrire for (int i = 0; i < strlen(s); i = i + 1) relit donc toute la chaîne à chaque tour : une boucle en devient . On calcule la longueur une fois, avant la boucle.
Quant à strcpy(dest, src), il copie jusqu'à la sentinelle sans jamais regarder la taille de dest. Si la destination est trop courte, on écrit au-delà — c'est le débordement de tampon, la vulnérabilité la plus célèbre de l'histoire de l'informatique.
1.7 Structures
Une structure regroupe plusieurs champs sous un même nom.
struct point_s {
double x;
double y;
};
typedef struct point_s point;
point p;
p.x = 1.5; /* accès à un champ d'une valeur */
point* q = &p;
q->y = -2.0; /* accès à un champ à travers un pointeur : la flèche */
q->y est exactement (*q).y, en plus lisible. L'organisation en mémoire des structures n'est pas à connaître.
/* Renvoie le milieu du segment [ab]. */
point milieu(point a, point b) {
point m;
m.x = (a.x + b.x) / 2.0;
m.y = (a.y + b.y) / 2.0;
return m;
}
Sans structure, il aurait fallu quatre paramètres et deux pointeurs de sortie. La structure porte l'intention : ces deux nombres sont un point.
1.8 Pointeurs et mémoire
1.8.1 Adresse et déréférencement
Un pointeur est une variable qui contient l'adresse d'une autre. On considère les pointeurs sur 64 bits.
int v = 42;
int* p = &v; /* p contient l'ADRESSE de v : & se lit « adresse de » */
int w = *p; /* w reçoit la VALEUR pointée : 42 : * se lit « valeur en » */
*p = 7; /* v vaut maintenant 7 : on a écrit À TRAVERS le pointeur */
NULL est le pointeur qui ne pointe sur rien. Déréférencer NULL — écrire p quand p vaut NULL — arrête brutalement le programme : c'est la violation de segment (segmentation fault*). C'est, paradoxalement, le bon cas : le système vous a arrêté au lieu de vous laisser corrompre des données.
Bonne pratique (`assert` avant de déréférencer)
L'annexe demande explicitement « l'utilisation de assert lors d'opérations sur les pointeurs, les tableaux, les chaînes ».
#include <assert.h>
void remplir(int* p, int valeur) {
assert(p != NULL); /* le contrat est vérifié, et il est LISIBLE */
*p = valeur;
}
Une assertion fausse arrête le programme avec un message précis, au bon endroit. C'est infiniment préférable à un résultat faux découvert trois heures plus tard.
1.8.2 Allocation dynamique
Un tableau statique a une taille connue à l'écriture du programme. Quand la taille n'est connue qu'à l'exécution, on demande la mémoire au tas :
#include <stdlib.h>
/* Renvoie un tableau de n entiers, tous nuls, ou NULL en cas d'échec.
L'appelant devra le libérer par free. Précondition : n >= 1. */
int* tableau_neuf(int n) {
assert(n >= 1);
int* t = malloc(n * sizeof(int));
if (t == NULL) { return NULL; } /* malloc peut ÉCHOUER */
for (int i = 0; i < n; i = i + 1) { t[i] = 0; }
return t;
}
/* ... plus tard ... */
int* t = tableau_neuf(1000);
/* usage */
free(t); /* on REND la mémoire : sans cela, elle est perdue */
sizeof(int) donne la taille d'un int en octets ; malloc rend un void* que l'on transtype implicitement vers le bon type de pointeur.
| Faute | Nom | Ce qu'on observe |
|---|---|---|
| Ne jamais appeler `free` | fuite de mémoire | le programme grossit sans fin |
| Appeler `free` deux fois | double libération | plantage, parfois plus tard |
| Utiliser après `free` | pointeur fou | valeurs aberrantes, aléatoires |
Les trois sont invisibles à la compilation. La discipline qui les évite est simple à énoncer : qui alloue documente qui libère. C'est pour cela que la spécification de tableau_neuf ci-dessus dit « l'appelant devra le libérer ».
Méthode : Un tableau à deux dimensions de taille dynamique : la linéarisation
L'annexe demande la « linéarisation de tels tableaux quand ils sont multidimensionnels ». Plutôt qu'un tableau de pointeurs vers des lignes, on alloue un seul bloc de cases et on calcule l'indice à la main :
/* Matrice l x c allouée d'un seul tenant. L'élément (i, j) est à l'indice i*c + j. */
double* m = malloc(l * c * sizeof(double));
assert(m != NULL);
m[i * c + j] = 3.14; /* au lieu de m[i][j] */
free(m);
Un seul malloc, un seul free, et les données contiguës en mémoire — donc lues plus vite.
1.8.3 Pointeurs et structures récursives
Une structure ne peut pas se contenir elle-même : sa taille serait infinie. Elle peut en revanche contenir un pointeur vers une structure de même type — un pointeur, lui, a toujours la même taille. C'est ainsi que naissent les listes chaînées, les arbres, les graphes.
struct maillon_s {
int valeur;
struct maillon_s* suivant; /* un POINTEUR vers un maillon : autorisé */
};
typedef struct maillon_s maillon;
La chaîne s'arrête là où suivant vaut NULL. On construira ces structures au chapitre chap:sequentielles.
1.9 Entrées et sorties élémentaires
int n = 42;
double x = 1.5;
printf("n vaut %d et x vaut %f\n", n, x);
scanf("%d", &n); /* noter le & : scanf a besoin de l'ADRESSE pour écrire */
L'annexe précise que « la syntaxe des chaînes de format n'est pas exigible » : on n'apprend pas la liste des %. Les trois flux standard sont en revanche au programme : stdin (entrée), stdout (sortie), stderr (erreurs).
1.10 Éléments utilisables après rappel
L'annexe A.2 liste ce qui doit pouvoir être employé à condition d'un rappel et d'une documentation. On ne l'apprend donc pas par cœur, mais on doit le reconnaître.
- En-têtes idempotents :
#define,#ifndef,#endif, pour qu'un fichier.hinclus deux fois ne pose problème qu'une. - Arguments de la ligne de commande :
int main(int argc, char* argv[]). - Conversion :
atoi, d'une chaîne vers un entier. - Initialisateurs : de tableau
{t0, t1, ...}, de structure{.x = 1.0, .y = 2.0}. - Compilation séparée de plusieurs fichiers
.c. - Fichiers :
fopen(modesretw),fclose,fscanf,fprintf. Chapitre chap:memoire. - Fils d'exécution :
pthread.h,pthread_t,pthread_create,pthread_join. Chapitre chap:concurrence. - Exclusion mutuelle :
pthread_mutex_tet ses trois opérations ; sémaphores :semaphore.h,sem_t,sem_init,sem_wait,sem_post,sem_destroy.
1.11 Ce qu'il faut retenir
- Les types sont déclarés et fixes. Le compilateur les vérifie — c'est une aide, pas une contrainte.
- Les entiers débordent en silence. sur un octet, sans message.
- Les accès hors tableau ne sont pas vérifiés. Un tableau voyage toujours avec sa taille.
- Le passage est par valeur. Pour modifier chez l'appelant, il faut un pointeur.
- La mémoire du tas se rend à la main. Qui alloue documente qui libère.
Ces cinq points ne sont pas des défauts du langage : ce sont les endroits où C vous montre la machine. Le chapitre suivant présente OCaml, qui fait les cinq choix inverses — et c'est en les comparant qu'on comprend ce que chacun coûte et rapporte.