Introduction
En programmation orientée par objets, le secret permettant de créer des classes robustes et le plus possible réutilisables possible est de s’assurer de garder une grande rigueur dans la programmation de ces classes. Cette rigueur passe par plusieurs éléments comme s’assurer de bien comprendre le code de nos classes, bien documenter ces dernières et respecter un standard stable qui permet de garantir au maximum la clarté du code.
L’élément que le « design » par contrats mets de l’avant est de s’assurer que le code que nous faisons soit bien compris (autant par le créateur de la classe que pour l’utilisateur de la classe). En fait, les contrats permettent de s’assurer que l’on comprend, en plus de ce que le code fait, ce que le code ne fait pas. Ils permettent de mettre en évidence les limites du code de nos classes. De plus, les contrats permettent de s’assurer que les utilisateurs de nos classes (que ce soit nous-mêmes ou d’autres programmeurs) comprennent le mieux possible les mécanismes de ces classes.
Qu’est-ce qu’un contrat
La définition d’un contrat est très simple. C’est un indicatif permettant de savoir certains éléments qui doivent toujours être vrai dans un contexte précis de la classe. Pour ceux qui ont déjà utilisé ce type de mécanisme de langage, un contrat est en fait un type structuré d’assertion de code (plus d’informations sur les assertions de code ici).
Pour donner une description plus complète, un contrat peut être défini par tous les éléments suivants:
-
- Indications permettant de définir ce qui doit être vrai à un certain moment dans le code;
- Permet de documenter le code;
- Permet au compilateur et au débogueur de savoir l’intention du développeur;
- Permet de diriger les tests.
L’utilité des contrats est, entre autres, de:
-
- Diminuer les bogues;
- Faciliter la réutilisation de code;
- Documenter les classes;
- Augmenter l’efficacité des tests;
- Faciliter la maintenance du logiciel;
- etc.
De plus, un des gros avantages de l’approche par contrats est qu’il permet de palier à certaines problématiques d’autres langages ont au niveau du principe objets. Par exemple, les langages basés sur C++ (incluant Java et C#) protègent l’intégrité interne des classes en utilisant la portée privée des méthodes et des attributs. Comme nous l’avons vu dans une théorie précédente, la portée privée va à l’encontre du principe objet puisque les enfants des classes n’ont pas accès à l’entièreté des méthodes et attributs que ces classes parentes ont accès. Les contrats permettent une robustesse des classes similaires à la portée privée (en fait meilleur en général), mais tout en respectant parfaitement le principe objets, puisque les contrats des parents s’appliquent toujours à l’entièreté des enfants.
Il y a plusieurs sortes de contrats. Ceux qui nous intéresseront dans ce cours sont les trois (3) types de contrats suivants: les préconditions, les postconditions et les invariants.
Les préconditions
Une précondition est un contrat ajouté à une routine qui permet de valider l’état de la classe avant l’exécution de la routine ou de valider les arguments de la cette dernière afin de s’assurer qu’elle s’exécute correctement. On note les préconditions dans la clause « require » d’une méthode. Voici un exemple utilisant des préconditions.
make(a_heure, a_minute, a_seconde:NATURAL_8)
-- Initialisation de `Current' utilisant `a_heure' comme `heure',
-- `a_minute' comme `minute' et `a_seconde' comme `seconde'
require
Heure_Valide: a_heure < 24
Minute_Valide: a_minute < 60
Seconde_Valide: a_seconde < 60
do
set_heure(a_heure)
set_minute(a_minute)
set_seconde(a_seconde)
end
Prendre note que pour cet exemple, je n’ai pas mis les postconditions qui devraient normalement être faites dans cette méthode.
On voit que cette méthode nécessite que le client de la classe envoie un argument a_heure
inférieur à 24 ainsi que des arguments a_minute
et a_seconde
inférieur à 60 (puisque ce sont des NATURAL
, les clients ne peuvent pas envoyer de sombre négatif en argument).
En plus de vérifier que les informations sont correctes lors du « débuguage » du système, les préconditions permettent aux clients de la classe de savoir ce qui devrait être testé afin d’utiliser la méthode. Par exemple, dans l’exemple précédent, le client de la classe devrait s’assurer que les préconditions sont respectés en lançant la classe:
if l_heure < 24 and l_minute < 60 and l_seconde < 60 then
create l_temps.make (l_heure, l_minute, l_seconde)
else
print("Une valeur n'est pas valide.%N")
end
Note sur l’héritage
Il est à noter qu’il est possible de mettre une précondition à une méthode qui est redéfinie. Par contre, au lieu d’utiliser la clause « require », il faut utiliser la clause « require else ». Il est également à noter que la méthode pourra être exécutée à l’instant ou les préconditions d’une des classes (enfants ou parents) sont valides. En d’autres mots, c’est un « OU » qui est fait entre les préconditions des différentes classes de l’héritage. Par exemple, supposons que je voulais faire une nouvelle classe qui ne limite pas l’heure à 24 heures. Voici comment la classe pourrait être écrite:
class
TEMPS_SANS_FIN
inherit
TEMPS
redefine
make, set_heure
end
create
make
feature {NONE} -- Initialisation
make(a_heure, a_minute, a_seconde:NATURAL_8)
-- <Precursor>
require else
Minute_Valide: a_minute < 60
Seconde_Valide: a_seconde < 60
do
Precursor(a_heure, a_minute, a_seconde)
end
feature -- Access
set_heure(a_heure:NATURAL_8)
-- <Precursor>
require else
True
do
Precursor(a_heure)
end
end
Le « require else True » de la méthode set_heure
permet en fait d’enlever complètement les préconditions de tous les parents.
Les postconditions
Les postconditions d’une méthode permettent de s’assurer que la méthode en question a effectué son travail correctement. Normalement, dans une méthode correctement écrite, si les préconditions de la méthode passent sans erreur, la méthode devrait se terminer correctement et les postconditions devraient être vraies, peu importe les arguments envoyés et l’état d’origine de l’objet. Les postconditions sont placées dans la clause « ensure » de la méthode. Voici la méthode précédente avec les postconditions:
make(a_heure, a_minute, a_seconde:NATURAL_8)
-- Initialisation de `Current' utilisant `a_heure' comme `heure',
-- `a_minute' comme `minute' et `a_seconde' comme `seconde'
require
Heure_Valide: a_heure < 24
Minute_Valide: a_minute < 60
Seconde_Valide: a_seconde < 60
do
set_heure(a_heure)
set_minute(a_minute)
set_seconde(a_seconde)
ensure
Heure_Assigne: heure = a_heure
Minute_Assigne: minute = a_minute
seconde_Assigne: seconde = a_seconde
end
On voit ici que la méthode garantit que les trois (3) attributs (heure, minute et seconde) seront correctement assignés en fonction des valeurs des trois (3) arguments (a_heure, a_minute et a_seconde).
Du côté du client, il est intéressant de comprendre que la postcondition d’une méthode peut servir de validation pour les préconditions d’une autre méthode. Par exemple, dans l’exemple de la classe TEMPS
vu plus haut, puisque les postconditions garantissent que les attributs (heure, minute et seconde) sont corrects, il n’est pas nécessaire de valider les valeurs (avec un if
comme dans l’exemple dans la section sur les préconditions). Par exemple, on peut simplement faire le code suivant:
l_temps1.set_heure (l_temps2.heure)
en étant persuadé que la précondition de set_heure
(que l’heure est inférieur à 24) est valide. Lorsque les préconditions et les postconditions sont bien fait, cet effet « domino » (les postconditions valide les préconditions) arrivent beaucoup.
On peut utiliser le mot clé old
pour aller chercher la valeur telle qu’elle était avant l’utilisation de la méthode. Par exemple:
ajouter(a_element:like element)
deferred
ensure
Element_Ajoute: nombre_elements = old nombre_elements + 1
end
Note sur l’héritage
Il est à noter qu’il est possible de mettre une postcondition à une méthode qui est redéfinie. Par contre, au lieu d’utiliser la clause « ensure », il faut utiliser la clause « ensure then ». Il est également à noter que la méthode devra respecter l’entièreté des postcondition des classes de l’arbre d’héritage (enfants ou parents). En d’autres mots, c’est un « ET » qui est fait entre les postconditions des différentes classes de l’héritage. Par exemple:
ajouter(a_element:like element)
deferred
ensure then
Element_Valide: dernier_element = a_element
end
Invariants de classe
Les invariants de classes permettent d’indiquer ce qui doit toujours être vrai dans la classe en cours. Les invariants de classes se trouvent toujours à la fin de la classe, dans la clause « invariant », avant le « end » final de la classe.
Voici la classe de TEMPS
complet avec les invariants à la fin:
note
description: "Représente un temps (heure, minute et seconde) d'une journée"
author: "Louis Marchand"
date: "Fri, 27 Nov 2020 19:22:47 +0000"
revision: "0.1"
class
TEMPS
create
make
feature {NONE} -- Initialisation
make(a_heure, a_minute, a_seconde:NATURAL_8)
-- Initialisation de `Current' utilisant `a_heure' comme `heure',
-- `a_minute' comme `minute' et `a_seconde' comme `seconde'.
require
Heure_Valide: a_heure < 24
Minute_Valide: a_minute < 60
Seconde_Valide: a_seconde < 60
do
set_heure(a_heure)
set_minute(a_minute)
set_seconde(a_seconde)
ensure
Heure_Assigne: heure = a_heure
Minute_Assigne: minute = a_minute
seconde_Assigne: seconde = a_seconde
end
feature -- Accès
heure:NATURAL_8
-- Le nombre d'heures s'étant déroulés depuis minuit.
set_heure(a_heure:NATURAL_8)
-- Assigne `heure' avec la valeur de `a_heure'.
require
Heure_Valide: a_heure < 24
do
heure := a_heure
ensure
Heure_Assigne: heure = a_heure
Minute_Inchange: minute = old minute
Seconde_Inchange: seconde = old seconde
end
minute:NATURAL_8
-- Le nombre de minutes s'étant déroulés depuis le début de l'heure.
set_minute(a_minute:NATURAL_8)
-- Assigne `minute' avec la valeur de `a_minute'.
require
Minute_Valide: a_minute < 60
do
minute := a_minute
ensure
Heure_Inchange: heure = old heure
Minute_Assigne: minute = a_minute
Seconde_Inchange: seconde = old seconde
end
seconde:NATURAL_8
-- Le nombre de secondes s'étant déroulés depuis le début de la minute.
set_seconde(a_seconde:NATURAL_8)
-- Assigne `seconde' avec la valeur de `a_seconde'.
require
Seconde_Valide: a_seconde < 60
do
seconde := a_seconde
ensure
Heure_Inchange: heure = old heure
Minute_Inchange: minute = old minute
Seconde_Assigne: seconde = a_seconde
end
invariant
Heure_Valide: heure < 24
Minute_Valide: minute < 60
Seconde_Valide: seconde < 60
end
Comment bien lire les contrats
Pour écrire de bons contrats efficaces, il est important de bien comprendre comment lire correctement ces contrats.
La meilleure façon de comprendre les contrats dans un contexte de programmation orientée objet est de se le représenter comme étant une transaction entre un client et un fournisseur.
-
- Le client a certaines obligations envers le fournisseur.
- Par exemple, le client doit payer pour les services offerts par le fournisseur.
- En programmation, les obligations du client sont inscrites dans les préconditions.
- Le fournisseur a également certaines obligations.
- Par exemple, le fournisseur doit offrir les services requis.
- En programmation, les obligations du fournisseur sont inscrites dans les postconditions.
- Dans tous les cas, autant le client que le fournisseur doivent respecter les lois en vigueur.
- En programmation, les lois sont les invariants de classe.
- Le client a certaines obligations envers le fournisseur.
Les autres types de contrats
Eiffel permet d’autres types de contrats qui ne seront pas évalués dans ce cours. Je vais faire ici un petit survol de ces contrats.
Les assertions simples
Les assertions simples permettent de valider un élément du code, en temps réel. C’est exactement la même chose que les assertions que nous pouvons trouver dans la majorité des langages. L’assertion simple se place dans une clause « check ». Par exemple:
l_valeur := get_valeur
check Valeur_Non_Null: l_valeur /= 0 end
Result := 10 / l_valeur
Il est à noter qu’on peut utiliser l’assertion simple sous la forme d’un « if ». Par exemple:
l_valeur := get_valeur
check Valeur_Non_Null: l_valeur /= 0 then
Result := 10 / l_valeur
end
Les variants de boucle
Un variant de boucle est un contrat qui permet de s’assurer qu’une boucle se terminera correctement. En fait, ça permet de s’assurer qu’une boucle n’est pas sans fin. Un variant de boucle est une instruction qui retourne un entier. Cet entier doit toujours diminuer, sans jamais atteindre la valeur 0. On place cette instruction dans une clause « variant », après le corps de la boucle.
Pour donner les exemples de variant et d’invariant de boucle, j’utiliserai la boucle suivante:
division_entiere(a_dividende, a_diviseur:INTEGER):INTEGER
-- Effectue une division entière entre `a_dividende' et `a_diviseur'
local
l_reste:INTEGER
do
Result := 0
from
l_reste := a_dividende
until
l_reste < a_diviseur
loop
l_reste := l_reste - a_diviseur
Result := Result + 1
end
end
Si j’exécute cette méthode comme ceci:
print("Le resultat: " + division_entiere(10, 3).out + "%N")
Ça donne la réponse prévue (c’est-à-dire: 3). Mais si j’utilise ceci:
print("Le resultat: " + division_entiere(10, 0).out + "%N")
J’obtiens une boucle sans fin. Si j’avais fait un variant de boucle comme ceci:
division_entiere(a_dividende, a_diviseur:INTEGER):INTEGER
-- Effectue une division entière entre `a_dividende' et `a_diviseur'
local
l_reste:INTEGER
do
Result := 0
from
l_reste := a_dividende
until
l_reste < a_diviseur
loop
l_reste := l_reste - a_diviseur
Result := Result + 1
variant
Reste_Diminue: l_reste
end
end</s
Le déboguer m’informerait dès la fin de la première utilisation du corps de la boucle puisque la variable l_reste
n’a pas été modifiée par rapport au début de la boucle.
Le variant de boucle, dans ce cas, nous montre qu’il manque une préconditon à la fonction. Voici la méthode avec la précondition.
division_entiere(a_dividende, a_diviseur:INTEGER):INTEGER
-- Effectue une division entière entre `a_dividende' et `a_diviseur'
require
Diviseur_Non_Null: a_diviseur /= 0
local
l_reste:INTEGER
do
Result := 0
from
l_reste := a_dividende
until
l_reste < a_diviseur
loop
l_reste := l_reste - a_diviseur
Result := Result + 1
variant
Reste_Diminue: l_reste
end
end
Invariant de boucle
Un invariant de boucle permet de s’assurer que les valeurs générées dans la boucle sont toujours valides. Ce type de contrat est généralement très difficile à faire et nécessite généralement des notions mathématiques assez avancées. L’invariant de boucle est une instruction qui retourne une valeur booléenne et qui doit toujours retourner vraie, avant l’exécution de toutes les itérations de la boucle. On place l’invariant de boucle dans la clause « invariant » avant la condition de la boucle (avant le « until »). Voici un exemple en utilisant la division entière vue plus haut:
division_entiere(a_dividende, a_diviseur:INTEGER):INTEGER
-- Effectue une division entière entre `a_dividende' et `a_diviseur'
require
Diviseur_Non_Null: a_diviseur /= 0
local
l_reste:INTEGER
do
Result := 0
from
l_reste := a_dividende
invariant
Reversible: a_dividende = Result * a_diviseur + l_reste
until
l_reste < a_diviseur
loop
l_reste := l_reste - a_diviseur
Result := Result + 1
variant
Reste_Diminue: l_reste
end
end
Les contrats dans les autres langages
Eiffel est le premier langage à avoir implémenté les contrats dans le langage. Il est à noter que depuis, plusieurs langages comme C#, Ada, Closure, Kotlin ou Scala ont intégré directement les contrats dans leurs langages. Par contre, d’autres langages peuvent utiliser des librairies logicielles externes afin d’ajouter au langage un système de contrat. Parmi eux, nous pouvons nommer Java, JavaScript, C, C++, Go, Python, PHP, etc.
La grosse problématique dans ces derniers langages, c’est que souvent, les librairies de base ne sont pas adaptées à l’utilisation de contrat. En effet, pour pouvoir utiliser au maximum les contrats, il faut s’assurer que les fonctions que nous utilisons sont pures. Par contre, certaines méthodes dans les librairies de bases de ces langages ne sont pas pures. Par exemple: en Java, il est impossible de faire un contrat en utilisant le prochain élément dans un Iterator.
Auteur: Louis Marchand
Sauf pour les sections spécifiées autrement, ce travail est sous licence Creative Commons Attribution 4.0 International.