IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)

Cours complet pour apprendre à programmer en D


précédentsommairesuivant

46. Programmation par contrat

La programmation par contrat est une approche en conception logicielle qui traite les parties d'un logiciel comme des entités individuelles qui se rendent mutuellement des services. Cette approche considère que le programme peut fonctionner en accord avec sa spécification tant que le fournisseur et le consommateur du service obéissent tous deux à un contrat.

Les fonctionnalités du D en matière de programmation par contrat considèrent les fonctions comme les unités de services du logiciel. Comme les tests unitaires, la programmation par contrat est basée sur les assertions.

La programmation par contrat en D est implémentée avec trois types de blocs de code :

  • les blocs in des fonctions ;
  • les blocs out des fonctions ;
  • les blocs invariant des structures et des classes.

Nous verrons les blocs invariant et l'héritage par contrat dans un chapitre ultérieur, après avoir couvert les structures et les classes.

46-1. Les blocs in pour les conditions d'entrée

L'exécution correcte des fonctions dépend habituellement de la correction des valeurs de leurs paramètres. Par exemple, une fonction racine carrée peut avoir besoin que son paramètre ne soit pas négatif. Une fonction qui traite des dates peut avoir besoin que le numéro de mois soit compris entre 1 et 12.

Nous avons déjà vu de telles vérifications dans le chapitre sur assert et enforceassert et enforce. Les conditions sur les valeurs des paramètres peuvent être forcées par des assertions à l'intérieur des définitions de fonctions :

 
Sélectionnez
string momentVersChaine(in int heure, in int minute)
{
    assert((heure >= 0) && (heure <= 23));
    assert((minute >= 0) && (minute <= 59));
 
    return format("%02s:%02s", heure, minute);
}

En programmation par contrat, les mêmes vérifications sont écrites dans les blocs in des fonctions. Quand un bloc in ou out est utilisé, le contenu réel de la fonction doit être placé dans un bloc body :

 
Sélectionnez
import std.stdio;
import std.string;
 
string momentVersChaine(in int heure, in int minute)
in
{
    assert((heure >= 0) && (heure <= 23));
    assert((minute >= 0) && (minute <= 59));
}
body
{
    return format("%02s:%02s", heure, minute);
}
 
void main()
{
    writeln(momentVersChaine(12, 34));
}

Un avantage d'un bloc in est que toutes les préconditions sont rassemblées et séparées du corps de la fonction elle-même. De cette manière, le corps de la fonction est libre de toute assertion concernant les préconditions. Au besoin, il est toujours possible et même conseillé d'avoir d'autres assertions dans le corps de la fonction qui ne correspondent pas à des préconditions et qui protégeraient des erreurs de programmation dans le corps de la fonction.

Le code qui est à l'intérieur du bloc in est exécuté automatiquement à chaque fois que la fonction est appelée. L'exécution de la fonction elle-même ne commence que si toutes les assertions du bloc in passent. Cela prévient l'exécution de la fonction avec des préconditions non respectées et, par conséquent, évite de produire des résultats incorrects.

Toute assertion qui échoue dans le bloc in indique que le contrat a été violé par l'appelant.

46-2. Les blocs out pour les postconditions

L'autre côté du contrat implique les garanties que la fonction donne. Un exemple de fonction avec une postcondition serait une fonction qui retourne le nombre de jours en février ; la valeur retournée sera toujours 28 ou 29.

Les postconditions sont vérifiées dans les blocs out des fonctions.

La valeur qu'une fonction retourne avec l'instruction return n'a pas besoin d'être définie comme une variable à l'intérieur la fonction, il n'y a habituellement pas de nom faisant référence à cette valeur de retour. Ceci peut être problématique parce que les assertions dans le bloc out ne peuvent pas utiliser la valeur de retour par son nom.

D propose une solution à ce problème en donnant un moyen de nommer la valeur de retour juste après le mot-clé out. Ce nom représente précisément la valeur que la fonction est en train de retourner :

 
Sélectionnez
int joursEnFevrier(in int annee)
out (resultat)
{
    assert((resultat == 28) || (resultat == 29));
}
body
{
    return estAnneeBissextile(annee) ? 29 : 28;
}

Même si resultat est un nom raisonnable pour une valeur de retour, d'autres noms parfaitement valides peuvent aussi être utilisés.

Certaines fonctions n'ont pas de valeur de retour ou la valeur de retour n'a pas besoin d'être vérifiée. Dans ce cas, le bloc out n'oblige pas à spécifier un nom :

 
Sélectionnez
out
{
    // ...
}

De manière similaire aux blocs in, les blocs out sont exécutés automatiquement après l'exécution du corps de la fonction.

