Adloun

Discipline de programmation, validation et test

Cours complet · informatique (MP2I/MPI), chapitre 4 · MP2I et MPI

Travailler ce chapitre sur Adloun Exercices corrigés de ce chapitre

4.1 Un chapitre qui ne se termine jamais

Les autres chapitres de ce livre s'ouvrent et se referment. Celui-ci s'ouvre et reste ouvert : le programme officiel marque ses deux sections S1 S2 S3-4, et précise qu'elles ont « vocation à être observée[s] dès le début et durant toute la durée des deux années d'enseignement ».

La raison est donnée sans détour : « 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 ». Un programme faux n'est pas seulement un programme faux ; c'est une porte. Ce chapitre fonde donc des habitudes, et chaque ligne de code du livre les appliquera.

4.2 Spécifier avant d'écrire

Définition 4.1Spécification

La spécification d'une fonction dit, sans dire comment :

  • sa signature : le type de chaque paramètre, le type du retour ;
  • sa précondition : ce que l'appelant garantit sur les entrées ;
  • sa postcondition : ce que la fonction garantit sur la sortie.

Le programme est catégorique : « les signatures des fonctions sont toujours précisées ».

Exemple 4.2La même spécification, dans les deux langages

/* Renvoie l'indice de la première occurrence de v dans les n premiers termes
   de t, ou -1 si v n'y figure pas.
   Précondition  : n >= 0, et t a au moins n cases.
   Postcondition : soit le résultat vaut -1 et v est absent de t[0..n-1],
                   soit 0 <= résultat < n et t[résultat] == v. */
int indice_de(const int t[], int n, int v);

(* indice_de t v renvoie Some i tel que t.(i) = v pour le plus petit tel i,
   et None si v ne figure pas dans t.
   Précondition : aucune. *)
val indice_de : 'a array -> 'a -> int option

Les deux versions décrivent le même contrat. Notez ce que le type OCaml dit tout seul : int option rend l'échec visible dans la signature, là où le de C doit être expliqué en français.

