Adloun

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.

ImportantCe chapitre est une annexe du programme, et elle est limitative

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

Exemple 1.1Le programme minimal

#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 &lt;stdio.h&gt; amène les déclarations d'entrées-sorties. Sans elle, printf est inconnu.
  • int main(void) : l'exécution commence toujours par la fonction main, 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 0 rend un code de sortie au système : zéro veut dire « tout s'est bien passé ».

1.2.1 Compilé, et non interprété

Définition 1.2De la source à l'exécutable

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.

iRemarqueFichier d'interface, fichier d'implémentation

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

Définition 1.3Types entiers

C distingue les entiers signés et non signés, et fixe leur taille en bits. L'annexe retient :

SignésNon signésBits
`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*.

AttentionLe dépassement de capacité n'est pas une erreur : c'est un résultat faux

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.

Exemple 1.4La division entière tranche vers zéro

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

Définition 1.5Les trois autres types de base
  • 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 constantes true et false, les opérateurs !, &amp;&amp;, ||.
ImportantUn entier n'est pas un booléen

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.

Définition 1.6Évaluation paresseuse

&amp;&amp; et || n'évaluent leur second opérande que si c'est nécessaire : si le premier de &amp;&amp; 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

Définition 1.7Conditionnelle et boucles

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.

Exemple 1.8La même somme, trois fois

/* 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

Définition 1.9Déclaration, définition, appel

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.

AttentionLe passage est par valeur : une fonction ne peut pas modifier son argument

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

Définition 1.10Tableau statique

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 */
AttentionLe langage ne vérifie pas la licéité des accès

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

Définition 1.11Une chaîne est un tableau à sentinelle nulle

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).

Attention`strlen` coûte cher, et `strcpy` ne vérifie rien

strlen ne connaît pas la longueur : il la cherche, en parcourant jusqu'à la sentinelle. Écrire for (int i = 0; i &lt; 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

Définition 1.12Type structuré

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-&gt;y est exactement (*q).y, en plus lisible. L'organisation en mémoire des structures n'est pas à connaître.

Exemple 1.13Une structure rend une fonction honnête

/* 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

Définition 1.14Pointeur

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 */
Définition 1.15Le pointeur nul

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

Définition 1.16`malloc` et `free`

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.

AttentionTrois fautes, trois symptômes
FauteNomCe qu'on observe
Ne jamais appeler `free`fuite de mémoirele programme grossit sans fin
Appeler `free` deux foisdouble libérationplantage, parfois plus tard
Utiliser après `free`pointeur fouvaleurs 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

Définition 1.17Le pointeur permet la récursivité des types

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

Définition 1.18`printf` et `scanf`

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.

1.11 Ce qu'il faut retenir

ImportantLes cinq différences avec Python qui coûtent le plus cher
  • 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.

Continuer sur Adloun : animation, QCM, fiches, exercices