Une assertion qui échoue dans un bloc out indique que le contrat a été violé par la fonction.

Évidemment, les blocs in et out sont optionnels. En incluant les blocs unittest, qui sont également optionnels, les fonctions D peuvent être constituées d'un nombre de blocs pouvant aller jusqu'à quatre :

  • in : optionnel ;
  • out : optionnel ;
  • body : obligatoire, mais le mot-clé body peut être omis s'il n'y a pas de bloc in ni de bloc out ;
  • unittest : optionnel et ne fait techniquement pas partie de la définition de la fonction, mais est couramment défini juste après la fonction.

Voici un exemple qui utilise tous ces blocs :

 
Sélectionnez
import std.stdio;
 
/*
 * Distribue la somme entre deux variables.
 *
 * Distribue d'abord à la première variable, mais ne lui donne
 * jamais plus de 7. Le reste de la somme est distribué
 * à la seconde variable.
 */
void distribuer(in int somme, out int premiere, out int seconde)
in
{
    assert(somme >= 0);
}
out
{
    assert(somme == (premiere + seconde));
}
body
{
    premiere = (somme >= 7) ? 7 : somme;
    seconde = somme - premiere;
}
 
unittest
{
    int premiere;
    int seconde;
 
    // Les deux doivent valoir 0 si la somme vaut 0
    distribuer(0, premiere, seconde);
    assert(premiere == 0);
    assert(seconde == 0);
 
    // Si la somme est plus petite que 7, tout doit être donné
    // à premiere
    distribuer(3, premiere, seconde);
    assert(premiere == 3);
    assert(seconde == 0);
 
    // Test d'une condition limite
    distribuer(7, premiere, seconde);
    assert(premiere == 7);
    assert(seconde == 0);
 
    // Si la somme est plus grande que 7, la première doit recevoir 7
    // et le reste doit être donné à la seconde
    distribuer(8, premiere, seconde);
    assert(premiere == 7);
    assert(seconde == 1);
 
    // Une grande valeur quelconque
    distribuer(1_000_007, premiere, seconde);
    assert(premiere == 7);
    assert(seconde == 1_000_000);
}
 
void main()
{
    int premiere;
    int seconde;
 
    distribuer(123, premiere, seconde);
    writeln("premiere : ", premiere, ", seconde : ", seconde);
}

Le programme peut être compilé et exécuté dans la console avec la commande suivante :

 
Sélectionnez
$ dmd essai.d -w -unittest // ou : gdc -Wall -funittest essai.d -o essai
$ ./essai
premiere : 7, seconde : 116

Même si le travail de la fonction lui-même consiste en seulement deux lignes, il y a un total de 19 lignes non triviales qui prennent en charge sa fonctionnalité. On peut discuter du fait qu'autant de code en plus est superflu pour une si petite fonction. Cependant, les bogues ne sont jamais intentionnels. Le programmeur écrit toujours du code qui devrait fonctionner correctement, et qui finit souvent par contenir divers types de bogues.

Quand les attentes sont explicitement spécifiées dans les tests unitaires et dans les contrats, les fonctions qui sont initialement correctes ont plus de chances de rester correctes. Je vous recommande de tirer parti de toutes les fonctionnalités qui améliorent la correction des programmes. Les tests unitaires comme les contrats sont des outils efficaces pour cela. Ils aident à réduire le temps passé à déboguer (au profit du temps passé à écrire le code).

46-3. Désactiver la programmation par contrat

Contrairement au test unitaire, la programmation par contrat est activée par défaut. L'option -release de dmd et les options -fno-in -fno-out -fno-invariants ou -frelease de gdc désactivent la programmation par contrat :

 
Sélectionnez
dmd essai.d -w -release // ou gdc essai.d -Wall -frelease essai

Quand la programmation par contrat est désactivée, le contenu des blocs in, out et invariant est ignoré.

46-4. Blocs in versus enforce

Nous avons vu dans le chapitre sur assert et enforceassert et enforce qu'il est parfois difficile de choisir entre assert ou enforce pour faire des vérifications. De manière similaire, il est parfois difficile de choisir entre les assertions dans des blocs in et des vérifications avec enforce à l'intérieur du corps des fonctions.

