Le « design » par contrats

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.

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.

Retour


Auteur: Louis Marchand
Creative Commons License
Sauf pour les sections spécifiées autrement, ce travail est sous licence Creative Commons Attribution 4.0 International.