Lorsqu'un donneur d'ordre (un client, un chef, un enseignant...) vous
demande d'écrire un logiciel, il vous fournit un
cahier des charges.
Par exemple, un client vous demande de pouvoir calculer l'aire d'un disque.
La spécification consiste à décrire, le plus précisément possible, la
fonctionnalité du futur logiciel (ou d'un composant d'un futur logiciel):
on doit dire ce que le logiciel doit faire (le
quoi).
Après cette
phase indispensable de spécification, on devra écrire le programme (on
dira que l'on
implante le logiciel). Pour cela, on se demandera
comment on peut faire faire à l'ordinateur la tâche attendue,
celle qui est définie par la spécification.
On spécifie un problème -- tout au moins pour le type de
problèmes que nous traiterons dans ce cours -- en répondant le plus
précisément possible aux trois questions suivantes:
-
Quelles sont les données?
- Quel est le résultat?
- Quelles propriétés relient les données et les résultats?
Exemple:
-
données: un nombre
- résultat: un nombre
- description: rend l'aire du disque de rayon le nombre donné
En informatique, on a l'habitude de scinder la spécification en deux
parties: l'interface et la sémantique. Prenons une métaphore pour
expliquer ces deux notions: définissons un mot de la langue française
qui nous était jusqu'alors inconnu, par exemple le mot « lapin ». Dans
un premier temps, on peut dire que c'est un nom commun masculin. Nous
pouvons alors écrire des phrases comme « le chasseur tue le lapin » ou
« ce matin, un lapin a tué un chasseur ». Ce sont deux phrases qui
sont parfaitement correctes en français, elles sont
syntaxiquement correctes. Mais la seconde phrase choque celui
qui connaît le
sens du mot « lapin »: la définition du
mot doit aussi indiquer quel est son sens (« Petit mammifère
(lagomorphes) à longues oreilles... »).
De même, la spécification d'un composant logiciel doit être vue sous
deux points de vue:
-
Un point de vue syntaxique: on doit dire comment on devra
l'utiliser afin d'avoir des phrases correctes par rapport à la
syntaxe du langage (même si ces phrases ne veulent rien dire),
autrement dit afin que le compilateur ne trouve pas d'erreur
lorsqu'il compile le composant qui l'utilise. Notons que cela dépend
du langage utilisé. Cette partie syntaxique de la spécification est
appelée l'interface du composant.
- Un point de vue sémantique: le programmeur doit connaître le
sens de ce nouveau « mot » afin que les logiciels écrits à l'aide
de ce composant aient un sens, autrement dit qu'ils fassent bien ce
que le programmeur attendaient d'eux. Cette
partie sémantique de la spécification est appelée la
sémantique du composant.
Naturellement, pour que le programme s'exécute, on doit dire aussi
comment le composant logiciel doit être calculé. Un
composant
logiciel est donc un triptyque (les deux premiers points
constituant la spécification):
-
son interface, c'est-à-dire la partie syntaxique du composant,
- sa sémantique, c'est-à-dire ce qu'il doit faire,
- son implantation, c'est-à-dire comment il le fait.
La solution informatique d'un problème est un programme constitué, en
ce qui nous concerne, de fonctions. L'interface correspond alors à la
signature de la fonction.
Exemple trivial:
- interface (signature):
- nous nommerons aire-disque cette fonction
qui a un paramètre nombre et qui rend un nombre,
- sémantique:
- cette fonction rend la surface du disque de rayon
le nombre donné;
- implantation:
- une implantation triviale:
(define (aire-disque r)
(* 3.1416 r r)))
Noter que pour ce problème trivial, il y a peu d'autres implantations.
Il n'en est pas de même si l'on veut calculer l'aire d'une surface
plus compliquée. En règle générale, pour un problème donné, il y a de
nombreuses implantations possibles, ces implantations étant plus ou
moins efficaces. Noter aussi que l'on peut très bien utiliser un
composant logiciel -- à partir du moment où l'on connait son interface
et sa sémantique -- sans connaître son implantation. Il en va ainsi de
toutes les primitives du langage.
Rappelons que dans nos programmes Scheme, nous écrivons la
spécification des fonctions sous forme de commentaires -- avec trois
points-virgules -- placés avant la définition. Exemple:
;;;
;;;
(* 3.1416 r r))
Remarquer:
-
la première ligne correspond à l'interface: elle indique le nom
de la fonction puis le type des données et enfin le type du résultat,
- les commentaires présents dans les lignes suivantes correspondent à la sémantique.
Lorsqu'on utilise une fonction (
i.e. lorsqu'on écrit une
application de cette fonction), il faut
-
utiliser la signature (interface) donnée par la spécification de la fonction,
- utiliser la sémantique donnée par la spécification de la fonction.
En ce qui concerne l'utilisation de la sémantique, il faut tout
simplement considérer que la fonction rend -- exactement, sans se poser
d'autres questions -- ce qui est dit.
Nous avons déjà dit que l'utilisation, par le programmeur de sa
connaissance de la signature permettait d'éviter des erreurs de
compilation (pas de faute d'orthographe dans le nom de la fonction,
application ayant un bon nombre d'arguments). Mais il faut aussi
utiliser la connaissance, présente dans la signature, des types des
données et du résultat en effectuant une
vérification de
type.
Rappelons qu'un programme Scheme est une suite de définitions de fonctions et
d'expressions, l'interprète affichant les résultats de l'évaluation des
expressions dans l'environnement qui contient les définitions.
Notons que dans un programme réel, il y a beaucoup de définitions de
fonctions et une seule expression: nous n'effectuerons la vérification
de type que pour les définitions de fonctions.
Définition de fonction
Pour que la définition
;;; f :
a *
b ->
g
(define (f a b) exp)
soit correcte vis-à-vis du typage, il faut que
-
le nombre de variables de la fonction soit égal au
nombre de types des donnés de la signature,
- le type de l'expression exp soit g (le type qui a
été donné comme type du résultat dans la signature) lorsque le type
de a (resp. b) est a (resp. b) (les types
qui ont été donnés comme types des données dans la signature) .
Ainsi, pour vérifier qu'une définition de fonction est correcte
vis-à-vis des types, la question est de savoir si une expression (au
départ l'expression de la définition)
-
est bien d'un certain type (au départ le type du résultat de la
fonction)
- sachant que les variables (les variables de la fonction) sont
d'un certain type (le type des données de la fonction).
Expressions présentes dans une définition de fonction
Dans cette étude nous ne traiterons que deux sortes d'expressions, les
applications et les alternatives, et la vérification consiste donc à vérifier qu'une expression est de type
d:
- lorsque l'expression est une
application, elle est de la forme (g e1 e2) et pour qu'elle
soit de type d:
-
il faut que le nombre d'arguments de l'application soit égal au
nombre de types des donnés de g,
- il faut que le type du résultat de g (type qui est donné
par la signature de g) soit d,
- si le type des données de g est a1 * b1 (type
qui est donné par la signature de g), il faut que l'expression
e1 (resp. e2) soit de type a1 (resp.
b1).
- lorsque l'expression est une
alternative, elle est de la forme (if c e1 e2) et pour qu'elle
soit de type d, il faut que
-
c soit de type bool (prédicat),
- les expressions e1 et e2 soient de type d.
Exemple: considérons la définition suivante:
;;;
(define (max m n)
(if (< m n) n m))
pour vérifier qu'elle est bien typée, il faut:
-
vérifier l'en-tête de la définition de la fonction...
- vérifier le type de l'expression (qui doit être
Nombre lorsque m et n sont de type
Nombre):
c'est une alternative...
-
la condition est une application (qui doit être de type
bool ou h + #f)...
- la conséquence est la variable n qui est bien, par
hypothèse, de type Nombre,
- l'alternant est la variable m qui est bien, par
hypothèse, de type Nombre.
Toutes les fois que vous écrivez une définition de
fonction, vous devez vérifier son typage, cela évitera un très grand
nombre d'erreurs.
On peut déjà classer les erreurs en deux catégories:
-
les erreurs détectées par l'évaluateur,
- les erreurs non détectées par l'évaluateur.
Clairement, les erreurs qui posent le plus de problèmes sont les
erreurs qui ne sont pas détectées: le programme affiche (ou imprime)
un résultat, qui a l'air d'un résultat, et l'utilisateur s'en sert
comme tel, mais qui n'est pas le bon résultat. Le plus souvent ce sont
des erreurs de conception du programme (le remède étant de programmer
avec méthode, en particulier de toujours vérifier le typage des
définitions de fonctions), mais cela peut être dû aussi à une mauvaise
utilisation d'un logiciel mal écrit (nous reviendrons sur ce point
dans un instant).
Classons maintenant les erreurs détectées par l'évaluateur:
Erreurs détectées par l'évaluateur
-
lors de l'analyse de l'expression
-
erreurs de syntaxe
(define (f x))
define: malformed definition
- nom de fonction ou variable inconnue
(sqt 4)
reference to undefined identifier: sqt
- lors de l'évaluation de l'expression
-
erreur de type
(sqrt "toto")
sqrt: expects argument of type <number>; given "toto"
- résultat non défini
(/ 1 0)
/: division by zero
Lorsque nous écrivons «
;;; aire-disque: Nombre -> Nombre », nous indiquons à l'utilisateur de cette fonction qu'il faut que la
donnée de cette fonction soit un nombre. Lorsqu'il écrit une
application de cette fonction, un utilisateur doit alors
obligatoirement vérifier que les arguments sont bien des valeurs
numériques, sous peine d'avoir une erreur détectée lors de
l'évaluation, voire, pire, un résultat sans signification.
On peut aussi préciser le domaine de validité, ce que nous faisons en
écrivant la contrainte derrière le type et entre /. Par exemple, le
rayon d'un disque doit être positif ou nul:
;;;
Considérons maintenant le problème du calcul de l'aire d'une couronne.
La donnée est constituée par deux nombres positifs, le résultat est un
nombre (positif) égal à l'aire de la couronne de rayon extérieur le
premier nombre donné et de rayon intérieur le second nombre donné.
Mais la signature:
;;;
n'est pas complètement satisfaisante. En effet, pour que les données
aient un sens, il faut de plus que le rayon extérieur soit supérieur
ou égal au rayon intérieur. Que fait-on dans le cas contraire? Deux
solutions:
-
l'implantation de la fonction détecte l'erreur,
- l'implantation de la fonction ne détecte pas l'erreur.
Voyons tout d'abord la première implantation (la seconde est triviale):
(define (aire-couronne r1 r2)
(if (< r1 r2)
(erreur 'aire-couronne
"rayon extérieur (" r1 ") <"
"rayon intérieur (" r2 ")")
(- (aire-disque r1) (aire-disque r2))))
et un exemple d'application:
(aire-couronne 5 7)
aire-couronne: ERREUR: rayon extérieur ( 5 ) < rayon intérieur ( 7 )
Noter la fonction (
erreur) utilisée pour signifier l'erreur:
-
elle a un nombre quelconque d'arguments, le premier argument
étant, par convention, le nom de la fonction où est détectée l'erreur précédé d'une
apostrophe;
- elle affiche le nom de la fonction, puis deux points, puis
« ERREUR » et enfin les autres arguments;
- en plus, son évaluation termine toute l'évaluation en cours.
Nous disposons aussi d'une fonction (
erreur?) qui teste si une
fonction, appliquée à des arguments donnés, provoque une erreur.
Par exemple:
(erreur? aire-couronne 5 3) ->
#F
(erreur? aire-couronne 5 7) ->
#T
Pour signifier à l'utilisateur que cette fonction signifie une erreur
lorsque r1 est inférieur à r2, nous l'indiquons après la signature:
;;;
;;;
;;;
(define (aire-couronne r1 r2)
(if (< r1 r2)
(erreur 'aire-couronne
"rayon extérieur (" r1 ") <"
"rayon intérieur (" r2 ")")
(- (aire-disque r1) (aire-disque r2))))
Noter que la détection de l'erreur a un certain coût en temps alors
qu'il se peut que, lors de l'utilisation de la fonction, nous soyons
sûrs qu'elle est utilisée dans de bonnes conditions (parce que les
arguments sont calculés par programme, programme tel que...). Il est
alors dommage de perdre du temps d'ordinateur pour rien. Dans ce cas,
l'implantation ne fait pas la vérification. Il est alors indispensable
--- car on est dans le cas où le programme rend un résultat, celui-ci
n'ayant pas de sens --- de l'indiquer aux utilisateurs de cette
fonction:
;;;
;;;
;;;
;;;
(define (aire-couronne-sans r1 r2)
(- (aire-disque r1) (aire-disque r2)))
En résumé, lorsqu'on utilise une fonction, on doit suivre la
spécification, sachant que
-
il faut que les arguments soient du type précisé (et, a priori,
on ne connait pas le comportement de la fonction dans le cas
contraire);
- il faut que les arguments vérifient les hypothèses et ne
vérifient pas les cas d'erreurs, sachant que
-
une erreur est signalée dans le second cas,
- aucune erreur n'est signalée dans le premier cas.