Bonne pratique (La spécification s'écrit avant le corps)

Une spécification écrite après coup décrit ce que le code fait ; une spécification écrite avant décide ce qu'il doit faire. La différence se voit à l'usage : la seconde attrape les cas limites — que renvoie-t-on sur un tableau vide ? sur une valeur en double ? — pendant qu'ils sont encore gratuits à traiter.

4.3 Annoter : précondition, postcondition, invariant

Définition 4.3L'annotation se fait en commentaire

Le programme demande l'« annotation d'un bloc d'instructions par une précondition, une postcondition, une propriété invariante », et précise : « ces annotations se font à l'aide de commentaires ».


/* Trie les n premiers termes de t par ordre croissant, en place.
   Précondition : n >= 0. */
void tri_selection(int t[], int n) {
    for (int i = 0; i < n - 1; i = i + 1) {
        /* INVARIANT : t[0..i-1] est trié, et tous ses termes sont
           inférieurs ou égaux à ceux de t[i..n-1]. */
        int mini = i;
        for (int j = i + 1; j < n; j = j + 1) {
            if (t[j] < t[mini]) { mini = j; }
        }
        int tmp = t[i]; t[i] = t[mini]; t[mini] = tmp;
    }
}

Bonne pratique (Un commentaire ne paraphrase pas le code)

« Les parties complexes de codes ou d'algorithmes font l'objet de commentaires qui l'éclairent en évitant la paraphrase », dit le programme. La règle se vérifie en supprimant le code : si le commentaire ne dit plus rien d'utile, il ne disait rien.


i = i + 1;     /* on incrémente i                      <- INUTILE : paraphrase */
i = i + 1;     /* on passe au candidat suivant         <- utile : l'INTENTION */

4.4 Programmation défensive

4.4.1 Assertions

Définition 4.4`assert`

Une assertion est une condition dont on affirme qu'elle est vraie à cet endroit. Si elle est fausse, le programme s'arrête immédiatement avec un message précis.


#include <assert.h>
assert(n >= 1 && t != NULL);

assert (Array.length t >= 1)

Le programme « encourage » leur usage « par exemple pour valider des entrées ou pour le contrôle de débordements ».

ImportantUne assertion échoue tôt, et c'est tout son intérêt

Sans assertion, une précondition violée produit un résultat faux qui se propage : on découvre le problème mille instructions plus loin, dans une fonction innocente. Avec elle, on l'apprend à l'endroit exact où le contrat a été rompu, et le message nomme le fichier et la ligne. Le temps gagné se compte en heures.

AttentionUne assertion n'est pas un traitement d'erreur

Une assertion dit « ceci ne peut pas arriver, et si cela arrive le programme est faux ». Elle sert à attraper les fautes du programmeur. Une donnée utilisateur erronée, un fichier absent, une saisie hors bornes ne sont pas des fautes du programmeur : ils se traitent, avec un message et un comportement défini — pas avec un assert.

4.4.2 Deux garanties de typage très inégales

Définition 4.5Typage faible, typage fort

Le programme demande de sensibiliser « à la différence de garanties apportées selon les langages, avec l'exemple d'un typage faible en C et fort en OCaml ».


int n = 3.9;          /* accepté : n vaut 3, la partie décimale est PERDUE */
char c = 300;         /* accepté : c vaut 44 */
char d = grand;       /* accepté, et SILENCIEUX : voir ci-dessous */
int* p = (int*) &c;   /* accepté : on lit 4 octets là où il y en a 1 */

let n : int = 3.9     (* ERREUR de compilation, et le message dit pourquoi *)

C convertit ; OCaml refuse.

Nuance mesurée. Sur une constante, C ne convertit pas tout à fait sans prévenir : gcc -Wall -Wextra signale les deux premières lignes — « implicit conversion from int to char changes value from 300 to 44 » et « from 3.9 to 3 ». Le compilateur voit la valeur, donc il voit la perte.

Le silence revient dès que la valeur vient d'une variable : char d = grand; ne produit aucun avertissement, car le compilateur ignore ce que grand contiendra. Le filet du compilateur s'arrête là où le calcul commence — et c'est précisément là que les débordements surviennent. D'où le partage du travail : en C, la vigilance est à la charge du programmeur et se matérialise en assertions ; en OCaml, une grande part est déléguée au compilateur — mais rien ne protège de l'arithmétique, ni d'une logique fausse.

4.4.3 Exceptions

Définition 4.6Signaler, et rattraper

exception Fichier_vide

let premiere_ligne nom =
  let f = open_in nom in
  try
    let l = input_line f in
    close_in f; l
  with End_of_file -> close_in f; raise Fichier_vide
iRemarqueLes exceptions ne servent pas qu'aux erreurs

Le programme le souligne : « on veille à ne pas laisser penser que les exceptions servent uniquement à gérer des erreurs ». Une exception est aussi une sortie anticipée : quitter d'un coup une récursion profonde dès que la réponse est connue, sans propager un drapeau à chaque niveau.


exception Trouve of int

(* Renvoie Some i pour un i tel que t.(i) = v, ou None. *)
let cherche t v =
  try
    Array.iteri (fun i x -> if x = v then raise (Trouve i)) t;
    None
  with Trouve i -> Some i

4.5 Tester

4.5.1 Un jeu de tests s'écrit à la main

Définition 4.7Jeu de tests

Un jeu de tests est une liste de couples (entrée, sortie attendue). Le programme précise le niveau exigé : « il n'est pas attendu de connaissances sur la génération automatique de jeux de tests ; un étudiant est capable d'écrire un jeu de tests à la main, donnant à la fois des entrées et les sorties correspondantes attendues ».


let tests_maximum () =
  assert (maximum [| 5 |] = 5);                (* cas minimal *)
  assert (maximum [| 1; 2; 3 |] = 3);          (* maximum en dernier *)
  assert (maximum [| 3; 2; 1 |] = 3);          (* maximum en premier *)
  assert (maximum [| 2; 3; 2 |] = 3);          (* maximum au milieu *)
  assert (maximum [| 7; 7 |] = 7);             (* doublons *)
  assert (maximum [| -5; -2 |] = -2);          (* que des négatifs *)
  print_string "maximum : tous les tests passent\n"
AttentionUn test qui passe ne prouve rien ; un test qui échoue prouve tout

Aucun jeu de tests fini ne démontre la correction d'un programme — c'est le rôle de l'invariant. Un test qui échoue, en revanche, établit sans appel qu'il y a un défaut. Les deux outils sont complémentaires : la preuve donne la certitude sur l'algorithme, le test attrape les fautes de transcription que la preuve, faite sur le papier, ne voit pas.

4.5.2 Partitionner les entrées, tester les limites

Méthode : Choisir ses cas de test

Le programme demande de sensibiliser « à la notion de partitionnement des domaines d'entrée et au test des limites ».

  • Partitionner : découper l'ensemble des entrées en classes dont on pense que le programme les traite de la même façon, et prendre un représentant par classe. Pour un tableau : vide, un élément, plusieurs ; trié, inversé, quelconque.
  • Tester les limites : aux frontières de chaque classe, et juste de part et d'autre. Si le domaine est , on essaie , , , , , .

Les fautes se logent aux frontières bien plus souvent qu'au milieu : c'est là que vivent les écrits .

4.5.3 Graphe de flot de contrôle et couverture

Définition 4.8Graphe de flot de contrôle

Le graphe de flot de contrôle d'un programme a pour sommets ses blocs d'instructions et pour arcs les passages possibles de l'un à l'autre. Un chemin de l'entrée à la sortie est faisable s'il existe une entrée qui le fait emprunter.

Exemple 4.9Le graphe d'une fonction à deux branches

int classe(int x) {          /* A */
    if (x < 0) {             /* B */
        return -1;           /* C */
    }
    if (x == 0) {            /* D */
        return 0;            /* E */
    }
    return 1;                /* F */
}

Trois chemins, tous faisables : emprunte , emprunte , emprunte . Ces trois entrées suffisent à couvrir tous les sommets et tous les arcs.

Définition 4.10Trois critères de couverture

Le programme demande de savoir écrire « un jeu de tests satisfaisant un critère de couverture des instructions (sommets) ou des branches (arcs) sur les chemins faisables ».

  • Couverture des sommets : chaque bloc est exécuté au moins une fois. Le plus faible.
  • Couverture des arcs : chaque transition est empruntée au moins une fois. Plus fort : il oblige à passer par le « sinon » d'un if sans else, que la couverture des sommets ignore.
  • Couverture des chemins : chaque chemin complet est parcouru. Le plus fort, et souvent impraticable : conditionnelles en séquence donnent chemins, et une boucle en donne une infinité — d'où la précision du programme, « avec ou sans cycle ».
AttentionSommets couverts n'implique pas arcs couverts

int f(int x) {
    int y = 0;
    if (x > 0) { y = 1; }      /* pas de else */
    return 10 / y;             /* division par zéro si x <= 0 ! */
}

La seule entrée exécute toutes les instructions : la couverture des sommets est atteinte à 100 %, et le programme paraît sain. L'arc « , on saute le corps » n'a jamais été emprunté. Il faut pour l'atteindre.

Et ce qu'on y trouve est pire qu'un plantage. Exécuté sur ce livre, f(-1) ne s'arrête pas : il rend compilé avec -O0, et compilé avec -O2 — code de sortie nul dans les deux cas. La division entière par zéro est un comportement indéfini : l'optimiseur en déduit que y ne peut pas valoir zéro, donc que la branche n'existe pas, et supprime la division. Le même programme rend deux résultats différents selon une option de ligne de commande, sans jamais signaler quoi que ce soit. Un plantage aurait été une chance.

4.5.4 Tester une condition composée, exhaustivement

Définition 4.11Toutes les façons de satisfaire une condition

Quand une condition « comporte des conjonctions ou disjonctions », le programme demande « de ne pas se contenter de la traiter comme étant globalement vraie ou fausse mais de formuler des tests qui réalisent toutes les possibilités de la satisfaire ».


if (a > 0 || b > 0) { ... }
conditionce que ce cas éprouve
VVvraieles deux à la fois
VFvraiele premier seul
FVvraiele second seul
FFfausseaucun

Un jeu de tests qui ne contiendrait que les lignes 1 et 4 satisfait la couverture des arcs — les deux branches sont prises — et laisse pourtant passer un &amp;&amp; écrit à la place du || : sur ces deux lignes, les deux opérateurs donnent le même résultat. Les lignes 2 et 3 les séparent.

iRemarqueL'évaluation paresseuse rend certains cas inatteignables

Dans if (i &lt; n &amp;&amp; t[i] == 0), le second membre n'est pas évalué quand le premier est faux. La ligne « et » du tableau n'existe donc pas : elle n'est pas faisable, et c'est précisément ce qui protège l'accès. Le programme limite d'ailleurs cet exercice « à des exemples simples pour lesquels les cas possibles se décèlent dès la lecture ».

4.6 Ce qu'il faut retenir

ImportantLa discipline, en six lignes
  • Toute fonction porte sa signature et sa spécification, écrites avant son corps.
  • Toute boucle non triviale porte son invariant, en commentaire, juste au-dessus.
  • Les préconditions se vérifient par assert ; les erreurs prévisibles se traitent.
  • Les commentaires disent l'intention, jamais ce que le code dit déjà.
  • Un jeu de tests partitionne les entrées et éprouve les limites.
  • On vise au minimum la couverture des arcs, et l'on éclate les conditions composées.

Aucune de ces six lignes ne fait fonctionner un programme. Toutes les six font qu'un programme qui ne fonctionne pas le dise tôt, et à l'endroit exact.

Continuer sur Adloun : animation, QCM, fiches, exercices