La possibilité de désactiver la programmation par contrat est une indication que celle-ci est là pour protéger contre les erreurs de programmation. Pour cette raison, la décision devrait ici être basée sur les mêmes règles que ce que nous avons vu dans le chapitre sur assert et enforceassert et enforce :

  • si une vérification est là pour prévenir une erreur de programmation, alors elle devrait être dans le bloc in. Par exemple, si la fonction est appelée seulement depuis d'autres endroits du programme pour construire une fonctionnalité, les valeurs des paramètres sont entièrement sous la responsabilité du programmeur. Pour cette raison, les préconditions d'une telle fonction devraient être vérifiées dans son bloc in ;
  • si la fonction ne peut pas jouer son rôle pour n'importe quelle autre raison, des valeurs de paramètres incorrectes incluses, alors elle doit lever une exception et enforce est un moyen pratique de le faire.
    Pour voir un exemple de cela, définissons une fonction qui retourne une tranche du milieu d'une autre tranche. Supposons que cette fonction est là pour être utilisée par les utilisateurs du module, et non une fonction interne utilisée uniquement par le module lui-même. Comme les utilisateurs de ce module peuvent appeler cette fonction avec divers paramètres potentiellement incorrects, il peut être approprié de vérifier les valeurs des paramètres à chaque fois que la fonction est appelée. Il serait insuffisant de ne les vérifier que pendant le développement, après lequel les contrats peuvent être désactivés.
    Pour cette raison, la fonction suivante vérifie ses paramètres en appelant enforce dans le corps de la fonction au lieu d'une assertion dans le bloc in :

     
    Sélectionnez
    import std.exception;
     
    inout(int)[] milieu(inout(int)[] trancheOriginale, size_t largeur)
    out (resultat)
    {
        assert(resultat.length == largeur);
    }
    body
    {
        enforce(trancheOriginale.length >= largeur);
     
        immutable debut = (trancheOriginale.length - largeur) / 2;
        immutable fin = debut + largeur;
     
        return trancheOriginale[debut .. fin];
    }
     
    unittest
    {
        auto slice = [1, 2, 3, 4, 5];
     
        assert(milieu(slice, 3) == [2, 3, 4]);
        assert(milieu(slice, 2) == [2, 3]);
        assert(milieu(slice, 5) == slice);
    }
     
    void main()
    {}
  • Le problème se pose moins pour les blocs out. Comme la valeur de retour de toute fonction relève de la responsabilité du programmeur, les postconditions doivent toujours être vérifiées dans le bloc out. La fonction que l'on vient de voir suit cette règle ;

  • un autre critère à considérer lors du choix entre les blocs in et enforce est de se demander si la situation est rattrapable. Si elle est rattrapable par les couches de code de plus haut niveau, alors il peut être plus approprié de lever une exception (ce qu'enforce rend pratique).

46-5. Exercice

Écrivez un programme qui augmente le nombre total de points de deux équipes de foot selon le résultat d'un match.

Les deux premiers paramètres de cette fonction sont les buts que les deux équipes ont marqués. Les deux autres paramètres sont les points de chaque équipe avant le match. Cette fonction doit ajuster les points des équipes selon les buts qu'elles ont marqués. Pour rappel, l'équipe gagnante prend trois points et l'équipe perdante ne prend pas de point. Dans le cas d'un match nul, les deux équipes prennent un point chacune.

De plus, la fonction devrait indiquer l'équipe gagnante : 1 si la première équipe a gagné, 2 si la seconde équipe a gagné, 0 si le match a fini par un match nul.

Partez du programme suivant et complétez les quatre blocs de la fonction de façon appropriée. Ne supprimez pas les assertions dans la fonction main ; elles montrent comment cette fonction doit se comporter.

 
Sélectionnez
int ajouterPoints(in int buts1,
              in int buts2,
              ref int points1,
              ref int points2)
in
{
    // ...
}
out (resultat)
{
    // ...
}
body
{
    int gagnante;
 
    // ...
 
    return gagnante;
}
 
unittest
{
    // ...
}
 
void main()
{
    int points1 = 10;
    int points2 = 7;
    int gagnante;
 
    gagnante = ajouterPoints(3, 1, points1, points2);
    assert(points1 == 13);
    assert(points2 == 7);
    assert(gagnante == 1);
 
    gagnante = ajouterPoints(2, 2, points1, points2);
    assert(points1 == 14);
    assert(points2 == 8);
    assert(gagnante == 0);
}

Note : il peut être plus judicieux de retourner une valeur énumérée depuis cette fonction :

 
Sélectionnez
enum ResultatJeu
{
    premiereGagne, secondeGagne, nul
}
 
ResultatJeu ajouterPoints(in int buts1,
                           in int buts2,
                           ref int points1,
                           ref int points2)
// ...

J'ai choisi de retourner un int pour cet exercice, de cette manière, la valeur de retour peut être comparée aux valeurs 0, 1 et 2.

La solutionProgrammation par contrat - Correction.


précédentsommairesuivant