,
Licence d'informatique • Ecoles d'ingénieurs
PROGRAMMATION , RECURSIVE (EN SCHEME) AnneBrygoo Titou Durand Maryse Pelletier Christian aueinnec Michèle Soria
PROGRAMMATION , RECURSIVE (EN SCHEME)
Consultez nos catalogues sur le Web
li......
e .....
'
Rtchttcht
LI·- ·- -...:..:••::.•..:T.::".::'•:...·_-_-_..:•:JI
0
CCMI!Onl
--
~
,._
1 .........
........,.
$N\t·V~
jfWI'Ie,.,.
-~'""'*"'---c.l6k• .,_H, .Jaoi'l
o._
Pierre·..lün
www.dunod.com
PROGRAMMATION , RECURSIVE (EN SCHEME) Cours et exercices corrigés AnneBrygoo Maître de conférences à I'UPMC Titou Durand Maître de conférences à I'UPMC Maryse Pelletier Maître de conférences à I'UPMC Christian Dueinnec Professeur à I'UPMC Michèle Soria Professeur à I'UPMC
DUNOD
Illustration de couverture réalisée à partir de l'image du dragon
le pictogramme qui figure ci-contre mérite une explication. Son objet est
d'ensei!P'fTlenl supérieur, P""""llml 1110 boisse bnmle des achats de livres et de d'aler1er le lecteur sur la menace que revues, au point que la possibilité même pour représente pour I'OYenir de récrit, les auteurs de aéer des COLM"OS particulièrement dons le domaine DANGER nouvelles et de les loire éditer co~ de l'édifion technique et uniWirsiraclement est aujourd'hui menoa!e. taire, le déWIIoppement massif du Nous rappelons donc que taule photacopillage. reproduction, partielle ou totale, le Code de la propriété intellecde la pré>ente publication est tuelle du 1"' juillet 1992 interdit LE PIIJ!t:C:œii/Œ interdite sans autorisation de en effet expressément la phataca- TUE LE UVRE l'auteur, de son éditeur ou du pie à usage collectif sans autariCentre françois d'exploitafion du sation des aycnts droit. Or, oet1e prafique droit de copie ICFC, 20, rue des s'est généralisée dans les établissements Grands-Augusfins, 75006 Paris!.
®
© Dunod, Paris, 2004 ISBN 210 007479 2
Le Code de la propriété intellectuelle n'autorisant, aux termes de l'article L. 122-5, 2° et 3° a), d'une part, que les «copies ou reproductions strictement ~servées à l'usage privé du copiste et non destinées à une utilisation collective» et, d'autre pert, que les analyses et les courtes citations dans un but d'exemple et
d'illustration, « toute représentation ou reproduction intégrale ou partielle faite sons le consentement de l'auteur ou de ses ayants droit ou ayants cause est
illicite • (art. l. 122-41. Cette représentation ou reproduction, par quelque procédé que ce soit, constituerait donc une contrefaçon sanctionnée par les articles L. 335-2 et suivants du
Code de la proprié1é intellectuelle.
Table des matières Introduction 1 Nos choix ........ 2 Comment lire ce livre . . 3 Comment utiliser ce livre 4 Comment enseigner ce livre 5 Remerciements • • • • •
0
1 4 5 •
1 Noyau de Scheme 1 Application de fonction . ..... 2 Définition de fonction . . ..... 3 Définition de fonction en Scheme . 4 Types de base . . . . . Expression booléenne . 5 6 Alternative ..... Conditionnelle . . . . . 7 8 Nommage de valeurs Notions de bloc et de portée 9 10 Exemple récapitulatif 11 Conclusion .... 12 Exercices corrigés . . 2 Art et usage des spécifications 1 Spécification d'une fonction . . . . . . . 2 Écriture de la spécification d'une fonction Utilisation de la spécification . . . . . . . 3 4 À propos des erreurs et des tests . . . . . Pour en savoir plus : langages typés et langages sûrs . 5 Conclusion . . . . 6 Exercices corrigés . 7 3
Récursion 1 Définition récursive . . . . . . . . . . . 2 Définitions récursives en Scheme . . . . 3 Pour en savoir plus : ordres bien fondés 4 Exercices corrigés . . . . . . . . . . . .
7 7 9 12 14 16 17 19 21 23 24 28 29 29 39 42 48 50 56 57 57
61 64 69 71
vi
4 Structure de liste 1 Deux notions importantes . Structure de données « liste » . . . . . . . . . 2 Définitions de fonctions simples sur les listes 3 Définitions de fonctions récursives sur les listes 4 Notion de couple . 5 Liste d'associations 6 Citation . . . . . . 7 Exercices corrigés . 8 5 Fonctionnelle 1 Application d'une fonction aux éléments d'une liste . 2 Sélection des éléments d'une liste . . . . 3 Réduction d'une liste par une fonction . . 4 Une fonction comme résultat de fonction . 5 Pour en savoir plus Exercices corrigés . 6 6 Modèle par substitution 1 Idée et problématique 2 Étapes d'évaluation 3 Environnement . . . 4 Substitution . . . . . 5 Récursion et portée globale . 6 Récursion et portée locale . 7 Pour en savoir plus Conclusion . . . . 8 Exercices corrigés . 9 7 Structuration des données 1 Structures de données en Scheme . Construire sa structure de données 2 3 Pour en savoir plus 4 Exercices corrigés . . 8 Structures arborescentes 1 Structure d'arbre . . 2 Arbres binaires . . . 3 Arbres binaires de recherche Arbres généraux . . . . . . 4 Systèmes de fichiers . . . . 5 Représentation des arbres . 6 Exercices corrigés . . . . . 7
Table des matières
83
84 87 88
94 95
98 100
117
120 123 129 132 134 147 148 149 150
156 156 157 160 160
161
168 177
178
193 195 199
206 212
219 222
Table des matières
9
Du bon usage des grammaires 1 Motivation . . . . . . . . . Lire et comprendre une grammaire 2 Concevoir une grammaire . . 3 Écrire la barrière syntaxique 4 Applications . . . . 5 Pour en savoir plus . . . . . 6
10 Évaluateur 1 Spécifications 2 Utilitaires .. 3 Barrière syntaxique 4 hnplantation de la fonction l i vre-eval 5 Barrière d'interprétation . . . . . . . . . 6 Barrière d'abstraction des environnements 7 Conclusions . . . . . 8 Code de l'interprète . Annexe 1 2 3 4
5 6 7 8
Carte de référence Spécification . . . Grammaire des types . Grammaire du langage Commentaires . Bibliothèque . . . . . . Suppléments . . . . . . Bibliothèque graphique Bibliothèque d'arbres
Bibliographie Index
vii
. . . . . .
247 248 253 257 267 274
. . . . . . . .
278 284 285 287 292 298 312 313
. . . . . .
327 327 328 329 330 332 333 . 334
Introduction Cet ouvrage est le livre du cours intitulé« Programmation récursive et processus d'évaluation », enseigné depuis 1999 aux étudiants de première année de l'université Pierre et Marie Curie (UPMC). il se veut une introduction à l'informatique vue comme une science, avec ses problématiques et ses idées fondamentales, mais aussi ses tours de main. Ne supposant aucune connaissance préalable en informatique, mais se fixant des objectifs ambitieux, ce livre s'adresse à différents types de public : premières années d'université scientifique, mais aussi étudiants plus avancés désirant s'initier à la programmation (écoles d'ingénieurs et écoles de commerce, licence et master d'autres disciplines ... ) Le but choisi est de montrer le processus par lequel un ordinateur convertit un texte (le programme représentant un calcul) en une valeur (le résultat obtenu après avoir effectué ce calcul). Pour expliquer cette évaluation (interprétation), il est nécessaire d'utiliser un langage de programmation, de savoir s'exprimer dans ce langage (syntaxe des expressions) et de comprendre la signification (sémantique) à donner aux expressions. L'étape ultime, qui confirme la mwùise du langage, est d'écrire un programme qui évalue les programmes, ainsi le langage s'évalue à l'aide de lui-même, s'auto-interprète. Peut-on espérer atteindre cette étape en ne supposant aucune connaissance préalable en informatique ? Pour prendre une image, on pourrait rêver d'apprendre la composition musicale sans passer par la mwùise d'un instrument et de maîtriser un instrument sans passer par le solfège. Hélas, il faut commencer par faire des gammes et de même faut-il patiemment commencer par écrire des petits programmes. Cependant grâce à notre langage-instrument, Scheme, dont la syntaxe simple facilite l'apprentissage et permet de se concentrer sur les principes de la programmation récursive et l'étude des mécanismes d'évaluation, nous parvenons au but: partant des notions les plus élémentaires, nous aboutissons, à la fin du livre, à l'écriture raisonnée d'un interprète de Scheme en Scheme. Et tout au long de l'ouvrage nous aurons approfondi différents aspects fondamentaux pour la conception et l'écriture des programmes : spécification et implantation, validation et efficacité, structuration des données, etc.
1 Nos choix Ce livre essaie de donner des fondements sur la programmation : il ne s'agit pas d'apprendre quelques règles de syntaxe pour écrire des programmes, mais d'étudier des notions générales. Le passage par un langage de programmation est incontournable, et nos programmes sont écrits en Scheme, mais cet ouvrage ne peut pas être considéré comme enseignant le langage Scheme. Il est à la fois beaucoup moins- nous n'exploitons qu'un tiers
2
Introduction
des possibilités de Scheme - et beaucoup plus - les principes de programmation que nous avons choisis d'énoncer sont transposables dans bien des langages, et en les choisissant nous avions aussi en tête d'autres langages que Scheme.
1.1
Le langage Scheme
Le choix initial est celui du langage Scheme (dont nous n'utilisons qu'un sous-ensemble, réduit à l'application de fonction et quelques formes spéciales), avec un style de programmation purement fonctionnel. Le choix de Scheme permet d'évacuer les problèmes d'entrées/sorties et de gestion de la mémoire. Une des grandes forces de Scheme est sa syntaxe, des plus simples et des plus régulières que l'on puisse imaginer puisqu'à base de trois marqueurs seulement -les parenthèses et l'espace -, et totalement parenthésée. Scheme n'a quasiment aucune restriction sémantique : toutes les valeurs mauipulées peuvent être passées en argument de fonction, en résultat de fonction, stockées en vecteurs, listes, etc. Le modèle d'évaluation du sous-ensemble de Scheme enseigné est le modèle par substitution. La récursivité de contrôle qu'il procure s'accorde avec la récursivité des données mauipulées ce qui permet de traiter des exercices algorithmiquement intéressants sur des structures complexes. La puissance d'expression du langage Scheme est la même que celle de tous les autres langages de programmation. Mais l'une des caractéristiques essentielles de Scheme est d'avoir éliminé tous les traits divers et variés déductibles des principes de base, ce qui permet d'en donner une description simple et courte. La norme de Scheme tient en une cinquantaine de pages, alors que les autres langages en nécessitent généralement au moins dix fois plus. Le rapport de taille est encore plus favorable à Scheme en ce qui concerne l'auto-évaluation, puisque c'est le seul langage normalisé permettant de décrire son propre interprète en une centaine de lignes. Scheme est utilisé ici dans le contexte de DrScheme, un environnement de programmation pour Scheme développé par le Programming Language Team mené par Matthias Felleisen [8]. Cet environnement est interactif: on écrit son programme et l'on obtient sa valeur quasiment instantanément. Les erreurs sont clairement indiquées dans le texte. Toutes ces caractéristiques rendent rapide l'acquisition du langage.
1.2 Les points importants Nous avons voulu écrire un livre initiatique, qui donne de bonnes habitudes et constitue une couche de base solide pour les enseignements d'informatique ultérieurs, mais qui offre aussi des à-côtés alléchants pour alimenter la réflexion des plus gourmands. Nous insistons particulièrement sur les points suivants : la spécification des fonctions, la programmation récursive, l'abstraction par rapport à la représentation des données et des programmes, le souci d'écrire des algorithmes et des programmes corrects et efficaces, ainsi que les mécauismes de construction d'un langage.
Spécification des fonctions : Scheme est un langage non typé statiquement (le typage statique vérifie, avant l'exécution, la cohérence des programmes du point de vue de leurs types). Outre la simplification de l'écriture de l'évaluateur, l'absence de typage statique a aussi l'avantage, pour le deôutant, de ne pas compliquer le modèle. Cependant l'étude de la
Nos choix
3
spécification d'une fonction, et en particulier de sa signature (type et nombre des arguments, type du résultat), est primordiale pour le programmeur, qu'il veuille utiliser une fonction ou la définir. Nous avons donc défini une grammaire des types pour notre langage, et imposé que chaque fonction soit précédée de sa signature et de sa description (sous la forme de commentaires Scheme). Cette étape nécessaire oblige à prendre un peu de recul avant de se lancer dans la programmation et permet de corriger les programmes en vérifiant « à la main » la cohérence entre l'utilisation et la définition d'une fonction. Récursion: le sous-langage de Scheme que nous utilisons ici est purement fonctionnel, le résultat de tout programme est obtenu par composition de fonctions. Nous insistons particulièrement sur la conception et la définition de fonctions récursives, en expliquant le principe de décomposition du traitement d'une donnée en traitements sur des données plus petites et en décrivant cette décomposition par une relation de récurrence sur laquelle apparaissent les cas de base. Le mécanisme de récursion s'applique à tous les types de données sur lesquelles on peut faire apparaître une structure récursive : les nombres entiers mais aussi les listes, vues comme constituées d'un premier élément et d'un reste qui est aussi une liste, ainsi que les arbres constitués d'une racine et de sous-arbres. Nous essayons de montrer la ligne directrice commune dans les traitements récursifs sur toutes ces données. Barrière d'abstraction : la structuration en couches (ou niveaux) est nécessaire dans toute organisation un tant soit peu complexe : à chaque niveau on explicite les éléments sur lesquels on s'appuie et ceux que l'on réalise. La communication entre les différentes couches se fait par le biais d'interfaces, mais les couches doivent être les plus étanches possible, au sens où les modifications apportées à un niveau se répercutent le moins possible sur les autres niveaux. Nous insistons sur ce problème en introduisant la notion de barrière d'abstraction. La barrière d'abstraction d'une structure de données est un ensemble de fonctions permettant de manipuler les données de ce type. Cette barrière est une interface qui délimite deux niveaux : le niveau « utilisateur », où l'on manipule ces données (à l'aide des fonctions de la barrière que l'on considère alors comme des primitives) et le niveau « programmeur», où l'on s'intéresse à la représentation des données et à 1' implantation des fonctions de la barrière. Correction et efficacité: il ne suffit pas d'écrire des programmes, encore faut-il qu'ils soient corrects, c'est-à-dire que leur résultat soit conforme à leur spécification, et efficaces, c'est-àdire que leur temps d'exécution ne soit pas prohibitif. Étant donnée la spécification d'une fonction, on demande de fabriquer en premier lieu, avant même d'écrire la définition de la fonction, une série de tests, confrontant expressions et valeurs attendues et prenant en compte tous les cas de figure possibles, qui serviront à vérifier le comportement de la fonction. À défaut d'une preuve formelle, on aura alors la conviction que la fonction semble correcte. Et le fait de préparer ces tests à l'avance permet non seulement d'aider à la compréhension de la spécification, mais aussi d'éviter d'être influencé par le code que l'on écrit pour la définition. Souvent nous montrons comment améliorer l'efficacité des fonctions : au niveau du code, par exemple en nommant les résultats utilisés à plusieurs reprises, pour éviter de les recalculer; au niveau des méthodes, par exemple en mettant en évidence le gain significatif apporté par les algorithmes dichotomiques par rapport aux algorithmes linéaires.
4
Introduction
Syntaxe et sémantique : l'objectif ultime de ce livre est de montrer les mécanismes de construction d'un langage, à partir du formalisme qui définit les expressions syntaxiquement correctes, jusqu'au programme qui interprète ces expressions pour leur donner un sens, c'està-dire une valeur. Au centre de ce processus se trouvent les grammaires, qui explicitent le lien entre la structure linéaire du texte du programme et la structure arborescente profonde sur laquelle se calcule la valeur. Nous consacrons un long développement aux structures arborescentes et aux grammaires. La grammaire de Scheme et celle que nous avons définie pour les types sont utilisées dès le début du livre pour écrire les spécifications et les définitions de tous les programmes ; et nous présentons en annexe la « carte de référence », une aide précieuse qui doit toujours servir de guide dans l'écriture des programmes. Les arbres sont aussi étudiés en tant que types de données, dont les applications informatiques sont multiples, de la recherche d'information à la représentation des images, en passant par la structuration des systèmes de fichiers.
2 Comment lire ce livre Ce livre est essentiellement composé de deux parties. Les chapitres 1 à 5 sont consacrés à l'étude du langage Scheme et de quelques principes de programmation. Les chapitres 6 à 10 abordent de nouveaux aspects comme les structures de données et les grammaires qui mènent finalement à l'interprète de Scheme en Scheme. Chaque chapitre présente tout d'abord un développement de cours, émaillé d'exemples, présentant les notions à étudier et éventuellement des avancées, intitulées « Pour en savoir plus >>.La partie cours est suivie d'un certain nombre d'exercices d'application et de problèmes faisant appel à une réflexion plus poussée. Les exercices et les problèmes sont tous corrigés. Nous nous sommes efforcés de présenter un ensemble cohérent, qui initie aux bonnes habitudes en les pratiquant; en programmation, encore plus qu'ailleurs, c'est par imitation que l'on apprend et que l'on acquiert son propre style. La carte de référence, placée à la fin du livre, décrit de façon exhaustive le langage de spécifications que nous avons défini et le sous-langage de Scheme que nous utilisons, ainsi que les extensions que nous y avons apportées. Nous détaillons ci-dessous les objectifs principaux de chaque chapitre. Le premier chapitre, intitulé << Noyau de Scheme », introduit le langage que nous utilisons, qui se restreint à l'application de fonctions et à sept formes spéciales. Ce langage est réduit mais complet puisqu'il sera ensuite étendu uniquement par l'adjonction de structures de données. n permet d'écrire, à ce niveau, les premiers petits programmes. Le chapitre 2, intitulé « Art et usage des spécifications », présente un certain nombre de règles à suivre pour rendre les programmes plus lisibles par des humains. Ce sont bien sûr des ordinateurs qui exécutent les programmes, mais ce sont des humains qui les écrivent, les utilisent et les corrigent. La lisibilité des programmes est donc un gage de leur fiabilité. Ces règles de programmation ne sont pas spécifiques à l'écriture de programmes en Scheme et sont transposables à tout autre type de langage de programmation. La programmation récursive est le cœur de ce livre. Le chapitre 3, intitulé << Récursion », introduit ce mécanisme puissant, permettant d'exprimer la répétition des opérations dans un programme. On y montre comment concevoir et définir des fonctions récursives, en s'attachant à écrire des programmes corrects et efficaces. Ce chapitre traite de la récursion sur les
Comment utiliser ce livre
5
nombres entiers, mais les principes qu'il énonce s'appliqueront aussi à la récursion sur les listes et les arbres. Le chapitre 4, intitulé « Notion de liste » présente la structure de liste, fondamentale en Scheme, qui permet de représenter une séquence ordonnée de valeurs. Les listes étant des structures intrinsèquement récursives, leur traitement est donc, par nature, récursif. On approfondit donc ici le mécauisme de récursion. La puissance des fonctions est développée au chapitre 5, intitulé « Fonctionnelles >>. Les fonctions sont non seulement applicables, mais aussi composables, mauipulables et stockables comme les autres valeurs. L'utilisation d'itérateurs sur les listes permet d'écrire des programmes concis et génériques. La possibilité d'abstraire sur les fonctions est un concept de plus en plus important en programmation (généricité en ADA, signature de modules en CAML et interfaces en Java ou C#). Le chapitre 6, «Modèle par substitution>>, s'attache à donner une sémantique au langage. L'évaluation des expressions se fait par récriture, en substituant les arguments par leur valeur. La compréhension générale de ce processus nécessite d'expliciter les notions essentielles d'environnement et de portée. Le chapitre 7, «Structuration des données>>, donne une présentation des différentes structures de données de Scheme, essentiellement les vecteurs et les S-expressions. Mais il s'attache aussi à montrer comment construire ses propres structures de données, en réalisant une interface permettant de séparer la mauipulation des objets de leur implantation. Les arbres sont présentés au chapitre 8, intitulé « Structures arborescentes ». Les arbres sont non seulement une structure de données omuiprésente en informatique, pour décrire tout ensemble hiérarchisé d'informations, mais ils expriment aussi l'essence même de la récursion : la récursion prend sa forme la plus générale lorsqu'elle s'applique aux structures arborescentes et inversement toute récursion est représentable par un arbre. Le chapitre 9, «Grammaire>>, montre le lien entre le texte d'un programme et sa structure arborescente sous-jacente à partir de laquelle il devient mauipulable, par exemple pour calculer sa valeur. Une grammaire est la spécification formelle d'un langage, et en informatique tout est langage. Cependant la grammaire ne donne qu'une vision statique du langage. Le but de tout formalisme informatique est de deveuir exécutable, l'idéal étant de dériver automatiquement une implantation à partir de spécifications formelles. Le dernier chapitre, intitulé << Évaluation », montre toutes les étapes de la construction de l'évaluateur, programme écrit en Scheme, qui permet d'évaluer les programmes Scheme, et donc de s'auto-évaluer. C'est le point d'aboutissement de l'ouvrage, une expérience que certains qualifient de<< mystique>>. Plutôt qu'une cerise c'est... la pastèque sur le gâteau, bien lourde mais pleine d'eau et rafraîchissante, même s'il y a quelques pépins à digérer... ou à recracher.
3 Comment utiliser ce livre Les dépendances entre les différents chapitres suivent essentiellement 1' ordre de leur présentation : la difficulté croît, du début jusqu'à la fin du livre, mais certains chapitres gagnent à être lus plusieurs fois, à différentes étapes de la progression. C'est le cas tout particulièrement pour << Art et usage des spécifications >> (chapitre 2), << Modèle par substitution » (chapitre 6) et « Structuration des données >> (chapitre 7).
Introduction
6
Le dernier chapitre a un statut particulier : on peut le lire sans essayer d'en comprendre les moindres détails ou le considérer comme le corrigé d'un problème dont l'énoncé tient en une seule petite phrase : «écrire un évaluateur pour Scheme ». Travailler ce problème mobilise et consolide toutes les connaissances et compétences acquises lors des chapitres précédents. Notre objectif, en écrivant ce livre, est de faire passer des concepts, mais aussi de communiquer des méthodes, un style de progranunation et de transmettre des savoir-faire et des tours de main. Voici quelques conseils pour en tirer profit. ll faut commencer par lire le cours et tester sa compréhension en implantant les exemples (toujours avec la carte de référence sous les yeux). Cette attitude active, apprendre en faisant, est illustrée par le fameux proverbe chinois que nous citons souvent à nos étudiants :
On écoute, on oublie On écrit, on se souvient On fait et on sait. C'est un énorme avantage d'avoir un système pour expérimenter: la machine sanctionne le travail, elle met en évidence les erreurs et fait surgir des questions ... Ensuite il faut chercher à résoudre les exercices sans regarder la solution (les corrigés sont reportés en fin de chapitre à cette intention), implanter sa propre méthode et tester qu'elle résout bien le problème posé. Etc' est alors seulement qu'il faut aller regarder la solution proposée dans le livre, pour comparer l'algorithme choisi, l'implantation, la complexité, s'imprégner du style de programmation. ~
Logiciels d'accompagnement
Dans notre enseignement à l'UPMC, Scheme est utilisé avec l'environnement de programmation DrScheme [8], auquel nous avons rajouté, au fil des ans, une bibliothèque de fonctions supplémentaires, nommée miastools. Pour ce qui est du graphique, cette bibliothèque s'appuie sur la bibliothèque fungraph de Max Hailperin, Barbara Kaiser et Karl Knight [9]. Cet environnement est disponible sur le site 1 de l'enseignement de« Programmation récursive>> de la licence d'informatique de l'UPMC. Ce site est utilisé par nos étudiants et peut servir de compagnon complémentaire dans l'étude de ce livre. ll contient tout un environnement logiciel que l'on peut te1écharger (et que nous distribuons chaque année à nos étudiants sous forme de cédérom) pour s'entraîner à la pratique de Scheme. Dans cet environnement, le lecteur travaille interactivement, non seulement en testant ses propres programmes, mais aussi en répondant aux quelques centaines d'exercices que nous y proposons :près de 400 questions d'auto-évaluations sur les notions de base et une soixantaine d'exercices de programmation, totalisant près de 250 définitions de fonctions. (Précisons que les exemples et exercices de ce livre sont, en très grande majorité, différents de ceux du logiciel.) Ce logiciel d'aide à l'apprentissage repose lui-même sur l'existence d'un interprète pour le langage étudié, qui permet de fournir des évaluations sur les réponses proposées par l' apprenant. Le lecteur intéressé par une description plus précise pourra se reporter à [4] et [6]. 1http://www.infop6.jussieu.fr/cederoms/li101/
Comment enseigner ce livre
7
4 Comment enseigner ce livre Ce livre est le résultat de l'enseignement dispensé depuis 1999, aux huit à neuf cents étudiants de première année de DEUG Mathématiques et Informatique Appliquées aux Sciences (MIAS) à l'UPMC ainsi qu'en seconde année du DEUG Science Pour l'Ingénieur (SPI). C'est aussi l'enseignement dispensé à partir de la rentrée 2004, en premier semestre de première année de la nouvelle licence d'informatique, mise en place dans le cadre de la réforme européenne de l'enseignement supérieur dite du LMD (Licence-Master-Doctorat). C'est une population très hétérogène au niveau des connaissances en informatique et du goût pour cette discipline. Un tiers seulement se destine à poursuivre des études en informatique, les autres choisissant les mathématiques, la mécanique, l'électronique ou la physique. Il s'agit donc de faire une introduction à l'informatique, qui serve de culture générale et technique aux non spécialistes et constitue aussi les fondements de la formation des futurs informaticiens. L'enseignement se déroule traditionnellement sur un semestre (50h, réparties à peu près également en heures de cours, de travaux dirigés et de travaux sur machine encadrés); nous avons aussi expérimenté un type d'enseignement« semi-présentiel »,avec suivi des étudiants à distance et rencontre une fois par semaine [4, 6]. Depuis le début, notre idée directrice a été de présenter le processus d'évaluation et d'utiliser pour cela le langage Scheme. Les premières annéees nous consacrions la moitié des douze semaines du semestre à étudier les bases du langage et la récursion sur les nombres et les listes, puis quatre semaines sur les barrières d'abstraction, les arbres et les grannnaires, et il nous restait deux semaines pour présenter 1' évaluateur. Mais nous avons pris conscience que ce projet était trop ambitieux pour un laps de temps si court; le fossé était trop grand entre la fin de la première partie et l'étude de l'évaluateur. Petit à petit la deuxième partie s'est donc concentrée sur les structures de données et la récursion sur les arbres, ne laissant plus beaucoup de place à l'évaluateur. Globalement, pour présenter le contenu de ce livre dans un ensemble cohérent, nous pensons donc qu'il faut de l'ordre de 80 heures, en incluant un projet final. La réalisation d'un projet, comme par exemple l'écriture d'un petit évaluateur pour un mini langage, nous semble être une conclusion nécessaire pour que l'étudiant puisse mobiliser toutes ses connaissances de façon gratifiante.
5 Remerciements Pascal Manoury nous a accompagnés pendant plusieurs années dans cette aventure, pour la réalisation du site et les expériences d'enseignement à distance, ainsi que pour la première phase de conception de ce livre. Nous regrettons que d'autres engagements l'aient empêché de se joindre à nous pour la rédaction finale : ses suggestions, ses commentaires et ses critiques nous auront beaucoup manqué. Nous remercions tous les collègues de l'UPMC avec lesquels nous avons enseigné depuis cinq ans. C'est leur participation qui nous a permis de dispenser cet enseignement à d'impressionnantes masses d'étudiants, et c'est aussi grâce à leurs nombreuses questions et remarques que nous avons peu à peu affiné le contenu de ce livre.
8
Introduction
Nos étudiants de ces dernières années nous ont poussés à écrire ce livre, car nous n'avions pas de réponse à donner à leur demande de bibliographie, aucnn livre existant ne correspondant à notre vision pédagogique. C'est un exercice parfois difficile de les convaincre de nous suivre dans notre démarche, mais nos efforts sont récompensés lorsque nous les voyons prendre goût à la beauté de la programmation récursive, lorsque les yeux de certains s'illuminent lors de l'« expérience mystique » où l' évaluateur de Scheme en Scheme est dévoilé et lorsqu'ils nous disent, quelques années plus tard, que cet enseignement a déterminé leur vocation d'informaticien. Nous tenons enfin à remercier toute la hiérarchie de l'UPMC, qui nous a permis de mener à bien notre enseignement et nous a toujours soutenus dans les expériences que nous avons tentées et les divers déploiements logiciels que nous avons réalisés. AnneBrygoo Titou Durand Maryse Pelletier Christian Queinnec Michèle Soria
Chapitre 1
Noyau de Scheme D'une façon générale, un programme infonnatique permet un traitement automatique sur des données. En Scheme, un programme a la forme d'un texte appelé expression qui, après évaluation par l'évaluateur de Scheme, fournira une valeur (unique). Pour pouvoir construire des expressions Scheme, il faut tout d'abord disposer d'outils de base et des types de données de base. Nous présenterons donc dans ce chapitre, non pas la totalité de Scheme mais seulement un noyau essentiel pour l'objectif de ce livre qui est d'écrire en Scheme un évaluateur d'expressions Scheme. Nous allons donc commencer par écrire des expressions simples comme des expressions qui appliquent des fonctions (c'est-à-dire utilisent des fonctions déjà définies), qui définissent de nouvelles fonctions, qui utilisent l'alternative (c'est-à-dire la possibilité de faire deux calculs différents en fonction du résultat d'une condition), et le nommage des variables. Nous verrons aussi les types de données de base dont nous disposons en Scheme.
1 Application de fonction TI existe différentes écritures linéaires d'une expression. En infixe, l'opérateur se situe entre ses opérandes (exemple : a+ b), en préfixe l'opérateur précéde ses opérandes (exemple : sin( x)) et en suffixe (ou postfixe) l'opérateur suit ses opérandes (exemple : n!). Face à cette variété d'écritures des expressions le choix fait en Scheme, est simple et uniforme : - écriture préfixe complètement parenthésée, - les parenthèses entourent toute l'expression, - le séparateur est l'espace (ou le retour à la ligne). Par exemple, l'expression usuellement écrite 3 +a devient en Scheme ( + 3 a}, cette expression est l'application de l'addition à 3 et a. On peut imbriquer des expressions. L'expression mathématique a fe+ 5 sin( bd) s'écrit: (+
(!ac}
(* 5
(sin (*bd}}}}
On peut écrire une expression sur plusieurs lignes, ce qui améliore la lisibilité en isolant les arguments : (+
(! a c} (* 5
(sin (* b d}}}}
10
Chapitre 1. Noyau de Scheme
Remarque: certaines fonctions peuvent avoir un nombre quelconque d'arguments et c'est pourquoi le parenthésage complet est nécessaire. C'est le cas des opérations arithmétiques: addition ( +) et multiplication ( * ). Ainsi, on pourra écrire : ( + ( * 3 x x) ( * 5 x) 8)
1.1 Terminologie Dans une application, - le premier élément (qui est une expression, comme nous le verrons plus loin) correspond à la fonction, - que l'on applique à des arguments, valeurs des expressions qui la suivent. Par exemple, dans l'expression ( + ( * 3 7 2 ) ( * 5 7) 8) , la fonction est l'addition, son nom est+, et ses arguments sont: - la valeur de ( * 3 7 2 ) soit 42, - la valeur de ( * 5 7 ) soit 35, - et la valeur de 8 soit 8. Noter que l'expression ( * 3 7 2) est, elle-même, l'application de la multiplication, valeur de *, aux arguments 3, 7 et 2.
ji En Scheme, les parenthèses jouent un rôle essentiel. La parenthèse ouvrante déclenche
~ le mécanisme d'application de la fonction qui se trouve juste après elle et la parenthèse fermante détermine les arguments à prendre en compte pour appliquer la fonction. Toutes les parenthèses vont par paire : à chaque ouvrante correspond une et une seule fermante (on parle de fermante appariée).
1
Attention au rôle des espaces (séparateurs) et des parenthèses (construction d'applica~ tions). Ainsi, l'expression (- 3) est équivalente à l'expression (- 0 3) et vaut -3. En revanche, l'expression (- 3) (sans espace entre le tiret et le 3) donne une erreur car -3 n'est pas une fonction: on ne peut donc pas l'appliquer.
1.2 Règle de grammaire d'une application On définit comment doit être écrite une application, en utilisant une règle de grammaire. Les règles de granunaires définissent la syntaxe des expressions du langage. En Scheme, la syntaxe d'une application est définie par:
--> ( <argument>*) Dans une telle règle de granunaire : - , qui est avant la flèche et est écrit entre chevrons (< et >), est l'unité syntaxique que 1' on est en train de définir ; - les parenthèses ( et ) , qui ne sont pas écrites entre chevrons, sont des terminaux écrits tels quels dans les expressions en Scheme ; - et <argument>, qui sont écrits entre chevrons, sont aussi des unités syntaxiques qni doivent être définies par une autre règle de granunaire ou, récursivement, par celle que 1' on est en train d'écrire ; les arguments et les fonctions sont aussi des expressions (c'est ce qui permet, entre autre, de pouvoir imbriquer les applications) : <argument> --> <expression> --> <expression>
11
Application de fonction
...JI
~
L'étoile«*» qui suit l'unité syntaxique <argument> indique que l'on peut placer à cet endroit de l'expression un nombre quelconque (éventuellement nul) d'arguments.
La règle de grammaire dit donc qu'une application est constituée par une parenthèse ouvrante, suivie d'une fonction, puis d'un nombre quelconque d'arguments, éventuellement nul (c'est le sens de l'étoile), et enfin d'une parenthèse fermante. La grammaire complète des expressions, utilisées dans ce livre, est donnée en annexe (page 327).
1.3 Syntaxe et sémantique Le paragraphe précédent montre comment doit être écrite une application pour être « comprise » par l' évaluateur Scheme : c'est, nous l'avons dit, la syntaxe de l'application. Mais il faut également savoir ce que représente une telle application, autrement dit quelle est sa valeur: c'est la sémantique du langage. Dans ce livre, nous donnerons trois formes de sémantiques dites : - naïve : où l'on décrit, « avec les mains >>, la valeur que doit afficher un évaluateur Scheme; c'est cette première vision que nous exposons dans ce chapitre mais elle ne permet pas de comprendre ce qui se passe lorsque l'expression est complexe; - modèle par substitution : une sémantique plus formelle, dite rrwdèle par substitution (chapitre 6) nous permettra de définir précisément la valeur des expressions. Cette sémantique est suffisante pour décrire le sens des expressions du sous-ensemble de Scheme que nous utilisons dans cet ouvrage mais elle n'est pas assez puissante ni complète pour décrire le sens de n'importe quelle expression Scheme; - opérationnelle : nous donnerons une dernière sémantique du langage Scheme, un évaluateur Scheme que nous écrirons dans la dernière partie de ce livre (chapitre 10). Ce programme constitue bien une sémantique puisqu'il indique ce que doit valoir une expression, mais celle-ci est de nature très différente du modèle par substitution puisqu'elle est donnée par un programme Scheme! Pour indiquer quelle est la valeur d'une expression, nous utiliserons la flèche-+ (qui se lit << a pour valeur >>) comme dans ( + 2 2 ) -+ 4 La sémantique définit globalement quelle est la valeur qu'il faut associer à une expression du langage. Et pour le langage de programmation que nous étudions dans cet ouvrage, cette valeur est le résultat de différentes étapes réalisées par 1' évaluateur. Ces étapes constituent le processus d'évaluation. Nous pourrons utiliser pour le décrire des figures que nous appellerons schémas d'évaluation.
,
1.4 Evaluation d'une application Pour évaluer une application, on évalue chacun de ses arguments - ce qui donne des valeurs- et on applique la fonction à ces valeurs. Par exemple, pour évaluer l'application: (+
(* 3 7 2)
(* 5 7)
8)
1. on évalue chacun des arguments :
(*
3 7 2)
(* 5 7)
8
-+ 8
-+ 42 -+ 35
12
Chapitre 1. Noyau de Scheme
2. on applique la fonction d'addition, valeur de+, aux valeurs obtenues: 42 35 B)
(+
85
-+
On peut détailler les étapes de ce processus par le schéma d'évaluation ci-dessous : 1
(+
1
(+
(* 3 7 2)
1
(* 5 7)
(* 3 7 2)
1
(* 5 7)
*
5 7)
1
42
35
[!])
1
42
35
8 )
1 (
+
42
1 (
+
1 (+
1 (
8)
B)
dans le cadre : l'expression globale à évaluer
1
8)
1
1
dans cadre intérieur : la sous-expression à évaluer d'abord en grisé: le résultat de l'étape précédente et dans le cadre intérieur : la prochaine sousexpression à évaluer dans le cadre : retour à l'expression globale
1
en grisé : le résultat final
85
Schéma d'évaluation: application On peut également visualiser ce processus de calcul en utilisant DrScheme et en faisant
évaluer l'expression en mode« Step »,c'est-à-dire pas à pas.
1.5 Fonctions prédéfinies Les exemples précédents utilisent des fonctions fournies par Scheme (on dit alors qu'elles sont prédéfinies). Notons que ces fonctions sont de deux genres : - des fonctions qui sont implantées dans l'évaluateur- c'est le cas des fonctions que nous avons utilisées jusqu'alors -, on parle alors de fonctions de base ou de primitives; - des fonctions qui ont été écrites en Scheme, par les écrivains de l' évaluateur ou par d'autres personnes; ces fonctions ont été regroupées par centre d'intérêt et sont mises à disposition de l'utilisateur, à condition que celui-ci le demande. On parle alors d'unités de bibliothèque et on dit que l'utilisateur demande le chargement d'une unité de bibliothèque. Par exemple, l'environnement de développement DrScheme, qui vous est fourni via le site, possède une entrée dans le menu MIAS. Cette entrée permet de charger l'unité de bibliothèque mias, écrite par nos soins pour les besoins de notre enseignement« Programmation récursive», initialisé en DEUG MIAS, et qui comporte des fonctions, non présentes dans Scheme, utiles pour ce livre. La carte de référence (page 327) présente la cinquantaine de primitives utilisées dans cet ouvrage et la trentaine de fonctions de la bibliothèque MIAS.
2 Définition de fonction Un programmeur qui utilise un langage de programmation doit, en général, effectuer des calculs ou des traitements spécifiques à un domaine d'application. Les fonctions fournies par
13
Définition de fonction
le langage et ses bibliothèques permettent la plupart du temps d'exprimer ces calculs outraitements. Cependant, soit parce que les expressions nécessaires sont trop complexes, soit parce que certaines recettes de calcul sont utilisées à plusieurs reprises, le programmeur souhaite définir ses propres fonctions comme, en mathématique, on exprime un calcul particulier sous forme d'une fonction. L'action de définir une fonction a pour but d'associer un nom à une formule de calcul (en mathématique) ou à un<< programme» de calcul (en informatique).
2.1
Rappels mathématiques
Avant de voir comment il est possible de définir des fonctions en Scheme, rappelons quelques pratiques mathématiques. Voici donc un énoncé « mathématique » : Soit fla fonction qui associe à un nombrer le nombre 1l'r 2 • Calculer f(22) et f( v'2). La première phrase de cet énoncé est une définition de la fonction f que l'on peut noter plus formellement par : f : Nombre ---> Nombre r ,_. 7rr 2 La seconde phrase de l'énoncé demande d'évaluer deux applications de la fonction f : d'abord appliquer f avec l'argument 22, ensuite appliquer f avec l'argument v'2 (qu'il faut d'abord évaluer). On peut ensuite définir une autre fonction utilisant f, par exemple g: g : Nombre x Nombre ---> Nombre (r, h) ,_. f(r) x h et demander d'évaluer g(22, 5), etc.
2.2 Type et signature d'une fonction Reprenons la première ligne de la définition formelle de la fonction g. g : Nombre x Nombre ---> Nombre Cette partie de la définition est appelée la signature de la fonction. Elle est composée du nom de la fonction suivi d'un caractère deux-points et du type de la fonction :
~
Nombre x Nombre---> Nombre
nom
type signature
Le type de la fonction est constitué par les types de ses arguments (séparés par x symbole du produit cartésien qui se prononce << croix >>), suivi du signe -+ (qui se prononce << donne >> ou « rend >>) et du type de son résultat.
2.3 Sémantique d'une fonction Supposons que l'on veuille calculer le poids au mètre d'un tube en fer, de rayon extérieur 49mm et d'épaisseur lmm (le fer est de densité 7,87). On peut utiliser la fonction g à condition d'avoir remarqué qu'elle calcule le volume d'un cylindre, de rayon le premier argument de la fonction et de hauteur le second argument de
14
Chapitre 1. Noyau de Scheme
la fonction. Notons bien que la forme de l'expression de calcul de la fonction g (que l'on utilise ou non la fonction f, que l'on écrive 1rr 2 h ou h1rr 2 ••• ) nous importe peu pour calculer le poids au mètre d'un tube en fer; tout ce qui nous intéresse c'est que cette fonction calcule effectivement le volume d'un cylindre, de rayon le premier argument de la fonction et de hauteur le second argument de la fonction. L'information« g(r, h) renvoie le volume du cylindre de rayon ret de hauteur h >>donne la sémantique de la fonction.
2.4 Spécification d'une fonction (premier regard) Si l'on veut utiliser une fonction, on doit donc donner sa signature et sa sémantique, c'est-à-dire sa spécification: g : Nombre x Nombre --+ Nombre g(r, h) renvoie le volume du cylindre de rayon ret de hauteur h. Ainsi, sachant ce que calcule g (peu importe comment), on peut l'utiliser pour formuler l'expression qui rend le poids au mètre de notre tube en fer: 7.87 x (g(0.049, 1)- g(0.048, 1)) Nous donnerons systématiquement la spécification des fonctions que nous définirons en Scheme. Pour cela, nous utilisons la possibilité d'intégrer des commentaires au texte des programmes. Ces commentaires sont destinés à l'être humain (le programmeur lui-même, les autres membres des équipes de développement, voire un enseignant...) et constituent la documentation du code. Les commentaires sont ignorés par le mécanisme d'interprétation, ils n'interviennent en rien dans les calculs. En Scheme, un point-virgule indique le début d'un commentaire qui continue jusqu'à la fin de la ligne. Par convention, dans ce livre, le nombre de points-virgules indique le genre de commentaire. Les lignes d'une spécification sont introduites par trois points-virgules, les commentaires que l'on pourra insérer dans le texte des définitions seront introduits par deux ou un seul points-virgules, selon leur ordre d'importance. Par exemple, pour notre fonction g citée plus haut, on notera en Scheme sa spécification sous forme des deux lignes de commentaire : ; ; ; g: Nombre *Nombre ->Nombre ; ; ; (g rh) rend le volume du cylindre de rayon «r» et de hauteur «h».
3 Définition de fonction en Scheme Dans le premier énoncé « mathématique >> ci -dessus, on reconnru"t, dans la fonction f, le calcul de l'aire d'un disque. Pour définir cette fonction en Scheme, nous prenons un identificateur plus« parlant>>, aire-disque, (cela facilite la mémorisation de la spécification des fonctions). Pour poser la définition de la fonction, on utilise le mot-clef du langage Scheme de fine: ; ; ; aire-disque : Nombre --+ Nombre ; ; ; (aire-disque r) rend la suiface du disque de rayon «r»
(define (aire-disque r) (* 3.1416
(* r
r)))
Définition de fonction en Scheme
15
Dans cette définition, l'expression ( * 3 .1416 ( * r r) ) ) indique comment obtenir la valeur de l'application (aire-disque r). On peut appliquer cette nouvelle fonction pour obtenir des aires particulières : (aire-disque 22) -> 1520.5344 (aire-disque (sqrt 2)) -> 6.283200000000002 On peut utiliser la fonction aire-disque pour définir la fonction de calcul de volume
d'un cylindre : ; ; ; volume-cylindre : Nombre *Nombre ->Nombre ; ; ; (volume-cylindre rh) rend le volume du cylindre de rayon «r» et de hauteur «h» (define (volume-cylindre r h) (* (aire-disquer) h))
et on peut appliquer cette fonction : (volume-cylindre 22 5) -> 7602.6720000000005 La fonction aire-disque est une fonction unaire (elle prend un unique argument) tandis que la fonction volume-cylindre est une fonction binaire puisqu'elle prend deux arguments. On dit que l' arité de aire-disque est 1 et que l'arité de volume-cylindre est 2.
3.1
Règle de grammaire d'une définition
Une définition de fonction suit la règle de grammaire suivante : --> (de fine ( <.nom-fonction>*) ) Une définition de fonction débute par une parenthèse ouvrante et le mot clef de fine. Vient ensuite, entre parenthèses, le nom de la fonction suivi de celui de ses variables. Puis vient le corps de la fonction. Et enfin, une parenthèse fermante qui clôt la définition. Le nombre de variables venant après le nom de la fonction peut être nul. Et lorsqu'il y en a plusieurs, les noms utilisés pour les désigner doivent être différents deux à deux.
jl Les noms de variables donnés ici sont ceux utilisés pour définir la fonction ; les variables ~ permettent de désigner formellement les arguments de la fonction lors de son application; il doit donc y avoir autant de variables dans une définition de fonction que d'arguments lors de chaque application de cette fonction. Syntaxiquement, les noms des variables sont représentés par des identificateurs. Un identificateur est une suite de caractères qui ne doit pas se confondre avec la représentation d'un nombre. Ainsi a, pi, aire-disque, f99, +, = sont des identificateurs légaux. L'usage en Scheme, pour le programmeur, est d'écrire en minuscules, de séparer les mots par des tirets et en général de débuter par une lettre. Le corps de la fonction exprime comment obtenir la valeur de l'application de la fonction à partir (de la valeur) des arguments. Dans une première version simplifiée, la règle de grammaire décrivant la syntaxe du corps de la définition est : --> <expression> La section 9.4 donne une règle plus complète.
3.2 Évaluation d'une définition Une définition est aussi évaluée par l' évaluateur, mais sa valeur est un objet plus complexe que ceux vus jusqu'ici (des nombres). Intuitivement, l'évaluation d'une définition ajoute à l'environnement de programmation une fonction:
16
Chapitre 1. Noyau de Scheme
- en mémorisant comment on doit évaluer une application d'une telle fonction (c'est le rôle du corps de la définition) ; - afin que le programmeur puisse utiliser le nom de cette fonction dans l'écriture d'une application, sous réserve qu'il écrive le bon nombre d'arguments (à savoir le nombre des variables de la définition de la fonction). Le chapitre 6 donne une vision plus précise et plus complète du processus d'évaluation des définitions.
4 Types de base Les exemples donnés jusqu'ici n'ont utilisé que des nombres qui pouvaient être des entiers, positifs ou négatifs ou des nombres « à virgule >> (en fait, un point, en Scheme) et que l'on qualifie plutôt de flottants en informatique. li existe d'autres types de valeur que sait manipuler le langage Scheme. Citons : les booléens ou les chaînes de caractères. Pour chacun de ces types de valeur, il existe une notation permettant d'en écrire les constantes. - Pour les nombres, on utilise les chiffres arabes (en base 10), le signe - ou le signe + et le point décimal. Voici quelques nombres : -2 -1.5 0 +1 1.5 2 -3.1416 - Les deux valeurs booléennes sont notées #t (ou #T) pour la valeur vraie et #f (ou #F) pour la valeur fausse. - Les valeurs constantes pour les chaînes de caractères sont des suites de caractères comprises entre deux « " >>. Voici quelques chaînes de caractères : ''abcd''
''alb2c3d
11
"a!b?c*d"
Il Il
(la constante " " est la chaîne de caractères vide).
Attention : il ne faut pas confondre la chaîne de caractères "12" avec le nombre entier 12 ! Le programmeur peut manipuler toutes ces valeurs à l'aide de fonctions comme on le fait dans le monde des nombres. La section suivante présente des fonctions booléennes. li existe également des fonctions permettant de calculer avec des chaînes de caractères. Par exemple pour concaténer deux chaînes de caractères : (string-append "Hello" " la compagnie!") ---+"Hello la compagnie!" Mais on peut aussi calculer des valeurs d'un certain type à partir de valeurs d'autres types. Voici quelques exemples : - pour le calcul d'un nombre entier à partir d'une chaîne de caractères, exemple du calcul de la longueur d'une chaîne : (string-length "abcdef") ---+ 6 - pour le calcul d'un booléen à partir d'un nombre, exemple de la parité d'un nombre : (even? 4) ---+ #T - pour le calcul d'une chaîne de caractères à partir d'une autre chaîne de caractères et de deux nombres, exemple de l'extraction d'une sous-chaîne à partir d'une chaîne: (substring "123456789" 3 7) ---+ "4567" La carte de référence (page 327) contient la spécification des fonctions utilisées ci-dessus. La spécification d'une fonction, telle que nous l'avons définie, contient, dans sa signature, le type de la fonction qui indique le type des arguments et celui du résultat. Les types de bases introduits ci-dessus seront écrits dans ce livre, par convention, de la façon suivante :
17
Expression booléenne
- le type des valeurs booléennes s'écrit boel; - le type des chaînes de caractères s'écrit string; - le type des nombres s'écrit, en général, Nombre. Il peut cependant parfois être utile de préciser un peu de quel geme de nombre il s'agit. Ainsi on distinguera: - le type des nombres entiers non négatifs (dit entiers naturels) qui s'écrit nat; - le type des nombres entiers relatifs qui s'écrit in t ; - le type des nombres« à virgule» (dit nombres flottants) qui s'écrit float. En Scheme, certaines fonctions peuvent prendre en argument une valeur d'un type non précisé (un booléen, un nombre, une chaîne de caractères, etc.). Nous désignerons simplement ce type par Valeur. Il contient bien évidemment tous les types boel, string, Nombre, etc.
5 Expression booléenne Une expression booléenne est une expression dont la valeur peut être le booléen faux ( #F ou # f) ou vrai ( #T ou # t ). On les utilise pour vérifier que d'autres valeurs satisfont ou non certaines propriétés, entretiennent ou non certaines relations. Ces propriétés sont exprimées soit par l'application simple de fonctions Scheme soit par combinaisons booléennes. Par exemple, on peut appliquer une fonction de comparaison à deux nombres pour savoir s'ils sont égaux ou non. (= 0 0 )
---t
(= 0 1)
--+
#T #F
Remarquer que l'écriture des comparaisons suit le schéma syntaxique uuiforme de l'application : écriture préfixe complètement parenthésée. Scheme cannait les principales relations entre nombres: <, >,<=et>=. Mais il cannait également d'autres fonctions, appelées prédicats, qui rendent vrai si une valeur satisfait une certaine propriété et faux sinon. Par exemple, on pourra savoir si une valeur est un nombre ou non en appliquant le prédicat number? : (number? 123) ---> #T (number? "12 3") ---t #F
On peut avoir d'autres prédicats testant des propriétés sur les nombres : (positive? 3) --+ #T (positive? 0) ---> #F (positive? -3) ---t #F
Par convention d'écriture, en Scheme, les prédicats ont, en général, un identificateur qui se termine par un point d'interrogation. On peut enfin obtenir des valeurs booléennes par combinaison d'autres valeurs booléennes au moyen des connecteurs propositionnels négation (introduite par not), conjonction (introduite par and) et disjonction (introduite par or). Par exemple: ; ; ; 1 est différent de 0 (not (= 0 1)) ---> #T ; ; ; 1 est dans l'intervalle [-3,3] (and (<= -3 1) (<= 1 3)) ; ; ; 5 est hors de l'intervalle [-3,3] (or (<=5-3) (<= 3 5))
--->
--+
#T
#T
Chapitre 1. Noyau de Scheme
18
Le and et le or peuvent être suivis d'un nombre quelconque d'expressions. Par exemple: ; ; ; 1 est dans l'intervalle [-3,3] et est non nul (and (<= -3 1)
(<= 1 3)
(not
(= 1 0)))
#T
-----+
~ Évaluation d'une conjonction et d'une disjonction
L'évaluation du and ou du or suit une règle particulière : les expressions qui les composent sont évaluées séquentiellement de gauche à droite et elles peuvent ne pas être toutes évaluées. En effet, une conjonction est fausse dès que l'une de ses expressions l'est et une disjonction est vraie dès que l'une de ses expressions l'est. La conjonction and et la disjonction or ne sont pas, en Scheme, des fonctions. Ce sont des formes spéciales (en Schemeforme =expression), le terme de spéciale indiquant que leur évaluation est spécifique et se distingue de l'évaluation des applications de fonctions. Voici deux exemples illustrant cette particularité : (and (not(= 0 0)) (= 0 (/ 1 0))) ---t #F (or ( = 0 0 ) ( = 0 ( 1 1 0) ) ) ---t #T Si les deux expressions sur lesquelles portent and et or avaient été évaluées, on aurait obtenu une erreur: on ne peut pas diviser 1 par 0! 1 (and exp1 exp2) 1
(and
1
1
(and
#T
~~
exp2)
1
exp2)
1
#F exp2 )
(and
#F
lexp21
Schéma d'évaluation :forme spéciale
and
Comme le montre ce schéma, exp1 a pour valeur #Tou #F. Si la valeur obtenue est vrai, le calcul continue par 1' évaluation de exp2 ; si la valeur obtenue est faux, le calcul s'arrête par obtention de #F et renvoie cette même valeur. Le schéma suivant illustre l'évaluation des disjonctions : 1 (or exp1 exp2) 1
1
1
(or
#T #T
(or 1exp1 1 exp2 )
exp2 )
1
(or
1
#F
exp2)
lexp21
Schéma d'évaluation :forme spéciale or
Alternative
19
6 Alternative Souvent, en mathématique, la valeur d'une fonction dépend d'une condition, réalisée ou non, par ses arguments. Par exemple, on définira ainsi la fonction donnant la valeur absolue d'un entier relatif: abs : Nombre ---> Nombre abs(n) n sin~ 0 abs(n) -n sinon On peut avoir plus de deux cas comme dans cette fonction qui rend -1 si son argument est strictement négatif, 0 s'il est nul ou 1 s'il est strictement positif: szgne : Nombre ---> Nombre signe(n) -1 sin< 0 signe(n) 0 sin= 0 signe(n) 1 sin> 0 Dans les langages de programmation, et donc en Scheme, la possibilité d'exprimer de telles structures alternatives ou conditionnelles est indispensable. Elle permet au programmeur d'avoir un certain contr8le sur la suite des calculs effectués par le processus d'évaluation de ses programmes.
6.1
Alternative if
Une alternative correspond au couple schématique si ... sinon .... En Scheme, la forme spéciale if permet une telle construction. Par exemple, la définition de la fonction valeurabsolue s'écrit: ; ; ; valeur-absolue : Nombre -+ Nombre ; ; ; (valeur-absolue x) rend la valeur absolue de «X» (define (valeur-absolue x) (if (>= x 0) x (- x)) )
On peut, en inversant le test, obtenir une autre définition de la fonction, pour une même spécification. n faut alors également inverser les termes de l'alternative : (define (valeur-absolue x) (if (< x 0) (- x)
x) )
6.2
Règle de grammaire d'une alternative
La syntaxe d'une alternative est donnée par la grammaire suivante : ---> (if ) ---> <expression> ---> <expression> ---> <expression> Dans cette définition la condition, la conséquence et l'alternant sont a priori des expressions quelconques. Cependant, l'utilisation d'un if n'a réellement de sens qui si la condition est une expression booléenne.
20
Chapitre 1. Noyau de Scheme
6.3 Évaluation d'une alternative if est une forme spéciale qui s'évalue ainsi: la condition est d'abord évaluée; si sa valeur
est vraie, alors seulement la conséquence est évaluée (et la valeur de l'alternative est égale à la valeur de la conséquence) sinon seulement l'alternant est évalué (et la valeur de l'alternative est égale à la valeur de l'alternant). On peut illustrer ce processus par le schéma suivant: 1 (if test exp1 exp2) 1
1
1
(if
#T
(if
1
test
exp1 exp2 )
1
exp1 exp2)
1
1
(if
#F
lexp11
1
exp1 exp2)
1
lexp2 1
Schéma d'évaluation :forme spéciale if
6.4
Alternatives imbriquées
L'alternative permet d'écrire des expressions dont la valeur est spécifiée selon deux cas. S'il y a plus de deux cas, on écrit une alternative dont la conséquence ou l'alternant est à son tour une alternative. Par exemple : (define (signe x) (if (< x 0) -1
(if (= x 0) 0 1)))
Noter au passage qu'une construction alternative est une expression puisqu'on peut l'utiliser partout où la grammaire autorise l'usage d'une expression (comme c'est le cas ici pour l'alternant).
6.5 Explication du and et du or avec le if ll est possible d'illustrer le comportement de l'évaluation des formes spéciales and et or à l'aide de la seule forme spéciale if. En effet, l'évaluation d'une expression de la forme (and e1 e2l estéquivalenteàl'évaluationdel'expression (if e1 e2 #F). Ainsi les trois expressions suivantes sont équivalentes : (and (not (= 0 0)) (= 0 (/ 1 0))) (if (not (= 0 0)) (= 0 (/ 1 0)) #F) (if ( = 0 0) #F ( = 0 (/ 1 0) ) )
Cette évaluation ne provoque pas l'erreur que pourrait causer une tentative de division par zéro puisque (not (= 0 0)) s'évalue en #F et le résultat obtenu est directement #F sans que ( = 0 (/ 1 0 l ) ) soit évalué. La forme équivalente d'une expression de la forme (or e1 e2) est (if e1 #T e2) . Comme pour and et or, if n'est pas une fonction. Ce sont toutes des formes spéciales.
Conditionnelle
21
6.6 Notion de semi-prédicat Un semi-prédicat est une fonction qui renvoie soit une valeur, soit le booléen faux. Cela sert à éviter, en programmation, le gag de celui qui répond « oui » lorsqu'on lui demande «avez-vous l'heure?». Le langage Scheme offre plusieurs semi-prédicats prédéfinis. Par exemple, la fonction string->nurnber renvoie: - soit le nombre représenté par la chaîne de caractères passée en argument; - soit la valeur #F si son argument n'est pas une chaîne de caractères qui correspond à l'écriture d'un nombre. Voici deux exemples d'évaluation: (string->nurnber "123") - t 123 (string->nurnber "cent vingt trois") - t #F Pour donner la signature d'un semi-prédicat, il faut indiquer que le résultat peut être une valeur d'un certain type ou la valeur booléenne faux. On utilise pour cela le signe +. Par exemple, la signature de la fonction string->number s'écrira: ; ; ; string->number: string -->Nombre + #f Du fait qu'en Scheme, toute valeur non égale à #Fest considérée comme étant vraie, les semi-prédicats peuvent être utilisés de façon très bénéfique comme condition d'une alternative. Par exemple1 , considérons la fonction f qui a comme spécification : ; ; ; f: string --> Nombre ; ; ; (f chaine) rend le nombre représenté par la chaîne de caractères «chaine» lorsque «chaine» ; ; ; correspond à l'écriture d'un nombre; rend 0 sinon
et qui peut être testée par : (f
"123")
-t
123
(f "cent vingt trois") --> 0 Elle peut être définie par : (define (f chaine) (if (string->nurnber chaine) (string->nurnber chaine) 0 ) )
Noter qu'il y a deux fois l'expression (string->nurnber chaine) dans cette définition : la première est une condition pour un if, condition qui peut être fausse (lorsque sa valeur est #F) ou vraie (dans tous les autres cas) ; la seconde occurrence de cette expression rend la valeur numérique attendue lorsque la chaîne donnée représente un nombre.
7 Conditionnelle À la place d'une cascade d'alternatives, il est possible, en Scheme, d'utiliser la construction conditionnelle cond qui permet d'enchaîner une suite de tests de façon plus lisible. ; ; ; signe : Nombre --> Nombre ; ; ; (signe x) rend -1, 0 ou +1 selon que «X» est négatif, nul ou positif.
(define (signe x) (cond ( (< x 0) -1) ( (=
x 0) 0)
(else 1))) 1 Ceci
n'est qn'un exemple d'école; une utilisation beaucoup plus intéressante est donnée page 'Tl.
Chapitre 1. Noyau de Scheme
22
ji Lorsqu'on a défini, en« mathématique», la fonction signe, on a énuméré explicitement ~ tous les cas : n < 0, n = 0 et n > O. En traduisant cette définition dans notre langage de programmation, on a, pour l'efficacité, supprimé le dernier test (n > 0) qui est inutile. En effet, si les deux premiers tests ont échoué, alors, nécessairement2 , n est strictement supérieur à 0 et il est inutile de calculer un nouveau test pour s'en assurer.
7.1
Règle de grammaire d'une conditionnelle La syntaxe d'une conditionnelle suit les règles de grammaire suivantes :
-+
-+
-+
( cond
)
* * (el se <expression> ) (
<expression> )
ji La règle de grammaire qui définit est écrite sur deux lignes ce qui signifie ~ que l'on peut prendre l'une ou l'autre des possibilités. Les fonctions définies dans cet ouvrage ne feront usage que de la seconde possibilité (où la dernière clause est un el se). il est en effet prudent qu'un programme prévoit un traitement pour tous les cas possibles y compris ceux qui a priori n'intéressent pas le programmeur! Attention aux parenthèses : il y a un couple de parenthèses pour la forme cond et chacune des clauses est également entourée de parenthèses. Une clause commence par une condition qui est dans la plupart des cas une application. Il y a donc, en général, deux parenthèses ouvrantes après cond.
7.2
Évaluation d'une conditionnelle Considérons une expression conditionnelle de la forme :
(cond (c1 ell (c2 e2}
(el se
ek} } }
Elle s'évalue ainsi : - la condition c1 est d'abord évaluée ; si sa valeur est vraie, alors, et alors seulement, l'expression e1 est évaluée et la valeur de l'expression conditionnelle est égale à la valeur de e1 ; - sinon, la condition c2 est évaluée ; si sa valeur est vraie alors e 2 est évaluée et la valeur de la conditionnelle est égale à la valeur de e2 ; - sinon, si toutes les conditions c1, c2, etc. ont rendu liF alors la valeur de l'expression conditionnelle est égale à la valeur de l'expression ek qui suit le el se. On peut voir ici el se comme une condition jamais fausse (el se est ainsi équivalent, mais plus lisible, que liT). 2 Ceci n •est bien sûr vrai que dans le sous-ensemble de Scheme auquel nous nous sommes restreints
les nombres complexes.
et qui exclut
23
Nommage de valeurs
Ainsi, dans la fonction suivante, l'évaluation de l'expression ne provoquera jamais d'erreur, quelle que soit la valeur de x, puisque l'enchaînement des conditions exclut toute division par zéro. (de fine (exo-cond x) (cond ( (>= x 3) (/ 1 (* (- x 1) (- x 2)))) ( (>= x 2) (/ 1 (* (- x 1) (- x 3)))) ( (>= x 1) (/ 1 (* (- x 2) (- x 3)))) (else ( 1 1 (* (-x 1) (-x 2) (-x 3))))))
Une autre façon d'expliquer l'évaluation d'une forme spéciale cond est de la traduire en imbrication d'alternatives. (cond (c1 etl (cond (cl etl
(else e2ll
(c2 e2l
=?(if c1 e1 e2l ... ) =? (if c1 e1 (cond (c2 e2l
... ) )
La sémantique du if explique alors celle du cond.
8 Nommage de valeurs Soit à écrire une définition de la fonction qui, étant données les longueurs des trois côtés d'un triangle quelconque, rend l'aire de ce triangle. Pour ce faire, on peut consulter un vieil ouvrage de mathématique et trouver le théorème suivant :
Théorème: l'aire A d'un triangle quelconque de côtés a, b etc est telle que
A=
J 8(8- a)(8- b)(8- c)
avec 8 = (a+ b + c)/2
On peut reformuler ce théorème de façon équivalente :
Théorème : étant donné un triangle quelconque de côtés a, b et c, en nommant (a+ b + c)/2, son aire est égale à J 8(8- a)(8- b){8- c).
8.1
8
la valeur
La forme let
En Scheme, on réalise cette opération de nommage avec une forme 1 et qui permet de lier la valeur d'une expression à un nom en vue de son usage ultérieur. Une fonction de calcul de l'aire d'un triangle pourra être défiuie ainsi : (define (aire-triangle a b c) (let ( ( s (! ( + a b c ) 2)) ) (sqrt (* s (-sa) (-sb)
(-sc)))))
L'association de la valeur d'une expression à un nom dans la forme let s'appelle une liaison. On peut nommer plusieurs valeurs. Par exemple, la fonction un -1 ier-polynome permet de calculer la valeur du polynôme à deux variables - x 2 y 2 + x 2 +y 2 -en nommant les valeurs de x 2 et y 2 et en les utilisant dans l'expression finale : (define (un-1ier-polynome x y) (let ((x2 (*xx)) (y2 ( * y y)) ) (+ (* x2 y2) x2 y2)))
24
Chapitre 1. Noyau de Scheme
8.2
Règle de grammaire du 1 et La forme let est régie par les règles de grammaires suivantes:
(let
---+
---+
(
( * ) )
<expression> )
Attention aux parenthèses: lorsque l'on n'a qu'une seule liaison, elle est entourée par deux couples de parenthèses !
8.3
Évaluation d'une forme let
La valeur d'une forme let est la valeur de l'expression qui constitue le corps du let. Pour évaluer cette expression, 1' évaluateur : - évalue, pour chaque liaison, l'expression apparaissant comme second terme de la liaison; - évalue le corps du let où les noms ont été remplacés par les valeurs associées. Voici, un petit schéma illustrant, sur un exemple, le mécanisme décrit : 1 (let
( (x2
( * 3 3) )
(y2
(let ( (x2 1 ( * 3 3) 1)
(let ((x2
9)
1
(let ( (x2 9)
1
(+
(* 9 25)
(y2
( * 5 5)) ) ( * 5 5) ) )
(y21 (*55) 1))
(y2
9 25)
25 ) )
(+
(+
( + ( * x2 y2) x2 y2) ) 1
( + ( * x2 y2) x2 y2) )
(* x2 y2)
x2 y2))
( * x2 y2) x2 y2) )
1
1
259
Schéma d'évaluation: forme spéciale let Le chapitre 6 donne une sémantique plus précise de la forme let.
9
Notions de bloc et de portée
Une forme spéciale let définit ce que l'on appelle un bloc délimité par la parenthèse ouvrante précédent le let et la parenthèse fermante appariée. Ce bloc détermine la portée des (noms des) variables, c'est-à-dire la portion de texte où une variable est connue, et donc utilisable.
Remarque: les notions de bloc et de portée ne concernent pas uniquement la forme let : une définition de fonction, introduite par le mot clef define, détermine également un bloc.
25
Notions de bloc et de portée
9.1
Portée des variables dans un let
Dans le bloc constitué par une forme let, la portée des variables liées par la forme let est le corps de la forme let. Ce dernier peut être soit une expression simple (une application, une alternative ... ) soit à nouveau une forme let. ~
Exemple Soit à écrire une définition de la fonction un-2eme-polynome de spécification:
; ; ; un-2eme-polynome : Nombre ---> Nombre ; ; ; (un-2eme-polynome x) rend la valeur de x 4 + (2 *Y?)
Si l'on est un peu pressé d'écrire le définition, on donnera cette formulation naïve: (define (un-2eme-polynome x) ( + ( * x x x x) ( * 2 x x) ) )
En regardant un peu cette définition, on réalise que x 4 = (x 2 ) 2 . On peut donc utiliser x 2 pour calculer x 4 • Connaissant l'existence de la forme 1 et on sera alors tenté d'écrire la définition erronée suivante : (define (un-2eme-polynome-errone x) (let ((x2 (*xx)) (x4 ( * x2 x2) ) ) ( + x4 ( * 2 x2) ) ) )
Mais ce serait méconnru"tre la forme let. Une telle définition est erronée car la variable x2, définie par la première liaison (x2 ( * x x) ) , n'est pas connue dans l'expression de la deuxième liaison (x4 ( * x2 x2) ) . En effet, la portée d'une liaison est le corps du let (ici, ( + x4 ( * 2 x2 ) ) ) et pas la suite des liaisons. Pour y remédier, avec la forme let, il faut imbriquer les liaisons comme dans la définition ci-dessous : (define (un-2eme-polynome x) (let ((x2 (*x x))) (let ((x4 (*x2x2))) ( + x4 ( * 2 x2) ) ) ) )
9.2
Forme let*
L'écriture de let imbriqués est un peu lourde. Pour remédier à cette lourdeur, il existe en Scheme une autre forme, le let* qui permet d'écrire plus simplement la fonction : (define (un-2eme-polynome x) (let* ((x2 (*xx)) (x4 (* x2 x2))) ( + x4 ( * 2 x2) ) ) )
L'évaluation d'une application de cette fonction, sur un exemple, obéit au schéma suivant : 1 (un-2eme-polynome 7) 1 1 (let* ( (x2 ( * 7 7) ) (let* ((x21 (* 7 7)
Il
(x4 ( * x2 x2) ) ) (x4 (* x2 x2)))
( + x4 ( * 2 x2) ) ) 1 (+ x4
(* 2 x2)))
26
Chapitre 1. Noyau de Scheme
(let*
( (x2
(let*
49 )
(x4
( (x2 49)
(let*
(x4
( (x2 49)
1
1
1
(x4
(* x2 x2)
(* 49 49)
2401 )
(+ 2401
1
(+ x4
1>
1>
(+ x4
(+ x4
(* 2 49))
(* 2 x2)))
(* 2 x2)))
(* 2 x2))
1>
1
2499
Schéma d'évaluation :forme spéciale let*
9.3 Masquage d'un nom Dans ce livre, on aurait pu ne présenter que la forme primitive let puisque l'on peut remplacer tout let* par une imbrication de let mais nous avons voulu présenter aussi la forme 1 et* pour ne pas avoir à alourdir nos définitions. Par contre, nous ne pouvions pas présenter uniquement la forme let* car il n'est pas toujours possible de remplacer un let par un let* comme le montre l'exemple d'école suivant: (let((a3)) (let ((a 5) (+
(b a) a b)
)
)
La portée de la variable a du let intérieur est le corps (uniquement le corps) de ce let soit l'expression ( + a b) ; le a de cette expression a donc pour valeur 5 et « masque » le a du let englobant. La portée de la variable a du let englobant est le corps de ce let soit le let intérieur; à la variable b est donc associée la valeur 3. L'expression ( + a b) a donc pour valeur : (+53)
----+8
La précédente explication est illustrée par le schéma d'évaluation suivant: 1
(let ((a 3))
1
(let ((a
3 ))
(let ((a 5)
1
(ba))
(let ((a 5)
(+ab)))
(b a))
1
(+ a b))
1> 1
Schéma d'évaluation :forme let (avec masquage) Arrivé en ce point, il faut remplacer a dans l'expression qui forme le corps du let. Ici, il s'agit d'une nouvelle forme let qui introduit un nouveau a et un b et qui utilise deux occurrences de a : la première comme valeur associée à b, la seconde dans l'expression ( + a b), corps du second let. Le remplacement n'a d'effet que sur la première utilisation de a.
Notions de bloc et de portée
27
1
(let ( (a 5)
1
(let ( (a
5 )
(b Œ]l)
(+ a b) )
1
(let ( (a
5 )
(b
3 ))
(+ a b)
1
(+ 5 3)
1
(b 3))
(+ a b))
1
l'
1
8
Schéma d'évaluation: (suite) Le let est une forme essentielle dont on ne peut pas se passer.
9.4
Définition interne
Dans une définition, il est possible de définir d'autres fonctions accessibles uniquement dans le bloc qu'elle détermine. On parle alors de définitions internes de fonctions. On pourra utiliser les définitions internes pour des raisons esthétiques (alléger l'écriture) mais aussi, et surtout, pour factoriser des calculs qui peuvent dépendre des variables de la définition englobante. Voici par exemple une petite fonction de manipulation de chaînes de caractères : ; ; ; trois-prefixes : nat * string * string * string ---t string ; ; ; (trois-prefixes n chi ch2 ch3) rend la chaîne constituée des 3 préfixes de ; ; ; longueur «n» de «chi», «ch2» et «ch3» separés par une barre de fraction «!». ; ; ; ERREUR si l'une des chaînes «chi», «ch2» ou «ch3» est de longueur inférieure à «n» (define (trois-prefixes n chl ch2 ch3) ; début de la définition interne ; ; prefixe-n : string ---t string ; ; (prefixe-n ch) renvoie le préfixe de longueur «n» de «ch» ; ; ERREUR si «Ch» est de longueur inférieure à <
et un exemple d'application de cette fonction : (trois-prefixes 3 "12345" "abcde" "ABCFEF") ---t "123/abc/ABC" Dans cette définition, la fonction interne prefixe-n dépend de l'argument n de la fonction englobante trois-prefixes et l'expression finale de trois-prefixes dépend de la fonction interne prefixe-n.
9.5
Règle de grammaire du corps Reprenons les règles de grammaire d'une définition et d'un let: ----+ ( def ine ( <nom-fonction> *) ) ----+ (let ( * ) )
28
Chapitre 1. Noyau de Scheme
Dans un premier temps, nous avions donné pour règle de grammaire du corps :
<expression>
--+
Mais en fait, le corps d'une définition ou d'un let peut être plus complexe qu'une simple expression puisque l'on peut ajouter des définitions internes:
* <expression>
--+
Remarque : nous verrons, page 282, que l'on peut avoir plusieurs expressions dans un corps, mais, hormis dans le dernier chapitre, nous n'utiliserons pas cette possibilité dans cet ouvrage.
10 Exemple récapitulatif Pour finir cette section, traitons un exemple qui utilise (presque) toutes les constructions Scheme que nous avons vues. Le problème est d'écrire la définition d'une fonction qui calcule le nombre de solutions d'une équation du second degré. Pour résoudre ce problème, nous allons écrire une fonction dont la spécification est : ; ; ; nombre-solutions :Nombre *Nombre *Nombre --+ nat ; ; ; (nombre-solutions abc) rend le nombre de solutions de l'équation a.x 2
+ b.x + c = 0
L'algorithme est bien connu : étant donnée une équation du second degré, ax 2 + bx + c = 0, on pose t:. = b2 - 4ac et, selon que t:. est négatif, nul ou positif, le nombre de solutions est respectivement 0, 1 ou 2. TI faudra donc obtenir, par exemple : (nombre-solutions 3 2 -3) --+ 2 (f:.. = 22 + 4 X 3 X 3 = 40) (nombre-solutions 3 2 3) --+ 0 (f:. = 22 - 4 x 3 x 3 = -32) (nombre-solutions 1 2 1) --+ 1 (f:.. = 22 -4 x 1 x 1 = 0) Voici une première définition de la fonction nombre-solutions: (define (nombre-solutions a b c) (let ((delta (- (* b b) (* 4 a c)))) (if (< delta 0) 0 (if (= delta 0) 1 2)
)
)
)
Cette définition utilise : - une forme let pour la valeur du déterminant f:.., - valeur qui est calculée avec des applications (imbriquées), - ainsi que la forme alternative if. Naturellement, il est possible de donner une autre définition de cette fonction en remplaçant les deux alternatives imbriquées par une forme conditionnelle cond : (define (nombre-solutions a b c) (let ((delta(- (*bb) (*4ac)))) (cond ((<delta 0) 0) ( (= delta 0) 1) (else 2) ) ) )
29
Conclusion
11
Conclusion
Ce chapitre a décrit le noyau d'un langage de programmation et vous êtes maintenant en mesure d'écrire des programmes en Scheme sur des sujets variés. N'ont été exposés que les traits structurants du langage, l'application et ce que l'on nomme ses formes spéciales (on parle également de mots-clefs dans d'autres langages de programmation). Pour être utilisable, un langage s'adjoint également le concours de fonctions prédéfinies et, plus il y en a, plus il y a de chance d'en trouver une adaptée à son problème. Toutefois le nombre de fonctions n'accroît pas la complexité du noyau qui n'est mesurée que par le nombre de formes spéciales et, sur ce plan, Scheme est le moins demandant de tous les langages de programmation de haut niveau. Le noyau et les fonctions prédéfinies tiennent sur la carte de référence (page 327) et permettront, au chapitre 10, d'écrire Scheme en Scheme!
12 Exercices corrigés 12.1 Énoncés Exercice 1 - Calcul de fonctions polynomiales Cet exercice a pour but de définir des fonctions simples de calcul de polynômes. Question 1 - Après avoir spécifié le problème, écrire un jeu de tests et une définition Scheme de la fonction, nommée polynomiale, telle que (polynomiale a b c d x) rend la valeur de la fonction qui à x associe ax 3 + bx 2 + ex + d. Par exemple : (polynomiale 1 1 1 1 2) (polynomiale 1 1 1 1 3)
-. 15 -. 40
Question 2 - Après avoir spécifié le problème, écrire un jeu de tests et une définition de la fonction polynomiale-carre qui rend la valeur de la fonction ax4 + bx 2 + c. Par exemple : (polynomiale-carre 1 1 1 2) (polynomiale-carre 1 1 1 3)
-. 21 -. 91
Exercice 2- Dessin d'un sablier L'objectif de cet exercice est de travailler sur les paramètres nécessaires pour résoudre un problème : bien sûr, il faut qu'il y ait tous les paramètres nécessaires à la résolution du problème, mais il ne faut pas qu'il y en ait davantage. Autrement dit les différents paramètres doivent être indépendants et l'on doit donc pouvoir appeler la fonction correspondante avec n'importe quelles valeurs, sous réserve de respecter les contraintes précisées dans la signature de la fonction (ici, les coordonnées sont comprises entre -1 et 1). Nous voudrions définir une fonction qui renvoie des dessins comme celui dessiné ci-après (où les deux triangles sont symétriques). 1. Quels sont les paramètres nécessaires ? En déduire une spécification de la fonction. 2. Donner un jeu de tests. 3. Donner une définition Scheme de la fonction correspondant à la spécification choisie.
30
Chapitre 1. Noyau de Scheme
Exercice 3 - Prédicat pour un nombre positif Cet exercice a pour but d'écrire des prédicats. Question 1- Écrire une définition Scheme du prédicat positif? qui, étant donné un nombre n, retourne vrai si, et seulement si, n est strictement positif. Par exemple : (positif? 3) (positif? -3) (positif? 0)
#T -+ #F -+ #F -+
Question 2- Écrire une définition Scheme du prédicat nombre-positif? qui, étant donnée une valeur quelconque x, retourne vrai si, et seulement si, x est un nombre et s'il est strictement positif. Par exemple : (nombre-positif? 3) -+ #T (nombre-positif? -3) -+ #F (nombre-positif? "toto") -+ #F
Exercice 4 - Prédicats pour division entière Cet exercice a pour but d'écrire des prédicats et un semi-prédicat vérifiant qu'un nombre divise un autre nombre. Question 1 - En n'utilisant pas les prédicats prédéfinis even? et odd?, écrire une définition Scheme du prédicat nombre-pair? qui, étant donné un nombre entier, rend vrai si, et seulement si, ce nombre est pair. Question 2- Écrire une définition Scheme du prédicat di vise? qui, étant donnés un entier strictement positif m et un entier n rend vrai si, et seulement si, m divise n. Par exemple : (divise? 3 12) (divise? 6 35) (divise? 8 2)
-+ -+ -+
#T #F #F
Question 3- Écrire une autre définition du prédicat nombre-pair?, qui utilise la fonction divise?.
Question 4- Écrire une définition Scheme du semi-prédicat quotient-si-divise qui, étant donnés un entier strictement positif m et un entier n rend la division de n par m si m divise n et le booléen faux sinon. Exercice 5 - Calcul des mentions Cet exercice a pour but de faire manipuler des alternatives imbriquées. ll s'agit de calculer la mention correspondant à une note sur 20. Question 1- Écrire une définition Scheme de la fonction mention qui calcule la mention correspondant à une note (sur 20) donnée. Par exemple : (mention (mention (mention (mention (mention
8.5) -+ "Eliminé" 10) -+ "Passable" 12. 5) -+ "AB" 15) -+ "B • 16.5) -+ "TB"
Les mentions sont attribuées selon les intervalles de notes suivants :
[0, 10[ [12,14[ [16, 20]
"Eliminé" "AB" "TB"
[10, 12[ [14,16[
"Passable" "B"
Dans cette question vous utiliserez des alternatives imbriquées. Question 2- Écrire une autre définition de la fonction men ti on (avec la même spécification), qui utilise la conditionnelle cond.
31
Exercices corrigés
12.2 Corrigés Exercice 1 - Calcul de fonctions polynomiales Solution de la question 1 :
1. Spécification : - Les données nécessaires au calcul du polynôme sont les 5 nombres a, b, c, d et x - Le résultat obtenu est un nombre, égal à ax3 + bx 2 + ex + d On en déduit : - le type de la fonction qui prend 5 nombres en arguments et donne pour résultat un nombre, - la signature de cette fonction, - et sa spécification : ; ; ; polynomiale : Nombre *Nombre *Nombre *Nombre *Nombre -+ Nombre ; ; ; (polynomiale a b c d x) rend la valeur de a *x3 + b*x' + c*x + d
2. Jeu de tests : un jeu de tests de la fonction doit comporter les exemples proposés mais aussi des applications de la fonction qui permettent de tester la définition de la fonction. {polynomiale {polynomiale {polynomiale {polynomiale {polynomiale {polynomiale {polynomiale
1 1 2 0 0 1 2
1 1 0 3 0 2 3
1 1 0 0 4 3 4
1 1 0 0 0 4 5
2} 3} 1} 1} 1} 0} 1}
-+ -+ -+ -+
-+ -+
-+
15 40 2 3 4 4 14
3. Implantation: - Première définition : {define {polynomiale a b c d x} {+
{* a x x
x}
{* b x x}
{* c x}
d}} - En utilisant le schéma de Homer, on obtient, pour la même spécification, une seconde définition, plus efficace, car elle réduit le nombre de multiplications par rapport à la première définition (et ce d'autant que le degré du polynôme est plus élévé): {define {polynomiale a b c d x} {+
{*
{+
{*
{+
{* a x}
b}
x}
c}
x}
d}}
Solution de la question 2 :
1. Spécification : - Les données nécessaires au calcul du polynôme sont les 4 nombres a, b, c et x - Le résultat obtenu est un nombre, égal à ax 4 + bx 2 + c On en déduit la spécification de la fonction : ; ; ; polynomiale-carre : Nombre *Nombre *Nombre *Nombre -+Nombre ; ; ; (polynomiale-carre abc x) rend la valeur de a*x4 + b*x2 + c
32
Chapitre 1. Noyau de Scheme
2. Jeu de tests : {polynomiale-carre {polynomiale-carre {polynomiale-carre {polynomiale-carre {polynomiale-carre {polynomiale-carre
1 1 1 2} 1 1 1 3} 2 0 0 1} 0 3 0 1} 2 3 4 0} 2 3 4 1}
21 91 --+ 2 --+ 3 --+ --+
--+ 4 --+ 9
3. Implantation : (a)
Première définition : {define {polynomiale-carre a b c x} { + { * a x x x x}
{*
b x x}
c}}
(b)
Deuxième définition (avec le schéma de Homer), pour la même spécification: {define {polynomiale-carre a b c x} {+
(c)
{*
{+
{* a x x}
b}
x x}
c}}
Troisième définition (en nommant une valeur auxiliaire), pour la même spécification: {define {polynomiale-carre a b c x} {let {{u {* x x}}} { + { * a u u} { * b u} c} } }
(d)
Quatrième définition (en nommant une valeur auxiliaire et en utilisant le schéma de Homer), pour la même spécification : {define {polynomiale-carre a b c x} {let {{u {* x x}}} {+
(e)
{*
{+
{* a u}
b}
u}
cl}}
Cinquième définition: bien entendu, on peut également définir polynomialecarre en utilisant la fonction plus générale polynomiale définie à la question précédente et en l'appliquant aux bons arguments : {define {polynomiale-carre a b c x} {polynomiale 0 a b c {*x x}}}
Exercice 2 - Dessin d'un sablier Voici une première solution :
1. La spécification où sont précisés les arguments : xl
x2
; ; ; ;
; ; ; ;
; ; ; ;
sablier : float/compris entre -1 et 11' --+ Image (sablier xl x2 yl y2) rend un sablier inscrit dans le rectangle délimité par les droites verticales d'abscisses «xl», «X2» et par les droites horizontales d'ordonnées «yi» et «y2».
33
Exercices corrigés
2. Un jeu de tests: (sablier (sablier (sablier (sablier (sablier (sablier (sablier (sablier
0.1 0.7 -0.2 0.8) -1 1 -1 1) -1 0 0 -1)
1 0 0 -1) -1 0 1 0) -1 0 -1 1) 0 1 1 0) 0.5 -0.5 0.5 -0.5)
3. Une définition correspondant à la spécification mentionnée précédemment : (define (sablier xl x2 yl y2) (let ((x-milieu (/ (+xl x2) (y-milieu {/ (+ yl y2) (overlay (filled-triangle xl yl x2 (filled-triangle xl y2 x2
2)) 2))) yl x-milieu y-milieu) y2 x-milieu y-milieu))))
Noter que dans la définition de la fonction, il n'est fait aucun test pour vérifier que les coordonnées passées en arguments sont bien comprises entre -1 et 1. Ces contraintes, que doivent respecter les arguments, sont précisées dans la signature de la fonction.
Autre solution : 1. La spécification où sont précisés les arguments : x
1
sym.
l]- ir---+---,
; ; ; ; ;
; ; ; ; ;
; ; ; ; ;
sablier2 : float/compris entre -1 et Il' --+Image (sablier2 xl sym yl y2) rend un sablier, symétrique par rapport à la droite verticale d'abscisse «sym», deux des sommets ayant comme coordonnées (xl, yi) et (xl, y2). ERREUR lorsque 2 *sym - xl n'est pas compris entre -1 et 1
2. Un jeu de tests: (sablier2 (sablier2 (sablier2 (sablier2 (sablier2 (sablier2 (sablier2 (sablier2
0.1 0.4 -0.2 0.8) -1 0 -1 1) -1 0 0 -1) 1 0 0 -1) -1 -0.5 1 0) 0 0.5 -1 1) -0.5 -0.3 1 0) 0.1 0.5 0.5 -0.5)
3. Une définition correspondant à la spécification mentionnée précédemment : (define (sablier2 xl sym yl y2) (let ((x2 (- (* 2 sym) xl)) (y-milieu (/ (+ yl y2) 2))) (overlay (filled-triangle xl yl x2 yl sym y-milieu) (filled-triangle xl y2 x2 y2 sym y-milieu))))
Noter, que nous avons donné pour nom à cette fonction, dans cette solution, « sablier2 »et non« sablier». Nous avons, de cette manière indiqué qu'il s'agit, non d'une autre définition
34
Chapitre 1. Noyau de Scheme
pour une même spécification de « sablier » mais bien d'une autre fonction permettant de dessiner, certes, un sablier mais avec une spécification différente de la première.
Autre solution- Et il y a d'autres solutions, par exemple en considérant l'axe de symétrie horizontale, ou en donnant les coordonnées du point central ou... Exercice 3 - Prédicat pour un nombre positif
Solution de la question 1 - Nous emploierons l'abréviation << ssi » pour << si et seulement si >> ce qui permet de raccourcir la spécification qui suit : ; ; ; positif? : Nombre - t boo/ ; ; ; (positif? n) rend #t ssi «n» est strictement positif (define (positif? n) (> n 0))
Attention : il serait totalement superfétatoire d'écrire : ; ; ; MOCHE!
(define (positif? n) (if (> n 0) #t #f ) )
Solution de la question 2 - ll faut que x soit un nombre et que ce nombre soit positif : la forme spéciale and est appropriée pour le traitement de ce cas car elle n'évalue la seconde expression qui la compose que si l'évaluation de la première renvoie vrai. ; ; ; nombre-positif? : Valeur - t boo/ ; ; ; (nombre-positif? x) rend #t ssi «X» est un nombre strictement positif (define (nombre-positif? x) (and (number? x)(> x 0)))
Remarquer que le type de l'argument utilisé dans la spécification est Valeur et non plus Nombre. On peut aussi bien sûr écrire : (define (nombre-positif? x) (and (number? x) (positif? x)))
Exercice 4 - Prédicats pour division entière
Solution de la question 1 :
1. Spécification : ; ; ; nombre-pair?: int - t boo/ ; ; ; (nombre-pair? n) rend vrai ssi «n» est pair
2. Un jeu de tests: (nombre-pair? (nombre-pair? (nombre-pair? (nombre-pair? (nombre-pair?
- t #T -10) t -9) #F 0) - t #T - t #F 5) 6) - t #T
3. Une définition : on peut tester que le reste de la division par 2 est nul. (define (nombre-pair? n) (= 0 (remainder n 2)))
35
Exercices corrigés
Solution de la question 2 :
1. Spécification : ; ; ; divise? : nat/non nuV * int ---> bool ; ; ; (divise? mn) rend vrai ssi «m» divise «n»
Remarquer comment, dans la spécification, le type de la fonction précise que le premier argument doit être un entier positif non nul (i.e. : un entier naturel non nul). 2. Un jeu de tests: (divise? (divise? (divise? (divise? (divise? (divise? (divise? (divise? (divise? (divise?
3 6 8 1 2 3 1 2 3 1
12) ---> #T 35) ---> #F 2) ---> #F 10) ---> #T 10) ---> #T 10) ---> #F -10) ---> #T --+ #T -10) -10) ---> #F 0) ---> #T
3. Une définition : (de fine (divise? rn n) (= 0 (remainder n rn)))
Solution de la question 3 : (define (nombre-pair? n) (divise? 2 n)) Solution de la question 4 :
1. Spécification: la fonction quotient-si-divise est un semi-prédicat. ; ; ; quotient-si-divise : nat/non nuV * int --+ int + #f ; ; ; (quotient-si-divise mn) rend la division de «m» par «n» si «m» divise «n» ; ; ; et le booléen faux sinon
2. Un jeu de tests: (quotient-si-divise (quotient-si-divise (quotient-si-divise (quotient-si-divise (quotient-si-divise (quotient-si-divise (quotient-si-divise (quotient-si-divise (quotient-si-divise (quotient-si-divise
3 6 8 1 2 3 1 2 3 1
12) ---> 4 35) ---> #F 2) ---> #F --+ 10 10) 10) ---> 5 10) ---> #F -10) ---> -10 -10) ---> -5 --+ #F -10) 0) ---> 0
3. Une définition: (define (quotient-si-divise rn n) (if (divise? rn n) (quotient n rn) #f))
36
Chapitre 1. Noyau de Scheme
Exercice 5 - Calcul des mentions Solution de la question 1 :
1. La spécification où sont précisés les arguments : ; ; ; mention: nat/<=201 - t string ; ; ; (mention n) rend la mention correspondant à la note «n»
2. Un jeu de tests: (mention (mention (mention (mention (mention (mention (mention (mention (mention (mention (mention
~ ''Eliminé 0) 8.5) ---+ ''El iminé '' 10) --+ "Passable'' --+ n Passable'' 11. 5) 12) --+ ''ABn --+ ''AB'' 12. 5) 14) --+ ''B'' 15) --+ ''B'' 16) --+ ''TB 16.5) --+ ''TB'' --+ ''TB 20) 11
11
11
3. ll y a beaucoup de solutions pour une définition correspondant à la spécification mentionnée précédemment : - En voici une première : (define (mention n) (if (< n 10) "Eliminé (if (< n 12) ''Passable'' (if (< n 14) 11
''AB''
(if
(< n uB''
16)
"TB")))))
Remarquer que l'évaluation de (mention n) nécessite un test lorsque 0 ~ n < 10, deux tests lorsque 10 ~ n < 12, trois tests lorsque 12 ~ n < 14, et quatre tests lorsque 14 ~ n ~ 20. - Lors de l'évaluation, la version suivante permet d'effectuer, statistiquement, moins de tests que la précédente, particulièrement si l'on suppose qu'il n'y a pas beaucoup de notes inférieures à 10 et de nombreuses notes supérieures à 12 (mais il y a plus de tests lorsque la plupart des étudiants n'ont pas la moyenne ... ). (define (mention n) (if (< n 12) (if (< n 10) ''Eliminén (if
''Passable'') (< n 14) ''ABn
(if
(< n
16)
''B''
"TB"))))
37
Exercices corrigés
Avec cette version, l'évaluation de (mention n) nécessite deux tests lorsque 0 !!( n < 14 et trois tests lorsque 14 !!( n !!( 20. - Et voici encore une autre version. Pensez-vous que l'on aura intérêt à l'utiliser, si l'on veut faire le moins de tests possibles pour calculer les mentions de votre promotion ? (define (mention n) (if
(>= n 16) "TB 11 (if (>= n 14) ''B'' (if (>= n 12) ''AB'' (if (>= n 10)
Passable" "Eliminé"))))) 11
Solution de la question 2- Le programme suivant est équivalent (c'est-à-dire fait les mêmes évaluations) que la solution 1 de la question 1. (define (mention-cond n) (cond ( (< n 10) "Eliminé") ( (< n 12)
"Passable") ( (< n 14) "AB") ( (< n 16) "B") (else "TB'')))
Chapitre 2
Art et usage des spécifications Le chapitre précédent a présenté les principales caractéristiques d'un langage de programmation ce qui permet de disposer d'un langage permettant d'écrire des programmes. La programmation cependant est un acte profondément littéraire. Connaître la grammaire d'une langue et disposer d'un riche vocabulaire ne suffisent pas pour la rédaction d'une dissertation. ll faut pour cela faire un plan, avancer ses arguments dans le bon ordre, les composer logiquement pour enfin arriver à la conclusion souhaitée. Et tout comme les dissertations, plus d'une conclusion est possible et plus d'un style est adoptable pour emporter l'adhésion! Le présent chapitre ne traite que de spécifications : ce qu'elles sont, à quoi elles servent, comment les écrire et comment (à l'aide de tests) les éprouver. Écrire un programme (dans un quelconque langage de programmation et pas seulement en Scheme) nécessite avant toute chose de spécifier ce qui en est attendu. L'écriture même du programme nécessitera d'utiliser des fonctions prédéfiuies qui ne sont utilisables que conformément à leur spécification. Une fois le programme écrit, il faudra vérifier qu'il est conforme à sa spécification. S'ill'est, il pourra être mis en œuvre au sein de programmes plus conséquents tout comme le furent les fonctions prédéfinies. C'est ainsi que, couche après couche, se construisent les logiciels les plus complexes.
1 Spécification d'une fonction La spécification d'une fonction est une description précise de cette fonction. Sont décrites: (i) les conditions avec lesquelles la fonction peut être employée (par le programmeur de la fonction ou par d'autres programmeurs), (ii) la valeur qu'elle calcule. Après cette phase indispensable de spécification, le programmeur devra écrire une définition de la fonction (on dit qu'il implante cette fonction). Pour cela, il se demandera comment l'ordinateur peut rendre la valeur définie par la spécification. Spécifier et implanter sont deux activités très différentes qui ne peuvent souvent pas se déduire l'une de l'autre. Certains problèmes de spécification simple n'ont pas d'implantation connue voire n'ont pas d'implantation du tout. Inversement certaines implantations sont si obscures qu'il est difficile de voir ce qu'elles calculent. Avec une terminologie plus mathématique, la spécification peut être vue comme un énoncé. Comprendre un énoncé n'équivaut pas à trouver une démonstration de cet énoncé.
40
Chapitre 2. Art et usage des spécifications
À spécification donnée il peut exister de multiples implantations plus ou moins efficaces, plus ou moins (re-)lisibles, plus ou moins délicates à concevoir. Plus curieusement peut-être, une même implantation peut être associée à plusieurs spécifications !
1
Une spécification ne doit pas s'intéresser au« comment». C'est un travers classique X chez les débutants que de « spécifier » une fonction en paraphrasant - en français le programme : encore une fois, il ne s'agit pas de dire comment sont effectués les calculs mais ce qu'ils calculent. Dire qu'une fonction fait ce qu'elle calcule n'apporte rien! Il existe toutefois des calculs tellement triviaux qu'il est difficile de faire le départ entre spécification et implantation. Et il existe des fonctions d'implantation simple mais de spécification délicate. Implanter et spécifier sont si dissemblables que ce sont souvent des métiers informatiques différents. La spécification est alors le contrat liant le spécifieur et l'implanteur: le spécifieur ne peut exiger plus que ce qui est décrit, l'implanteur ne peut réaliser moins que ce qui est décrit. En revanche, l'implanteur est libre de ses moyens de réalisation, de son style et de sa technique. Symétriquement, le spécifieur est libre d'utiliser comme ille souhaite la fonction produite. Le langage Scheme lui-même n'est qu'une spécification! Le document décrivant ce langage est actuellement intitulé le Revised revised revised revised revised report on Scheme ou, plus familièrement, le R 5 RS. C'est une norme d'une cinquantaine de pages (à titre de comparaison, la spécification de Java (hors bibliothèques) mesure environ 800 pages; la norme de C++ (avec bibliothèques) tient en 1800 pages). L'environnement de développement DrScheme contient une implantation de Scheme (et plein d'autres choses encore comme des fenêtres/menus/boutons, des fonctions prédéfinies non prévues par Scheme); la documentation de DrScheme tient en un peu moins de 800 pages. il existe d'autres implantations de Scheme comme Bigloo, Gambit, MIT-Scheme. Une fonction est spécifiée 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 le résultat ? Prenons un exemple: considérons la fonction que nous nommerons quantieme et qui, étant donnée une date, rend son quantième (c'est-à-dire le nombre de jours écoulés depuis le premier janvier de l'année de la date donnée). Pour cette fonction: - la donnée, une date, peut être constituée par trois entiers - le jour, le mois et l'année ou par deux entiers -le numéro du jour et l'année - et par une chaîne de caractères -le mois. Prenons la seconde possibilité et précisons encore l'ordre des données « jour », «mois>> et« année>> car c'est l'ordre normal en français (un américain choisirait plutôt l'ordre « mois >>, «jour >> et << année ») ; nous pouvons encore préciser que le mois est écrit en minuscules et sans accent, que le jour est un entier positif inférieur ou égal à 31 et que l'année est un entier supérieur ou égal à 1582 (année de naissance du calendrier grégorien). - le résultat est un entier, - qui est égal au nombre de jours écoulés depuis le premier janvier de l'année donnée. Ainsi, en attendant la forme verifier (cf 4.2), pourrions-nous écrire les tests suivants : (and (= 1 (quantieme 1 "janvier" 2001)) (= 33 (quantieme 2 "fevrier" 2001)) (= 365 (quantieme 31 "decembre" 2001))
Spécification d'une fonction
41
1.1 Utilisation d'une fonction Noter que l'on ne sait pas encore comment calculer ce résultat mais que, par contre, comme l'on sait ce que calcule cette fonction, il est déjà possible de prévoir son utilisation dans des calculs plus complexes comme le calcul du nombre de jours entre une date et la fin de l'année ou encore le nombre de jours que vous avez vécus depuis votre naissance (cf. exercice 8). ll suffit, pour appliquer une fonction, de connaître sa spécification : on doit savoir comment l'utiliser dans le langage et ce qu'elle rend. Par contre, la connaissance de son implantation est sans importance. C'est la même situation que pour les fonctions prédéfinies du langage : la carte de référence (page 327) contient la spécification des différentes fonctions mais ne dit rien sur leur implantation : sont-elles en Scheme, en C, en assembleur? Prenons une métaphore. Le permis de conduire exige de connru.l:re la spécification d'une voiture (volant, pédales, clignotant, etc. code de la route, usage divers, etc.) mais exclut la connaissance du moteur à explosion, des suspensions à cardan ou de la barre de torsion.
1.2 Syntaxe et sémantique En informatique, la spécification est habituellement scindée en deux parties : la syntaxe et la sémantique. La syntaxe fixe les règles d'emploi, la sémantique fixe le sens. Prenons encore une métaphore pour distinguer ces deux notions. La phrase« Napoléon est triangulaire >> est syntaxiquement correcte : elle est régulièrement construite, utilise des mots connus et correctement accordés. La phrase est étrange du point de vue du sens mais correcte sur le plan syntaxique. La phrase « le lapin tue le chasseur >> est non moins syntaxiquement correcte mais elle choque ceux qui connaissent le sens des mots «lapin>> et« chasseur >>. La syntaxe est réglée par la grammaire française (relativement) indépendamment du sens. La phrase « la chasseur sont mort >> est syntaxiquement incorrecte. Les catégories grammaticales de verbe, substantif, article... permettent de régenter la correction de la syntaxe. Prenons un exemple départageant syntaxe et sémantique. Dans la fonction quantierne, une date est exprimée par un triplet (mois, jour, année) avec quelques restrictions sur les valeurs que peuvent prendre ces trois composantes. Ainsi 14 "juillet" 1789 est une date syntaxiquement correcte tout comme l'est 2 9 " fevrier" 19 o o. En revanche, la date du 32 "decembre" 2003 n'est syntaxiquement pas correcte car le numéro du jour doit être inférieur à 31. Ainsi que l'illustre la date 29 "fevrier" 1900, ce qui est syntaxiquement correct n'a pas nécessairement de sens (car 1900 n'est pas bissextile). L'idéal est bien sûr d'essayer, le plus souvent, de faire en sorte que la syntaxe interdise ce qui n'a pas de sens. C'est infaisable pourla langue française etc' est déjà pénible à exprimer pour les seules dates. La sémantique est une description du sens des mots, des concepts, des fonctions. Mais pour décrire, il faut un cadre de référence supposé connu, inru.nbigu, immuable. Malgré ses imperfections (pas forcément connu, largement pourvu d'ru.nbiguïtés et changeant avec le temps), nous utiliserons le français pour cela. La spécification d'une fonction peut donc être vue sous deux points de vue : - Un point de vue syntaxique : il faut dire comment la fonction devra être utilisée afin d'avoir des phrases correctes par rapport à la syntaxe du langage. Cette partie syntaxique de la spécification est aussi appelée l'interface de la fonction ou la signature de la fonction et se fonde principalement sur la notion de typage.
42
Chapitre 2. Art et usage des spécifications
Nous utiliserons les mots « interface » et « signature » de façon interchangeable. La signature met l'accent sur le typage, l'arité, les contraintes sur les arguments. L'interface recouvre de façon plus indifférenciée ce qui doit être mis en œuvre pour utiliser la fonction. - Un point de vue sémantique : le programmeur doit connaître le sens de cette nouvelle fonction afin que les expressions écrites avec son aide aient un sens, autrement dit qu'elles aient bien la valeur que le programmeur attendait d'elles. Cette partie de la spécification est appelée la sémantique de la fonction. Connru"tre seulement la signature ou seulement la sémantique est insuffisant pour programmer! Savoir brancher une prise de courant n'est qu'un problème d'interface (dépendant du pays où l'on est) mais ne renseigne aucunement sur la nature du courant qui se cache derrière. Savoir que le courant est du 230 V, 60 Hz, monophasé est intéressant mais inexploitable sans prise!
,
2 Ecriture de la spécification d'une fonction Dans les programmes Scheme, nous écrivons (par convention propre aux auteurs de cet ouvrage) la spécification des fonctions sous forme de commentaires placés avant la définition. Les commentaires de Scheme que nous utiliserons seront préfixés de trois points-virgules en deôut de ligne. Les fonctions internes définies à l'intérieur d'une fonction seront précédés d'une spécification écrite dans des commentaires préfixés par deux points-virgules seulement. Par exemple : ; ; ; quantieme: nat/0<,<=311 *string* nat/>=15821---> nat ; ; ; (quantiemej rn a) rend le quantième dans l'année de la date «j» «rn» «a»
La première ligne correspond à la signature : elle indique le nom de la fonction puis le type des données et enfin le type du résultat. Les lignes suivantes énoncent la sémantique.
quantieme ..______.,__..,
nat E [1, 31] x string x nat :::: 1582 --> nat
nom
type signature
La signature indique la nature des arguments et mentionne également les contraintes que doivent respecter les arguments qui sont des nombres. La spécification est une description qui n'est pas libre puisqu'elle a la structure que nous venons d'indiquer. Comment assurer que les spécifications respectent cette structure? En définissant une syntaxe c'est-à-dire une grammaire, spécifiant l'écriture d'une spécification1 et en demandant aux programmeurs de respecter cette grammaire !
2.1
Grammaire pour la signature La signature met en correspondance le nom de la fonction (un identificateur) et son type. <signature> --> <nomfonction> : <J;ype-fonction>
10n
voit ici l'usage, que l'on qualifie de réflexif, où les concepts (syntaxe, sémantique) sont appliqués à ces mêmes concepts.
Écriture de la spécification d'une fonction
43
2.1.1 Types de base Un type représente un ensemble de valeurs. Le tableau 2.1 recense les principaux types de base. Les types de base entretiennent eux-mêmes des relations entre eux. Ainsi Nombre est-il le type de tous les nombres possibles, ce type contient donc les entiers naturels (déjà identifiés par nat à savoir 0, 1, 2, ... ), les entiers relatifs (identifiés par int ... , -2, -1, 0, 1, 2, ... ), les nombres flottants (identifiés par float comme par exemple -3.14159 ou 6e+23) mais aussi les nombres rationnels (comme 2/3) et, pour certaines implantations de Scheme, les nombres complexes (comme 1+li). Le type Nombre réunit tous les nombres lorsqu'il n'est pas utile de différencier les sous-types possibles. Cette union permet notamment d'identifier les nombres 1, 1.0, 2/2 et 1+0i comme étant les multiples avatars d'un unique concept (qui est prononcé «un>>). Le type Valeur est l'ensemble de toutes les valeurs possibles en Scheme. Ainsi un nat, un Nombre, une Image sont-ils tous des éléments de Valeur. Tous les types qui seront manipulés dans ce livre ne figurent pas dans le tableau 2.1 qui sera donc complété au fur et à mesure des chapitres suivants. N'est ici mentionné, en avance de phase, que le type Image qui recense toutes les petites vignettes rectangulaires que vous pourrez dessiner à l'aide des fonctions appropriées. nat int float Nombre string bool Image Valeur
-->
entiers naturels (positifs ou nul) (c'est-à-dire N) entiers relatifs (c'est-à-direZ) nombres flottants (un sous-ensemble de IQ>) nombres en tout genre chaînes de caractères booléens (Vrai et Faux) rectangles de pixels valeur en tout geme
nat ou int ou float ou Nombre ou string ou bool ou Image ou TAB. 2.1 -Types de base (partiel)
2.1.2 Types fonction Les types de fonctions ne sont pas exprimables à partir des types de base. Le langage des types va donc être enrichi afin de pouvoir représenter le type des fonctions. Un type de fonction est construit à l'aide de la flèche. Après la flèche se trouve le type de la valeur calculée par la fonction. Quelques fonctions prédéfinies comme display ou newline renvoient une valeur sans intérêt ce qui est noté par le type de destination Rien. Avant la flèche, se trouvent les types des arguments. Si la fonction n'a pas d'argument, comme read ou newline alors il n'y a rien avant la flèche. Enfin, par souci de confort, nous nous permettrons, lorsqu'un même type intervient plusieurs fois consécutivement, de le noter avec un exposant suivant la coutume en mathématiques. Ainsi Z x Z x Z x Z --+ N pourra-t-il s'écrire Z 4 --> N.
44
Chapitre 2. Art et usage des spécifications
-> ->
-+
-+
Rien
:!:
-+
* A
Attention, le type ainsi typographié natxstring-+int s'il satisfait pleinement les yeux, pose toutefois un problème de saisie dû au clavier des ordinateurs et aux normes internationales concernant les jeux de caractères. À l'aide des caractères couramment disponibles, on écrira nat * string -> int. C'est la même intention, cela se prononce de la même manière (type des fonctions prenant un entier naturel et une chaîne de caractères et calculant un entier relatif) mais cela ne s'écrit pas pareiL En d'autres termes, la sémantique est la même, seule la syntaxe diffère. La flèche -+ sera représentée par la séquence ->. Le produit cartésien x sera représenté par une étoile * 2 • Attention, l'étoile est un symbole particulièrement surchargé! Scheme utilise l'étoile comme nom de la multiplication (comme dans l'expression ( * 2 3 l donne 6), nous utilisons l'étoile pour marquer le produit cartésien dans les types (comme dans nat * string -> int) et nous verrons, au chapitre 9, encore un usage de l'étoile. Le même problème se pose avec la typographie int 4 -+ nat qui est saisie, au clavier, comme la suite de caractères : int"4 -> nat. L'accent circonflexe (souvent prononcé « chapeau») indique l'intention de mettre ce qui snit en exposant. Destinée à être lue par des humains, la carte de référence spécifie les fonctions avec des types typographiés. Ce livre contient, le plus souvent mais pas toujours, des types typographiés ; les fonctions que vous écrirez en Scheme utiliseront toutes les seuls caractères dont vous disposez : ceux de votre clavier.
2.1.3 Type semi-prédicatif Les semi-prédicats calculent une valeur utile ou renvoient le booléen faux. Une telle fonction est, par exemple, string->nurnber 3 qui prend une chaîne de caractères et tente de la convertir en un nombre. Le type de string->number est noté string -> Nombre + #f. Le type Nombre + #f est donc formé de l'ensemble des nombres auquel on ajoute le booléen faux. On sait distinguer, dans ce type, les nombres du booléen faux avec, par exemple, une alternative. L'idiome classique d'emploi d'un semi-prédicat est donc: (let ( (r (string->nurnber "qqch"))) (if r
(traiter r) (traiter-erreur) } ) 2 L' étoile est le
; convertir ; tester si la conversion a fonctionné ; traiter le résultat de la conversion ; traiter la conversion inaboutie
symbole du produit dans la plupart des laugages de programmation. fonction, déjà vue page 21, a bien pour nom: string->nurnber. Ce nom est intimement lié à la fois à sa sémantique (elle décode une chaîne de caractères en un nombre) et à son type (chaîne de caractères vers nombre). 3 Cette
Écriture de la spécification d'une fonction
45
2.1.4 'fYpe contraint
Dans l'exemple de la fonction quantieme, certains arguments doivent respecter des contraintes supplémentaires pour être admissibles. Nous allons donc étoffer notre langage de types et procurer des types contraints c'est-à-dire un type associé à une contrainte. La contrainte sera écrite entre barres de fraction comme l'indique la règle granunaticale suivante: - t 1 1 Le langage des types contient donc un sous-langage pour exprimer les contraintes. Nous aurions pu prendre Scheme (ou Java ou un jeu de notations mathématiques), nous avons préféré ne pas spécifier ce langage et privilégier l'intuition. Par exemple, apparaît dans la carte de référence que la fonction racine carrée, nommée sqrt, est associée à la contrainte ainsi rédigée Nombre 1>=0 1 pour indiquer qu'elle ne prend que des nombres positifs ou nuls4 • 2.1.5 'fYpes répétés
Certaines fonctions prédéfinies prennent un nombre indéterminé d'arguments. C'est le cas de nombreuses fonctions arithmétiques comme l'addition +,la multiplication *,le maximum max, le minimum min ... Le type de l'addition est noté ainsi : Nombre* Nombre* ... ->Nombre ce qui indique que l'addition prend au moins deux arguments de type Nombre. Le type répété est destiné à être seulement lu car cet ouvrage ne vous enseignera pas comment définir de telles fonctions. En conséquence, nous ne le ferons pas apparaitre dans la grammaire des types que vous aurez à écrire. 2.1.6 'fYpes polymorphes
Certaines fonctions sont capables de travailler sur des données dont il n'est pas utile de connrutre la structure fine. Ainsi, lorsque par exemple, on dispose d'une collection de valeurs, savoir compter les valeurs présentes dans cette collection ne dépend pas de la nature de ces valeurs. Si l'on sait compter des pommes, on sait aussi compter des images! Un des rares exemples que nous puissions produire en attendant le chapitre 4 est le type de la fonction identité : ; ; ; identité: a ---t a ; ; ; (identité v) rend v.
Le type de l'identité se verbalise ainsi : pour tout type a, la fonction identité prend une valeur de type a et rend une valeur de ce même type. Ainsi la fonction identité peut-elle prendre une image (alors a est le type Image) et la rendre. Ou prendre un nombre (alors a est le type Nombre) et rendre ce nombre. Plus fort encore, si demain vous inventez un nouveau type, alors la fonction identité sera capable de prendre une valeur de ce nouveau type et de vous la rendre. Les variables de types sont le plus souvent notées par des lettres grecques (a, {J, 'Y lorsque typographiées, alpha, beta, gamma lorsqu'écrites au clavier). Le type typographié a --> a est donc lisible mais on l'écrit alpha - > alpha avec un clavier standard d'ordinateur. 4 Pour les
implantations de Scheme qui disposent de nombres complexes, cette restriction est bien s1lr inutile.
46
Chapitre 2. Art et usage des spécifications
2.1.7 l)rpes fonctionnels
Les fonctions sont, en Scheme, des valeurs comme les autres. Elles ont un type, elles peuvent être appliquées à des arguments. Rien n'empêche donc de passer des fonctions en arguments et de les renvoyer en résultat. L'expression mathématique f o g (prononcez« f rond g >>)exprime la composition des fonctions f et g. Ainsi (f o g)(x) = g(f(x)). En fait, si l'on y réfléchit bien, o est une fonction qui prend deux fonctions en argument (l'une à gauche et l'autre à droite) et qui renvoie une fonction qui est leur composition. Ainsi la spécification de la fonction o (à cause de ce maudit clavier, « rond >> est représenté par la lette « o >>) est : ; ; ; o: (a---> (3) x ((3---> 'Y) ---> (a---> 'Y) ; ; ; (ofg)rendlacompositiondesfonctionsfetg.Ainsi((ofg)x) = (g(fx)). Si donc fa le type alpha -> be ta et que ga le type beta -> gamma alors
f g) prend une valeur de type alpha, applique f pour obtenir une valeur de type beta, applique g sur cette valeur pour obtenir une nouvelle valeur de type gamma. La composée est donc bien une fonction qui prend des valeurs de type alpha et produit des valeurs de type gamma. La composée de fonctions est valable quels que soient les types représentés par les variables a, (3, "Y· La seule contrainte qu'exprime le type de la composition est que le codomaine de f doit être le domaine de g ou, en d'autres termes, que les valeurs calculées par f sont acceptables comme arguments par g. (o
2.1.8 Conclusions sur la signature
Le langage des types est bel et bien un langage avec des constantes prédéfinies (les types de base) et des opérations entre types (flèche, plus, produit cartésien) produisant de nouveaux types plus complexes. La sémantique de ce langage est trop délicate pour être ici exposée de façon formelle, nous nous sommes limités à des explications en français. La syntaxe de ce langage fait l'objet des règles de la table 2.2 (page 47). Les noms des types prédéfinis apparaissent en minuscules lorsque ce sont des types reconnaissables par des prédicats appropriés (int est reconnu par integer?, bool parboolean?, etc. 5 ). Les types dont le nom débute par une majuscule correspondent à des types conceptuels ou à des types inventés pour les besoins du livre. Tous les types dont l'usage est préconisé par un exercice comme Image, ArbreBinaire, Dictionnaire sont dans ce cas.
2.2
Sémantique d'une fonction
La seconde partie de la spécification est la sémantique qui s'attache au sens et non plus au règles d'emploi que fixe la syntaxe exprimée par la signature. La sémantique est exprimée en français mais comme tout langage, son expression obéit à certaines règles syntaxiques. ~
Syntaxe de la sémantique
La sémantique débute par un exemple d'appel à la fonction suivi d'une phrase indiquant ce qu'est le résultat. Voici quelques exemples où nous avons répété, en première ligne, la signature ; la sémantique apparaît donc à partir de la seconde ligne. 5 Le type nat
est un cas particulier. n représente l'ensemble des entiers naturels 0, 1, 2, ... c'est-à-dire les entiers relatifs positifs ou nuls. nat est donc analogue à in t 1;;;, 01.
47
Écriture de la spécification d'une fonction
--->
--->
--->
+ #f ( )
nat ou int ou float ou Nombre ou string ou bool ou Valeur ou Image ---> --->
1 1 alpha ou be ta ou ...
TAB. 2.2- Grammaire (partielle) des types
; ; ; volume-cylindre: Nombre *Nombre --+ Nombre ; ; ; (volume-cylindrer h) rend le volume du cylindre de rayon «r» et de hauteur «h». ; ; ; quantieme: nat/E [0, 31]/ *string * nat!?_ 15821--+ nat ; ; ; (quantiemej ma) rend le quantième dans l'année de la date«}» «m» «a». ; ; ; o: (a--+ (3) * ((3--+ -y)--+ (a--+ -y) ; ; ; (of g) rend la composée def et g. Ainsi ((of g) x)= (g (jx)).
Deux règles supplémentaires existent. Parfois il est nécessaire de placer une contrainte sur plusieurs arguments à la fois ce que ne permet pas d'exprimer la notion de type contraint. Par exemple, la fonction, overlay de la bibliothèque graphique prend deux images et construit l'image résultant de la superposition de ces deux images (les pixels blancs sont considérés comme transparents), voir la figure ci-contre.
·.
Cette fonction ne donne un résultat que si les deux images ont les mêmes hauteur et largeur. La fonction overlay vérifie que c'est le cas avant de calculer la superposition. Si ce n'est pas le cas, une erreur est signalée et le calcul est interrompu. La fonction ne renvoie rien mais par contre signale une erreur. La spécification de la fonction overlay est donc: ; ; ; overlay: Image • Image --+ Image ; ; ; (overlay image] image2) rend une image superposant le contenu de «imagel» et «image2». ; ; ; ERREUR lorsque images de tailles différentes
Parfois, la contrainte est de calcul si complexe que la vérifier, dans le corps de la fonction, prend autant de temps que de calculer le résultat. Parfois encore, la vérification a déjà été faite ailleurs et il est inutile de la réitérer dans le corps de la fonction. Dans le fragment qui suit, la non-nullité du diviseur d étant explicitement testée, il est inutile que quotient refasse ce test. (if (not (= d 0)) (quotient n d) -1 )
Chapitre 2. Art et usage des spécifications
48
Dans tous ces cas, on assume que certaines propriétés sont garanties par l'appelant de la fonction (il peut les avoir explicitement vérifiées ou les avoir engendrées par un calcul garantissant ces mêmes propriétés). Cette garantie doit être inscrite dans le « contrat ». La spécification comporte donc une clause HYPOTHÈSE requérant cette garantie. Reprenons la fonction quantieme où nous avions dit que les noms de mois étaient écrits en minuscule et sans accent. TI est donc hors de question d'accepter des abréviations ou des mois en anglais, seuls douze noms de mois sont possibles. Plutôt que de les expliciter tous (ce serait alors une définition par extension), nous avons choisi d'ajouter une hypothèse les spécifiant et c'est alors une définition par compréhension. D'ailleurs, seules les définitions par compréhension peuvent décrire des ensembles de cardinal infini. ; ; ; quantieme: nat/E [0, 31 ]1 *string * nat/?. 15821--+ nat ; ; ; (quantiemej ma) rend le quantième dans l'année de la date «j» «m» «a». ; ; ; HYPŒHÈSE: «m» est un nom de mois écrit en français, en minuscule et sans accent.
Lorsqu'un utilisateur invoque une fonction avec des arguments qui violent les contraintes ou les hypothèses, il se place hors contrat. À ce moment, la fonction invoquée est libre de se comporter comme elle veut : elle n'est plus liée par le contrat puisque celui-ci n'est pas respecté par l'invoqueur. La fonction peut alors signaler une erreur (c'est le meilleur des cas), rendre un résultat quelconque (que 1' invoqueur prendra pour argent comptant s'il ne s'est pas rendu qu'il avait violé le contrat), refaire exploser Ariane V, etc.
3 Utilisation de la spécification Lors de l'utilisation d'une fonction (i.e. lorsque le programmeur écrit une application de cette fonction), il faut :
1. utiliser la signature donnée par la spécification de la fonction, 2. 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. La connaissance de la signature permet d'éviter des fautes d'orthographe dans le nom de la fonction et de l'appliquer avec le bon nombre d'arguments. Surtout elle permet d'effectuer une vérification de type: en reprenant l'exemple de la fonction quanti erne, si exp1, exp2, et expa sont des expressions Scheme, lorsque le programmeur écrit (quanti erne exp1 exp2 exp3 } il doit vérifier que les valeurs de exp1 et de exp3 sont bien des valeurs entières et que la valeur de exp2 est bien une valeur chaîne de caractères. Il doit de plus vérifier que les contraintes et hypothèses sont satisfaites : la valeur de exp1 doit être entre 1 et 31, etc. Prenons un autre exemple : définissons la fonction force-centripete qui calcule la force centripète nécessaire pour un mouvement circulaire uniforme, de vitesse v et de rayon r, d'un objet de masse m. La spécification de cette fonction peut être: ; ; ; force-centripete : Nombre/?. ()il -+ Nombre ; ; ; (force-centripete v r m) rend la force centripète nécessaire pour un mouvement ; ; ; circulaire uniforme, de vitesse «V» et de rayon «r», d'un objet de masse «m».
Son type est de prendre trois nombres positifs ou nuls et de retourner un nombre car Nombre est le nom du type comprenant, entre autres, les entiers, les flottants et les ration-
nels. Ce type ne correspond qu'à la nature informatique des données échangées: toutes des nombres.
Utilisation de la spécification
49
En termes plus physiques, la signature devrait être de prendre une vitesse, une longueur et une masse et de retourner une force mais ceci est encore trop flou et un meilleur type, toujours au sens physique, serait de prendre des mètres par seconde, des mètres et des kilogrammes et de retourner des newtons. On pourrait aussi prendre des unités plus exotiques comme de prendre des nœuds, des pieds et des livres, et retourner des poundals. ll n'y a qu'un tout petit nombre de langages de programmation qui sachent manipuler ces types physiques et aucun n'est vraiment connu. Les informaticiens vivent donc avec un système de type moins précis que celui de la physique sur le plan des unités mais beaucoup plus riche en termes de structures de données car ils sont capables de parler de fonctions, de n-uplets, de listes etc. Implantons la fonction force-centripete : (define (force-centripete v r rn) (/ ( * rn v v) r)) La vérification des types pour l'expression ( 1 ( * rn v v) r) ) est très simple : * est une fonction dont les arguments doivent être de type Nombre - ce qui est bien le cas ici - et qui rend un nombre; les arguments de la fonction 1 sont alors bien de type Nombre comme il se doit et elle rend un nombre. La vérification de types à laquelle procède l'informatique n'est qu'une vision dégradée de l'équation aux dimensions de la physique : l'implantation précédente est la mise en œuvre 2
de la formule F
= m ~. En physique, pour vérifier que cette formule a un sens, on remarque r
qu'en notant L la dimension d'une longueur, T la dimension d'un temps et M la dimension d'une masse : - la dimension d'une vitesse est LT- 1 , - la dimension d'une force est M LT- 2 - et qu'en remplaçant dans la formule précédente les variables par leur dimension, on (LT-1 )2 obtient M LT- 2 = M L , qui est bien correct.
Vérification du typage Au début de ce chapitre, la notion de spécification a été détaillée et, en particulier la notion de signature. Cette connaissance avait été utilisée pour vérifier que les applications étaient correctes. Dans la présente section, nous voulons vérifier qu'une définition de fonction est correctement typée. Pour que la définition suivante ; ; ;f:a *{3-+"f
(define (f a b) ~) soit correcte vis-à-vis du typage, il faut que :
1. le nombre de variables (2) de la fonction soit égal au nombre de types donnés dans la signature, 2. le type de l'expression exp soit 'Y (le type qui a été donné comme type du résultat dans la signature) lorsque le type de a (resp. b) est o: (resp. {3). Ces types qui ont été spécifiés dans la signature, sont donc considérés comme les hypothèses à envisager pour calculer le type résultant de exp. Réciproquement, pour qu'un appel à la fonction f soit correct vis-à-vis des types, il faut que
50
Chapitre 2. Art et usage des spécifications
1. le nombre d'arguments soit égal à l' arité de f (ici 2), 2. que le type du premier (resp. second) argument soit a (resp. /3), 3. enfin que le type résultant 1 soit compatible avec le contexte où f est invoquée. La question plus générale est donc de déterminer le type de toute expression. Les formes spéciales ont leur propre règle spéciale de typage. Ainsi une alternative exige-t-elle que sa condition soit booléenne et que sa conséquence et son alternative aient le même type6 • Prenons un exemple (bien typé) et la carte de référence : ; ; ; abs: int---+ int
; ; ; (abs x) rend la valeur absolue de x. (define (abs x) (if
(>
x
0)
x (- x)
(abs
)
(abs -3))
Si la fonction abs prend un entier alors x a pour type int dans le corps de la définition de abs. La carte de référence indique que l'expression ( > x 0) qui compare deux int est sensée et renvoie un booléen ce qui est correct puisque c'estjustement la condition de l'alternative (if . . . l. La conséquence de l'alternative est x dont on sait déjà qu'il a int pour type, l'alternant (- x) a également pour type int puisque c'est le type de retour de la soustraction lorsqu'elle reçoit un int comme argument. La définition semble donc correcte. En ce qui concerne 1' invocation (abs ( abs -3 ) ) , la fonction abs calcule un int qui est repris par abs pour calculer un entier. Le type résultant est donc un entier.
j\ Recommandation très importante : 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 . ...
4 A propos des erreurs et des tests Nous présenterons tout d'abord dans cette section une taxinomie des différents types d'erreur qui peuvent être rencontrés en Scheme puis nous aborderons l'écriture de tests visant à augmenter la confiance que l'on peut accorder aux programmes.
4.1
Taxinomie des erreurs
ll y a deux grands types d'erreurs : ( i) les erreurs détectées par l' évaluateur, (ii) 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 1'utilisateur s'en sert comme tel, mais ce 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 programme mal écrit. Lorsqu'il se saisit d'un programme, un évaluateur passe par plusieurs étapes avant d'imprimer son résultat. Les erreurs sont caractéristiques des étapes. La première étape est syntaxique, la seconde sémantique (tiens, tiens !). 6En fait,
les semi-prédicats (cf. page 21) sont un peu moins exigeants.
À propos des erreurs et des tests
51
4.1.1 Erreur syntaxique Lorsque l'expression est lue, sa syntaxe est vérifiée et les erreurs de syntaxe sont signalées. Ces erreurs sont de plusieurs natures mais apparaissent lorsque vous écrivez des programmes dont la syntaxe n'est pas conforme à la grammaire de Scheme. Les programmes qui suivent sont tous erronés mais ils n'illustrent pas toutes les formes d'erreurs syntaxiques. (define f)
Tous ces programmes sont INCORRECTS!!! définition incomplète
. . define : malformed definition (define (f x))
fonctionsansco~s
. . define : malformed definition (de fine (et 1 "deux") 3) . . not an identifier : 1 (define (1 foo) foo) . . not an identifier : 1 (de fine ("deux" x) ( * 2 x)) . . not an identifier : "deux" ( if x) . . if: malformed expression (if (define (f) 2) 3 4)
nom de variable incorrecte nom de fonction incorrecte nom de fonction incorrecte alternative incomplète définition interdite dans une alternative
. . definition : invalid position for internai definition
Lorsque les programmes deviennent plus importants (dépassent une ligne), il est important de bien les présenter c'est-à-dire de les disposer lisiblement comme le sont les programmes de ce livre. La plupart des erreurs syntaxiques sont dues à des programmes mal présentés dont la structure n'apparaît pas. Les éditeurs modernes de programmes positionnent, quand vous passez à la ligne, le curseur à l'endroit qu'ils jugent appropriés. Prenez l'habitude, quand vous passez à la ligne, de déterminer où doit aller le curseur. Si le curseur ne va pas à cet endroit, vous avez déjà commis nne erreur! Corrigez-la avant de continuer. Voici quelques exemples de programmes syntaxiquement incorrects : Tous ces programmes sont INCORRECTS!!! (let ((x (if (foo x)
(f z)
(g t)
(foo 3) ) (bar 45 x) ) ) ) .let : malformed expression (define (f v ; vitesse rn ; masse ) (if (> rn 0) (faire1 rn) (faire2 v) (faire3 rn v) ) ) . . if: malformed expression
Pour toutes ces erreurs, présentez correctement vos programmes et assurez-vous qu'ils vérifient la grammaire de Scheme. Remarquez aussi qu'une erreur peut en cacher une autre ! Avez-vous remarqué dans le premier exemple ci-dessus que l'alternative est incorrecte?
52
Chapitre 2. Art et usage des spécifications
4.1.2 Erreur d'arité Cette erreur n'est détectée que lorsque le programme est exécuté. L'erreur d'arité (une fonction est invoquée avec un mauvais nombre d'arguments) est courante chez le programmeur débutant. Voici quelques exemples : Tous ces programmes sont INCORRECTS!!! (modulo) erreurd'arité • modulo : expects 2 arguments, given 0 (modulo 55) erreur d'arité
• modulo : expects 2 arguments, given 1 (modulo 55 4 3) erreur d'arité •
modulo : expects 2 arguments, given 3
La fonction modulo attend exactement deux arguments. Pour corriger ce type d'erreur, consultez la spécification des fonctions utilisées et mémorisez l' arité des fonctions les plus couramment utilisées (une vingtaine).
4.1.3 Erreur de noms Ces erreurs ne sont détectées, elles aussi, que lorsque le programme est exécuté. Elles correspondent à des erreurs de frappe (un nom est incorrect). Voici quelques exemples : ( sqt 16 9)
Tous ces programmes sont INCORRECTS!!! variable sqt indéfinie
• reference to undejined identifier : sqt (let ( (x 2 . 7 8) ) (log e) ) variable e indéfinie •
reference to undejined identifier : e
La racine carrée se nomme sqrt et non sqt. La constante de Neper (mathématiquement notée e) n'est pas définie en Scheme. Notez que le même message d'erreur est produit lorsqu'un nom n'est pas défini qu'il soit en position de fonction ou d'argument. Pour corriger ce type d'erreur, consultez la carte de référence pour connaître les noms exacts des fonctions utilisables, vérifiez que vos variables sont bien orthographiées (dans le dernier exemple, ne fallait-il pas écrire x plutôt que e ?). En DrScheme, l'outil Check Syntax (cf. page 153) permet, à l'aide de la souris et pour chaque variable, de visualiser où elle est définie et utilisée.
4.1.4 Erreur de domaine Les erreurs de domaine sont également très répandues. Elles correspondent à des violations de spécifications. Voici quelques exemples : Tous ces programmes sont INCORRECTS!!! (sqrt "deux")
•
sqrt: expects argument of type ; given "deux" ''trois 11 ) • + : expects type as 2nd argument, given: "trois"; other arguments were: 3 (+ 1 (string->number "3,14")) + : expects type as 2nd argument, given : # f; other arguments were : 1 (+ 3
53
À propos des erreurs et des tests
La dernière erreur s'explique par le fait que la fonction string->nwnber est un semiprédicat qui répond faux si la chaîne ne peut être comprise comme un nombre (ce qui est ici le cas car un nombre décimal est caractérisé par un point décimal et non une virgule). Pour corriger ces erreurs, consultez les spécifications des fonctions concernées et, surtout, vérifiez (dans votre tête) les types de vos programmes.
4.1.5 lndéfinition Certaines erreurs peuvent survenir, de manière plus rare, comme les divisions par zéro. Tous ces programmes sont INCORRECTS!!! (/ 1 (random 1))
Ill
1: division by zero (log 0)
Ill log : undefined for 0 4.1.6 Erreur et spécification 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 : ; ; ; aire-disque: Nombre/>=01--> Nombre
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 : ; ; ; aire-couronne: Nombre/>=01 * Nombre/>=01--> Nombre
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. ~
Avec détection d'erreur
Pour garantir que la fonction aire-couronne signale une erreur à l'utilisateur lorsque le rayon extérieur est inférieur au rayon intérieur, nous le spécifions dans la signature grâce à la clause ERREUR lorsque ... ; ; ; aire-couronne: Nombre/>=01 * Nombrei>=OI ->Nombre ; ; ; (aire-couronne ri r2) rend l'aire de la couronne de rayon extérieur «r1 » et de rayon intérieur «r2» ; ; ; ERREUR lorsque ri < r2 (define (aire-couronne r1 r2) (if (< r1 r2) (erreur 'aire-couronne Il
rayon extérieur
(Il
rl
n )
54
Chapitre 2. Art et usage des spécifications '"rayon intérieur ( .. r2 ") ••) (- (aire-disque rl) (aire-disque r2))))
Voici un exemple d'application erronée: (aire-couronne 5 7) aire-couronne : ERREUR : rayon extérieur ( 5) < rayon intérieur ( 7) Pour signaler une erreur, nous utilisons la fonction erreur (que les auteurs ont prédéfinie
dans la bibliothèque de fonctions associées à ce livre) : - 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 apostrophe7 ; - 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. Aucun message n'est alors produit. Par exemple: (erreur? aire-couronne 5 3) -+ #F (erreur? aire-couronne 5 7) -+ #T ~
Sans détection d'erreur
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. ll est alors dommage de perdre du temps d'ordinateur pour rien. Dans ce cas, l'implantation ne fait pas la vérification. ll 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 avec la clause HYPOTHÈSE. ; ; ; aire-couronne-sans: Nombre/>=01 * Nombre/>=01 ->Nombre ; ; ; (aire-couronne-sans r1 r2) rend l'aire de la couronne de rayon extérieur ; ; ; «r1 » et de rayon intérieur «r2» ; ; ; HYPOTHÈSE: rl >= r2 (define (aire-couronne-sans rl r2) (- (aire-disque rl) (aire-disque r2)))
~
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é ; - il faut que les arguments vérifient les hypothèses ; - il faut que les arguments ne vérifient pas les cas d'erreurs.
4.2
Tests des définitions de fonctions
Dès que le programmeur a écrit une définition de fonction, il doit l'essayer pour se convaincre- et convaincre les autres- qu'il n'a pas fait d'erreur de logique (ou d'étourderie). n est bien sûr possible d'écrire quelques applications de la fonction dans la fenêtre d'interaction et de vérifier visuellement, mais cette façon de faire présente, au moins, trois graves défauts: 7 Le
sens de cette apostrophe sera expliqué plus loin cf. page 99
À propos des erreurs et des tests
55
- les essais ne sont pas reproductibles (sauf si celui qui fait les essais mémorise les jeux d'essais 8); - la séance d'essais est rébarbative pour celui qui doit l'effectuer ; - on ne sait pas si ce dernier va bien essayer le programme avec des données significatives, c'est-à-dire que l'on ne sait pas si la séance d'essais pourra nous donner une bonne confiance pour le programme testé. n est alors possible d'écrire les applications dans la fenêtre de définition mais cette façon de faire présente, au moins, deux défauts : (i) lorsque l'on fait exécuter la fenêtre de définition, un grand nombre de valeurs sont affichées dans la fenêtre d'interaction, (ii) le lecteur ne sait pas à quoi elles correspondent! TI est donc obligé de lire les tests pour voir s'ils ont fonctionné correctement. Pourquoi donc ne pas charger la machine de faire cette lecture et vérification ? Nous avons donc introduit dans la bibliothèque miastools une nouvelle forme spéciale nommée verifier qui permet d'écrire agréablement des tests et de les vérifier lorsqu'exécutés. Donnons de suite un exemple pour tester la fonction aire-couronne : (verifier aire-couronne (aire-couronne 5 5) -- 0.0 (aire-couronne 5 7) -- (* 3.14159 2 12) (erreur? aire-couronne 7 5) -- #t )
Nous venons d'écrire trois tests : le premier teste la surface d' nn couronne d'aire nulle, le troisième teste si la fonction aire-couronne vérifie bien que le premier rayon est bien inférieur au second rayon. Une fois ces tests écrits, chaque fois que le fichier les contenant est rechargé, la fonction est testée à nouveau et un message explicatif sera produit en cas d'anomalie. ~
Quelques conseils pour le choix des jeux d'essais
La détermination des jeux de tests est une question difficile. Même si, dans un fichier, les tests ne peuvent qu'apparaî'tre après les définitions des fonctions qu'ils exercent, ils doivent être écrits avant. En effet les tests font partie de la spécification des fonctions. Les écrire avant, accompagnés des valeurs attendues, permet de vérifier que la signature pressentie pour la fonction (encore à écrire) a du sens : que les argmnents dont on a besoin pour le calcul du résultat sont bien présents, que l'ordre selon lequel ils sont fournis à la fonction est un ordre raisonnable et même plaisant (on ne donne pas l'ordonnée y avant l'abscisse x par exemple, les arguments généraux sont énoncés avant les argmnents plus spécifiques, etc.). TI existe quelques règles de bon sens : - il faut toujours tester les valeurs extrêmes : dans l'exemple de la fonction quanti erne, il faut tester le 1er janvier et le 31 décembre, - il faut connaître le résultat attendu pour chacune des données du jeu d'essai- sauf si l'on veut vérifier seulement que le programme ne « plante >> pas, - il faut regarder la spécification du problème pour dégager des sous-ensembles significatifs de données. Ainsi, dans l'exemple de la fonction quanti erne: 8 Signalons
à ce propos, qu'un mécanisme d'historique existe dans la fenêtre d'interactions de DrScheme. La séquence de touches Esc Pou Echappement P (avec P comme Previous) permet de remonter dans l'historique et donc de retrouver les expressions précédentes. La séquence Esc N (avec N comme Next) permet de redescendre de 1'historique.
56
Chapitre 2. Art et usage des spécifications
- d'une part, il y a deux types d'années (bissextiles ou non) : il faudra tester des dates pour une année bissextile et une année non bissextile, - d'autre part, il y a les dates qui précèdent et qui suivent le 28 février : il faudra tester les deux cas. Pour l'exemple, en résumé, nous proposons de tester le premier janvier, le 28 février, le premier mars et le 31 décembre de deux années, l'une bissextile et l'autre pas, ainsi que le 29 février d'une année bissextile.
Remarques: - Nous pourrions aussi tester un jour de chaque mois : la confiance pour le programme testé serait alors bien meilleure. D'une façon générale, sous réserve que les jeux de tests soient bien choisis, plus on teste et meilleure est la confiance, mais aussi plus le test prend du temps pour le programmeur ou pour l'ordinateur. - Nous avons parlé de confiance induite par des jeux de tests et non de validation du programme. En effet, dès 1969, Edsger DIJKSTRA notait que : « Essayer un programme peut servir à montrer qu'il contient des erreurs,
jamais qu'il est juste!»
5
Pour en savoir plus : langages typés et langages sûrs
De nombreuses propriétés différencient les langages de programmation. Par exemple, il y a des langages typés et des langages sûrs. Un langage typé possède un système de types permettant de qualifier les valeurs que sait manipuler ce langage à savoir : des valeurs simples comme des chaînes de caractères ou des vecteurs d'entiers mais aussi des valeurs plus compliquées comme des fonctions. Un système de types est un langage décrivant des types (syntaxe) accompagné de règles de typage (sémantique). La vérification du bon typage du programme est effectuée avant l'exécution et garantit que quelles que soient les valeurs réelles que posséderont les variables, l'exécution du programme ne saurait mener à une erreur de type (comme, par exemple, l'addition d'un booléen et d'une chaîne). La plupart des langages (C, Caml, Java, Ada) sont- plus ou moinstypés, Scheme ne l'est pas tout comme bash, Perl ou PHP. La vérification de types dénonce rapidement de très nombreuses erreurs ce qui permet de les corriger non moins rapidement. En revanche, tout ne peut être vérifié par typage : une division par zéro, une tentative de lecture d'un fichier qui n'existe pas sont des erreurs hors d'atteinte des sytèmes de types. Un langage sûr est un langage qui, à l'exécution, vérifie que le programme se comporte correctement et qui signale immédiatement toute anomalie. Scheme (comme Caml, Java ou Ada) est un langage sûr, C, PHP ne le sont pas. Dans un langage sûr, l'addition ne peut être appliquée à un booléen car cette valeur n'est pas un nombre ; une division par zéro est également signalée car anormale ; enfin, tout accès hors vecteur est dépisté. Pour assurer cette sûreté, les valeurs de Scheme sont typées, il est possible de les interroger à l'aide des prédicats comme string? ou integer? pour connaître leur type. En ce qui concerne Scheme, il a des valeurs typées, il n'est pas un langage typé mais il est un langage sûr.
57
Conclusion
6 Conclusion Syntaxe et sémantique sont étroitement imbriquées bien que portant sur des aspects disjoints. L'usage raisonné d'une fonction passe par la lecture de sa spécification qui permet de l'employer à bon escient en respectant son arité, ses arguments et leurs domaines de définition tout en sachant exactement ce qu'elle calcule mais sans savoir (ni même avoir besoin de savoir) comment elle calcule. La carte de référence est une courte liste de spécifications, l'implantation des fonctions de la carte de référence un long programme écrit en Scheme, en C, en C++, en Makefile, etc.
7 Exercices corrigés 7.1
Énoncés
Exercice 6 - Mystère Voici une définition de fonction : (define (mystere? n) (define (secret? p) (if
(> p (if
1) (=
0 (modulo n
p))
#f
(secret? (- p 1)) ) #t ) )
(secret? (- n 1)) )
Question 1- Vérifier le typage de la définition précédente et en déduire une signature pour cette fonction.
Question 2 - Écrire une spécification pour cette fonction. Question 3- En fait quand on regarde de près aux bornes, que vaut (mystere? 1) ? Que vaut (mystere? 0) ou encore (mystere? -1) ? Affinez votre spécification en conséquence. Exercice 7 - Boule de gomme Voici une définition de fonction : (define (mystere v) ( cond ( ( integer? v) (/ ( * v v) v) ) ((boolean? v) (not (not v))) (else v) ) )
Question 1- Vérifier le typage de la définition précédente et en déduire une signature pour cette fonction.
Question 2 - Écrire une spécification pour cette fonction.
58
Chapitre 2. Art et usage des spécifications
Exercice 8 - Antiquantième Voici la spécification de la fonction quantieme décrite dans ce chapitre. ; ; ; quantieme: nat/E [1 ... 31 ]1 x string x nat!> 15821---> nat ; ; ; (quantieme jour mois an) calcule le quantième. ; ; ; HYPOTHÈSE: mois est écrit en français, en minuscule et sans accent. Écrire, grâce à elle, la fonction nommée an ti -quantieme qui prend des arguments semblables à quan tieme mais qui calcule le nombre de jours qui restent dans l'année jusqu'au 31 décembre. La somme du quantième et de l'anti-quantième d'unjour doit redonner le nombre de jours que comporte l'année. Exercice 9 - Quantième Voici une définition de la fonction quantieme ; ; ; quantieme: nat/E [1 ... 31]1 x string x nat/>15821---> nat ; ; ; (quantieme jour mois an) calcule le quantième. ; ; ; HYPOTHÈSE: mois est écrit en français, en minuscule et sans accent. (define (quantieme jour mois an) (cond ( (equa1? mois "janvier") jour) ((equal? mois "fevrier") (+ 31 jour)) (else (+ 31 (if (bissextile? an) 29 28) (cond ( (equal? mois ''mars") jour) ( (equal? mois ''avril'') (+ 31 jour) ) (+ 31 30 jour) ) ( (equal? mois ''mai'') ( (equal? mois ''juin") (+ 31 30 31 jour)) ( (equal? mois ''juillet'') (+ 31 30 31 30 jour)) ( (equal? mois ''aout") (+ 31 30 31 30 31 jour)) ( (equal? mois ''septembre'') (+ 31 30 31 30 31 31 jour) ) ( (equal? mois ''octobre'') (+ 31 30 31 30 31 31 30 jour) ) ( ( equal? mois ~~novembre'') (+ 31 30 31 30 31 31 30 31 jour) ( ( equal? mois "decembre" ) (+ 31 30 31 30 31 31 30 31 30 jour) ) ) )) ) ) Écrire des tests pour cette fonction en utilisant la forme spéciale verifier.
7.2
Corrigés
Exercice 6 - Mystère
Solution de la question 1 - La fonction est un prédicat : il est en effet simple de s'assurer qu'elle ne peut renvoyer qu'un booléen (ce que suggère son nom se terminant par un point d'interrogation). Elle ne peut prendre qu'un nombre en argument puisqu'on peut lui soustraire 1, le comparer à 1. On peut être un peu plus spécifique quant à la nature de ce nombre si l'on remarque qu'on prend son modulo ce qui suggère que c'est un entier positif donc un entier naturel. La signature est donc : nat --+ bool Solution de la question 2- La fonction est un prédicat qui rend faux lorsqu'elle trouve un nombre divisant l'argument. C'est donc une fonction dont l'intention est de vérifier si un nombre est premier. Voici donc une spécification :
59
Exercices corrigés
; ; ; premier?: nat--+ bool ; ; ; (premier? n) vérifie que «n» est premier.
Solution de la question 3- La spécification précédente indique que l'argument est un entier naturel, ce qui rend la question (mystere? -1) hors contrat et donc insensée. En revanche 0 et 1 ne sont pas des nombres premiers (au sens mathématiques) bien que la fonction mystere? renvoie vrai pour chacun d'entre eux. ll existe deux façons de rectifier la spécification:
1. exclure zéro et un comme argument à l'aide d'une contrainte ce qui donne: ; ; ; premier?: nat!> JI--+ boo/ ; ; ; (premier? n) vérifie que «n» est premier.
2. permettre zéro et un mais modifier la sémantique pour décrire ces deux cas ce qui donne: ; ; ; premier?: nat---+ booZ ; ; ; (premier? n) vérifie que «n» est égal à zéro ou égal à un ou bien un nombre premier.
Exercice 7 - Boule de gomme
Solution de la question 1 - Le code suggère que si l'argument est un entier alors un entier est rendu, si elle prend un booléen, elle rend un booléen et si elle prend n'importe quoi d'autre, elle rend cette même chose. Elle semble donc devoir accepter n'importe quoi et rendre une valeur du même type, ce qui se traduit par la signature : a -+ a Solution de la question 2 - Cette fonction rend presque partout un résultat égal à son argument: nous la rebaptiserons donc identite. La seule exception est quand on lui donne 0 car elle effectue alors une division par zéro ce qui est erroné. D'où la spécification : ; ; ; identite: a ---+ a ; ; ; (identite v) renvoie «V». ; ; ; ERREUR lorsque «V» est égal à O.
Exercice 8 - Antiquantième
Voici cette fonction qui n'a nul besoin de savoir comment est implantée quanti erne pour être définie (cf exercice 9). Elle utilise le prédicat bissextile? vérifiant si une année est bissextile. ; ; ; anti-quantieme: nat/E [1 ... 31]1 x string x nat/>15821--+ nat ; ; ; (anti-quantieme jour mois an) calcule le nombre de jours restant dans l'année. ; ; ; HYPCJTHÈSE: «mois» est écrit en français, en minuscule et sans accent. (define (anti-quantieme jour mois an) (if (bissextile? an) (- 366 (quantieme jour mois an)) (- 365 (quantieme jour mois an))
) )
; ; ; bissextile?: nat!> 15821 --+ boo/ ; ; ; (bissextile? an) vérifie que «an» est bissextile. (define (bissextile? an) (or (= 0 (modulo an 400)) (and (= 0 (modulo an 4)) (not (= 0 {modulo an 100))) ) ) )
60
Chapitre 2. Art et usage des spécifications
Exercice 9 - Quantième Voici quelques tests utilisant des années bissextiles ou pas : (verifier quantieme ; ; 2000 est bissextile (quantieme 1 njanvier•• (quantieme 1 nfevrier•• (quantieme 28 nfevrier•• (quantieme 29 nfevrier•• (quantieme 1 nm.arsll (quantieme 7 naoUt 11 ; ; 2001 n'est pas bissextile (quantieme 1 njanvier" (quantieme 28 fevrier•• (quantieme 1 nmarS 11 ; ; 2004 est bissextile (quantieme 1 njanvier•• (quantieme 1 nfevrier•• (quantieme 28 fevrier•• (quantieme 29 nfevrier•• (quantieme 1 nm.arsll ; ; 1900 n'est pas bissextile (quantieme 1 njanvier" (quantieme 1 fevrier•• (quantieme 28 nfevrier•• (quantieme 1 nmarS 11 (quantieme 7 naoUt 11
11
11
11
2000) 2000) 2000) 2000) 2000) 2000)
-- 1
2001) 2001) 2001)
-- 1
2004) 2004) 2004) 2004) 2004) 1900) 1900) 1900) 1900) 1900)
32 -- 59
-- 60 -- 61 -- 220
-- 59 -- 60 1 -- 32
-- 59 -- 60 -- 61 -- 1
-- 32 -- 59 -- 60
219
Chapitre 3
Récursion La récursion est un mécanisme puissant permettant d'exprimer la répétition des opérations dans un programme. C'est un mode de pensée qui permet de concevoir des programmes dont l'écriture condense en peu de lignes l'exécution de calculs éventuellement très longs. Apprendre à programmer récursif est l'objectif premier de cet ouvrage. Le présent chapitre traite de la récursion sur les nombres entiers, en faisant le parallèle avec la récurrence rnathématique sur les nombres. Nous insistons sur la conception et la définition de fonctions récursives, basées sur la décomposition du traitement d'un entier naturel en traitements sur des entiers strictements plus petits. Cette décomposition se décrit par une relation de récurrence sur laquelle apparaissent les cas de base qui arrêtent la récursion. Nous abordons aussi le problème de l'efficacité des fonctions récursives, au niveau des algorithmes - linéaires ou dichotomiques-, et de leur implantation- nommer pour éviter l'explosion des calculs intermédiaires.
1 Définition récursive En mathématiques, on définit la factorielle d'un entier positif n, notée n!, comme le produit des entiers de 1 à n: n! = 1 x 2 x · · · x n (et par convention on pose O! = 1). Une autre façon de définir la fonction factorielle consiste à donner la règle du calcul récursif den! : sin vaut 0, n! vaut 1, et sinon il faut calculer (n- 1)! et multiplier le résultat obtenu par n. D'où la définition !récursive ni-
1
.- { n x (n- 1)!
si si
n=O n>O
Cette définition de la fonction, qui se réfère à elle-même, est dite récursive : la valeur de la fonction en n s'exprime au moyen de la valeur de la fonction en n - 1. Mais il y a une valeur den pour laquelle ce processus récursif s'arrête (pour n = 0 la valeur de la fonction est 1); et cette valeur d'arrêt des appels récursifs sera atteinte quelle que soit la valeur initiale de n entier positif, puisque que l'on diminue de lia valeur de l'argument de la fonction à chaque appel. Noter l'analogie de cette explication avec une preuve par récurrence. Pour montrer que 1' expression récursive définit bien la fonction factorielle, on montre :
62
Chapitre 3. Récursion
- Cas de base : 0! = 1 - Étape de récurrence : pour n > 0, si par hypothèse de récurrence (n -1)! = 1 x 2 x··· x (n -1), alors n! = n x (n -1)! = 1 x 2 x··· x (n -1) x n. Attention : toutes les expressions récursives ne donnent pas lieu à des calculs finis. Par exemple, bien que la propriété n! = (~~~)! (et O! = 1) soit vraie, elle est impropre pour calculer récursivement n! car elle exprime n! en fonction de (n + 1)!, ce qui évidemment n'assure pas la décroissance stricte den.
1.1 Schémas de récursion Nous présentons ici différents schémas de récursion sur les entiers naturels. La récursion linéaire repose sur la définition par récurrence de N ; la récursion dichotomique est liée à la décomposition den en base 2; enfin la récursion générale s'appuie sur l'ordre défini dans N. 1.1.1 Récursion linéaire Dans une récursion linéaire, l'étape récursive fait passer n à n -1. La définition récursive de n! correspond à une récursion linéaire, en effet : - Étape récursive: pour n > 0, f(n) = n x f(n- 1) - Cas de base : la valeur de la fonction en n = 0 est donnée. Autre exemple : la fonction puissance (fonction des deux arguments : x et n ). Elle est définie par: xn est le produit den facteurs tous égaux à x (et par convention x 0 = 1). Et elle admet l'expression récursive linéaire : sin= 0 sin> 0 1.1.2 Récursion dicbotomique Dans une récursion diclwtomique, l'étape récursive fait passer n à n + 2 (le symbole+ note la division entière) : - Étape récursive: pour n > 0, f(n) s'exprime en fonction de f(n + 2) - Cas de base : la valeur de la fonction en n = 0 est donnée. La récursion dichotomique est importante en informatique car souvent très intéressante au point de vue de l'efficacité du calcul. Nous illustrons cette efficacité sur la fonction puissance, qui s'exprime récursivement de façon dichotomique par:
sin= 0 sin est pair si n est impair Noter que le principe de la récursion dichotomique, lié à la décomposition den en base deux, s'étend à n'importe quelle base et en particulier à la base dix. Nous en verrons des exemples dans les exercices qui traitent de l'extraction de chiffres dans des nombres en écriture décimale.
63
Définition récursive
1.1.3 Récursion générale dans N Plus généralement, la définition récursive d'une fonction f se décompose en deux parties : - Étape récursive : pour n > no, f(n) s'exprime en fonction de (certains des) /(0),
/(1), ... f(n- 1) - Le(s) cas de base: pour n = 0, ... , no, la valeur de f(n) est donnée. ~
Exemples
Les nombres de Fibonacci sont un exemple de définition récursive où les cas de base sont n = 0 et n = 1 et où fib(n) s'exprime en fonction de fib(n- 1) mais aussi de fib(n- 2). 0 1
fib(n) =
{ fib(n-1)
+ fib(n- 2)
sin=O sin= 1 sin> 1
Les premières valeurs de la suite de Fibonacci sont: 0, 1, 1, 2, 3, 5, 8, 13 ... Les nombres de Fibonacci apparaissent dans des domaines aussi variés que les mathématiques, la physique, la botanique, la biologie, l'architecture. Les nombres de Catalan sont un exemple de définition récursive où b(n) s'exprime en fonction de tous les b(p), avec p < n.
b(n)
~ ~ {
sin= 0
b(p) x b(n- 1 - p)
sin> 0
On peut calculer les premières valeurs : b(O) = 1, b(1) = 1, b(2) = 2, b(3) = 5, b(4) = 14, b(5) = 42 ... Les nombres de Catalan sont omniprésents en informatique : par exemple b(n) est le nombre d'arbres binaires de taille n (voir chapitre 8).
1.2 Méthode de travail Nous présentons ici une méthode de travail qui synthétise la démarche d'écriture d'une fonction récursive. Le principe consiste à décomposer le traitement d'une donnée x en le traitement de dounées plus petites que x. Soit X un ensemble ordonné1 ; pour écrire la définition récursive d'une fonction f : X --> Y, on suit la méthode suivante: a) Décomposer la dounée x en éléments d1(x) ... de X, qui sont« plus petits>> que x. b) Écrire la relation de récurrence, c'est-à-dire une égalité (vraie pour des valeurs de x suffisamment grandes) entre f(x) et une expression qui contient des occurrences de f(di(x)). c) Déterminer les valeurs x (dites « valeurs de base >>) pour lesquelles : i) l'égalité n'est pas satisfaite (en particulier les valeurs pour lesquelles la relation de récurrence n'est pas définie), ii) les valeurs di(x) ne sont pas strictement« plus petites>> que x. d) Déterminer la valeur de la fonction f pour les valeurs de base. 1
Plus précisément un ensemble possédant un ordre bien fondé.
64 ~
Chapitre 3. Récursion
Exemples
Reprenons les deux exemples déjà vus (factorielle et puissance dichotomique) pour mettre en évidence la relation de récurrence et déterminer les cas de base. C>
Factorielle
Pour le calcul de la fonction factorielle, l'égalité dont on cherche le domaine de validité est: f(n) = n x f(n- 1). Cette égalité n'est pas satisfaite lorsque n = 0 car alors f(n -1) n'est pas défini. ll faut donc déterminer séparément la valeur de f(O) (ceci illustre le point c-i de l'énoncé de la méthode de travail). C>
Puissance dichotomique
Pour le calcul de la puissance dichotomique, l'égalité dont on cherche le domaine de validité peut s'écrire sous la forme :
p(x, n) = p(x, n-;- 2) x p(x, n-;- 2) p(x, n) =x x p(x, n + 2) x p(x, n + 2)
sin est pair si n est impair.
Lorsque n = 0 la condition n -;- 2 < n n'est pas vérifiée (ceci illustre le point c-ii de l'énoncé de la méthode de travail), donc n = 0 est un cas de base. ll faut donc déterminer séparément la valeur de p(O), ce qui donne l'expression récursive écrite précédemment. Noter que pour la fonction puissance dichotomique, on peut aussi écrire la relation de récurrence sous la forme :
p(x, n)
= p(x, n + 2)
x p(x, n
+ 2) x p(x, n mad 2).
Dans ce cas, lorsque n = 1la condition n mod 2 < n n'est pas vérifiée non plus. ll faut déterminer séparément la valeur des cas de base, i.e. de p( 0) et de p(1), ce qui produit l'écriture récursive suivante : 1
xn =
x
{ xn+2 x xn+2 x xn mod 2
sin=O sin= 1 sin > 1
La suite de ce chapitre est consacrée à l'implantation en Scheme de définitions récursives linéaires et dichotomiques sur les nombres.
2 Définitions récursives en Scheme Cette section s'attache, d'une part, à montrer comment concevoir, et écrire en Scheme, la définition récursive d'une fonction. Et, d'autre part, on s'intéresse à l'évaluation de l'application d'une telle fonction, en traçant, pas à pas, l'exécution.
..JI
~
Par abus de langage, une fonction Scheme est dite récursive lorsque son propre nom apparaît dans sa définition.
65
Définitions récursives en Scheme
2.1
Exemple 1 : fonction factorielle
2.1.1 Conception et définition ll est très simple de traduire en Scheme la fonction factorielle à partir de la définition mathématique récursive donnée précédemment. Encore une fois, insistons sur la façon de concevoir ce programme récursif : dans le cas général, on exprime f (n) en fonction de f (n - 1), et on traite directement le cas de base (cas qui ne peut pas se ramener à un cas plus simple). Attention: dans le programme ci-dessous, le cas de base est 1 (et non 0); ce choix est fait pour raccourcir les traces d'exécution, qui sont présentées dans le paragraphe suivant. ; ; ; factorielle: natl>=ll--+ nat ; ; ; (factorielle n) rend la factorielle de «n» (define (factorielle n) (if (= n 1) 1
(* n (factorielle (- n 1)))))
Pour écrire la définition récursive d'une fonction, il faut y croire: supposer que l'on sait calculer la fonction sur des valeurs plus petites (ici n - 1)- sans essayer d'expliciter ce calcul-, et exprimer un calcul qui permet d'obtenir la valeur de f(n) à partir de celle de f(n- 1). Attention « il faut y croire » ne signifie pas que 1' on peut écrire n'importe quoi et qu'il suffit d'y croire !
'
X
2.1.2 Évaluation Mais comment 1' évaluation se fait-elle ? Par exemple, comment le calcul de l'application (factorielle 3) se déroule-t-il? Évaluons (factorielle 3) en remplaçant chaque expression (factorielle i) par le corps correspondant de la fonction factorielle: (factorielle 3)
1
1
(if 1 (= 3 1) 11 (* 3 (factorielle(- 3 1}))) 1
(if
false
(* 3
1
(* 3
1
(* 3 (* 3
(factorielle
(factorielle
1 (-
2 )
3 1)
1
1> 1>
1>
{if 1 (= 2 1) 11 (* 2 (factorielle (- 2 1)))}
(* 3 {* 3
1 (* 3 (factorielle (- 3 1)))}
1
(if
false
1 {* 2 {factorielle (- 2 1)}}}
1>
{* 2 {factorielle 1 {- 2 1) 1>) (*
21
(* 3 ( * 2
(factor i e lle
1 }
1>)
(if 1 ( = 1 1) 1 1 ( * 1 ( f a ctorielle (- 1 1) ) )} ) }
Chapitre 3. Récursion
66
(* 3 ( * 2 (* 3 (* 3
1
1 (
1
* 2 2 )
(if 1 )
true
1 {* 1 (factorielle {- 1 1) ) ) )
1> )
1>
1
6
On a reproduit ci-dessus le déroulement obtenu en utilisant le mode« Step »(pas à pas) de DrScheme. Pour pouvoir utiliser cet outil de pas à pas, DrScheme oblige à se mettre en mode Beginning Student ou lntermediate Student, ce qui limite les possibilités du langage. Drscheme propose aussi une autre façon de tracer l'évaluation des programmes : la fonction trace permet de simuler une évaluation pas à pas tout en restant en mode Full Scheme. Pour l'utiliser, il faut inclure la bibliothèque trace.ss en début de programme (par l'application {require-1 ibrary "trace. ss" ) en version 103, et par l'application {require (lib "trace. ss" > > à partir de la version 200). On peut ensuite obtenir un pas à pas plus ou moins détaillé selon les fonctions dont on demande la trace, comme l'illustrent les trois exemples ci-dessous (du moins détaillé au plus détaillé). (trace factorielle)
(trace factorielle *)
1 (factorielle 3) 1 (factorielle 2)
1 1 (factorielle 1)
(factorielle 3) 1 (factorielle 2) 1 1 {factorielle 1)
1 11
1 11
1
16
2
1
(trace factorielle (factorielle 3) (; 3 1) #f (factorielle 2)
1 1 (* 2 1)
1
1 12 1
2
1
(* 3 2) 6
1
16
* ;)
(= 2 1)
l#f (factorielle 1) 1 {= 1 1) 1
fl:t
1
Il 1
{* 2 1)
12 2
(*
3 2}
6
6
Dans la première colonne, on a simplement montré la succession des appels récursifs - (factorielle 3) appelle (factorielle 2), qui appelle (factorielle 1) -, etle résultat de ces appels- {factorielle 1) vaut 1,puis (factorielle 2) peut être calculé comme le produit de 2 et de 1, et enfin ( factor i e 11 e 3 > peut être calculé comme le produit de 3 et de 2; la dernière ligne montre le résultat de l'évaluation de (factorielle 3). Dans la deuxième colonne, on a demandé aussi la trace de la fonction produit (*), donc l'affichage montre en plus les applications de la fonction produit, et leurs évaluations. Dans la dernière colonne, on fait de même avec la fonction d'égalité ( = ). Comparer cette dernière trace avec celle obtenue par le pas à pas. Remarque: tracer les fonctions permet de visualiser le processus d'évaluation, pour comprendre le mécanisme, éventuellement faire apparaître et corriger une erreur de programmation. Nous utiliserons aussi ce moyen pour mettre en évidence l'efficacité des programmes.
67
Définitions récursives en Scheme
Mais il faut bien différencier cette étape de déroulement de l'exécution et l'étape de conception/écriture des programmes, où il faut uniquement <
2.2 Exemple 2 : fonction puissance Pour la fonction puissance, nous avons donné deux définitions récursives, la première linéaire et la seconde dichotomique. Nous montrons ici que la version dichotomique est beaucoup plus efficace (à condition de ne pas évaluer plusieurs fois les calculs intermédiaires). 2.2.1 Récursion linéaire La définition récursive linéaire se traduit immédiatement en Scheme : ; ; ; puissance : Nombre *nat --. Nombre ; ; ; (puissance x n) rend xn (define (puissance x n) (if
(= n 0)
1
(*x (puissance x (- n 1)))))
On peut évaluer l'efficacité de cette définition en calculant le nombre T(n) de multiplications effectuées pour évaluer (puissance n) (noter que T(n) est aussi le nombre d'appels récursifs lors de l'évaluation de (puissance n)):
T(n) = {
~ + T(n- 1)
sin= 0 sin> 0
Cette récurrence se résout en T(n) = n. Le temps d'exécution de (puissance n) est donc proportionnel à n : on dit que la fonction est de complexité linéaire. 2.2.2 Récursion dichotomique La traduction en Scheme de la définition dichotomique demande un peu de réflexion, si l'on veut que le calcul soit efficace. Commençons par une définition qui reproduit la formule de calcul : (define (puissance-dicho-lent x n) (if
(= n
0)
1
(if (even? n) (* (puissance-dicho-lent x (puissance-dicho-lent x (*x (puissance-dicho-lent (puissance-dicho-lent x
(quotient n (quotient n x (quotient (quotient n
2)) 2))) n 2)) 2))))))
Cette définition produit bien le résultat xn attendu, mais le calcul est très inefficace. En effet pour évaluer (puissance-dicho-lent x n), on évalue deux fois (puissancedicho-lent x (quotient n 2)). Par exemple pour calculer x 8 , on calcule deux fois x 4 , chaque calcul de x 4 nécessite de calculer deux fois x 2 , chaque calcul de x 2 nécessite de calculer deux fois x 1 , et chaque calcul de x 1 nécessite de calculer deux fois x 0 . On peut calculer précisément le nombre T(n) d'appels récursifs lors de l'évaluation de
68
Chapitre 3. Récursion
(puissance-dicho-lent n)):
T(n)={
~+hT(n+2)
sin= 0 sin> 0
Cette récurrence est facile à résoudre lorsque n est une puissance de 2 : pour n = 2k, on a T(n) = 1 + 2 + 4 + 8 + ... + k = 2n- 1, et plus généralement T(n) est proportionnel à n, pour tout n. Cette implantation de la récursion dichotomique n'est donc pas plus efficace que la version linéaire. Pour une définition efficace, il faut éviter l'explosion des calculs intermédiaires, en évaluant une seule fois (puissance-dicho-lent x (quotient n 2}} dans le corps de la fonction. Pour cela on propose deux solutions : la première utilise une fonction auxiliaire, et la seconde utilise le nommage. Solution 1 : on définit la fonction carre (define (carre x)
( * x x}} La fonction carre évalue son argument, puis multiplie la valeur obtenue par ellemême, il y a donc une seule évaluation de xn 72 dans la définition suivante. (define (puissance-dicho-1 x n) (if
(= n 0)
1
(if (even? n} (carre (puissance-dicho-1 x (quotient n 2})) (*x (carre (puissance-dicho-1 x (quotient n 2}}}}}}}
Solution 2: ici on nomme (par un let) le résultat de l'évaluation de xn 72 (define (puissance-dicho-2 x n} (if
(= n 0}
1
(let ((y (puissance-dicho-2 x (quotient n 2}}}} (if (even? n)
( * y} (* x y
y}} } } }
Le calcul de xn au moyen de l'une ou l'autre des fonctions puissance-dicho-1 et puissance-dicho-2 ne nécessite qu'environ log 2 (n) opérations. En effet le nombre d'appels récursifs pour calculer xn vérifie alors la récurrence suivante (que le lecteur résoudra facilement dans le cas où n est une puissance de 2) :
T(n) = {
~ + T(n + 2)
sin= 0 sin> 0
Le récursion dichotomique montre alors toute sa force : les deux fonctions précédentes ont une complexité logarithmique. Pour le lecteur non convaincu par les explications théoriques, voici des traces du calcul de 27 qui se passent de commentaires (on a renommé les fonctions : puissance devient lineaire, puissance-dicho-lent devient dicho-lent et puissance-dicho-2 devient dicho).
69
Pour en savoir plus : ordres bien fondés (lineaire 2 7) (lineaire 2 6) (lineaire 2 5) (lineaire 2 4) 1 (lineaire 2 3) 1 (lineaire 2 2) 1 1 (lineaire 2 1) 1 1 (lineaire 2 0) 1 1 1
1 12 1 4
lB 16 32
64 128
(dicho-lent 2 7) (dicho-lent 2 3) 1 (dicho-lent 2 1) 1 (dicho-lent 2 0) 1
1
1
(dicho-lent 2 0)
1
1
12
(dicho 2 7) (dicho 2 3) 1 1 (dicho 2 1) 1 1 (dicho 2 0) 1 1 1 1 1
1 12 1 8 1128
(dicho-lent 2 1) 1 (dicho-lent 2 0) 1
1
1
1
(dicho-lent 2 0)
1
1
12 8 (dicho-lent 2 3) 1 (dicho-lent 2 1) 1 (dicho-lent 2 0) 1 1 1 (dicho-lent 2 0) 1
1
12 1 1
(dicho-lent 2 1) (dicho-lent 2 0)
1
1
1
(dicho-lent 2 0)
1
1
12 8
128
3 Pour en savoir plus : ordres bien fondés La récursion sur les entiers naturels consiste à transformer un problème sur un entier naturel n en un ou plusieurs problèmes sur des nombres plus petits. Comme il n'existe pas de suite infinie strictement décroissante dans l'ensemble des entiers naturels, la récursion finit par atteindre un cas de base comme 0 (ou 1 ou... ), pour lequel une solution directe au problème est fournie. L'ensemble des entiers naturels est dit «bien fondé» car il n'existe pas de suite infiniment décroissante. Cette propriété assure la récursion sur des ensembles plus complexes comme, par exemple, les couples d'entiers, les listes ou les arbres. Nous présentons ci-après la notion d'ordre bien fondé et le principe d'induction dans les ensembles munis d'ordres bien fondés.
3.1
Ordres bien fondés et principe d'induction
Soit E un ensemble muni d'un ordre~, non nécessairement total. L'ordre~ est dit bien fondé s'il n'y a pas de suite infinie strictement décroissante d'éléments de E. Tout ensemble
70
Chapitre 3. Récursion
E muni d'un ordre bien fondé ~ vérifie le principe d'induction énoncé ci-dessous : Principe d'induction: soit P une propriété définie dansE. Si, pour tout élément x de E, P(x) est vraie dès que Pest vraie pour tous les éléments de E strictement inférieurs à x, alors Pest vraie pour tous les éléments de E. Pour paraphraser ce principe : si on veut prouver qu'une propriété P est vraie dans E, on prend n'importe quel élément x de E, on suppose que P(y) est vraie pour tout y strictement inférieur à x et on montre alors que P(x) est vraie; si x est minimal dansE, i.e. s'il n'existe pas d'élément de E strictement inférieur à x, alors il faut prouver directement que P(x) est vraie.
3.2
Exemples d'ordres bien fondés
Entiers naturels: l'ordre :!(habituel sur N est bien fondé, le principe d'induction est identique au principe de récurrence complète. Remarque : il y a un seul élément minimal qui est O. Ensemble N x N : il y a plusieurs ordres bien fondés sur N x N : - ordres produits, au sens large et au sens strict : - au sens large : (a, b) ~ (c, d) si et seulement si a :!( c et b :!( d. Remarque : cet ordre n'est pas total (par exemple, on ne peut pas comparer (0, 1) et (1, 0)) et le seul élément minimal est le couple (0, 0). - au sens strict: (a, b) ~ (c, d) si et seulement si (a, b) = (c, d) ou (a< cet b < d). Remarque: cet ordre n'est pas total (par exemple, on ne peut pas comparer (0, 1) et (0, 2)) et les éléments minimaux sont tous les couples (n, 0) et (0, n). - ordre lexicographique: (a, b) ~ (c, d) si et seulement si a< cou (a= cet b :!( d). Remarque: cet ordre est total et le seul élément minimal est le couple (0, 0). - ordre« zigzag»: (a, b) ~ (c, d) si et seulement si a+ b < c + d ou (a+ b = c + d et a :!( c). Remarque: cet ordre est total et le seul élément minimal est (0, 0). Listes: appelons liste sur un ensemble F toute suite finie, éventuellement vide, d'éléments de F. L'ordre ~ défini sur l'ensemble des listes par : L 1 ~ L 2 si et seulement si la liste L1 est une suite extraite de la liste L2 (on dit que x;, ... x;. est une suite extraite de x1 ... Xn ssi 1 :!( i1 < i2 < ... < Îk :!( n), est bien fondé. Le seul élément minimal est la liste vide. ll y a d'autres ordres bien fondés sur les listes, par exemple l'ordre par taille : L 1 ~' L 2 si et seulement si la liste L1 a moins d'éléments que la liste L2, ou bien les deux listes sont identiques. Ici aussi, le seul élément minimal est la liste vide. Les listes jouent un rôle central en Scheme. Elles seront abordées au chapitre 4. Arbres : une autre structure fondanlentale en informatique est celle d'arbre (cf. chapitre 8). La notion de sous-arbre permet de définir un ordre bien fondé sur l'ensemble des arbres.
3.3
Récursion dans les ensembles munis d'ordres bien fondés
Le principe d'induction dans les ensembles munis d'ordres bien fondés justifie les définitions récursives dans de tels ensembles. Dans un ensemble muni d'un ordre bien fondé, la définition récursive d'une fonction f se décompose en deux parties :
Exercices corrigés
71
- Cas de base : pour x minimal (et éventuellement pour certains autres éléments), la valeur de f (x) est donnée, - Étape récursive: pour x non minimal, f(x) s'exprime en fonction des f(y), avec y strictement inférieur à x. Ce schéma de récursion est utilisé dans l'ensemble N x N (muni de l'ordre produit au sens large ou bien de l'ordre lexicographique), pour le calcul du pgcd par différences successives (voir l'exercice 14, page 73, «Calcul du pgcd »). ll sera utilisé intensivement pour les listes (voir chapitre 4) et pour les arbres (voir chapitre 8).
4 Exercices corrigés L'objectif de ces exercices est avant tout d'amener le lecteur à « penser récursif », en insistant davantage sur la conception des algorithmes que sur leurs preuves. Mais il faut bien garder à l'esprit que la conception d'un algorithme et sa preuve vont de pair. Prouver un algorithme récursif consiste à montrer que : ( i) la suite des appels récursifs se termine, (ii) le résultat obtenu est correct. Dans certains des exercices suivants, nous expliquons brièvement pourquoi la récursion s'arrête, mais nous laissons au lecteur le soin de montrer que le résultat obtenu est correct. Nous donnons aussi une idée de la complexité des fonctions, comptée en nombre d'appels récursifs (ce nombre reflète le nombre d'opérations effectuées lors de l'évaluation), sans insister sur la façon de calculer cette complexité.
4.1
Énoncés
Exercice 10- Nombres et chiffres Cet exercice utilise une méthode proche du schéma dichotomique, à la différence près qu'ici, les appels récursifs se font en divisant l'argument par 10, et non plus par 2. Question 1- Écrire une définition de la fonction somme-des-chiffres qui rend la somme des chiffres d'un entier naturel n. D'où le jeu d'essais: (verifier somme-des-chiffres (somme-des-chiffres 345) == 12 (somme-des-chiffres 7) == 7 (somme-des-chiffres 0) == 0
Question 2- Écrire une définition du prédicat existe-chiffre? qui, étant donné un chiffre centre 0 et 9, et un entier naturel n, rend vrai si et seulement si le chiffre c apparaît dans l' écriture en base 10 de n. On supposera que l'écriture des nombres autres que zéro ne commence pas par le chiffre O. D'où le jeu d'essais : (verifier existe-chiffre? (existe-chiffre? 5 546) == #T (existe-chiffre? 0 6045) == #T (existe-chiffre? 0 645) == #F )
Question 3 - Écrire une définition de la fonction somme-des-sommes qui, étant donné un entier naturel n, renvoie le nombre à un seul chiffre obtenu en calculant la somme des chiffres
72
Chapitre 3. Récursion
den et en réitérant le procédé jusqu'à ce que le résultat ne comporte qu'un seul chiffre. Par exemple: (somme-des-sommes 54695)
-+
2
Exercice 11 - Puissance de 2 Cet exercice utilise le schéma de récursion dichotomique, il montre l'intérêt du nommage et revient sur la notion de semi-prédicat. Écrire une définition du semi-prédicat puissance-de-2 qui, étant donné un entier positifn, rend #f sin n'est pas une puissance de 2 et sinon, rend l'entier k tel que n = 2k. D'où le jeu d'essais : (verifier puissance-de-2 (puissance-de-2 32) == 5 (puissance-de-2 44) == #F (puissance-de-2 1) == 0 )
Exercice 12- Tirage aléatoire On se propose de tirer au hasard des entiers naturels en imposant qu'à chaque tirage, le nombre tiré soit strictement inférieur au précédent. Avec ce processus, on finira toujours par tomber sur O. En partant de 1000000000, en combien de tirages arrivera-t-on à 0? Le résultat est aléatoire (on peut seulement dire qu'il est compris entre 1 et 1000000000). On peut calculer sa valeur moyenne de façon théorique, mais on peut aussi l'estimer en répétant l'expérience un grand nombre de fois (par exemple mille fois). Afin d'étudier expérimentalement ce problème, on considèrera une fonction alea répondant à la spécification suivante : ;;;
alea:nat~nat
; ; ; (alea n) renvoie le nombre de tirages pour obtenir 0, en tirant au hasard ; ; ; des nombres de plus en plus petits, à partir de «n» (exclu) Mais l'évaluation de (alea 1000000000 J ne risque-t-elle pas d'être très longue, dans le
cas (le pire) où 1000000000 de tirages seraient nécessaires? Est-il raisonnable d'envisager de répéter mille fois l'expérience, c'est-à-dire mille fois l'évaluation de (alea 10 0 0 0 0 0 0 0 0 J ? Un raisonnement probabiliste très simple montre que la probabilité que (alea n) renvoie n est égale à ~~. Par conséquent, la probabilité que (alea 1000000000) renvoie 1000000000 est égale à woootooool , ce qui est très, très faible (beaucoup plus petit que 10 -woooooooo). Un raisonnement plus élaboré montre que la valeur moyenne de {alea n) est égale à 1 +! + ~ + ... + ~, qui est proche de log( n ), lorsque n est grand. On peut aussi montrer que la valeur de (alea n) est centrée autour de sa valeur moyenne. Puisque log(lOOOOOOOOO) Rl 20.72, il n'est pas déraisonnable de faire évaluer mille fois (alea 1000000000 J. Cet exercice a pour objectif de vérifier expérimentalement que la valeur moyenne de (alea n) est proche de log(n) (lorsque n est grand). Question 1 - Donner une définition de la fonction alea répondant à la spécification donnée ci-dessus. Question 2 - Donner une définition de la fonction moyenne-alea qui, étant donnés deux entiers n et k, renvoie la valeur moyenne de (alea n) lorsque l'on fait k évaluations. Tester la fonction moyenne-alea avec différentes valeurs de n et k (n = 1000000000, n = 2000000000, k = 1000, k = 10000... ).
73
Exercices corrigés
Exercice 13 - Carrés concentriques Cet exercice présente un exemple de fonction récursive renvoyant une image.
Question 1- Écrire la définition de la fonction dessin-carre qui, étant donné un nombre c compris entre 0 et 2, rend le pourtour d'un carré centré en (0, 0) et dont les côtés sont horizontaux et verticaux et de longueur c.
ci-«mlre"'""""'"""'
.r-
P , =mple,l'""""' 1doooin-o=<• 1). L' fichage se fait dans le cadre de dessin de DrScheme, qui est un carré centré à l'origine et de côté 2.
~~
1
1
Question 2- Écrire la définition de la fonction concentriquesl qui, étant donnés un entier naturel net un nombre c compris entre 0 et 2, rend l'image des pourtours den carrés centrés en (0, 0), tels que le côté le plus grand est de longueur c et la longueur du côté diminue de moitié d'un carré à l'autre. Par exemple, {concentriquesl 5 1. 4) rend l'image ci-contre:
Dans la question précédente, le nombre de carrés est un paramètre du problème. Dans la question suivante, la récursion s'arrête lorsque la taille du carré devient plus petite qu'une certaine valeur.
Question 3- Écrire la définition de la fonction concentriques2 qui, étant donnés un nombre c compris entre 0 et 2 et un nombre e, rend l'image des pourtours de carrés centrés en (0, 0), tels que les longueurs de tous les côtés sont supérieures ou égales à e, le côté le plus grand est de longueur cet la longueur du côté diminue de moitié d'un carré à l'autre.
Pw
~=pl~
. 'l rerull'""""'
1oonoonCd""""' U
ci~"'"
~~~ D ~~~
Exercice 14- Calcul du pgcd Cet exercice propose deux algorithmes de calcul du pgcd de deux nombres. Le premier algorithme procède par différences successives. Sa conception est très simple mais sa justification est assez délicate : en effet, la récursion ne porte plus sur un seul argument mais sur les deux arguments de la fonction pgcd. Le second est de conception plus sophistiquée (il utilise la notion de modulo) mais sa justification est plus aisée (le raisonnement par récurrence porte sur un seul argument). Un premier algorithme consiste à calculer le pgcd de deux nombres par différences successives. On peut exprimer cet algorithme en utilisant les équations suivantes :
pgcd(m,O) pgcd(O,n) pgcd(m,n) pgcd(m,n)
m
-
n
-
pgcd(m,n- m) pgcd( m - n, n)
sim< n sinon
Question 1 - Donner une définition de la fonction pgcd qui implante cet algorithme.
74
Chapitre 3. Récursion
Le deuxième algorithme proposé procède par divisions successives. Il utilise les égalités suivantes:
pgcd(m, 0) pgcd(m,n)
-
m pgcd(n,mmodn)
sin# 0
Question 2- Donner une définition de la fonction pgcd qui implante l'algorithme décrit par ces équations.
Exercice 15 - Miroirs, palindromes, facteurs Cet exercice présente des exemples de fonctions récursives dont les arguments ne sont pas des nombres, mais des chaînes de caractères, que nous désignerons de façon un peu abusive par mots. Bien que les arguments soient des mots, la récursion porte en fait sur des nombres : les longueurs des mots. D'une part, un test sur la longueur permet d'arrêter la récursion et d'autre part, le calcul pour des mots de longueur plus petite permet d'amorcer la récursion.
Question 1 - On appelle miroir d'un mot 8 le mot obtenu en écrivant le mot 8 « à l'envers». Par exemple, le miroir de "abcdef" est "fedcba". Écrire une définition de la fonction miroir qui, étant donné un mot 8, rend le miroir de s. Par exemple: (miroir "abcdef '' ) --+ '' fedcba '' (miroir "scheme '' ) --+ '' emehcs '' (miroir "noyon'') ----+ ''noyon''
Question 2- On dit qu'un mot est un palindrome s'il est égal à son miroir. Par exemple "elle", "noyon", "eluparcettecrapule", "esoperesteicietserepose" sont des palindromes, mais "maman" et "papa" n'en sont pas. Écrire une définition du prédicat palindrome? qui, étant donné un mot seulement si s est un palindrome.
8,
rend #t si et
Question 3- On dit qu'un mot west unfacteur d'un mot 8 s'il existe des mots u et v tels que 8 soit égal à la concaténation de u, w et v. Par exemple "ela" est un facteur de "chocolat" mais n'est pas un facteur de "pouleaupot". On dit qu'un mot west un suffixe d'un mots s'il existe un mot u tel que s soit égal à la concaténation de u et w (autrement dit si 8 se termine par w). Par exemple "clat" est un suffixe de "chocolat" mais "ela" n'en est pas un.
Écrire une définition du semi-prédicat facteur qui, étant donnés deux mots w et 8, rend #f si w n'est pas un facteur de 8 et sinon, rend le plus long suffixe de 8 débutant par w. D'où le jeu d'essais : (verifier (facteur (facteur (facteur (facteur
facteur ''ola'' "chocolat") == "olat'' "ela" "cholacolat") == "olacolat" ''olat'' ''chocolat == ''olat" "ela" "pouleaupot") == #F ) 11
)
75
Exercices corrigés
4.2 Corrigés Exercice 10- Nombres et chiffres Solution de la question 1 - La somme des chiffres d'un entier naturel n, noté en base 10, s'obtient en ajoutant le dernier chiffre den ( (remainder n 10} en Scheme) à la somme des chiffres du nombre n privé de son dernier chiffre ((quotient n 10} ), selon l'équation: sc(n)
= sc(n-;- 10) + n
mod 10.
Pour déterminer le cas de base, on applique la méthode de travail présentée dans le cours : la condition n -;- 10 < n n'est vérifiée que pour n > 0 donc la récursion s'arrête lorsque l'argument devient zéro. Le nombre d'appels récursifs est égal au nombre de chiffres den, c'est-à-dire à la partie entière de log 10 (n) augmentée de 1. ; ; ; somme-des-chiffres : nat --> nat ; ; ; (somme-des-chiffres n) rend la somme des chiffres de l'entier naturel «n» (define (somme-des-chiffres n} (if
(= n
0 (+
0}
(remainder n 10} (somme-des-chiffres (quotient n 10}}}}}
Solution de la question 2- Pour savoir si le chiffre c apparaît dans l'écriture en base 10 d'un entier n il faut regarder si c est égal au dernier chiffre de n ou si c apparaît dans l'écriture de n privé de son dernier chiffre : c = n mod 10
OU
e(n, c)
= e(n-;- 10, c).
En réalité l'expression n-;-10 ne renvoie n privé de son dernier chiffre que lorsque n n'est pas un chiffre (en effet dans ce cas n-;- 10 vaut 0, alors que n privé de son dernier chiffre n'a pas d'écriture- est vide-). Donc l'équation de récurrence n'est pas vérifiée quand n est un chiffre, et la récursion s'arrête en renvoyant le résultat de la comparaison de n et de c. ; ; ; ; existe-chiffre? : nat/entre 0 et 91 * nat --> boo/ ; ; ; ; (existe-chiffre? c n) rend vrai ssi le chiffre «C» apparaît dans ; ; ; ; l'écriture en base JO de l'entier naturel «n» (define (existe-chiffre? c n} (if (< n 10} (= c n} (or (= c (remainder n 10}} (existe-chiffre? c (quotient n 10}}}}}
Dans le cas le pire (c'est-à-dire le cas où c n'est pas un chiffre den ou bien le cas où c est uniquement le chiffre des plus grandes unités den), le nombre d'appels récursifs est égal au nombre de chiffres de n. Dans les autres cas, la récursion s'arrête en cours de route et le nombre d'appels récursifs est inférieur au nombre de chiffres den. Par exemple, avec n = 61313, il y a 1 comparaison sic= 3, il y en a 2 sic= 1, il y en a 5 sic= 6 ou sic n'est pas un chiffre de 61313.
Solution de la question 3- Pour calculer (somme-des-sommes 54695}, on peut calculer d'abord la somme des chiffres de 54695, qui vaut 29, puis la somme des chiffres de 29, qui vaut 11, puis la somme des chiffres de 11; comme elle vaut 2, le calcul s'arrête.
76
Chapitre 3. Récursion
Plus généralement, en notant ss la somme des sommes et sc la somme des chiffres, on a la récurrence :
ss(n)
= ss(sc(n))
Clairement la condition sc(n) < n n'est pas vérifiée lorsque n < 10. Le raisonnement suivant assure qu'elle est vérifiée pour n ~ 10 : si n s'écrit bk ... b1 bo en base 10 alors n = bk * 10k + ... + b1 * 10 + bo et la somme de ses chiffres est s = bk + ... + b1 + bo ; il est immédiat que s est inférieur à n, dès que n ~ 10. Voici la fonction Scheme : ; ; ; somme-des-sommes : nat ~ nat ; ; ; (somme-des-sommes n) rend le nombre à un seul chiffre obtenu en calculant la somme des chiffres ; ; ; de «n» et en réitérant le procédé jusqu'à ce que le résultat ne comporte qu'un seul chiffre
(define (somme-des-sommes n) (if
(< n 10)
n
(somme-des-sommes (somme-des-chiffres n))))
Exercice 11 - Puissance de 2 La relation de récurrence s'écrit: 0
p2(n) =
{
#! #f 1+p2(n+2)
sin= 1 si n est impair sip2(n + 2) vaut#f sinon
La récursion s'arrête lorsque n est impair (la condition n + 2 < n est toujours vérifiée pourn > 0). Rappelons qu'en Scheme, toute valeur différente de #fest évaluée à vrai dans un test. Ainsi, soit k2 une valeur rendue par le serni-prédicat puissance-de-2; ou bien k2 vaut #f, ou bien k2 est un entier. Donc l'expression (if k2 ( + k2 1) # f) est égale à # f si k 2 est égal à #f et à k2 + 1 sinon. ; ; ; puissance-de-2 n : nat/positif/-+ nat + #f ; ; ; (puissance-de-2 n) rend #f si «n» n'est pas une puissance de 2 et sinon, ; ; ; rend l'entier «k» tel que n = 2k
(define (puissance-de-2 n) (if
(= n 1)
0
(even? n) (let ((k2 (puissance-de-2 (quotient n 2)))) (if k2 (+ k2 1) #f) ) #f) ) ) Voici une autre définition de puissance-de-2 : (define (puissance-de-2 n) (if
(if
(= n 1)
0
(and (even? n) (let ((k2 (puissance-de-2 (quotient n 2)))) (and k2 (+ k2 1))))))
77
Exercices corrigés
En effet, l'évaluation de l'expression (and a b) rend #f si a est égal à #f et renvoie l'évaluation de b dans tous les autres cas (par exemple, l'évaluation de (and 3 4) rend 4). Attention : comme dans le calcul de la puissance n-ième par dichotomie, il existe aussi une version sans nommage. Bien que cette solution soit déconseillée (voire interdite), on définit ici une fonction puissance-de-2 -lent qui n'utilise pas la forme let, dans le but de mettre en évidence par les traces le gain d'efficacité dû au nommage. (define (puissance-de-2-lent n) (if
(= n 1)
0 (if
(even? n) (if (puissance-de-2-lent (quotient n 2)) (+ (puissance-de-2-lent (quotient n 2)) 1) #f) #f) ) )
TI est clair que, dans le cas où n est pair, cette fonction évalue deux fois la même chose, alors que la version avec nommage évite cet inconvénient. Et bien sûr, dans le cas où n est une puissance de 2, cette double évaluation inutile se reproduit à chaque appel récursif. C'est pourquoi la fonction puissance-de-2 est beaucoup plus efficace que la fonction puissance-de-2-lent, comme l'illustrent leurs traces. (puissance-de-2 4) (puissance-de-2 2) 1 1(puissance-de-2 1) 1 1
(puissance-de-2-lent 4) (puissance-de-2-lent 2) 1(puissance-de-2-lent 1)
1 10 1
1D
1
1
12
(puissance-de-2-lent 1)
ID 1
(puissance-de-2-lent 2) 1 (puissance-de-2-lent 1)
ID 1
(puissance-de-2-lent 1)
ID 1
2
Avec 4 comme argument, la trace de puissance-de-2 montre 3 appels récursifs et celle de puissance-de-2-lent en montre 7. Avec 1024 comme argument, la trace de puissance-de-2 montre 11 appels récursifs alors que celle de puissance-de-2-lent en montre 2047 ... Plus généralement, sin= 2k, l'évaluation de (puissance-de-2 n) nécessite k + 1 = log 2 (n) + 1 appels récursifs alors que celle de (puissance-de-2-lent n) en nécessite 2n- 1. Ainsi, la bien nommée fonction puissance-de-2-lent est exponentiellement plus lente que puissance-de-2 (dans le pire cas).
Exercice 12- Tirage aléatoire Solution de la question 1 - Si n = 0, le nombre de tirages est égal à O. Sinon, il faut tirer au hasard un nombre p strictement inférieur à n (au moyen de (random n)) et ajouter 1 au nombre de tirages nécessaires pour obtenir 0 en partant de p. (define (alea n) (if
(= n D)
0
(+ 1 (alea (random n)))))
78
Chapitre 3. Récursion
Puisque (random n) renvoie un nombre p strictement inférieur à n, le principe de récurrence complète dans N assure que la récursion se termine. Voici les traces de trois évaluations de (alea lOO) : (alea 100) (alea 91) 1 (alea 10) 1 (alea 2) 1 1 (alea 1) 1 1 (alea 0) 1 1 0 1 11 1 2
1 (alea 100) 1 (alea 30) 1 1 (alea 0) 1
ID
1 1 12
13
1 (alea 100) 1 (alea 51) 1 1 (alea 40) 1 1 (alea 5) 1 1 1 (alea 0) 1 1 10 1 1 1 1 12 1 3
14
4
5
Sur ces trois évaluations, la valeur moyeune de (alea 100) est environ 3.66. Solution de la question 2- On définit tout d'abord une fonction cumul-alea qui, étant dounés deux entiers net k, renvoie la somme de k valeurs calculées par (alea n). Si k est positif, on effectue la somme de k- 1 valeurs calculées par (alea n) et on lui ajoute une valeur calculée par (alea n). La somme de 0 valeur calculée par (alea n) vaut O. ; ; ; cumul-alea : nat * nat --> nat ; ; ; (cumul-alea n k) renvoie la somme de «k» valeurs calculées par (alea n) (define (cumul-alea n k) (if
(= k
0)
0 (+
(alea n)
(cumul-alea n
(- k 1)))))
Pour calculer la moyenne, il suffit de diviser la somme par le nombre d'essais. ; ; ; moyenne-alea : nat * nat ----t nat ; ; ; (moyenne-alea n k) renvoie la moyenne de «k» valeurs calculées par «(alea n)» (define {moyenne-alea n k) (exact->inexact (/ (cumul-alea n k) k))) Noter l'emploi de la fonction exact->inexact qui permet d'obtenir des valeurs décimales : par exemple, (exact->inexact ( 1 125 10)) rend 12.5 alors que (! 125 10) rend 25/2. Exercice 13 - Carrés concentriques Solution de la question 1 : ; ; ; dessin-carre : Nombre/compris entre 0 et 21--> Image ; ; ; (dessin-carre c) rend le pourtour d'un carré centré en (0,0) et ; ; ; dont les côtés sont horizontaux et verticaux et de longueur «C» (define {dessin-carre c) (let ( {d (/ c 2) ) ) (overlay (line (- d) (- d) d (- d)) ( line d (- d) d d) (line d d (- d) d) ( line (- d) d {- d) (- d) ) ) ) )
Remarquer le nommage qui évite d'évaluer seize fois la valeur de c/2.
79
Exercices corrigés
Solution de la question 2 - On superpose le dessin du carré de côté c et l'image formée de
n - 1 carrés concentriques, le plus grand ayant un côté de longueur c/2. ccl(n, c) = superposition(carre(c), ccl(n- 1, c/2)) La relation précédente n'est pas définie pour n l'image rendue est l'image vide.
= O. Dans ce cas la récursion s'arrête et
; ; ; concentriques] : nat *Nombre/compris entre 0 et 21---> Image ; ; ; (concentriques] n c) rend l'imageformée des pourtours de «n» carrés centrés en (0,0), tels que le ; ; ; ctJté le plus grand est de longueur«c» et la longueur du ctJtédiminue de moitié d'un carré à l'autre (define (concentriquesl n c ) (if
(= n 0)
(image-vide) (overlay (dessin-carre c) ( concentriquesl (- n 1)
(!
c 2) ) ) ) )
Solution de la question 3 - On superpose un carré de côté cet l'image obtenue en divisant
l'argument c par 2 (l'argument e est inchangé). On obtient donc la récurrence cc2(c, e) Lorsque c
= superposition(carre(c), cc2(c/2, e)).
< e, la récursion s'arrête et l'image rendue est l'image vide.
; ; ; concentriques2 :Nombre/compris entre 0 et 21 *Nombre/positif/---> Image ; ; ; (concentriques2 ce) rend l'image formée des pourtours de carrés centrés en (0,0), tels que ; ; ; les longueurs de tous les ctJtés sont supérieures ou égales à «e», le ctJté le plus grand est ; ; ; de longueur «C» et la longueur du ctJté diminue de moitié d'un carré à l'autre (define (concentriques2 c e) (if
(< c e)
(image-vide) (overlay (dessin-carre c) (concentriques2 (/ c 2) e))))
Exercice 14- Calcul du pgcd ll suffit de traduire l'énoncé en Scheme. Voici une définition utilisant une conditionnelle (plutôt qu'une imbrication d'alternatives) : Solution de la question 1 -
; ; ; pgcd : nat * nat ---> nat ; ; ; (pgcd mn) rend le plus grand diviseur commun de «m» et de <
( ( < m n) (pgcd m (- n m) ) ) (else (pgcd (-mn) n))))
Mais comment être sûr que la récursion s'arrête? Intuitivement, les deux arguments de la fonction pgcd sont toujours positifs ou nuls et, à chaque appel récursif, l'un des deux arguments diminue : par conséquent, l'un des deux arguments finira par être égal à O. Solution de la question 2 : (define (pgcd m n) (if
(= n 0) m
(pgcd n (modulo mn))))
80
Chapitre 3. Récursion
ll est très facile de montrer intuitivement que la récursion s'arrête, puisque le deuxième argument de la fonction pgcd diminue à chaque appel récursif. Mais si l'on veut faire une preuve plus rigoureuse, il faut faire attention à bien formuler l'hypothèse de récurrence : il faut montrer (par récurrence sur n) que, pour tout entier m, le calcul de (pgcd rn n) se termine.
Exercice 15 - Miroirs, palindromes, facteurs Solution de la question 1 - Le miroir d'un mot s est obtenu en concaténant le miroir du mot s privé de son premier caractère et le mot formé du premier caractère de s. La phrase précédente n'a pas de sens si s est le mot vide ; le cas de base est donc le cas où le mot est vide (longueur nulle) et il est alors son propre miroir. ; ; ; miroir : string ~ string ; ; ; (miroirs) rend le miroir du mot «S» (define (miroir s) (let ((n (string-length s))) (if
(= n
0)
Il Il
(string-append (miroir (substring s 1 n)) (substring s 0 1)))))
Remarquer que la longueur du mot décroît à chaque appel récursif : s est un mot de longueur n, qui est égal à (substring s 0 n), quant à (substring s 0 (- n 1) ), c'est un mot de longueur n- 1, qui est égal à s privé de son premier caractère. ll n'y a pas de raison particulière de privilégier le premier caractère plutôt que le dernier puisque la fonction substring permet d'avoir accès à n'importe quelle partie du mot. On peut donc écrire un autre algorithme qui consiste à concaténer le mot formé du dernier caractère de s et le miroir du mot s privé de son dernier caractère : (define (miroir s) (let ((n (string-length s))) (if
(= n
0)
Il Il
(string-append (substring s (- n 1) n) (miroir (substring s 0 (- n 1)))))))
Remarque: on ne peut pas appliquer la fonction miroir à un nombre, car l'argument de miroir doit être une chaîne de caractères, mais on peut calculer le miroir d'un nombre n en composant les fonctions number->string qui convertit son argument en une chaîne de caractères, puis miroir, et enfin string->number qui convertit une chaîne de caractères en nombre. Par exemple, l'expression (string->number (miroir (number->string 2 9 8 5 l ) ) est évaluée de la manière suivante : (number->string 2985) estévaluéeen "2985",puis (miroir "2985") apourvaleur ·· 5892 ·· et enfin (string->number "5892") a pour valeur le nombre 5892.
Solution de la question 2 - Une première solution consiste à calculer le miroir du mot s et à vérifier s'il est égal à s. Le corps de la fonction s'exprime simplement : ( equal? s (miroir s) ) ) , mais cette méthode présente l'inconvénient de faire le calcul du miroir de s, ce qui nécessite n concaténations (où n est la longueur du mot), alors qu'il suffit parfois d'une seule comparaison pour savoir qu'un mot n'est pas un palindrome. Par exemple, sis est le mot "j enesui spasunpal indrome", il suffit de comparer le premier caractère de s et son
81
Exercices corrigés
dernier caractère (ou encore les mots "j" et "e") pour savoir que s n'est pas un palindrome (alors que le calcul du miroir de s nécessite 23 concaténations). On utilisera donc un algorithme qui calcule directement si un mot est un palindrome : s est un palindrome ssi le premier caractère de 8 et son dernier caractère sont égaux et si le mot s privé de son premier caractère et de son dernier caractère est un palindrome. Lorsque s est un mot de longueur n, l'application (subs tr ing s 1 (- n 1) ) calcule le mot 8 privé de son premier caractère et de son dernier caractère. Cette expression n'étant pas définie si n ::;; 1, la récursion s'arrête lorsque le mot est vide ou comporte un seul caractère (le mot est alors un palindrome). ; ; ; palindrome? : string --+ bool ; ; ; (palindrome? s) rend #t si «S» est un palindrome et#fsinon
(define (palindrome? s) (let ((n (string-length s))) (if
(<= n 1)
#t
(and (equal? (substring s 0 1) (substring s (- n 1) n)) (palindrome? (substring s 1 (- n 1)))))))
Solution de la question 3 - Si le mot 8 débute par w alors le résultat est 8, sinon on cherche si w est un facteur de 8 privé de son premier caractère. Lorsque la longueur de west supérieure à la longueur de 8, w ne peut pas être un facteur des, ceci constitue donc le cas de base. ; ; ; facteur : string * string --+ string + #f ; ; ; (facteur w s) rend le plus long suffixe de «S» débutant par le mot «W» ; ; ; si «W » est un facteur de « s» et rend #f sinon
(define (facteur w s) (let ( (p (string-length w)) (n (string-length s))) (if
(> p n)
#f
(if (equal? w (substring s 0 p)) s
(facteur w (substring s 1 n)))))) La recherche d'un facteur dans un mot est un problème de base en informatique : recherche d'un mot dans un texte, recherche d'une séquence d'ADN dans un génome, etc. L'algorithme présenté ici est un algorithme naïf, il existe des algorithmes beaucoup plus efficaces (comme, par exemple, l'algorithme de Knuth-Morris-Pratt) qui mémorisent les comparaisons déjà faites.
Chapitre 4
Structure de liste Jusqu'à présent, les données et les résultats des fonctions que nous avons étudiées étaient des entiers, des réels, des booléens, des images ou des chaînes de caractères. ll s'agissait d'informations « simples >>. Dans ce chapitre nous introduisons la notion de structure de données et plus particulièrement la notion de liste, structure récursive pour représenter une séquence ordonnée de valeurs. Une liste non vide est constituée d'un premier élément et d'un reste, qui est aussi une liste. Cette définition récursive induit un schéma récursif pour le traitement des listes, qui est une extension de la récursion linéaire sur les nombres entiers. La liste est la structure de données historique de Lisp (Lisp a même été inventé autour de cette notion par John McCarthy en 1958 au MIT (le fameux Massachussetts Institute of Technology) et le nom de Lisp signifie List Processor : le manipulateur de listes). Pour Scheme, langage issu de Lisp, la liste est toujours la structure de données fondamentale, qui permet de représenter, outre des séquences de données, les expressions (programmes) du langage.
1 Deux notions importantes 1.1
Notion de séquence en informatique Considérons la fonction suivante, qui calcule la moyenne de trois notes :
, , , moyenne-3-notes: Note *Note *Note-+ Nombre ; ; ; où Note est le type: 0<= Nombre <=20 ; ; ; (moyenne-3-notes nl n2 n3) rend la moyenne des trois notes données en argwnent. (define (moyenne-3-notes nl n2 n3) ( 1 ( + nl n2 n3 ) 3 ) ) Supposons maintenant que l'on ait à calculer la moyenne de p notes. La donnée d'un tel
problème est une séquence finie de notes. Une telle séquence, notée de façon relativement informelle no, n1, ... ,np-1• peut être vue de différentes façons : - les notes peuvent être notées n(O), n(1), ... n(p- 1) et la séquence est une application de {0, 1, 2, ...p-1} dans 0 .. 20 (nous étudierons, page 163, les séquences sous cet angle de vue dans la section traitant des vecteurs) ;
84
Chapitre 4. Structure de liste
- d'un autre point de vue, la séquence peut aussi être décomposée, de façon récursive, en sa première note, et les autres notes qui constituent encore une séquence (avec moins de notes que la séquence complète). En itérant récursivement cette décomposition, on arrive à une séquence qui n'a qu'un élément, constituée elle-même de sa première note et d'une séquence qui n'a pas d'élément (nommée la séquence vide). En Scheme, ce second angle de vue sur les séquences est mis en œuvre par la notion de liste que nous développons dans ce chapitre, après avoir introduit la notion de structure de données.
1.2 Notion de structure de données Dans la fonction moyenne-3 -notes on manipule trois informations simples, représentées par les trois variables dans la définition de la fonction. Pour calculer la moyenne de p notes, p n'étant pas fixé, on ne sait pas écrire1 la défiuition d'une fonction avec p variables représentant les p notes données. n faut que la donnée des p notes soit une (unique) donnée de la fonction. Mais alors cette donnée est constituée de plusieurs informations et, pour regrouper ces informations on les structure dans ce qu'on nomme une structure de données. Pour manipuler une donnée structurée il faut un ensemble de fonctions de base permettant de fabriquer une telle donnée structurée et de retrouver les différentes informations présentes dans la donnée structurée : - un constructeur permet de fabriquer une donnée structurée ; - un accesseur permet de retrouver une information dans une donnée structurée ; - un reconnaisseur est un prédicat qui permet de savoir si une donnée structurée est d'une certaine forme. La notion de structure de données sera développée au chapitre 7. Nous travaillons plus particulièrement ici sur les listes.
2 Structure de données « liste » La liste est une structure de données qui permet de rassembler en une unique valeur une succession ordonnée non bornée de valeurs. Elle met en œuvre la vision récursive d'une séquence d'éléments (on dit aussi termes). Ainsi une liste est: - soit la liste vide, - soit constituée de son premier élément et du reste des éléments qui forment aussi une liste. Les listes que nous manipulons, au début dans ce livre, sont typées : toutes les valeurs sont de même nature. On parle alors de listes homogènes. On pourra ainsi construire des listes de nombres, des listes d'images, des listes de chaînes de caractères ou des listes de fonctions prenant un entier et renvoyant une image, etc. Par exemple, la donnée de la fonction moyenne-notes qui calcule la moyenne d'une suite de notes est une liste de nombres naturels. Nous noterons LISTE [Note] le type d'une telle liste. La signature de la fonction est donc : ; ; ; moyenne-notes: USTE[Note}-t Note ; ; ; avec Note 1 avec
= 0 <=Nombre <=20
les connaissances de Scheme que l'on vous a données jusqu'à présent
Structure de données
«
liste
»
85
D'une façon générale, nous noterons LISTE [a] une liste d'éléments de type a. Ainsi la notation LISTE [ l indique la structure de données et le symbole a indique le type des éléments de la liste. Nous étudions maintenant les fonctions de base sur les listes : constructeurs, accesseurs et reconnaisseurs des listes.
2.1
Constructeurs list et cons TI existe principalement deux constructeurs de listes, les primitives list et cons.
~
Constructeur 1 i s t
La fonction 1 is t permet, étant donnée une séquence (une suite) d'éléments, de construire la liste constituée de tous ces éléments. Cette fonction prend donc un nombre quelconque d'arguments 2 (et lorsque le nombre d'argument est zéro elle renvoie la liste vide). ; ; ; list: a * ... --->liSTE[a] ; ; ; (list el...) crée une liste dont les éléments sont les arguments; (list) rend la liste vide.
Par exemple : (list 3 5 2 5) (list) ---> ()
--->
(3 5 2 5)
ji Noter la signature de la fonction list: a *. . . ->LISTE [a], c'est une fonction qui ~ a un nombre quelconque d'arguments (ce que l'on représente par les points de suspension). Tous les arguments sont d'un type a. Elle rend une liste (notée par LISTE [ ] ) d'élément de ce même type a. ~
Constructeur cons
Une liste peut également être construite, comme l'indique la définition récursive, à partir de son premier élément et d'une autre liste. Cette construction se fait en utilisant la fonction cons: ; ; ; cons: a* liSTE[a]-+ liSTE[a] ; ; ; (cons eL) rend la liste dont le premier élément est «e» et dont les ; ; ; éléments suivants sont les éléments de la liste «L».
Par exemple : (cons 7 (list 3 52 5)) ---> (7 3 52 5) (cons 5 (cons 2 (cons 5 (list)))) -+ (52 5)
ji Noter la signature de la fonction cons: a * LISTE [a] -+LISTE [a] :elle a deux ~ arguments: un élément de type quelconque a et une liste d'éléments de ce même type (notée LISTE [a]); et elle rend une liste d'éléments de type a. Remarque : en fait, le constructeur list n'est pas indispensable (sauf pour construire la liste vide), on peut toujours composer des constructeurs cons. Par exemple, (list 5 2 3) équivaut à (cons 5 (cons 2 (cons 3 (list)))). 2 11 est donc possible de définir en Scheme une fonction ayant un nombre arbitraire d'arguments, mais cela dépasse
le cadre de cet ouvrage. Nous nous contenterons d'utiliser de telles fonctions déjà définies.
86
Chapitre 4. Structure de liste
2.2 Accesseurs car et cdr Les accesseurs d'une structure de dounées permettent d'accéder aux informations qui la composent. Ici (toujours à partir de la définition récursive d'une liste), une liste non vide est composée d'un premier élément et d'un reste qui est une liste constituée de ses autres éléments. Ainsi y a-t-il deux accesseurs permettant d'accéder, d'une part, au premier élément et, d'autre part, au reste de la liste. ~
Accesseur car Le premier élément d'une liste non vide est donné par la fonction car :
; ; ; car: LISTE[a] ---> a ; ; ; (carL) rend le premier élément de la liste «L». ; ; ; ERREUR lorsque «L» est vide.
Par exemple : (car (list 3 52 5))
~
-+ 3
Accesseur cdr Le reste d'une liste non vide est donné par la fonction cdr :
; ; ; cdr: LISTE[a]---> LISTE[a] ; ; ; (cdr L) rend la liste formée de tous les éléments de «L» sauf son premier. ; ; ; ERREUR lorsque «L» est vide.
Par exemple : (cdr (list 3 52 5))
(52 5)
--->
ji Les accesseurs permettent de« défaire>> ce qui a été construit avec le constructeur cons, ~ et inversement le constructeur cons permet de fabriquer une liste à partir de son premier élément et de son reste. On exprime ces propriétés par les règles algébriques suivantes : - pour toute liste L et toute valeur x, on a -
(car (cons x L) ) - x (cdr (cons x L)) - L
- pour toute liste non vide L, on a L
=
(cons (carL)
(cdr L)).
2.3 Reconnaisseur pair? TI faut aussi savoir si une liste est vide ou non. Nous utiliserons pour cela le prédicat pair? qui rend vrai lorsqu'une liste a un car et un cdr, donc lorsqu'elle n'est pas vide: ; ; ; pair?: LISTE[a]---> boo/ ; ; ; (pair? L) rend vrai ssi la liste «L» contient un car et un cdr.
Par exemple : (pair? (list 3 52 5)) (pair? (list)) -+ #F
---> #T
Définitions de fonctions simples sur les listes
87
3 Définitions de fonctions simples sur les listes Voici quelques définitions de fonctions simples sur les listes pour se familiariser avec les fonctions de base : calculer le deuxième élément d'une liste, ou une liste privée de ses deux premiers éléments, ou vérifier qu'une liste comporte au moins deux éléments.
3.1
Exemples
Exemple 1 :pour calculer le deuxième élément d'une listeL il faut prendre le premier élément du reste de L. deuxieme: USTE[01]---> 01 ; ; ; (deuxieme L) rend le deuxième élément de la liste «L». ; ; ; ERREUR lorsque la liste donnée a moins de deux éléments.
(define (deuxieme L) (car (cdr L)))
Par exemple, (deuxieme (list 3 52 5))
--->
5
Exemple 2 : pour calculer une liste L privée de ses deux premiers éléments, il faut prendre le reste du reste de L. reste2 : USTE[01] ---> USTE[01] ; ; ; rend la liste des éléments de «L» sauf ses deux premiers éléments. ; ; ; ERREUR lorsque la liste donnée a moins de deux éléments.
' ''
(define (reste2 L) (cdr (cdr L)))
Par exemple, (reste2 (list 3 52 5))
(2 5) Exemple 3 : comment écrire un prédicat lg> 1? qui calcule si une liste a au moins deux --->
éléments ? Par exemple : (lg>l? (list 3)) ---> #F (lg>l? (list 3 4)) ---> #T La spécification de lg>l? est: ; ; ; lg> 1? : USTE[01] ---> bool ; ; ; (lg> 1? L) rend vrai ssi la liste «L» a au moins deux éléments
D'où le jeu d'essais : (verifier lg>l? (lg>l? (list 3 52 5)) -- #T (lg>l? (list)) == #F (lg>l? (list 3)) == #F (lg>l? (list 3 5)) == #T
En voici une définition : (define (lg>l? L) (and (pair? L) (pair? (cdrL))))
...JI
and est une forme spéciale (si le premier argument vaut faux, on n'évalue pas le second
~ argument) ; son utilisation est indispensable ici car, lorsque la liste donnée est vide, ( cdr L) donne une erreur.
88
Chapitre 4. Structure de liste
3.2 Abréviations De nombreux problèmes nécessitent de désigner le deuxième, troisième ... élément d'une liste ou ce qui reste après le deuxième, troisième ... élément d'une liste. Aussi Scheme a des abréviations qui permettent de désigner ces valeurs et remplacent une expression combinaut des car et des cdr. La règle de formation de ces abréviations est très simple : on écrit<< c >>, puis, éventuellement, un << a » (pour un car) puis des << d >> (pour des cdr) et enfin un << r >>. Par exemple, caddr est une telle abréviation pour (car (cdr ( cdr L) ) ) et donne le troisième élément d'une liste. cdddr est une abréviation pour (cdr (cdr (cdr L))) et donne la liste privée de ses trois premiers éléments.
4 Définitions de fonctions récursives sur les listes On aborde maintenant le traitement récursif d'une liste, pour les fonctions dont le calcul
du résultat nécessite le parcours de toute la liste qui leur est donnée en argument. Après avoir étudié de près l'exemple du calcul de la longueur d'une liste, on présente le schéma général de récursion sur les listes, que l'on illustre ensuite par plusieurs autres exemples.
4.1
,
Etude d'un exemple : longueur d'une liste
On désire écrire une définition de la fonction longueur qui rend la longueur d'une liste donnée. Le jeu d'essais pourrait être:
(verifier longueur (longueur (list 3 52 5)) -- 4 (longueur (list)) == 0 (longueur (list 3)) == 1 (longueur (list 3 5)) 2 ) 4.1.1 Conception et définition
Pour calculer la longueur d'une liste, deux cas sont à considérer : - le cas général : la liste n'est pas vide, sa longueur est égale à 1 plus la longueur de la liste qui reste après avoir enlevé le premier élément ; - le cas de base : la liste est vide, sa longueur est daus ce cas égale à O. D'où le source Scheme: ; ; ; longueur : USTEa] ---> nat ; ; ; (longueur L) rend la longueur de la liste «L» donnée. (define (longueur L) (if (pair? L) (+ 1 (longueur (cdr L))) 0) )
Daus la définition précédente on exprime longueur( L) en fonction de longueur( cdrL). On utilise la fonction longueur daus sa propre définition, qui est donc récursive. De plus à chaque appel récursif on travaille sur nne liste plus petite (le reste de la liste initiale), donc on arrivera nécessairement sur la liste vide, qui constitue le cas de base de la récursion.
Définitions de fonctions récursives sur les listes
89
4.1.2 Évaluation
Comme cela a déjà été indiqué dans le chapitre précédent, il est possible de tracer l' éva· luation d'une expression, à condition d'avoir charger la bibliothèque trace. ss et de demander l'évaluation de la trace de la fonction longueur par (trace longueur) : (trace longueur) (longueur (list 10 20 30 40) permet d'obtenir l'affichage suivant : (longueur) 1 (longueur (10 20 30 40)) 1 (longueur (20 3 0 40) ) 1 1 (longueur (30 40)) 1 1 (longueur (40)) 1 1 1 (longueur () ) 1 1 10 1 1 1
1 12 1
3
14 Noter bien l'évolution de la liste passée en argument de la fonction longueur : la liste (10 20 3 o 40), puis la liste ( 20 30 40), puis la liste (3 o 40), puis la liste ( 40) et enfin la liste vide. Remarque : de fait cette fonction longueur est une primitive de Scheme et se nomme length. Nous avons nommé ici cette fonction par le mot français longueur car la trace d'une fonction primitive ne permet pas de montrer les appels récursifs. Dans la suite nous redéfinirons d'autres primitives non seulement parce que ce sont des exemples intéressants mais aussi parce qu'il est bon de savoir comment les primitives sont implantées pour évaluer l'efficacité des algorithmes qui les utilisent. En utilisant la fonction longueur ou la primitive length, on peut donner une nouvelle définition de la fonction lg>1? vue précédemment: ; ; ; lg> 1? : liSTE[a] __. bool ; ; ; (lg> 1? L) rend vrai ssi la liste «L» a au moins 2 éléments.
(define (lg>1? L) (> (longueur L) 1))
jl Cette définition paraît meilleure (car l'expression est plus simple) que la définition don~ née précédemment mais elle est à proscrire car elle est beaucoup moins efficace : - avec la première définition, une évaluation de ( lg>l? L) applique la fonction pair? au plus deux fois, - avec la seconde définition, si n est la longeur de la liste L, l'évaluation de ( lg> 1? L) applique la fonction pair? n + 1 fois.
4.2
Schéma de récursion (simple) sur les listes
La définition d'une liste étant basée sur la vision récursive d'une séquence, il n'est pas étonnant que la plupart des définitions de fonctions sur les listes soient des définitions récursives. Le schéma récursif fondamental sur les listes est calqué sur la définition récursive des listes : on commence par traiter le cas général d'une liste non vide (appel récursif sur le reste
90
Chapitre 4. Structure de liste
de la liste et combinaison du résultat avec un traitement du premier élément de la liste), puis l'on traite le cas de base (la liste vide). ; ; ; fn-sur-liste: USTE[alpha] --+••• (define (fn-sur-liste L) (if (pair? L) (combinaison (car L) (fn-sur-liste ( cdr base) )
L) ) )
où combinaison est la fonction combinant le premier élément de L avec l'appel récursif de la fonctionftz-sur-liste sur le reste de la liste L; et où base est la valeur à rendre lorsque la liste est vide. Ainsi, pour la fonction longueur, vue précédemment, ce schéma s'instancie comme suit : ftz-sur-liste est la fonction longueur, base vaut 0, combinaison est l'opération qui ajoute 1 à son argument (on n'utilise pas la valeur de (car L) ), Schéma base (combinaison (carL) lfn-sur-liste (cdr L)))
longueur 0 (+ 1 (longueur (cdr L)))
Somme des éléments d'une liste: écrivons une définition de la fonction somme-liste qui rend la somme des éléments d'une liste de nombres dounée en argument. Par exemple : (somme-liste (list 2 57)) --+ 14 (somme-liste (list)) --+ 0 - lorsque la liste donnée n'est pas vide, la somme de ses éléments est égale à la valeur de son premier élément plus la somme des éléments du cdr de la liste ; - la somme des éléments d'une liste vide est 0 ; ; ; somme-liste: USTE[Nombre]-+ Nombre ; ; ; (somme-listeL) rend la somme des éléments de la liste «L». Rend 0 pour la liste vide. (define (somme-liste L) (if (pair? L)
(+ (car
L)
(somme-liste (cdr
L)))
0) )
Si l'on considère le schéma précédent, on a les instanciations suivantes : - ftz-sur-liste est la fonction somme-liste, - combinaison est la fonction+, - base vaut O.
4.3
Concaténation de listes
Concaténer deux listes Ll et L2 consiste à construire une liste contenant les éléments de LI suivis de ceux de L2. Ce constructeur est une primitive de Scheme, nommée append3 . Nous montrons ici une définition de cette fonction et étudions sa complexité, en terme de parcours de liste. Mais avant de s'attaquer à la concaténation, il est intéressant de traiter l'exemple similaire de l'ajout d'un élément en fin de liste. 3La fonction
append permet en fait de concaténer un nombre quelconque de listes: c'est une fonction avec un nombre arbitraire d'arguments.
91
Définitions de fonctions récursives sur les listes
4.3.1 Définition de la fonction aj out- en-fin Soit à écrire une définition de la fonction ajout-en-fin qui ajoute un élément x à la fin d'une listeL. Par exemple: (ajout-en-fin (list 1 2 3) 4) -> (1 2 3 4) (ajout-en-fin (list) 1) -> (1)
- lorsque la liste L n'est pas vide, il faut ajouter le premier élément de L en tête de la liste obtenue en ajoutant x en fin du reste de L ; - lorsque la liste L est vide on rend la liste ayant x pour unique élément.
; ; ; ajout-en-jin: USTE[a] * 01.-> USTE[a] ; ; ; (ajout-en-jin Lx) rend la liste obtenue en ajoutant «X» à la jin de la liste «L». (define (ajout-en-fin L x) (if (pair? L) (cons (car L) (ajout-en-fin (cdr L) x)) (list x)))
Si l'on considère le schéma précédent, on a les instanciations suivantes : - fn-sur-liste est la fonction ajout-en-fin, - combinaison est la fonction cons, - base vaut (list x). 4.3.2 Définition de la fonction append Soit maintenant à redéfinir la primitive append de Scheme qui concatène deux listes dounées en arguments. Par exemple : (append (list 1 2)
(list 3 4 5))
-. (1 2 3 4 5)
TI suffit de faire une récursion sur la première liste comme le montre le dessin suivant (et la seconde liste joue un rôle semblable à celui de l'élément x dans la fonction ajout- en-fin) : L1
L2
~
IMI
1
:~~---------------~
'~--<' ~
(append (cdr L1)
L2)'
: (tj
:
'u .~
''
'~-----------------~' (append Ll L2)
Voici donc la définition de append : ; ; ; append: USTE[a] * USTE[a]-> USTE[a] ; ; ; (append LI L2) rend la concaténation de «Ll» et de «L2». (define (append L1 L2) (if (pair? L1) (cons (car L1) (append (cdr L1) L2)) L2) )
Si l'on considère le schéma de récursion, on a les instanciations suivantes : - fn-sur-liste est la fonction append, - combinaison est la fonction cons, - base vaut L2.
92
Chapitre 4. Structure de liste
4.3.3 Du bon usage des constructeurs Nous avons vu deux constructeurs de listes, list et cons. La fonction append permet aussi de construire une liste en concaténant deux listes données mais elle est assez coûteuse car elle appelle la fonction cons autant de fois que la première liste a d'éléments. Ainsi, nous disposons de trois fonctions permettant de construire une liste. Laquelle choisir? Cela dépend bien sûr du contexte. Si on doit créer une liste dont on connaît les éléments (dans l'ordre), on utilise la fonction list. Si on doit créer une liste élément par élément en les ajoutant par la gauche, cons s'impose. Enfin si on dispose déjà de listes toutes faites et que l'on cherche à les réunir, il est possible d'employer append (mais en se posant le problème de l'efficacité). Noter, que le plus souvent, une simple analyse des types des valeurs que l'on souhaite réunir en liste permet de savoir quel constructeur employer puisqu'ils ont tous des types distincts. Ainsi pour ajouter un élément en tête de liste, il faut absolument écrire {cons el L),etnonpas {append {list el) L) quinécessitedetransformerl'élémentelenla liste ayant pour unique élément el avant de la concaténer à la liste L.
1
Les listes sont dissymétriques : on ajoute facilement un élément en tête de liste (avec ) ( cons), on retire facilement le premier élément (avec cdr), mais c'est toujours une très mauvaise idée d'ajouter, ou de retirer, un élément en queue de liste, quelle que soit la méthode employée car le temps d'évaluation est proportionnel à la longueur de la listeL. Par exemple l'expression {append L {list el)) apourrésultatd'ajouterel àlafindelalisteLmais il ne faut pas oublier que son temps d'évaluation est proportionnel à la longueur deL.
4.4
Définitions justes et efficaces
Les définitions récursives sur les listes ne sont cependant pas toutes des instances du schéma donné plus haut; nous en verrons divers exemples dans les exercices, et nous étudions ici l'exemple de la fonction rang. Cet exemple illustre aussi l'importance des tests pour se convaincre de la correction d'une définition et il permet encore de revenir sur le nommage des valeurs pour éviter les recalculs. TI s'agit d'écrire une fonction rang telle que {rang a L) rend le rang de la première occurrence de a dans la liste L, et rend 0 si a n'a pas d'occurrence dans L. Par exemple : {rang 3 {list 1 2 53 8 3 4)) {rang 3 {list 1 2 8 5)) -. 0
->
4
4.4.1 Définition (fausse) Essayons de raisonner comme pour la définition de la fonction longueur. Deux cas sont à considérer : - le cas général: si a est égal à (carL), rang(a, L) est égal à 1 et, sinon, rang( a, L) est égal à 1 +rang( a, (cdrL)); - le cas de base : la liste est vide, rang( a, L) est égal à O. En suivant cette équation de récurrence (qui est fausse !), nous obtenons le programme suivant (qui est faux lui aussi, bien évidemment!) {define {rang-faux a L) {if {pair? L) {if {= {car L) a) 1
erroné erroné
erroné erroné
93
Définitions de fonctions récursives sur les listes
(+ 1 (rang-faux a
(cdr L))))
0)
erroné erroné
4.4.2 Tests En effectuant les tests, prévus par avance pour vérifier la correction de la fonction, on constate que le programme est faux dans le cas où il n'y a pas d'occurrence de a dans la liste donnée. (rang-faux 3 (list 1 2 53 8 3 4)) -+ 4 (rang-faux 3 (list 1 2 8 56)) -+ 5
4.4.3 Définition correcte mais inefficace Que se passe-t-il avec la définition précédente? Le cas où a n'a pas d'occurrence dans L n'a pas été traité. Lorsque la listeL n'est pas vide et que a n'a pas d'occurrence dans L, rang( a, L) et rang(a, (cdrL)) sont égaux à 0: l'égalité considérée dans le cas général est fausse (encore nne « démonstration » de 1 + 0 = 0 !). TI faut en fait exclure de l'appel récursif « normal » le cas spécial où L n'est pas vide et a n' apparail: pas dans L. Ce cas spécial se reconnaît au fait que le résultat de l'appel récursif est nul. On obtient donc la définition suivante (la fonction est nommée rang-0 car, bien que correcte, elle est« nulle» du point de vue de l'efficacité). (define (rang-0 a L) (if (pair? L) (if (= (car L) a) 1
(if (= 0 (rang-0 a
(cdr L)))
0 (+ 1
(rang-0 a
(cdr L)))))
0))
4.4.4 Efficacité et nommage de valeurs La définition donnée pour la fonction rang- o est à proscrire. Elle est juste (la fonction rend bien ce que l'on attend d'elle), mais elle n'est pas efficace! En effet, dans l'expression définissant la fonction, le calcul de (rang- o a ( cdr L) ) est fait deux fois. Avec la définition que nous avons donnée, pour calculer (rang-0 L), il faut donc un peu plus que deux fois le temps pour calculer (rang-0 a (cdr L)). Or si Lan éléments, (cdr L) an- 1 éléments: en nommant tn le temps de calcul de l'application de la fonction à une liste den éléments, t,.. est plus grand que 2 x t,.._ 1 . tn est donc au moins de l'ordre de 2n. Dès que la liste est un peu longue, un tel temps de calcul exponentiel est prohibitif et ... personne n'aura le courage d'attendre le résultat ! Pour n'évaluer qu'une fois l'application (rang-0 a (cdr L)), il faut nonuner sa valeur (à l'aide d'un let). Noter que l'on doit écrire le bloc dans l'alternant de l'alternative car, avant, ( cdr L) peut ne pas être définie (lorsque L est vide). ; ; ; rang: a* USTE[a]-+ nat ; ; ; (rang aL) rend le rang de la première occurrence de «a» dans la liste «L» ; ; ; et 0 si «a» n'a pas d'occurrence dans «L». (define (rang a L)
94
Chapitre 4. Structure de liste
(if (pair? L) (if (= (car L) a) 1
(let ((rang-a-cdr-L (rang a (if (= 0 rang-a-cdr-L) 0 (+ 1 rang-a-cdr-L))))
(cdr L))))
0) )
Cette dernière définition est correcte et efficace : pour calculer le rang on parcourt au plus une fois tous les éléments de la liste, le temps est donc de l'ordre de n, si n est le nombre d'éléments de la liste donnée (complexité linéaire).
5 Notion de couple Un couple est une suite de deux éléments (le premier et le second) pas nécessairement de même type. Par exemple, le premier élément peut être un nombre et le second élément peut être un entier natureL La notion de couple est un cas particulier de la notion de n-uplet que nous verrons plus loin, page 168. Nous noterons COUPLE [a ,8] le type des couples dont le premier élément est de type a et le second de type {3. Ainsi, le type de l'exemple est COUPLE [Nombre nat]. Dans un premier temps, nous implantons les couples par une liste Scherne, le premier élément du couple étant le premier élément de la liste et le second élément du couple étant le second élément de la liste. On utilise donc les fonctions de base sur les listes pour manipuler les couples : - pour constrnire le couple dont le premier élément est u et dont le second élément est v, il suffit d'écrire (list u v) ; - lorsque c est un couple, - le premier élément du couple c est égal à (car c) , - le second élément du couple c est égal à (cadr c) . Notons que lorsque c est le couple (u, v) et que l'on veut obtenir le couple (w, v) - les seconds éléments des deux couples sont identiques -, on peut écrire : i) soit (list
w
(cadr Cl) (c'estlaconstructiond'uncouplevueprécédemment)
ii) soit (cons w ( cdr c) ) (en effet, (cdr second élément du couple c).
c)
est la liste qui contient un élément, le
Cette notion de couple est importante lorsqu'une fonction doit retourner deux valeurs. En effet, en Scheme comme en mathématiques et comme dans tous les langages de programmation, le résultat d'une fonction est une unique valeur. Mais cette valeur peut être composée; lorsqu'elle comporte deux éléments on la représente par un couple. Par exemple, supposons que l'on veuille calculer le nombre d'occurrences du maximum dans une liste, on pourrait composer deux fonctions, l'une calculant le maximum et l'autre calculant le nombre d'occurrences d'un élément donné dans une liste donnée (cf exercice page 102). Mais cette solution est peu efficace, au sens où l'on parcourt deux fois la liste : une fois pour évaluer le maximum, et une autre fois pour calculer son nombre d'occurrences. Pour résoudre le problème en un seul parcours de la liste, on va définir une fonction max-nbre-max, qui étant donnée une liste non vide de nombres rend le couple formé du
Liste d'associations
95
maximum de la liste et de son nombre d'occurrences. La spécification de cette fonction est donc: ; ; ; max-nbre-max: USTE[Nombre]lrwn vide/--> COUPLE[Nombre nat] ; ; ; (max-nbre-max L) rend le couple formé du maximum de «L» et de son nombre d'occurrences.
Pour avoir le nombre d'occurrences du maximum dans la listeL, il suffit alors d'extraire le second élément du résultat de l'application de la fonction max-nbre-max à L. La fonction recherchée s'écrit donc finalement : ; ; ; nombre-de-max: USTE[Nombre]/non vide/--> nat ; ; ; (nombre-de-max L) rend le nombre d'occurrences du maximum de la liste «L». (define (nombre-de-max L) (cadr (max-nbre-max L)))
ll reste à définir la fonction max-nbre-max. Essayons, comme d'habitude pour les listes, d'effectuer un appel récursif sur (cdr L). Une telle application renvoie le couple, (m, n), constitué du maximum des éléments de (cdr L) et du nombre d'occurrences de ce maximum dans (cdr L J ; plusieurs cas se présentent alors : - si (car L J est égal à m, le maximum de L est m et le nombre d'occurrences de m dans Lest égal à n + 1; - si (car L) est strictement plus grand que m, le maximum deL est (car L) et le nombre d'occurrences de ce maximum dans Lest égal à 1; - si (car L) est strictement plus petit que m, le maximum deL est met le nombre d'occurrences de m dans Lest égal à n. La fonction max-nbre-max n'étant pas définie pour la liste vide et étant appliquée à ( cdr L J dans la relation de récurrence précédente, il faut exclure de l'appel récursif le cas où (pair? (cdr L) J estfaux(casoùLaunseulélémentet (max-nbre-max L) est alors égal au couple constitué par l'élément de L et la valeur 1). Pour implanter cet algorithme en Scheme, il faut encore remarquer que l'on doit nommer la valeur de (max-nbre-max (cdr L) J pour ne pas la calculer plusieurs fois. D'où le source: ; ; ; max-nbre-max: USTE[Nombre]lnon vide/--> COUPLE[Nombre nat] ; ; ; (max-nbre-max L) rend le couple formé du maximum de «L» et de son nombre d'occurrences. (define (max-nbre-max L) (if (pair? (cdr L)) (let ((max-nb-cdr (max-nbre-max (cdr L)))) (cond ((= (carL) (car max-nb-cdr)) (list (car max-nb-cdr) (+ 1 (cadr max-nb-cdr)))) ( (> (car L) (car max-nb-cdr) J (list (carL) 1)) (else max-nb-cdr))) (list (carL) 1)))
6 Liste d'associations Une liste d'associations est une liste dont chaque élément est un couple formé d'une clef et d'une valeur. C'est une structure permettant de rechercher une valeur par sa clef. On présente ici les fonctions d'ajout et de recherche dans une liste d'associations.
96
Chapitre 4. Structure de liste
Définitions : une association est un couple dont le premier élément est appelé la clef et le second la valeur. Si la clef est de type a et la valeur de type (3, le type de l'association est COUPLE [Œ (3].
On appelle liste d'associations une donnée de type LISTE [COUPLE [Œ
(3]].
L'exemple suivant est une association de type COUPLE[nat string], la clef est 1 et la valeur est "un" : on associe "un" à 1 (list 1 "un
11
)
----t
{1 ''un'')
Et voici une liste d'associations de type LISTE[COUPLE[nat string]] : (list (list 1 "un") ( list 2 "deux") ((1 "un") (2 "deux") (3 "trois"))
( list 3 "trois"))
---t
6.1
Ajout dans une liste d'associations
La fonction ajout ajoute une nouvelle association à une liste d'associations. Cet ajout se fait en tête de liste, car c'est la façon la plus efficace d'augmenter une liste (à l'aide du constructeur cons). La fonction d'ajout prend trois arguments: une clef de type a, une valeur de type (3, ainsi qu'une liste d'associations LISTE [COUPLE [a f3ll et rend en résultat une liste du même type. Donnons quelques exemples : (ajout 3 "trois"
---t
(list)) ---> ((3 "trois"))
(ajout 1 "un" (ajout 2 "deux" (ajout 3 "trois" ((1 "un") (2 "deux") (3 "trois"))
(list))))
(let*
( (Ll (ajout 3 "trois" (list))) (L2 (ajout 2 "deux" Ll)) (ma-L (ajout 1 "un" L2))) (ajout 2 "two" ma-L)) ---t ( (2 "two") (1 "un") (2 "deux") (3 "trois"))
Pour définir la fonction ajout, on fabrique le couple constitué des deux premiers arguments et on construit la liste formée de ce couple et de la liste initiale : , , , ajout: a* f3 * llSTE[COUPLE[a /311---> llSTE[COUPLE[a /311 ; ; ; (ajout clef valeur aliste) rend la liste d'associations obtenue en ajoutant ; ; ; l'association (clef valeur) en tête de la liste d'associations «aliste». (define (ajout clef valeur aliste) (cons (list clef valeur) aliste))
6.2 Recherche dans une liste d'associations La fonction de recherche dans une liste d'associations doit rechercher la première association de la liste qui a une clef donnée. Étant donné que l'ajout d'une nouvelle association se fait en tête de liste, lors d'une recherche, on rend toujours l'association la plus récente pour une clef donnée. Cette fonction est une primitive de Scheme, qui a pour nom assoc. 4 La fonction as soc est un semi-prédicat : elle rend la valeur« faux» # f lorsqu'il n'y pas d'association avec la clef donnée; et, lorsqu'il en existe, elle rend la valeur<< vrai» sous une forme plus informative, à savoir l'association elle-même. Donnons quelques exemples : 4 En fait,
la primitive as soc est plus générale que celle que nous présentons ici, puisqu'elle permet de travailler sur des listes de n-uplets plutôt que de couples.
Liste d'associations
97
- lorsque la clef n'existe pas : (assac 2 (ajout 3 "trois" (list))) --> #f - lorsque la clef existe une fois : (let* ( (L1 (ajout 3 "trois" (list))) (L2 (ajout 2 "deux" L1)) (ma-L (ajout 1 "un" L2))) (assac 2 ma-L)) --> (2 "deux") - lorsque la clef existe plusieurs fois : (let* ( (L1 (ajout 3 "trois" (list))) (L2 (ajout 2 "deux" L1)) (ma-L (ajout 1 "un" L2))) (assac 2 (ajout 2 "two" ma-L))) ----t ( 2
n
two •• )
La définition de la fonction as soc suit le schéma récursif sur les listes : - lorsque la liste d'association aliste n'est pas vide, si la clef de la première association est celle cherchée on rend ce couple, et sinon on poursuit la recherche sur le reste de la liste d'association ; - lorsque la liste d'association est vide on rend # f. ; ; ; assoc: a* USTE[COUPLE[a (3]]-+ COUPLE[a (3] + #f ; ; ; (as soc clef aliste) rend la première association de «aliste» ; ; ; dont le premier élément est égal à «clef». Rend la valeur #j'en cas d'échec.
(define (assac clef aliste) (if (pair? aliste) (if (equal? clef (caar aliste)) (car aliste) (assac clef (cdr aliste))) #f) ) Lorsque la clef cherchée existe, la fonction as soc renvoie un couple, mais on peut aussi vouloir récupérer uniquement la valeur associée à cette clef. C'est l'objectif de la fonction valeur-de, qui est aussi un semi-prédicat. Par exemple : (valeur-de 2 (ajout 3 "trois" (list))) --+ #F (let* ( (L1 (ajout 3 "trois" (list))) (L2 (ajout 2 "deux" L1)) (ma-L (ajout 1 "un" L2))) (valeur-de 2 ma-L)) ~"deux"
La définition de la fonction valeur-de peut s'écrire en utilisant la fonction assac : on recherche la clef dans la liste d'associations, lorsque le résultat de cette recherche est un couple on en renvoie le deuxième élément, et sinon on renvoie #f. Deux points importants sont à noter dans le programme suivant: d'abord le nommage du résultat de la recherche (qui évite de calculer une fois pour vérifier que l'association existe, puis une seconde fois pour extraire la valeur), ensuite la richesse d'utilisation du semi-prédicat, du fait que dans une forme conditionnelle, toute condition qui n'a pas la valeur #fa une valeur de vérité vrai. ; ; ; valeur-de: a * USTE[COUPLE[a (3]]--+ (3 + #f , , , (valeur-de clef aliste) rend la valeur de la première association de «aliste» ; ; ; dont le premier élément est égal à «clef». Rend #fen cas d'échec.
98
Chapitre 4. Structure de liste
(define (valeur-de clef aliste) (let ((couple (assoc clef aliste))) (if couple (cadr couple) #f)) )
Remarque: des exemples d'utilisation des listes d'associations sont donnés dans l'exercice page 134 du chapitre 5, dans l'exercice page 179 du chapitre 7, et en page 270 du chapitre 9.
7 Citation Dans le langage courant, faire une citation signifie rapporter un texte « tel quel >>, sans l'assumer. Citer une expression signifie aussi, pour l'interprète Scheme, la prendre « telle quelle>>, c'est-à-dire sans l'évaluer.
7.1
Notions de constante et de symbole
Dans les programmes Scheme, nous avons utilisé des expressions telles que #f, #t, 12, 1. 2, "Scheme est beau" ... ce sont des constantes, les deux premières étant les constantes booléennes, les deux suivantes des constantes numériques et la dernière une constante chaîne de caractères. ---> #tou #f NOMBRE CHAÎNE
12
"Scheme est beau" Mais nous avons aussi utilisé des expressions telles que *,ma-L, map, if ... ce sont des symboles. Noter bien que chaque symbole, même s'il est constitué de plusieurs lettres est considéré comme une entité, Scheme ne regardant pas comment il est« fabriqué >>. Parmi les symboles précédents, if est un mot-clef, les autres symboles sont des identificateurs pour nommer des fonctions ou des variables (et un mot-clef ne peut pas être utilisé comme identificateur). Un programme Scheme est en fait constitué uniquement de constantes, de symboles et de quelques caractères particuliers : les parenthèses, l'espace et le retour chariot qui sont des séparateurs et le point-virgule pour signaler les commentaires.
7.2 Expression et valeur d'une expression ll faut tout d'abord bien différencier une expression Scheme de sa valeur. Par exemple (list 1 ( + 1 1) 3) ou (list 1 2 3) sont des expressions dont la valeur (après évaluation des arguments et de la fonction list) est la liste ayant comme premier élément 1, comme deuxième élément 2 et comme troisième élément 3 ; cette liste est notée ( 1 2 3 J • Pour noter des listes en argument de fonctions, on a jusqu'ici écrit des expressions, par exemple pour la fonction append, les deux listes données en arguments sont écrites (list 1 2 ) et (list 3 4 5) . Comment faire pour exprimer (citer) la valeur de ces expressions ? Et pourquoi ne pas écrire directement comme listes données ( 1 2 J et ( 3 4 5 J ? Le problème vient de ce que, en Scheme, programmes et valeurs sont représentés par des listes ou des symboles, et la notation parenthésée ( ... ) indique une application fonctionnelle ou une forme spéciale. Ainsi, si dans le programme nous écrivons (1 2), l'interprète va
99
Citation
vouloir appliquer la fonction nonunée 1, qui, bien sftr n'existe pas, à l'argument 2. En fait, ce que nous voudrions, c'est citer la valeur de la liste ( 1 2) à l'intérieur du progranune. Pour pouvoir citer des valeurs à l'intérieur d'un progranune, on utilise la forme spéciale quete ou son abréviation, le caractère apostrophe. Ces deux formes n'ont pas la même syntaxe: pour citer une expression e, on écrit (quete e) ou 'e. Les deux écritures s'évaluent de la même façon: la citation d'une expression e signifie<< ne pas évaluer l'expression e et la retourner telle quelle ». La règle suivante décrit plus précisément la syntaxe de la citation : --> (quote <donnée>) '<donnée> où donnée est soit une constante, soit un symbole, soit une liste de données. ll faut donc maintenant préciser conunent s'évaluent la citation d'une constante, d'un symbole ou d'une liste. Noter que dans les paragraphes suivants, on utilise deux notations, - et-->, qui doivent être lues différenunent : _ signifie « notation équivalente » et --t signifie « est évalué, par définition du quete, en>>.
7.2.1 Citation d'une constante
=
La citation d'un nombre est le nombre lui-même, par exemple (quete 2) '2 et il en est de même pour toutes les constantes (booléennes et chaînes de caractères).
--t
2,
7.2.2 Citation d'un symbole La citation d'un symbole désigne le symbole cité, par exemple : (quete ab) -
'ab
--t
ab.
Ainsi, un symbole peut être utilisé conune identificateur mais aussi pour lui même. Voici quelques exemples pour clarifier les idées : (let (let (let (let
((a ((a ((a ((a
3)) a) -->3 3)) (mmiber? a)) --> ilt 3)) 'a) ->a 3)) (symbel? 'a)) ---> ilt
Remarque : depuis le deôut de cet ouvrage, nous avons beaucoup utilisé les chaînes de caractères; c'est parce que nous n'avions pas la citation à notre disposition! En fait, presque tous les exemples que nous avons vus seraient écrits, en « bon >> Scheme, en utilisant des symboles qui seraient cités. 7.2.3 Citation d'une liste La citation d'une liste (une liste est une suite d'expressions séparées par des espaces et entourées par des parenthèses) est la liste des citations des expressions. Voici quelques exemples: 1.
(quete (a b c) ) -
' (a b c)
(list 'a 'b 'c) ---> (a b c)
2. -
3.
(quo te { na•• nb" ne Il) ) (list , ''a,, '''b" 1 11 C n )
-
, ( .. a,,
(list ''a''
(quote(l23))-'(123) (list 1 2 3) ---> (1 2 3)
nb''
n
C 11)
''b''
Il CIl)
--t
(''a"
''b"
Il CIl)
100
Chapitre 4. Structure de liste
4.
(quo te --+
5.
() )
()
(quete ( (1 I)
-
' ()
(list '(1 I)
(2 II) '(2 II)
(list (list 1 'I) --+
( (1 I)
(2 II)
(3 III))) -
' ( (1 I)
(2 II)
(3 III))
'(3 III))
(list 2 'II)
(list 3 'III))
(3 III))
Et voici un dernier exemple, pour ceux qui désirent approfondir la notion de citation. On considère des associations de type COUPLE [Symbole fonction] où le premier élément du couple est un opérateur (c'est un symbole), et le second une opération (c'est une fonction). Par exemple, l'expression (list '+ +) a pour valeur la liste ( + il) où le second élément de la liste est la primitive d'addition : - ( (cadr (list '+ +)) 3 2) a pour valeur 5, puisque le second élément de cette association a pour valeur l'opération d'addition ; - en revanche ( (car (list ' + +) ) 3 2 ) rend une erreur, puisque le premier élément de 1' association a pour valeur le symbole + (l'opérateur) et non l'opération (la fonction).
ji Bien saisir la différence entre opérateur (symbole) et opération (fonction) est nécessaire ~ pour comprendre la fonction relation du chapitre 9 page 269 et la section« Barrière d'interprétation» du chapitre 10 en page 294. Attention à une autre difficulté : les expressions (list équivalentes, en effet : (list '+ +) , ('+ +) -+
'+ +)
et ' ( '+
+),
ne sont pas
-+ (+ il) ('+ +)
et donc l'expression ( (cadr ' ( ' + valeur le symbole +.
+) ) 3 2)
rend une erreur car (cadr ' ( ' +
+) )
a pour
ji En fait, il n'existe pas d'expression constituée de la seule citation et qui a pour valeur
~
(list '+ +) : cette valeur ne peut être obtenue qu'en utilisant une application (ici de la fonction 1 i s t, mais on aurait pu utiliser la fonction cons par exemple).
8 Exercices corrigés 8.1 Énoncés Exercice 16 - Colonies de vacances Cet exercice fait manipuler les différents constructeurs et accesseurs définis pour les listes. On considère une colonie de vacances dans laquelle des moniteurs ont chacun la responsabilité d'un groupe d'enfants. On représente l'équipe formée par un moniteur et les enfants dont il a la charge par la liste de tous les prénoms, le prénom du moniteur figurant en tête de liste. Par commodité, on appellera équipe une telle liste. On supposera qu'une liste représentant une équipe n'est jamais vide (elle contient au moins le prénom du moniteur). Question 1 - Écrire une définition de la fonction equipe1 qui rend l'équipe formée de la monitrice Bea et des enfants Eve, Isa et Luc. Écrire aussi une définition de la fonction
101
Exercices corrigés
equipe2 qui rend l'équipe formée du moniteur Paul et des enfants Bob, Lea, Ali et Jean. Voici les évaluations des fonctions equipel et equipe2 : (eguipel) (eguipe2)
{"Bean ''Eve" 11 Isa'' ''Luc'') ---+{"Paul'' ''Bob'' ''Lea" "Ali'' ----?
11
Jean'')
Question 2- Écrire une définition de la fonction changement-moniteur qui, étant donnés un prénom et une équipe, remplace le prénom du moniteur de l'équipe par le nouveau prénom. Par exemple : (changement-moniteur ''Lu 11
(equipel))
--+
(''Lu'' "Eve"
11
!Sa 11
11
Luc'')
Question 3- Écrire une définition de la fonction nouvel-enfant qui, étant donnés un prénom d'enfant et une équipe, renvoie l'équipe obtenue en ajoutant le nouvel enfant au groupe initial. Par exemple : (nouvel-enfant ''Igor''
(equipel))
(''Bean
---+
"Igor''
''Eve''
11
lSa
11
"Luc'')
Question 4- Écrire une définition de la fonction prise-en-charge qui, étant données deux équipes E1 et E2, rend l'équipe dont le moniteur est le moniteur de E1 et dont le groupe d'enfants est composé de tous les enfants de E1 et E2. Par exemple : (prise-en-charge (equipel) (equipe2)) -+ {"Bea Il
''Eve"
Il
Isa Il
''Luc"
''Bob''
11
Lea Il
''Ali Il
"Jean I l }
Question 5 - Écrire une définition de la fonction reunion -moniteurs qui, étant données deux équipes E 1 et E 2, rend le couple dont les éléments sont les moniteurs de E 1 et E 2. Par exemple: (reunion-moniteurs (equipel) (equipe2) ) -+ ( "Bea" "Paul") Question 6- Écrire une définition de la fonction reunion-enfants qui, étant données deux équipes E1 et E2, rend le couple dont les éléments sont les groupes d'enfants de E1 et E2. Par exemple : (reunion-enfants (equipel) (equipe2)) -+ ( ("Eve''
Il
Isa Il
nLucn)
( nsobn
''Lea Il
li
Ali Il
li
Jean I l ) )
Question 7- Écrire une définition de la fonction echange-equipes qui, étant données deux équipes E1 et E2, rend le couple dont les éléments sont les équipes obtenues en échangeant les moniteurs de E 1 et E 2 : la première équipe aura comme nouveau moniteur l'ancien moniteur de la deuxième équipe et la deuxième équipe aura comme nouveau moniteur l'ancien moniteur de la première équipe. Par exemple : (echange-equipes (equipel) (equipe2)) -+ (("Paul''
11
EVe''
''ISa 11
''Luc'')
(''Bea''
11
BOb"
''Lea''
11
Ali''
''Jean''))
Exercice 17- Division euclidienne On illustre dans cet exercice comment une fonction peut calculer « plusieurs » valeurs et comment utiliser celles-ci. La division euclidienne de a par b calcule deux entiers naturels : un quotient q et un reste r < b tels que a= b * q + r. Question 1- En utilisant les fonctions quotient et remainder, écrire une définition de la fonction quotient-et-reste qui, étant donnés un entier naturel a et un entier naturel non nul b, renvoie le couple formé du quotient et du reste de la division euclidienne de a par b. D'où le jeu d'essais: (verifier quotient-et-reste (quotient-et-reste 0 5) == ' (0 0)
102
Chapitre 4. Structure de liste
(quotient-et-reste 3 5) == • (0 3) (quotient-et-reste 10 5) == '(2 0) (quotient-et-reste 11 5) == '(2 1)
Question 2- Sans utiliser les fonctions quotient et remainder, écrire une définition de la fonction quotient-et-reste. Cette définition sera basée sur le principe suivant: -si a~ balors - le quotient de a par b est égal au quotient de a - b par b, augmenté de 1 - le reste de a par b est égal au reste de a - b par b - si a < b alors le quotient est égal à 0 et le reste est égal à a. Question 3- Écrire une autre définition de la fonction quotient-et-reste, basée sur le principe suivant : - si a ~ b alors on calcule le quotient q de a - 1 par b et le reste r de a - 1 par b : - si r < b - 1 alors le quotient de a par b est égal à q et le reste est égal à r + 1 - si r = b - 1 alors le quotient de a par b est égal à q + 1 et le reste est égal à 0 - si a < b alors le quotient est égal à 0 et le reste est égal à a.
Exercice 18 - Occurrences dans une liste et maximum Le but de cet exercice est de calculer le nombre d'occurrences du maximum dans une liste de nombres. Nous allons donc écrire d'abord une fonction qui calcule le nombre d'occurrences d'un nombre dans une liste, puis une fonction qui calcule le maximum d'une liste et enfin une fonction qui calcule le nombre d'occurrences du maximum. Question 1 - Écrire une définition de la fonction nombre-occurrences qui, étant donnés un nombre x et une listeL de nombres, renvoie le nombre d'occurrences de x dans L. Par exemple: (nombre-occurrences 3 (list 1 3 2.5 3 6.1 5)) (nombre-occurrences 3 (list)) -+ 0
-+ 2
Question 2- Écrire une définition de la fonction max-liste qui, étant donnée une liste non vide de nombres, renvoie la valeur du maximum de la liste. Par exemple : (max-liste (list 1.3 3 8.7 57 8.7 2)) -+ 8.7 (max-liste (list -3 -8 -2 -6 -1)) -+ -1
Question 3 - Écrire une définition de la fonction nombre-de-max qui, étant donnée une liste non vide de nombres, renvoie le nombre d'occurrences du maximum de la liste. (nombre-de-max (list 1 8 2.3 8 6.4 58))
-+
3
Exercice 19- Intervalle d'entiers Cet exercice présente des exemples de fonctions récursives retournant des listes. Ces fonctions utilisent le constructeur cons. Question 1- Écrire une définition de la fonction intervalle-croissant qui, étant donnés deux nombres entiers a et b, rend la liste, rangée en ordre croissant, des entiers qui sont supérieurs ou égaux à a et inférieurs ou égaux à b. La liste est vide si a > b. D'où le jeu d'essais: (verifier intervalle-croissant (intervalle-croissant -2 3) == '(-2 -1 0 1 2 3) (intervalle-croissant 3 3) == '(3) (intervalle-croissant 7 10) '(7 8 9 10) (intervalle-croissant 12 3) == '() )
103
Exercices corrigés
Question 2- Écrire une définition de la fonction intervalle-decroissant qui, étant donnés deux nombres entiers a et b, rend la liste, rangée en ordre décroissant, des entiers qui sont supérieurs ou égaux à a et inférieurs ou égaux à b. La liste est vide si a > b. D'où le jeu d'essais: (verifier intervalle-decroissant (intervalle-decroissant -2 3) == '(3 2 1 0 -1 -2) (intervalle-decroissant 3 3) == '(3) (intervalle-decroissant 7 10) -- '(10 9 8 7) (intervalle-decroissant 12 3) == '() )
Question 3- Écrire une définition de la fonction intervalle-par-pas qui, étant donnés trois nombres entiers a, b et k, rend la liste des entiers obtenus en comptant de k en k à partir de a jusqu'à b. La fonction signale une erreur lorsque le pas k est nul. D'où le jeu d'essais : (verifier intervalle-par-pas (intervalle-par-pas -2 18 3) == '(-2 1 4 7 10 13 16) (intervalle-par-pas 18 -2 -3) == '(18 15 12 9 6 3 0) (intervalle-par-pas -2 18 -3) == '() (intervalle-par-pas 18 -2 3) == '() (erreur? intervalle-par-pas -2 18 0) == #T )
On remarque qu'avec un pas positif, la liste est vide si a
la liste est vide si a
> b, alors qu'avec un pas négatif,
< b.
Exercice 20 - Liste d'une certaine longueur Dans cet exercice, on souhaite déterminer si une liste a une certaine longueur. La façon la plus simple est probablement d'utiliser la fonction prédéfinie length et d'écrire: ; ; ; lg-naif?: nat* USTE[alpha]--+ bool ; ; ; (lg-naif? n L) vérifie que la liste «L» a pour longueur «n». (define (lg-naif? n L) (= n (length L)) )
Malheureusement calculer la longueur d'une liste a un coût proportionnel à la longueur de cette liste (on parle de coût linéaire). L'absurdité est alors de demander si une liste d'un million de termes a une longueur égale à zéro (ce qui peut se tester plus simplement et à coût constant avec le prédicat pair?). Dans cet exercice, on veut donc déterminer si une liste a une certaine longueur n en appliquant au plus n fois la fonction cdr. Écrire une définition du prédicat lg? qui prend un entier naturel net une listeL et vérifie si la listeL est de longueur n. D'où le jeu d'essais: (verifier lg? (lg? 0 (list)) == #T (lg? 2 (list "a" "bb" "CCC")) == #F (lg? 2 (list "a" "bb")) == #T )
Le prédicat lg? doit appliquer au plus n fois la fonction cdr et être le plus efficace possible.
Chapitre 4. Structure de liste
104
Exercice 21- Opérations sur des listes d'images Cet exercice présente des exemples de fonctions récursives dont les arguments sont des listes d'images. Les exemples utiliseront la liste d'images rendue par la fonction (sans argument) liste-imagesl et représentée ci-dessous: .-------,
1
-
Question 1- Écrire une définition de la fonction overlay-liste qui, étant donnée une liste d'images L, rend l'image obtenue en superposant toutes les images de la liste L. Par exemple, voici l'image rendue par (overlay-liste (liste-imagesl}} :
Question 2- Écrire une définition de la fonction quart- tour-lis te qui, étant donnée une liste d'image L, rend la liste des images obtenues en tournant tous les éléments de la liste L de 90 degrés dans le sens des aiguilles d'une montre. Par exemple (quart-tour-liste
-
(liste-imagesl}} rendlarli=·s=te~=-----,
1
Question 3- Écrire une définition de la fonction quart- tour-1 i ste-erne qui, étant donnés un entier naturel net une liste d'image L, rend la liste des images obtenues en tournant de 90 degrés dans le sens des aiguilles d'une montre l'élément de la listeL qui est en position n, si n est inférieur à la longueur de la liste L et laisse la liste L inchangée sinon. Attention : par convention, le premier élément de la liste est en position 0, le deuxième en position 1... Par exemple (quart-tour-liste-erne 1 (liste-imagesl}} rend la liste:
1
-
Exercice 22- Efficacité Dans cet exercice, on considère une fonction nommée somme-cumulee et on demande d'en donner une implantation qui soit la plus efficace possible. Dans le corrigé, on présentera aussi d'autres implantations de cette fonction et on comparera l'efficacité des différentes solutions proposées : on verra que certaines implantations sont si peu efficaces qu'elles sont à prohiber. Écrire la signature et une définition, la plus efficace possible, de la fonction sommecumulee qui, étant donnée une listeL de nombres rend la liste dont le premier élément est
105
Exercices corrigés
égal à la somme des éléments de L, dont le deuxième élément est égal à la somme des éléments du reste de L ... dont le dernier élément est égal au dernier élément deL. Par exemple : (somme-cumulee (list 1 2 3 4))
-+
(10 9 7 4)
Exercice 23 - Tri par sélection Le but de cet exercice est d'implanter une fonction de tri reposant sur l'algorithme de tri par sélection. Cette méhode est simple : on sélectionne le minimum de la liste, on détermine la liste obtenue en privant la liste initiale de son minimum, on trie cette nouvelle liste et on insère le minimum en tête de la liste triée. Mais elle n'est pas des plus efficace : le tri par insertion de n éléments requiert O(n2 ) comparaisons alors que des algorithmes plus performants ont une complexité en 0 (n log n) (voir exercice suivant).
Question 1- Écrire une définition de la fonction min-sauf-min qui, étant donnée une liste non vide de nombres, rend le couple formé du minimum de la liste et de la liste obtenue en privant la liste initiale de son minimum. Par exemple : (min-sauf-min' (10 3 52 4 2))
-+
(2 (10 3 52 4))
Question 2- Écrire une définition de la fonction tri-selection qui, étant donnée une liste de nombres retourne la liste de ces nombres en ordre croissant. Par exemple : (tri-selection' (10 3 52 4 2)) -+ (2 2 3 4 5 10) La définition de la fonction tri -selection utilisera le principe du tri par sélection
exposé au début de l'exercice.
Exercice 24 - Tri fusion Le but de cet exercice est d'implanter une fonction de tri reposant sur un algorithme classique : le tri par fusion. C'est une méhode efficace : le tri par fusion de n éléments requiert 0 (n log n) comparaisons alors que des algorithmes plus naïfs ont une complexité en
O(n 2 ). Question 1- Écrire une définition de la fonction interclassement qui, à partir de deux listes de nombres, croissantes au sens strict, construit la liste, croissante au sens strict, résultant de l'interclassement des deux listes initiales. Attention, le résultat ne doit pas contenir de doublons. D'où le jeu d'essais: (verifier interclassement (interclassement '(4 6 8) '(1 3 4 7)) == '(1 3 4 6 7 8) (interclassement '() '(2 4 5 7 9)) == '(2 4 5 7 9) (interclassement '() '()) == '() )
Question 2- Écrire des définitions des deux fonctions pos-paires et pas-impaires qui, à partir d'une liste d'éléments de type quelconque, construisent chacune une liste: - pos-paires rend la liste des éléments en position paire dans la liste initiale, - pos- impaires, la liste des éléments en position impaire dans la liste initiale. Attention, par convention, le premier élément de la liste est en position 0 (considérée comme paire), le deuxième en position 1... (pos-paires '(abc de f g h)) -+ (ace g) (pas-impaires ' (a b c d e f g h) ) -+ (b d f h) (pos-paires '(12)) -+ (12) (pas-impaires '(12)) -+ ()
Si l'on veut obtenir à la fois la liste des éléments en position paire et la liste des éléments en position impaire, la méthode exposée dans la question précédente présente l'inconvénient de parcourir deux fois la liste initiale. Pour éviter ce double parcours, on peut utiliser un
Chapitre 4. Structure de liste
106
couple de deux listes : la liste des éléments en position paire et la liste des éléments en position impaire. C'est l'objet de la question suivante. Question 3- Écrire une définition de la fonction pas-paires-impaires qui, à partir d'une
liste d'éléments de type quelconque, construit un couple de deux listes : la liste des éléments qui sont en position paire dans la liste initiale et la liste des éléments qui sont en position impaire dans la liste initiale. (pas-paires-impaires '(abc de f g h)) (pas-paires-impaires '(12)) -+ ((12) ())
-+
((ace g)
(bd f h))
Question 4- En déduire une définition de la fonction tri- fusion qui, étant donnée une liste de nombres, retourne la liste de ces nombres en ordre strictement croissant (chaque nombre de la liste initiale n'apparaît qu'une fois). Exemples :
(tri-fusion' (51 59 1 2 4 3 10 13 1 6)) (tri-fusion' (1 1 11)) -+ (1)
-+
(1 2 3 4 56 9 10 13)
Pour écrire cette définition, vous devez suivre le principe du tri par fusion qui est le suivant : si la liste donnée est vide ou réduite à un élément, on la renvoie telle quelle ; sinon on la sépare en deux sous-listes -la sous-liste des éléments en position paire et la sous-liste des éléments en position impaire -le résultat recherché est alors l'interclassement du tri de chacune de ces deux sous-listes.
8.2
Corrigés
Exercice 16 - Colonies de vacances Solution de la question 1- C'est le constructeur list qui est le mieux adapté. ; ; ; equipe] : -+ USTE[ string] ; ; ; (equipel) rend l'équipeformée de Bea, Eve, lsa et Jean (define {equipel) (list
11
Bea 11
nEve''
''Isa''
''LUC 11
))
; ; ; equipe2 : -+ USTE[ string] ; ; ; (equipe2) rend l'équipe formée de Paul, Bob, Lea, Ali et Luc (define {equipe2) (list Paul'' BOb'' Lean "Ali'' Jean Noterla différence entre l'expression (list "Bea" ... ) et sa valeur ("Bea" ... ). 11
11
11
11
11
))
Solution de la question 2 - On utilise ici 1' accesseur cdr pour accéder à la liste des enfants et le constructeur cons pour adjoindre le prénom du nouveau moniteur en tête de cette liste. ; ; ; changement-moniteur: LISTE[string]/non vide/-+ USTE[string] ; ; ; (changement-moniteurs E) rend l'équipe obtenue en remplaçant le prénom ; ; ; du moniteur de l'équipe «E» par le nouveau prénom «S» (define (changement-moniteur s E) (cons s (cdr E))) Solution de la question 3 - On utilise ici les accesseurs car (pour accéder au prénom du moniteur) et cdr (pour accéder à la liste des enfants) et deux fois le constructeur cons (une fois pour adjoindre le nouvel enfant en tête de la liste des enfants et une fois pour adjoindre le moniteur en tête de l'équipe). ; ; ; nouvel-enfant : LISTE[string]/non vide/-+ USTE[string] ; ; ; (nouvel-enfants E) rend l'équipe obtenue en ajoutant l'enfant «S» au groupe de l'équipe «E»
Exercices corrigés
107
(define (nouvel-enfant s E) (cons (car E) (cons s (cdr E))))
Solution de la question 4 - On utilise ici les accesseurs car (pour accéder au prénom du moniteur de la première équipe), cdr (pour accéder aux listes d'enfants), et les constructeurs append (pour rassembler les deux listes d'enfants) et cons (pour adjoindre le moniteur en tête de l'équipe). ; ; ; prise-en-charge: USTE[string]/non vide/--+ USTE[string] ; ; ; (prise-en-charge El E2) rend l'équipe dont le moniteur est le moniteur de «El» ; ; ; et dont le groupe d'enfants est composé de tous les enfants de «El» et «E2» (define (prise-en-charge El E2) (cons (car El) ( append ( cdr El) ( cdr E2 ) ) ) ) Solution de la question 5- On utilise ici l'accesseur car (pour accéder aux prénoms des moniteurs) et le constructeur list (pour former le couple). ; ; ; reunion-moniteurs: USTE[string]lnon vide/ 2 --+COUPLE[string string] ; ; ; (reunion-moniteurs El E2) rend le couple dont les éléments sont moniteurs de «El» et «E2» (define (reunion-moniteurs El E2) (list (car El) (car E2))) Solution de la question 6- On utilise ici l'accesseur cdr (pour accéder aux listes d'enfants), et le constructeur list (pour former le couple). ; ; ; reunion-enfants: USTE[string]!non vide/ 2 --+ COUPLE[USTE[string] USTE[string]] ; ; ; (reunion-enfants El E2) rend le couple dont les éléments sont les groupes d'enfants ; ; ; de «El» et «E2» (define (reunion-enfants El E2) (list (cdr El) (cdr E2))) Solution de la question 7- On utilise ici les accesseurs car (pour accéder aux prénoms des moniteurs), cdr (pour accéder aux listes d'enfants), et les constructeurs cons (pour adjoindre les moniteurs en tête de leurs nouvelles équipes) et list (pour former le couple). ; ; ; echange-equipes: USTE[string]/non vide/ 2 --+ COUPLE[USTE[string] USTE[string]] ; ; ; (echange-equipes El E2) rend le couple dont les éléments sont les équipes ; ; ; obtenues en échangeant les moniteurs de «El» et «E2» (define (echange-equipes El E2) (list (cons (car E2) (cdr El)) (cons (car El) (cdr E2))))
Exercice 17- Division euclidienne Solution de la question 1 : ; ; ; quotient-et-reste: nat * nat/>01--+ COUPLE[nat nat] ; ; ; (quotient-et-reste a b) calcule le couple formé du quotient et du reste ; ; ; de la division euclidienne de a par b (define (quotient-et-reste a b) (list (quotient ab) (remainder ab)))
En appliquant le principe donné et en utilisant un let pour stocker le résultat de la division euclidienne de a - b par b, on obtient Solution de la question 2 -
(define (quotient-et-reste a b) (if (>= a b) (let ((qr (quotient-et-reste (-ab) b))) (cons (+ (car qr) 1) (cdr qr))) (list 0 a) ) )
108
Chapitre 4. Structure de liste
Solution de la question 3 - En appliquant le principe donné, on obtient (define (quotient-et-reste a b) (if (>= a b) (let* ((qr (quotient-et-reste (-a 1) b)) (q (car qr)) (r (cadr qr))) (if
(< r
(- b 1))
(list q ( + r 1 J J (list (+ql J 0))) (list 0 a) J J
Exercice 18 - Occurrences dans une liste et maximum Solution de la question 1 - Le nombre d'occurrences d'un élément dans une liste est égal au nombre d'occurrences de l'élément dans le reste de la liste, auquel on ajoute 1 ou non, selon que l'élément cherché est égal au premier élément de la liste ou non. Le nombre d'occurrences est égal à 0 si la liste est vide. ; ; ; nombre-occurrences: Nombre * LJSTE[Nombre]-+ nat ; ; ; (nombre-occurrences xL) rend le nombre d'occurrences de «X» dans «L». (define (nombre-occurrences x L) (if (pair? L) (if (=x (carL)) (+ 1 (nombre-occurrences x (cdr L))) (nombre-occurrences x (cdr L))) 0) )
Autre solution de la question 1 - Voici une version plus fonctionnelle de la fonction qui repose sur la remarque que « ne rien ajouter est équivalent à ajouter 0 ». On utilise alors une expression alternative conune terme de la sonune. (define (nombre-occurrences x L) (if (pair? L) (+ (if (=x (carL)) 1 0) (nombre-occurrences x (cdr L))) 0) )
Notons que, si elle est d'une écriture plus compacte, cette deuxième version est en revanche plus gourmande en calculs puisqu'elle évalue systématiquement une sonune à chaque appel récursif. Ce que ne faisait pas notre première version.
Solution de la question 2 - Les listes dont on détermine le maximum sont supposées non vides car parler du maximum d'une liste vide n'a pas de sens. Le maximum d'une liste ayant au moins deux éléments est égal au plus grand des deux entre le premier élément de la liste et le maximum du reste de la liste. Le maximum d'une liste réduite à un seul élément est égal à cet élément. ; ; ; max-liste : LJSTE[Nombre]/non vide/--+ Nombre ; ; ; (max-listeL) rend la valeur du maximum de la liste «L». (define (max-liste L) (if (pair? (cdr L)) (max (carL) (max-liste (cdr L))) (car L) ) )
Solution de la question 3- Cette solution utilise les fonctions max-liste et nombreoccurrences.
Exercices corrigés
109
; ; ; nombre-de-max: USTE[Nombre]lnon vide/-+ nat ; ; ; (nombre-de-max L) rend le nombre d'occurrences du maximum de la liste «L». (define (nombre-de-max L) (nombre-occurrences (max-listeL) L))
Remarquer que cette solution est moins efficace que la solution présentée dans le cours, au sens où l'on parcourt deux fois la liste: une fois pour évaluer le maximum, et une autre fois pour calculer son nombre d'occurrences.
Exercice 19- Intervalle d'entiers Solution de la question 1 - Lorsque a .::::; b, on construit une liste dont le premier élément est le plus petit élément de l'intervalle [a, b]- c'est-à-dire a- et dont le reste résulte de l'appel récursif (intervalle-croissant ( + a 1) b) . Le cas de base de la récursion est a > b, qui renvoie la liste vide. ; ; ; intervalle-croissant : int * int -+ liSTE[int] ; ; ; (intervalle-croissant a b) rend la liste, rangée en ordre croissant, des ; ; ; entiers qui sont supérieurs ou égaux à «a» et inférieurs ou égaux à «b» (define (intervalle-croissant a b) (if (<= a b) (cons a (intervalle-croissant (+a 1) b)) (list)))
Solution de la question 2 - Une première solution consiste à calculer la liste renversée de la liste calculée par intervalle-croissant. Le corps de la fonction s'exprime simplement: (reverse (intervalle-croissant ab))
mais cette méthode présente l'inconvénient d'utiliser la fonction reverse et effectue donc deux fois le parcours de la liste (une fois pour la construire et une fois avec reverse), alors qu'un seul parcours est suffisant. Nous prétèrerons donc une solution calquée sur la définition de la fonction intervallecroissant. Lorsque a .::::; b, on construit une liste dont le premier élément est le plus grand élément de l'intervalle [a, b], c'est-à-dire b, et dont le reste résulte de l'appel récursif (intervalle-croissant a (- b 1) ) . Le cas de base de la récursion est a > b, qui renvoie la liste vide. ; ; ; intervalle-decroissant : int * int -+ USTE[int] ; ; ; (intervalle-decroissant ab) rend la liste, rangée en ordre décroissant, des ; ; ; entiers qui sont supérieurs ou égaux à «a» et inférieurs ou égaux à «b» (define (intervalle-decroissant a b) (if (<= a b) (cons b (intervalle-decroissant a (- b 1))) (list)))
Solution de la question 3 - Afin de ne pas tester à chaque appel récursif si le pas est positif, négatif ou nul, nous définissons deux fonctions récursives internes, aux-pas-positif et aux-pas-negatif. Ainsi, le signe du pas est testé une seule fois : si le pas est positif, la fonction aux-pas-positif est appelée, si le pas est négatif, la fonction aux-pas -negatif est appelée et, si le pas est nul la fonction erreur est appelée. ; ; ; ;
; ; ; ;
; ; ; ;
intervalle-par-pas: int * int * int-+ USTE[int] (intervalle-par-pas ab k) rend la liste des entiers obtenus en comptant de «k» en «k» à partir de «a» jusqu'à «b» ERREUR lorsque le pas «k» est nul
110
Chapitre 4. Structure de liste
(define (intervalle-par-pas a b k) ; ; aux-pas-positif: int * int --t USTE[int] ; ; (aux-pas-positif ab) rend la liste des entiers obtenus en comptant de ; ; «k» en «k» à partir de «a» jusqu'à «b» (en montant) ; ; HYPOTIIÈSE : «k» est positif (define (aux-pas-positif a b) (if (<= a b) (cons a (aux-pas-positif (+a k) b)) (list))) ; ; aux-pas-negatif: int * int--> USTE[int] ; ; (aux-pas-negatif a b) rend la liste des entiers obtenus en comptant de ; ; «k» en «k» à partir de «a» jusqu'à «b» (en descendant) ; ; HYPOTIIÈSE : «k» est négatif (define (aux-pas-negatif a b) (if (>= a b) (cons a (aux-pas-negatif (+a k) b)) (list))) (cond ((> k 0) (aux-pas-positif ab)) ((< k 0) (aux-pas-negatif ab)) (else (erreur 'intervalle-par-pas "pas nul"))))
Exercice 20 - Liste d'une certaine longueur Dans le cas où la liste L est non vide et où l'entier naturel n est non nul, la longueur de L est égale à n si et seulement si la longueur du reste de L est égale à n - 1. Dans les autres cas (L vide ou n = 0), la longueur deL est égale à n si et seulement si Lest vide et n = O. ; ; ; lg ?: nat * USTE[alpha] --t bool ; ; ; (lg? n L) vérifie que la liste «L» a pour longueur «n». (define (lg? n L) (if (and (pair? L) (> n 0)) (lg? (- n 1) (cdr L)) (and (not (pair? L)) (= n 0))))
Remarquer que cette solution applique au plus n fois la fonction cdr. Voici deux autres solutions qui, elles aussi, appliquent au plus n fois la fonction cdr. Elles sont de même complexité que la première (et nous font même économiser un test!).
Autre solution: (define (lg? n L) (if (pair? L) (and (> n 0) (lg? (- n 1)
(cdr L)))
(=nO)))
Autre solution: (define (lg? n L) (if
(> n 0)
(and (pair? L) (lg? (- n 1) (cdr L))) (not (pair? L) ) ) )
Exercices corrigés
111
Autre solution - Voici une dernière définition, mais moins efficace : (define (lg-lent? n L) (if (pair? L) (lg-lent? (- n 1) (cdr L)) (=nO)))
À première vue, cette définition ressemble beaucoup à la deuxième solution proposée mais elle est moins efficace que les autres car elle parcourt toute la liste, même si n est plus petit que la longueur de la liste.
Exercice 21- Opérations sur des listes d'images Solution de la question 1- Si la liste n'est pas vide, l'image rendue est la superposition du premier élément de la liste et de l'image obtenue en appliquant la fonction overlay-liste
au reste de la liste. Le cas de base de la récursion est la liste vide, qui renvoie l'image vide. ; ; ; overlay-liste: LISTE[Image1--> Image ; ; ; (overlay-liste L) rend l'image obtenue en superposant tous les éléments de la liste d'images «L» (define (overlay-liste L) (if (pair? L) (overlay (carL) (overlay-liste (cdr L))) (image-vide) ) ) Solution de la question 2- Si la listeL n'est pas vide, on construit la liste dont le premier
élément est le premier élément de la liste L tourné d'un quart de tour vers la droite et dont le reste résulte de l'appel récursif de quart-tour-liste sur le reste de la listeL. Le cas de base de la récursion est la liste vide, qui renvoie la liste vide. ; ; ; quart-tour-liste : LISTE[Image 1 --> LISTE[Image1 ; ; ; (quart-tour-listeL) rend la liste des images obtenues en tournant tous les éléments de ; ; ; la liste d'images «L» de 90 degrés dans le sens des aiguilles d'une montre (define (quart-tour-liste L) (if (pair? L) (cons (quarter-turn-right (carL)) (quart-tour-liste (cdr L))) (list))) Solution de la question 3- Si la liste L n'est pas vide, deux cas peuvent se présenter : ou
bien l'entier naturel n n'est pas nul et alors il faut tourner l'élément qui se trouve en position n- 1 dans le reste de la listeL, ou bien l'entier naturel n est égal à 0 et c'est alors le premier élément de la listeL qu'il faut tourner. Si la liste L est vide, la fonction renvoie la liste vide. ; ; ; quart-tour-liste-eme : nat *LISTE[Image 1 --> LISTE[Image1 ; ; ; (quart-tour-liste-eme n L) rend la liste des images obtenues en tournant de ; ; ; 90 degrés dans le sens des aiguilles d'une montre l'élément de la liste «L» qui ; ; ; est en position «n», si «n» est iriférieur à la longueur de «L» et rend «L» sinon (define (quart-tour-liste-erne n L) (if (pair? L) (if (= n 0) (cons (quarter-turn-right (carL)) (cdr L)) (cons (carL) (quart-tour-liste-erne (- n 1) (cdr L)))) (list)))
Chapitre 4. Structure de liste
112
Exercice 22- Efficacité Voici la définition la plus efficace de la fonction somme-cumulee : ; ; ; ;
; ; ; ;
; ; ; ;
somme-cumulee: USTE[Nombre] --t USTE[Nombre] (somme-cumulee L) rend la liste dont le premier élément est égal à la somme des éléments de «L» , dont le deuxième élément est égal à la somme des éléments de (cdr L) ... dont le dernier élément est égal au dernier élément de «L» .
(define (somme-cumulee L) ; ; aux : USTE[Nombre ]/non vide!-> USTE[Nombre] ; ; aux a la même sémantique que «somme-cumulee»
(define (aux L) (if (pair? (cdr L)) (let ((sc (aux (cdr L)))) (cons (+ (car L) (car sc)) sc)) L) )
(if (pair? L)
(aux L) (list))) La liaison, par un let, évite de calculer plusieurs fois le même appel de la fonction aux (et ce, à chaque appel récursif) et la définition interne évite de tester, à chaque appel récursif, si la liste est non vide. Avec cette définition, on effectue aproximativement n additions et n tests pair? pour une liste de longueur n. Autres solutions : voici maintenant quatre autres implantations, présentées par ordre décroissant d'efficacité: 1. un peu moins efficace que la solution recommandée, mais encore acceptable (define (somme-cumulee2 L) (if (and (pair? L) (pair? (cdr L))) (let ((sc (somme-cumulee2 (cdr L)))) (cons (+ (carL) (car sc)) sc) ) L) )
L'absence de définition interne oblige à tester à chaque appel récursif si la liste est non vide (le nombre de tests pair? est donc approximativement deux fois plus grand que dans la première définüon).
2. beaucoup moins efficace, et fortement déconseillée (define (somme-cumulee3 L) (if (pair? L) (cons (somme-listeL) (somme-cumulee3 (cdr L))) (list))) Cette solution utilise la fonction récursive somme-liste (définie dans la partie cours) et présente l'inconvénient de recalculer la somme des éléments de (cdr L) (dans (somme-liste L)) alors qu'elle a déjà été calculée dans (somme-cumulee3 (cdr L) ) .
3. très, très peu efficace, et interdite (define (somme-cumulee4 L) ; ; aux: USTE[Nombre]/non vide/-> USTE[Nombre] ; ; aux a la même sémantique que «somme-cumulee»
Exercices corrigés
113
(define (aux L) (if (pair? (cdr L)) (cons (+ (carL) (car (aux (cdr L)))) (aux (cdr L))) L)) (if (pair? L) (aux L) (list)))
Ici, (aux ( cdr L) ) est calculé deux fois et ce à chaque appel récursif, ce qui entraîne une complexité exponentielle. 4. très, très peu efficace, et interdite (define (somme-cumuleeS L) (if (and (pair? L) (pair? (cdr L))) (cons (+ (carL) (car (somme-cumuleeS (cdr L)))) (somme-cumuleeS (cdr L))) L) )
C'est la pire ! À la complexité exponentielle de la définition précédente, elle ajoute encore des tests pair? inutiles. Le tableau suivant donne le nombre exact d'additions et le nombre exact de tests pair? effectués par chacune des fonctions précédentes, pour une liste de longueur n. Le nombre d'appels à la fonction cons ne figure pas dans ce tableau car c'est exactement le même que le nombre d'additions.
somme-cumulee somme-cumulee2 somme-cumulee3 somme-cumulee4 somme-cumuleeS
additions n-1 n-1 n(n + 1)/2 2n-l- 1 2n-l- 1
tests pair? n+1 2n (n + 2)(n + 3)/2- 2 2n 2(2n- 1)
Le tableau suivant donne un ordre de grandeur du nombre d'additions et du nombre de tests pair?
somme-cumulee somme-cumulee2 somme-cumulee3 somme-cumulee4 somme-cumuleeS
additions
tests pair?
n n n2 2n 2n
n n n2 2n 2n
ll suffit de calculer approximativement 2 10000 (ce qui donne un résultat de l'ordre de mille milliards) pour se convaincre que la solution ayant une complexité exponentielle est à bannir.
Chapitre 4. Structure de liste
114
Exercice 23 - Tri par sélection Solution de la question 1 - Si la liste L a au moins deux éléments, on applique la fonction min-sauf-min au cdr de L : on calcule ainsi un couple formé du minimum min-cdr de (cdr L} et de la liste cdr-moins-min obtenue en privant (cdr L} de son minimum. Deux cas peuvent alors se présenter : - si le premier élément deL est inférieur à min-cdr alors le minimum deL est égal au premier élément de L et la liste L privée de son minimum est égale à la liste L privée de son premier élément; - si le premier élément de L est supérieur ou égal à min- cdr alors le minimum de L reste égal à min-cdr et, pour obtenir la listeL privée de son minimum, il suffit d'adjoindre le premier élément deL à cdr-moins-min. Si la liste L a un seul élément alors le minimum de L est égal à cet élément et la liste obtenue en privant L de son minimum est égale à la liste vide. ; ; ; min-sauf-min: USTE[Nombre]lnon vide/-> COUPLE[Nombre USTE[Nombre]] ; ; ; (min-sauf-min L) rend le couple formé du minimum de la liste «L» et de la liste ; ; ; obtenue en privant la liste «L» de son minimum.
(define (min-sauf-min L} (if (pair? (cdr L}} (let ((C (min-sauf-min (cdr L}}}} (if (< (car L} (car C}} (list (car L} (cdr L}} (list (car Cl (cons (car L} (cadr (list (car L} (list}}}}
Cl}}}}
Solution de la question 2 - On suit la méthode de tri exposée dans l'énoncé de l'exercice, en utilisant la fonction min-sauf-min pour sélectionner le minimum de la liste et déterminer la liste obtenue en privant la liste initiale de son minimum. ; ; ; tri-selection: USTE[Nombre] ---t USTE[Nombre] ; ; ; (tri-selection L) rend la liste obtenue en triant les nombres de «L» en ordre croissant
(define (tri-selection L} (if (pair? L} (let ((C (min-sauf-min L}}} (cons (car C} (tri-selection (cadr (list}}}
C}}}}
Exercice 24 - Tri fusion Solution de la question 1 - Cette fonction utilise une récursion simple sur deux listes : lorsque
l'une des deux listes données est vide, on renvoie l'autre; sinon, il faut comparer (car Ll} et (car L2} ; lorsque (car Ll} est strictement inférieur à (car L2}, on renvoie la liste dont le premier élément est (car Ll} et le reste résulte de l'interclassement de ( cdr Ll} et de L2. Le cas où (car L2} est strictement inférieur à (car Ll} est similaire. Enfin, dans le cas où (car L2} est égal à (car Ll}, comme on ne veut pas de doublons dans la liste résultat, on renvoie la liste dont le premier élément est (car Ll} et le reste résulte de l'interclassement de (cdr Ll} et de ( cdr L2}. Voici une solution avec une conditionnelle : ; ; ; ;
; ; ; ;
; ; ; ;
interclassement: USTE[Nombre] * USTE[Nombre]---> USTE[Nombre] HYPOTHÈSE : chacune des deux listes à interclasser est croissante au sens strict ( interclassement Ll L2) rend la liste strictement croissante résultant de l'interclassement des deux listes «Li» et «L2», en supprimant les doublons.
Exercices corrigés
115
(define (interclassement Ll L2) (cond ((not (pair? Ll)J L2) ((not (pair? L2)) Ll) ( (< (car Ll) (car L2)) (cons (car Ll) (interclassement (cdr Ll) L2))) ( ( = (car Ll) {car L2 ) ) (cons (car Ll) (interclassement (cdr Ll) (cdr L2)JJJ (else (cons (car L2) (interclassement Ll (cdr L2))))))
Solution de la question 2 - Voici une solution, qui utilise une récursivité croisée : la fonction pos-paires appelle la fonction pos-impaires, et réciproquement. Si Lest une liste non vide alors: - la liste des éléments en position impaire dans L est égale à la liste des éléments en position paire dans le reste de L, - la liste des éléments en position paire dans L est égale à la liste composée du premier élément de L et des éléments qui sont en position impaire dans le reste de L. ; ; ; pos-paires: USTE[alpha]--> USTE[alpha] , , , (pos-paires L) rend la liste des éléments en position paire ; ; ; dans la liste «L» (le premier élément a la position 0)
(define (pos-paires L) (if (pair? L) (cons {car L) {pos-impaires {cdr L) J J (list) J J ; ; ; pos-impaires: USTE[alpha]--> USTE[alpha] ; ; ; (pos-impaires L) rend la liste des éléments en position impaire ; ; ; dans la liste «L» (le premier élément a la position 0)
(define (pos-impaires L) (if (pair? L) (pos-paires (cdr L)) (list) J J
Solution de la question 3 - Voici une première solution, décalquée sur la solution de la question précédente : ; ; ; ;
; ; ; ;
; ; ; ;
pos-paires-impaires: USTE[Nombre]--> COUPLE[USTE[Nombre] USTE[Nombre]] (pos-paires-impaires L) rend le couple dont le premier élément est la liste des éléments en position paire dans la liste «L» et dont le deuxième élément est la liste des éléments en position impaire dans la liste «L»
(define (pos-paires-impaires L) (if (pair? L) (let* {(Z {pos-paires-impaires (cdr L))J (ZP (car Z)) (ZI (cadr Z))) (list (cons {carL) ZI) ZP)) (list (list) (list) J J J
Proposons une autre solution (que l'on aurait pu adopter dans la question précédente) : - si L est une liste ayant au moins deux éléments alors : - la liste des éléments en position paire dans L est égale à la liste composée du premier élément de L et des éléments qui sont en position paire dans le reste du reste de L,
Chapitre 4. Structure de liste
116
- la liste des éléments en position impaire dans L est égale à la liste composée du deuxième élément de L et des éléments qui sont en position impaire dans le reste du reste deL; si la liste L est vide alors les deux listes sont vides ; si la liste L a un seul élément alors la liste des éléments en position paire est égale à L et la liste des éléments en position impaire est vide. (define (pos-paires-impaires L) ; ; aux: USTE[Nombre]lnon vide/--+ COUPI.E[USTE[Nombre] USTE[Nombre]] ; ; (aux L) rend le couple dont le premier élément est la liste des éléments ; ; en position paire dons la liste «L» et dont le deuxième élément est la ; ; liste des éléments en position impaire dans «L» (define (aux L) (if (pair? (cdr L)) (let* ((Z (pas-paires-impaires (cddr L))) (ZP (car Z)) (ZI (cadr Z))) (list (cons (car L) ZP) (cons (cadr L) ZI))) (list L (list)))) (if (pair? L) (aux L) (list (list) (list))))
Remarquer la défiuition d'une fonction interne, qui évite de tester à chaque appel récursif que la liste est non vide. Solution de la question 4 - Cette fonction utilise une récursivité double sur la liste : lorsque
la liste a 0 ou 1 élément on la renvoie telle quelle, et sinon on interclasse les listes obtenues en triant d'une part la liste des éléments en position paire et d'autre part la liste des éléments en position impaire. Voici une première défiuition, qui utilise la fonction pas-paires-impaires : ; ; ; tri-fusion: USTE[Nombre] --+ USTE[Nombre] ; ; ; (tri-fusion L) retourne la liste, croissante au sens strict, des éléments ; ; ; ayant (au moins) une occurrence dans «L» (define (tri-fusion L) (if (and (pair? L) (pair? (cdr L))) (let* ((Z (pos-paires-impaires L)) (Ll (car Z)) (L2 (cadr Z))) (interclassement (tri-fusion Ll) (tri-fusion L2))) L) )
et une deuxième solution, qui utilise les fonctions pos-paires et pos-impaires : (define (tri-fusion L) (if (and (pair? L) (pair? (cdr L))) (interclassement (tri-fusion (pos-paires L)) (tri-fusion (pas-impaires L))) L) )
Remarquer que la première solution est plus efficace que la seconde : dans la première solution, on parcourt une seule fois la liste pour déterminer les éléments en position paire et les éléments en position impaire alors que dans la seconde solution, on la parcourt deux fois.
Chapitre 5
Fonctionnelle Jusqu'à présent, nous n'avons vu que des fonctions prenant pour argument des nombres ou des listes ou des booléens... en fait tout type de données, et renvoyant un résultat d'un type de données. Nous allons voir maintenant qu'il est possible, pour une fonction, d'avoir pour argument une fonction et, aussi, d'avoir pour résultat une fonction. Une fonction qui manipule des fonctions (en argument ou en résultat) est appelée une fonctionnelle. La possibilité de définir des fonctionnelles permet de traiter les fonctions comme des objets de base de la programmation. La facilité d'écriture des fonctionnelles constitue un des principaux avantages du langage Scheme et donne des programmes concis et génériques. Ce chapitre traite principalement des fonctionnelles sur les listes, on dit aussi itérateurs, qui permettent d'appliquer un traitement (une fonction) à tous les termes d'une liste. La plus classique est la fonctionnelle map, qui prend, en argument, une fonction f et une liste L, et renvoie, en résultat, la liste obtenue en appliquant la fonction f à chacun des termes de la liste L. On présente aussi la fonctionnelle filtre qui étant donnés un prédicat pet une listeL, renvoie la liste formée uniquement des termes de L qui vérifient p. Enfin les fonctionnelles reduce et combine permettent de composer les éléments d'une liste par une fonction. On aborde aussi la construction de fonctionnelles dont le résultat est une fonction : composition de fonctions, itérée d'une fonction, transformation d'une fonction f de deux arguments (par exemple deux nombres x et y) en une fonctionnelle qui prend comme argument un nombre x et renvoie comme résultat la fonction y H f (x, y). Ces fonctionnelles sont utilisées pour elles mêmes ou comme arguments des itérateurs sur les listes.
1 Application d'une fonction aux éléments d'une liste Dans cette section, on commence par définir plusieurs fonctions avec des caractéristiques communes: - leur argument est une liste de type USTE[a], - leur résultat est une liste de type USTE[,B], obtenue en appliquant récursivement une même fonction f, de type a --+ ,8, sur les termes de la liste initiale. Puis on montre comment abstraire le schéma commun à toutes ces fonctions, en passant la fonction f en argument. On redéfinit ainsi la fonctionnelle map, qui est une primitive du langage Scheme.
Chapitre 5. Fonctionnelle
118
1.1 Quelques exemples ~
Fonction liste-carres
On veut, par exemple, écrire une définition de la fonction liste-carres qui rend la liste des carrés des éléments d'une liste de nombres. En voici une application : (liste-carres (list 1 2 3 4))
-. (1 4 9 16)
On définit tout d'abord la fonction qui rend le carré de sa donnée: ; ; ; carre : Nombre -> Nombre ; ; ; (carre x) rend le carré de «X» (define (carre x)
( * x x) ) Puis on écrit la définition suivante de la fonction liste-carres : ; ; ; liste-carres: USTE[Nombre1-> USTE[Nombre1 ; ; ; (liste-carres L) rend la liste des carrés des éléments de «L» (define (liste-carres L) (if (pair? L) (cons (carre (carL)) (liste-carres (cdr L))) (list))) ~
Fonction liste-racines-carrees
On peut aussi vouloir écrire une définition de la fonction liste-racines-carrees qui rend la liste des racines carrées des éléments d'une liste de nombres positifs. Par exemple: (liste-racines-carrees (list 16 25 36))
-+
(4 5 6)
En voici une définition : ; ; ; liste-racines-carrees : USTE[Nombre/?_011 -. USTE[Nombre1 ; ; ; (liste-racines-carrees L) rend la liste des racines carrées des éléments de «L» (define (liste-racines-carrees L) (if (pair? L) (cons (sqrt (carL)) (liste-racines-carrees (cdr L))) (list))) ~
Fonction liste-even?
Voici un troisième exemple de fonction qni applique une fonction sur chaque terme d'une liste donnée en entrée. Cette fonction prend en argument une liste d'entiers et renvoie une liste des booléens indiquant la parité de chaque élément. ; ; ; liste-even? : USTE[int1 ---> USTE[bool1 ; ; ; (liste-even? L) rend la liste des booléens qui indique, pour chaque élément de «L», ; ; ; s'il est pair ou non (define (liste-even? L) (if (pair? L) (cons (even? (carL)) (liste-even? (cdr L))) (list)))
Par exemple : (liste-even? (list 5-8 0 4 8 -7))
-+
(#F #T #T #T #T #F)
119
Application d'une fonction aux éléments d'une liste
1.2 Schéma de fonction récursive Les trois définitions de fonctions précédentes suivent le même schéma récursif d'application d'une fonction à une liste, la seule différence étant la fonction à appliquer. Ainsi en appelant f-sur-elem, la fonction à appliquer à chaque élément de la liste, on reconnaît chacune des fonctions liste-carrees, liste-racines-carrees et liste-even? dans le schéma suivant : (de fine (schema-f-sur-tts-elems-liste L) (if (pair? L) (cons lf-sur-elem (car L) ) ( schema-f-sur-tts-elems-liste ( cdr L) ) ) (list)))
Si l'on paramètre ce schéma, en donnant en argument la fonctionf-sur-elem, on obtient une fonction générique qui permet de réaliser toutes les définitions précédentes : c'est la fonctionnelle map.
(
(
Al
A2
A3
A4
f
f
f
f
Bl
B2
B3
B4
f
1.3 Fonctionnelle rnap La fonction map reçoit en arguments une fonction f et une liste L, et renvoie une liste de même taille, dans laquelle chaque terme est le résultat de l'application de la fonction f au terme correspondant de la listeL. Le code suivant est une redéfinition de la fonctionnelle map, qui est une primitive du langage Scheme. , , , map: (a:--+ {3) * USTE[o:]--+ USTE[/3] ; ; ; (mapfL) rend la liste dont les éléments résultent de l'application de la fonction«!» ; ; ; aux éléments de «L» (define (map f L) (if (pair? L) (cons (f (carL)) (map f (cdr L))) (list)))
ji Remarquer la notation du type de map: (o:---> (3) * USTE[o:]---> USTE[(3]. ~ On a noté (o: ---> (3) le type de la fonction passée en argument, en indiquant que cette fonction reçoit elle-même un argument de type o: et a un résultat de type (3. Les parenthèses autour du type de la fonction permettent de l'isoler en tant qu'argument.
1.3.1 Exemples d'application de map (map carre '(1 2 3 4)) (map sqrt '(16 25 36)) (map even? '(1 2 3 4))
..... (1 4 9 16) ..... (4 5 6) --+
(#F #T #F #T)
120
Chapitre 5. Fonctionnelle
Le premier exemple est équivalent à l'application (liste-carres • ( 1 2 3 4) ) ; le deuxième est équivalent à (liste-racines-carrees '(16 25 36)), et le troisième à (liste-even? '(1 2 3 4))
Voici aussi d'autres applications de la fonction map : (map odd? '(1 2 3 4 5)) -> (#T #F #T #F #T) (map list '(1 2 3 4)) -> ( (1) (2) (3) (4)) Enfin la définition de la fonction liste-carres pourrait s'écrire sous la forme: (define (liste-carres L) (map carre L) )
1.3.2 Définition de la fonction à appliquer
Dans les exemples précédents, nous avons utilisé, comme argument de la fonction map, des fonctions unaires déjà définies (sqrt, even? ... ) mais nous pouvons, bien sûr, définir nos propres fonctions comme nous l'avons déjà fait avec carre. Mais si, par exemple, nous désirons savoir si les nombres d'une liste sont compris entre deux bornes, nous ne pouvons pas utiliser la fonction entre-bornes? ci-dessous, car cette fonction n'est pas unaire : ; ; ; entre-bornes? : Nombre *Nombre *Nombre -> bool ; ; ; (entre-bornes? x borne/nf borneSup) rend vrai ssi «X» est entre «borne/nf» et «borneSup», ; ; ; homes comprises (define (entre-bornes? x borneinf borneSup) (and (<= borneinf x) (<=x borneSup)))
Pour résoudre le problème, il faut rendre unaire la fonction utilisée comme argument du map, en en faisant une fonction interne à la fonction verification-entre-bornes. Cette fonction interne n'a qu'un seul argument (le nombre dont on doit vérifier s'il est entre les bornes), mais a comme variables globales les deux bornes, arguments de la fonction verification-entre-bornes : ; ; ; verification-entre-bornes: Nombre *Nombre* USTE[Nombre]-> USTE[bool] ; ; ; (verification-entre-bornes borne/nf borneSup L) rend une liste de booléens, ; ; ; chacun valant vrai ssi le terme correspondant de «L» est entre «borne/nf» et «borneSup», (define (verification-entre-bornes borneinf borneSup L) ; ; entre-bornes-inf-sup?: Nombre-> bool ; ; (entre-bornes-inf-sup? x) rend vrai ssi «X» est entre «borne/nf» et «borneSup» (define (entre-bornes-inf-sup? x) (and (<= borneinf x) (<=x borneSup))) (map entre-bornes-inf-sup? L))
Voici alors une utilisation de cette fonction : (verification-entre-bornes -1 8 '(2 0 9 8 -4))
->
(#T #T #F #T #F)
2 Sélection des éléments d'une liste Un autre problème important consiste à sélectionner les éléments d'une liste selon un certain critère. Autrement dit, il s'agit de filtrer les éléments d'une liste, en ne laissant passer que ceux qui vérifient un certain prédicat. Les fonctions répondant à cette spécification générale : - ont pour argument une liste de type LISTE[ a], - et le résultat est une liste de type LISTE[a], obtenue en conservant uniquement les éléments qui vérifient le prédicat.
Sélection des éléments d'une liste
121
Ces fonctions suivent un même schéma récursif et ne diffèrent que par le prédicat utilisé. La fonction générique correspondante, dans laquelle le prédicat est passé en argument, est la fonction filtre.
2.1
Exemple
Étant donnée une listeL d'entiers, supposons que l'on veuille rendre la liste des éléments de L qui sont pairs. Par exemple : (filtre-pairs (list 1 2 3 58 6)) -. (2 8 6) On peut écrire la fonction suivante : ; ; ; filtre-pairs: USTE[int]-+ USTE[int] ; ; ; (filtre-pairs L) rend la liste des éléments de «L» qui sont pairs
(define (filtre-pairs L) (if (pair? L) (if (even? (carL)) (cons (carL) (filtre-pairs (cdr L))) (filtre-pairs (cdr L))) (list))) Si l'on veut rendre la liste des éléments de L qui sont impairs, il faut écrire une autre fonction filtre- impairs dont la définition ne diffère de la précédente que par l'utilisation du prédicat odd? au lieu du prédicat even?. Et si l'on veut rendre la liste des éléments strictement positifs d'une liste de nombres, il faut encore écrire une autre fonction sur le même modèle en utilisant le prédicat po si tive?.
2.2 Schéma de fonction récursive Les trois fonctions précédentes suivent le même schéma de fonction récursive qui filtre les termes d'une liste vérifiant un prédicat test ? : (de fine (schema-test ?-filtre L) (if (pair? L) (if (test? (car L)) (cons (car L) (schema·test ?-filtre (cdr L))) (schema-test ?-filtre (cdr L))) (list))) La seule différence, d'une fonction à l'autre, est le prédicat test? à appliquer à chaque élément de la liste. ll est donc possible d'abstraire ce schéma par une nouvelle fonction fi 1 tre
qmprendoo-
-c;,.~,--~1 m
M
m A3
-Ï -1 ) m A4
M
)
122
Chapitre 5. Fonctionnelle
2.3 Fonctionnelle filtre Ainsi, on peut écrire une fonction générique pour mettre en œuvre les définitions précédentes: ; ; ; filtre: (a--> boo/) *LISTE[a]-> LISTE[a] ; ; ; (filtre test? L) rend la liste des éléments de la liste «L» qui vérifient le prédicat «test?». (define (filtre test? L) (if (pair? L) (if (test? (carL)) (cons (car L) (filtre test? (cdr L))) (filtre test? (cdr L))) (list))) La fonction filtre est une fonctionnelle du fait qu'elle prend un prédicat (et donc une
fonction) en argument. Une autre définition de la fonction filtre-pairs est alors : (define (filtre-pairs L) (filtre even? L))
Le lecteur écrira aisément les définitions des fonctions filtre-impairs et filtrepositifs et pourra alors constater la concision de ces définitions, et se convaincre de la puissance d'expression et de la généricité apportée par les fonctionnelles. Une question un peu plus compliquée est celle d'écrire une fonction list- inf qui, étant donnés un entier n et une liste L, rend la liste de tous les éléments de L qui sont strictement inférieurs à n. Ici il faut définir le prédicat servant à filtrer, et de plus cette définition doit être interne car elle utilise l'argument n de la fonction list-inf. ; ; ; list-in[: Nombre * LISTE[Nombre]--> LISTE[Nombre] ; ; ; (list-in[ n L) rend la liste des éléments de la liste «L» qui sont strictement inférieurs à «n» (define (list-inf n L) ; ; inf-n : Nombre --> boo/ ; ; (infn m) rend vrai ssi le nombre «m» est strictement inférieurs à «n» (define (inf-n rn) (< rn n)) ; ; expression de (list-in[ n L): (filtre inf-n L))
2.4 Points communs et différences entre map et fi 1 tre ll est intéressant, à ce point du développement, de mettre en valeur les ressemblances et les différences entre les itérateurs map et filtre. Avant d'entrer dans les détails, il faut souligner un point essentiel : l'utilisation d'un itérateur sur les listes fait qu'il n'y a plus de récursion apparente dans la définition de la fonction de traitement de la liste: c'est l'itérateur qui effectue la récursion. Ainsi lorsqu'on «reconnaît>> (par exemple graphiquement) que le traitement est de nature« application d'une fonction aux éléments d'une liste>>, ou« sélection des éléments d'une liste>>, il faut utiliser l'itérateur map ou l'itérateur filtre, en lui laissant la charge de la récursion. Pour comparer les deux itérateurs, observons leurs signatures : ; ; ; map: (a--> {3) *LISTE[a]-->LISTE[{J] ; ; ; filtre: (a--> boo/) *LISTE[a]-> LISTE[a]
Réduction d'une liste par une fonction
123
1. Points communs entre map et filtre : - ce sont des fonctionnelles (elles reçoivent en argument une fonction) ; - elles ont comme arguments une fonction et une liste ; - le premier argument est une fonction qui attend un argument du type des éléments de la liste passée en second argument ; - elles rendent une liste. 2. Différences entre map et filtre : - pour map, le résultat de la fonction donnée en argument est d'un type quelconque alors que, pour filtre, le résultat de la fonction donnée en argument est obligatoirement un booléen ; - la liste résultat de map est de même longueur que sa liste donnée alors que la liste résultat de filtre est d'une longueur inférieure ou égale à celle de sa liste donnée; - les éléments de la liste résultat de filtre sont des éléments de sa liste donnée alors que les éléments de la liste résultat de map sont différents de ceux de sa liste donnée. Un exemple pour illustrer ces différences : (map integer? (list 2 2.5 3 3.5 4)) -+ (#T #F #T #F #T) (filtre integer? (list 2 2.5 3 3.5 4)) -+ (2 3 4)
Remarque: la fonction map (mais pas filtre) est une primitive de Scheme.
3 Réduction d'une liste par une fonction Dans cette section, on définit des fonctions qui, étant donnée une liste, la condensent en un unique résultat, en composant une fonction binaire sur les termes de la liste. À partir de ces fonctions qui suivent un même schéma récursif, on montre comment abstraire le schéma commun. Et l'on écrit ensuite la fonction générique redu ce, dans laquelle la fonction de réduction est passée en argument.
3.1
Exemple
Parmi les exemples de récursion simple sur les listes, nous avons déjà vu la fonction somme-liste qui somme tous les éléments d'une liste de nombres. ; ; ; somme-liste: USTE[Nombre]-+ Nombre ; ; ; (somme-listeL) rend la somme des éléments de «L»; rend 0 pour la liste vide (define (somme-liste L) (if (pair? L) (+ (car L) (somme-liste (cdr L))) 0) )
Nous pouvons aussi définir la fonction produit-liste qui rend le produit de tous éléments d'une liste de nombres. La définition de cette fonction ne diffère de la précédente que par l'utilisation de la fonction* au lieu de la fonction+ et par l'utilisation de l'élément neutre 1 au lieu de l'élément neutre O. De même la fonction overlay-liste qui rend une image superposant toutes les images d'une liste d'images, sera obtenue en remplaçant la fonction+ par la fonction overlay et le cas de base 0 par le cas de base (image-vide).
Chapitre 5. Fonctionnelle
124
3.2 Schéma de fonction récursive Le même schéma récursif correspondant aux fonctions précédentes est : (define (schema-J-Uste L) (if (pair? L) ([-binaire (car L) (schema-f-liste ( cdr L) ) ) base) )
oùf-binaire est le nom de la fonction binaire et où base est la valeur du cas de base, lorsque la liste est vide. Voici une visualisation des opérations effectuées lorsqu'on applique re duce à une liste de cinq éléments (El E2 E3 E4 ES) :le résultat de l'application de f à base et E5 est utilisé comme argument de f avec E4 ; puis le nouveau résultat sert d'argument à f avec E3 . .. et ainsi de suite jusqu'à obtenir le résultat final. E3
E2
ES
E4
)
base
f 1
f f f f
1
1
1
1
1
3.3 Fonctionnelle reduce On peut donc écrire une fonction générique reduce pour mettre en œuvre les définitions précédentes, en passant en arguments la fonction binaire et la valeur du cas de base. La fonction reduce reçoit ainsi trois arguments : - une fonction binaire, de type a* f3---> (3, - une valeur pour le cas de base, de type (3, - et une liste de type LISTE [a]. La fonction reduce condense la liste en un unique résultat de type (3, en composant la fonction binaire sur les termes de la liste. ; ; ; reduce: (a* f3---> {3) * f3 * USTE[a]-> f3 ; ; ; (reducefbaseL)rendlavaleurf(el,f(e2, ... /(en, base) ... ) ; ; ; où «el», «e2», ... «en» sont les éléments de la liste «L».
(define (reduce f base L) (if (pair? L) (f (carL) (reduce f base (cdr L))) base) l 3.3.1 Exemples d'application de reduce Voici quelques exemples d'application de la fonction reduce: (reduce + 0 '(1 2 3 4 5)) ---> 15
125
Réduction d'une liste par une fonction
ce qui donne la même valeur que {+ 1 2 3 4 5) , car l'opération + est associative :
(1
+ (2 + (3 + (4 + 5)))) = ((((1 + 2) + 3) + 4) + 5)
(reduce * 1 '(1 2 3 4 5)) -+ 120 ( reduce append ' () ' { ( 1 2 ) ( 3 4 5) ( 6 7) ) ) (reduce + 0 (map carre' {1 2 3 4))) -+ 30
-+
(1
2 3 4 5 6 7)
Piège: quelle est la valeur de {reduce - 0 • (30 20 10 5)) ? Si vous répondez -5 (au lieudel5),enpensantquec'estlamêmevaleurque {- 30 20 10 5),vousêtestombé dans le piège: l'opération- n'est pas associative et
(30- (20- (10- 5))) =1 (((30- 20)- 10)- 5). Pour bien comprendre la différence, nous vous invitons : - à faire la trace de la fonction reduce et de la fonction - ; - à visualiser par un dessin (comme ci-dessus), l'ordre des opérations effectuées, pour obtenir la valeur 15 ; - à comparer votre dessin avec le dessin ci-dessous qui visualise les opérations effectuées pour le « moins n-aire » et permet d'obtenir la valeur -5.
(
30
20
10
5
)
-5
Une définition pour simuler le moins n-aire est donnée dans l'exercice corrigé<< Autour de reduce »(page 136).
3.3.3 Rôle de l'argument base de reduce L'argument base de la fonction reduce permet, comme on l'a vu précédemment, d'initialiser le processus de calcul. Il est aussi appelé, suivant le point de vue où l'on se situe, valeur finale (si l'on considère base comme le dernier élément de la liste) ou encore valeur de départ (si l'on se place du point de vue de la première évaluation de la fonction binaire prenant comme argument le dernier élément de la liste et base). Remarquer que d'un point de vue algébrique, on a aussi utilisé la base comme élément neutre pour les opérations commutatives (0 pour l'addition, 1 pour la multiplication ... ). Pour illustrer le rôle de 1' argument base, définissons une fonction sol de qui, étant donnée une somme de départ, et une liste de recettes et de dépenses, permet de calculer le nouveau solde: ; ; ; solde: Nombre * USTE[Nombre]-+ Nombre ; ; ; (soldes L) rend la somme de «S» et de la somme des éléments de la liste «L» (define {solde s L)
(reduce + s
L))
base a ici pour valeur s. Une application de la fonction solde : (solde 300 • (100 -60 -30)) -+ 310
Chapitre 5. Fonctionnelle
126
3.4 Remarques avancées au sujet de redu ce 3.4.1 Fonctionnelle combinaison La fonctionnelle combinaison, très semblable à la fonctionnelle reduce, permet d'utiliser comme base le dernier élément de la liste passée en argument (la liste doit donc avoir au moins un élément), comme le montre le dessin suivant :
; ; ; combinaison: (a* a-+ a)* USTE[a]-+ a ; ; ; (combinaison j'(el e2 ... en-1 en)) rend (fel (fe2 ... (fen-1 en) .. .)) ; ; ; ERREUR lorsque la liste est vide (define (combinaison f L) (if (pair? (cdr L)) ( f (car L) (combinaison f (cdr L))) (car L) l l Cette fonction combinaison nous permet, par exemple, de calculer le maximum d'une
liste comme dans l'exemple suivant: (combinaison max '(25 -30 40 10))
-+ 40
On ne peut pas calculer le maximum d'une liste aussi simplement avec reduce (il faut nommer la liste pour récupérer une valeur de base qui est le premier élément de la liste, alors que pour combinaison il s'agit du dernier élément de la liste) : (let ( (L '(25 -30 40 10))) (reduce max (carL) (cdr L)))
3.4.2 Définition de la fonction à composer Dans tous les exemples précédents, nous avons utilisé comme argument de la fonction redu ce, des fonctions binaires déjà définies (+, *, ... ) mais nous pouvons, bien sûr, définir nos propres fonctions. ~
Exemple : fonction et
Si l'on désire savoir si une liste d'entiers est constituée uniquement d'entiers impairs (respectivement pairs) il suffit d'écrire : (reduce et #T (map even? (list 1 2 3 4 56 7))) (reduce et #T (map odd? (list 1 3 57))) -+ #T la fonction et étant définie par : ; ; ; et : bool * bool ..... bool ; ; ; (et a b) rend #t ssi «a» et «b» sont égaux à #t (define (et a b) (and a bl l
-+ #F
Réduction d'une liste par une fonction
127
Remarque: la définition de la fonction et est nécessaire pour l'application de la fonction reduce. En effet, on ne peut pas utiliser and car c'est une forme spéciale et non une fonction. Mais alors, le parcours de la liste se fera en totalité même si, en cours de parcours, le résultat final # f est une évidence. Donc, dans ce cas, l'utilisation de reduce est à éviter pour cause de non efficacité. 3.4.3 Fonction de type a x {3
-+
{3
La fonction argument de combinaison est obligatoirement de type a x a -+ a. En revanche, la fonction argument de reduce est de type a x {3 -+ {3, mais, jusqu'à présent, pour des raisons de simplicité, nous n'avons montré que des exemples où a et {3 sont le même type. Voici trois exemples où la fonction passée en argument est de type plus général : .- Minimum et maximum d'une liste
n s'agit d'écrire la définition d'une fonction min-max qui,
étant donnée une liste de nombres, rend le couple formé du minimum et du maximum de la liste. Par exemple : (min-max' (20 5 10 3 2 10)) -+ (2 20) Une première définition pourrait être : ; ; ; min-max: USTE[Nombre]-+ COUPLE[Nombre Nombre] ; ; ; (min-max L) rend le couple formé du minimum et du maximum de la liste «L». ; ; ; HYPaTHÈSE: la liste est non vide
(define (min-max L) (list (combinaison min L) (combinaison max L))) Cette première définition applique deux fois combinaison : une fois en itérant la fonction qui calcule le minimum de deux nombres et un autre fois en itérant la fonction qui calcule le maximum de deux nombres. Ce traitement nécessite deux parcours de la liste. D serait plus efficace de ne parcourir la liste qu'une seule fois. Mais il faut alors itérer sur la liste une fonction qui calcule en même temps le minimum et le maximum de deux nombres. ll suffit pour cela de regrouper le minimum et le maximum en un couple, et de considérer une fonction de type: Nombre * COUPLE[Nombre, Nombre] -+COUPLE[Nombre, Nombre]. Le cas de base doit alors aussi être un couple de nombres. On peut donner comme base le couple formé de deux fois le premier élément de la liste (supposée non vide). La définition suivante réalise ce traitement : partant du couple initial comportant deux fois le premier élément de la liste, elle réduit la liste par une fonction auxiliaire minMaxAux, qui réajuste au fur et à mesure le maximum et le minimum du couple. (define (min-max L) ; ; minMaxAux: Nombre * COUPLE[Nombre Nombre]-+ COUPLE[Nombre Nombre] ; ; (minMa.xAux e mi-ma) rend le couple contenant le minimum et le maximum ; ; de «e» et des deux nombres ordonnés contenus dans «mi-ma»
(define (minMaxAux e mi-ma) (if (< e (car mi-ma)) (cons e (cdr mi-ma)) (if (> e (cadr mi-ma)) (list (car mi-ma) e) mi-ma))) (reduce minMaxAux (list (carL) (carL)) (cdr L)))
128 ~
Chapitre 5. Fonctionnelle
Le nombre de zéro dans une liste est-il pair?
ll s'agit d'écrire la définition d'une fonction qui, étant donnée une liste de nombres 0 ou 1, rend vrai si et seulement si le nombre de 0 dans la liste est pair (une telle fonction peut intervenir dans la détection d'erreurs éventuelles lors d'une transmission de données). D'où le jeu d'essais : (verifier nbre-pair-0? (nbre-pair-0? '(1)) == #t (nbre-pair-0? '(0)) == #f (nbre-pair-0? '(0 0)) == #t (nbre-pair-0? '(0 0 0)) == #f (nbre-pair-0? '(0 1 1 1 0 0 1)) -- #f (nbre-pair-0? '(1 0 1 1 0 0 0)) -- #t (nbre-pair-0? '()) == #t) En supposant qu'il existe un prédicat zero?, qui rend vrai si, et seulement si, son argument vaut 0, une première définition pourrait être : (define (nbre-pair-0? L) (even? (length (filtre zero? L)))) Mais cette définition présente l'inconvénient de nécessiter deux parcours de liste (un parcours pour appliquer la fonction filtre avec la fonction zero? et un autre parcours avec la fonction length). Voici une deuxième définition avec un seul parcours de liste. (define (nbre-pair-0? L) (if (pair? L) (if (= 0 (car L)) (not (nbre-pair-0? (cdr L))) (nbre-pair-0? (cdr L))) #t) ) La fonction fait basculer (de Vrai vers Faux ou l'inverse) le booléen résultant de l'application récursive de nbre-pair-0? sur le cdr de la liste, dans le cas où le car de la liste vaut O. Et elle renvoie vrai si la liste est vide. Cette deuxième définition suggère l'idée de bascule d'un booléen par une fonction binaire, de type: {0,1} *booZ-+ booZ. Étant donnés un chiffre (0 ou 1) et un booléen, on bascule le booléen si le chiffre est 0 et on le laisse inchangé si le chiffre est l. L'itération de cette fonction sur la liste de départ rendra donc le résultat attendu, à condition de l'amorcer avec le cas de base vrai. On en vient donc à la troisième définition de la fonction nbre-pair-0?, qui réduit la liste par la fonction basculeSiO. Noter que la fonction basculeSiO, qui est une fonction auxiliaire, est donnée ici en définition interne, mais on aurait tout aussi bien pu mettre sa définition à l'extérieur de la fonction. (define (nbre-pair-0? L) ; ; basculeSiO : nat/0 ou JI *boo/ ---> boo/ ; ; (basculeSiO e b) rend «b» si «e» vaut 1 et (not b) si «e» vaut 0
(define (basculeSiO e b) (if
( = 0 e)
(not b) b)) ; ; expression de la définition de nbre-pair-0?:
(reduce basculeSiO #tL))
129
Une fonction comme résultat de fonction ~
Bégaiement d'une liste
ll s'agit d'écrire la définition d'une fonction qui, étant donnée une liste d'éléments, rend la liste obtenue en dupliquant chaque élément, par exemple : (begaiement '(1 3 4 1 2 2))
-+
(1 1 3 3 4 4 1 1 2 2 2 2)
Le résultat de l'application de reduce est ici une liste, deux fois plus longue que la liste initiale! On va utiliser l'itérateur reduce, qui duplique chaque élément à l'aide de la fonction begaie, de type: a* LISTE[a] ---> LISTE[a] : ; ; ; begaie: a * USTE[a]--+ USTE[a] ; ; ; (begaie e L) rend la liste «L» à laquelle on a ajouté en t€te deux fois «e» (define (begaie e L) (cons e (cons eL)))
Et finalement : ; ; ; begaiement: USTE[a]-+ USTE[a] ; ; ; (begaiement L) rend la liste ou chaque élément de «L» est dupliqué (define (begaiement L) (reduce begaie '() L)) Noter qu'ici aussi, la fonction begaie aurait pu être définie à l'intérieur de la fonction begaiement.
4 Une fonction comme résultat de fonction Cette partie traite de la construction de fonctionnelles dont le résultat est une fonction. Elle est plus ardue que les parties précédentes et peut être sautée en première lecture. On présente différents exemples : composition de deux fonctions, itérée d'une fonction, transformation d'une fonction de deux arguments en une fonctionnelle d'un seul argument. On voit apparaître dans tous ces cas des fonctions auxiliaires qui doivent être définies de façon interne, car leur portée est locale à la définition qui les englobe.
4.1
Composition de fonctions
On s'intéresse ici à deux exemples classiques de fonctionnelles dont les arguments et les résultats sont des fonctions. ~
Composition de deux fonctions
Étant données une fonction f de type a ---> f3 et une fonction g de type f3 ---> 'Y, leur composée est la fonction f o g (prononcer «f rond g») de type a ---> 'Y , qui à tout élément x de a fait correspondre l'élément g(f(x))- on applique d'abord f à x, puis g à f(x)). Par exemple si f est la fonction qui associe à un élément son carré, et g est la fonction qui associe à un élément son double, alors la composition de f et g fait correspondre à tout élément x le double de son carré x f-> 2x 2 , alors que la composition de g et f fait correspondre à x le carré de son double x f-> (2x ) 2 • La définition en Scheme nécessite une définition interne de la fonction qui à x associe (g(fx)) car les fonctions f et g qui figurent dans l'expression de la fonction f o g n'existent
130
Chapitre 5. Fonctionnelle
que dans la portée de la fonction composition (ce sont les arguments de la fonction). Et c'est cette fonction interne qui est renvoyée en résultat de la fonctionnelle composition: ; ; ; composition: (a--+ {3) * ({3--+ -y) --+ (a--+ -y) ; ; ; (composition! g) renvoie la fonction «fog» (prononcer «/rond g») (define (composition f g) :: fog: a--+ 'Y ; ; (fog x) renvoie (g (fx)) (define (fog x) (g
(f x)))
; ; expression de (composition f g) : fog)
Notez que le résultat de l'application de la fonction composition est une fonction, que l'on peut appliquer ensuite à des arguments. Par exemple, en supposant définies les fonctions double et carre: ((composition carre double) 6) ((composition double carre) 6) ~
..... 72
..... 144
Itérée d'une fonction
Étant donnés une fonction f de type a -+ a et un naturel n, la niemeitérée de f est la fonction f of o ... of, résultat de n compositions. Si n vaut 0, la n iemeitérée de f est l'identité. Par exemple itérer 5 fois la fonction double donne la fonction qui associe à 3 la valeur 96, puisque : (double (double (double (double (double 3)))))
--+ 96
La définition suivante réalise cette fonctionnelle : ; ; ; iteration: (a--+ a) *nat--+ (a--+ a) ; ; ; (iterationfn) renvoie la fonction «fofo... of», résultat de la composition de«/» par«/», «n» fois (define (iteration f n) ; ; identite : a ---+ a ; ; (identite x) renvoie x (define (identite x) x) ; ; expression de (iterationfn): (if
(= n
0)
identite (composition f
(iteration f
(- n 1)))))
On a, par exemple : ((iteration double 5) 3) --+ 96 ((iteration carre 4) 2) --+ 65536 ((iteration list 5) (list 0 1)) --+ ((((((0 1))))))
4.2 Réduction de l'arité d'une fonction On appelle arité d'une fonction son nombre d'arguments. Toute fonction d'arité 2 (fonction binaire) peut être transformée en une fonction d'arité 1 (fonction unaire) par un mécanisme dit «curryfication» (en hommage au logicien H.B. Curry). Considérons par exemple une fonction f binaire de type a * f3 -+ 'Y, qui au couple (x, y) associe f(x, y). Soit Fx la fonction unaire, de type f3-+ "(,telle que Fx(Y) = f(x, y). Et soit Fla fonction, de type a -+ (f3-+ 7), telle que F(x) = F,.. On a ainsi f(x, y) = F,.(y) =
Une fonction comme résultat de fonction
131
(F(x))(y). Et l'on a remplacé la fonction binaire f par la fonction F qui est une fonction unaire, dont le résultat est une fonction (unaire). Donnons un exemple simple en Scheme pour calculer la somme de deux nombres : - une fonction avec deux arguments : ; ; ; somme : Nombre *Nombre -+ Nombre ; ; ; (somme x y) rend la somme de «X» et de «y» (define (somme x y) ( + x y) )
sa curryfication : ; ; ; ajoute: Nombre -+ (Nombre -+Nombre) ; ; ; (ajoute x) rend la fonction qui ajoute «X» à un nombre (define (ajoute x) ; ; ajoute-x: Nombre -+Nombre ; ; (ajoute-x y) ajoute «X» à «Y» (define (ajoute-x y) ( + x y) ) ; ; expression de la définition de ajoute : ajoute-x)
Et l'on a par exemple : (somme 3 4) -+ 7 ((ajoute 3) 4) -+ 7
La curryfication se généralise à des fonctions d' arité quelconque, et l'on peut donc toujours diminuer l'arité d'une fonction et ultimement la ramener à 1. ~
Curryfication et itérateurs
Ce mécanisme est très utile pour utiliser des fonctions de l' arité voulue dans les itérateurs sur les listes. Par exemple pour ajouter la valeur 10 à tous les nombres d'une listeL, on pourra écrire l'expression (map (ajoute 10) L). Autre exemple, pour filtrer dans une liste de nombres tous ceux qui sont strictement compris entre deux bornes données, on pourra définir la fonctionnelle compris-entre, qui prend en argument deux nombres net m, avec n < m, et renvoie le prédicat entre-n-et-rn? : ; ; ; compris-entre : Nombre *Nombre -+ (Nombre -+ bool) ; ; ; (compris-entre n m) rend le prédicat qui renvoie vrai ssi un nombre est ; ; ; compris strictement entre «n» et «m» ; ; ; HYPŒHÈSE: n < m (define (compris-entre n rn) ; ; entre-n-et-rn?: Nombre-+ bool ; ; (entre-n-et-rn? x) rend vrai ssi «X» est compris strictement entre «n» et «m» (define (entre-n-et-rn? x) (and (< n x) ( < x rn) ) ) ; ; expression de la définition de compris-entre : entre-n-et-rn?) Et utiliser le résultat de l'application de compris-entre à deux nombres, comme prédicat de l'itérateur filtre, par exemple : (filtre (compris-entre 3 9) '(53 4 7 9 2 1 6)) -+ (54 7 6)
Chapitre 5. Fonctionnelle
132
5 Pour en savoir plus Manipuler des fonctions rend l'écriture de programmes plus régulière car les fonctions (un type de donnée doté d'une unique opération : l'application) ne sont plus un cas particulier. Si, par exemple, dans une alternative, une même fonction est appliquée à deux expressions différentes, il est alors simple de factoriser la fonction. Ce que l'on notera par la règle algébrique suivante : ( if condition ( f ( if condition ( f expression] ) ( f expression2 )
-
expression] ) expression2)
Dualement s'il s'agit de deux fonctions, au sein d'une alternative, s'appliquant à une même expression, on écrira : ( ( if condition ( if condition expression]) expression2)
(expression] e ) ( expression2 e )
e )
Mettre en facteur commun l'argument ou la fonction, c'est toujours factoriser. Les mêmes règles syntaxiques de factorisation s'appliquent donc à des données en situation d'argument ou de fonction. Considérez le schéma récursif général sur les listes (page 89). Nous l'avions exprimé comme un texte typographié en mélangeant code et annotations en italiques : ; ; ; fn-sur-liste : USTE[a]-+ f3 (de fine (fn-sur-liste L) (if (pair? L) (combinaison (car L) base) )
(jn-sur-liste ( cdr L) ) )
Grâce à la présence de fonctions manipulables, nous pouvons exprimer ce schéma sous la forme d'une fonction normale de Scheme, fonctionnelle qui a comme donnée la valeur base et la fonction combinaison et qui rend la fonction réalisant ce schéma. Voici sa signature et sa définition : ; ; ; schema-recursif-liste: f3
x
(a
x f3--+ f3)--+ (USTE[a]-+ {3)
(define (schema-recursif-liste base combinaison) ; ; fn-sur-liste : USTE[a]-+
f3
(define (fn-sur-liste L) (if (pair? L) (combinaison (carL) (fn-sur-liste (cdr L))) base ) ) fn-sur-liste ) Par exemple, la fonction begaiement n'est-elle que le résultat d'un judicieux appel à schema-recursif-liste: ; ; ; begaiement: USTE[a]-+ USTE[a]
(define (begaiement L) ; ; repetition: a x USTE[a]--+ USTE[a] (define (repetition mot reste) (cons mot (cons mot reste)) ) ; ; expression de (begaiement L) :
((schema-recursif-liste '() repetition) L) )
Pour en savoir plus
133
Dans cette définition, la solution du problème dans le cas de base est la liste vide et combiner un mot avec la solution du problème sur le cdr s'effectue en répétant ce mot grâce à repeti tian. Le schéma précédent qu'incarne la fonction schema-recursif-liste n'est valable que sur les listes puisqu'il utilise les fonctions pair?, car et cdr qui imposent que le problème soit représenté par une liste. Mais l'essence de la récursion, quelle que soit la donnée, est la même : subdiviser un problème en sous-problèmes jusqu'à ce qu'ils deviennent triviaux, combiner les solutions partielles pour bâtir la solution entière. Pourquoi ne pas exprimer cette idée plus générale sous la forme d'une nouvelle fonction en Scheme? Voici cette idée incarnée sous la forme d'une fonction à deux étages, le premier étage correspondant au type de la donnée et le second étage étant lié au problème posé sur ce type de données. Plus précisément, considérons un type a avec les accesseurs debut - de type a ---> /3- et reste- de type a ---> a- et le reconnaisseur composee- de type a --->baal. Ainsi, dans un tel type, une information composée est constituée d'un début et d'un reste, ce dernier, qui est un sous-problème du problème posé, étant de type a. La fonction schema-recursif-general prend, dans un premier temps, le reconnaisseur et les deux accesseurs du type a et elle rend une fonctionnelle (que nous nommons schema-recursif-alpha) spécialisée dans la manipulation des problèmes implantant un schéma récursif linéaire sur le type a. Cette dernière fonctionnelle prend comme données la fonction qui rend la valeur du problème pour les informations non composées (cas triviaux) et la fonction qui combine le début de l'information donnée et l'appel récursif sur le reste de l'information lorsque cette dernière est composée : ; ; ; schema-recursif-general : (a ---> bool) x (01 ---> f3) x (a ---> a) ;;; --->((a-+ 'Y) x ((3 x 'Y-+ 'Y)-+ (a-+/))
(define (schema-recursif-general composee? debut reste) ; ; schema-recursif-alpha : (01 ---> 'Y)
x (f3 x 'Y
---> 'Y) ---> ( 01 ---> 'Y)
(define (schema-recursif-alpha solution-triviale combinaison) ;f:a--->1
(define (f donnee) (if (composee? donnee) (combinaison (debut donnee) (f (reste donnee))) (solution-triviale donnee) ) ) f
)
schema-recursif-alpha ) Ce schéma plus général doit bien sûr s'appliquer à notre précédent exemple, la fonction begaiement: ; ; ; begaiement: USTE[a]---> USTE[01] (define (begaiement L) ; ; liste-vide: USTE[01]-+ USTE[01]
(define (liste-vide
L)
'())
; ; repetition: a x USTE[a]---> USTE[01]
(define (repetition mot reste) (cons mot (cons mot reste)) ) ; ; expression de (begaiement L) :
(((schema-recursif-general pair? car cdr) liste-vide repetition) L)
)
Mais comme il est général, il s'applique aussi à notre bien connue factorielle:
134
Chapitre 5. Fonctionnelle
; ; ; factorielle : nati>O/ x nat (define (factorielle n) ; ; identite : nati>O/ x nat (define (identite n) n) ; ; moins-un : nati>O/ x nat (define (moins-un n) (- n 1)) ; ; sup-un?: nat/>0/ x bool (define (sup-un? n) (> n 1)) ; ; expression de (factorielle n) : (((schema-recursif-general sup-un? identite moins-un) identite*) n)
)
Ici, la reconnaissance d'une donnée composée est le prédicat sup-un? et les accesseurs permettant de paramétrer le schema-recursif-general sont l'identité qui extrait l'information d'un nombre à savoir lui-même et la fonction moins-un qui simplifie le problème; la solution du problème trivial (la factorielle de 1 est 1) s'obtient par l'identité et la composition est la multiplication. Ces deux derniers exemples montrent bien l'aspect similaire que revêt pour les nombres ou les listes, le concept de récursion: factorielle et begaiement, même combat! Que les fonctions soient manipulables permet donc d'écrire, en Scheme même, les schémas de récursion que nous avions naguère présentés ex cathedra. ils deviennent donc de vraies fonctions dont il n'est nul besoin de comprendre la définition pour les utiliser. On peut ainsi élaborer de nouveaux schémas de récursion plus astucieux ou plus efficaces les uns que les autres et les masquer aux programmeurs qui n'ont plus besoin de s'en soucier. Inversement, lire ces schémas en Scheme permet, peut-être, des les mieux comprendre.
6
Exercices corrigés ,
,
6.1 Enonces Exercice 25 - Un dictionnaire franco-anglais Un dictionnaire bilingue associe, à tout mot d'une première langue, un mot d'une seconde langue. Il peut donc être implanté par une liste d'associations. Par exemple, un (tout petit) dictionnaire français-anglais peut être implanté par la liste : '((chat cat)
(chien dog)
(souris mouse)).
On nomme << Dico >>, le type égal à USTE[COUPLE[Symbole Symbole]] où le premier symbole est le mot de la première langue et le second symbole le mot de la seconde langue.
Question 1- Écrire une fonction dico-french-anglais qui rend un (petit) dictionnaire français-anglais. Question 2 - Écrire une fonction qui rend la liste des mots de la première langue présents dans un dictionnaire bilingue donné. Par exemple : (mots-langue-1 '((chat cat)
(chien dog)))
-+
(chat chien)
Question 3 - Écrire une fonction qui rend la liste des mots de la seconde langue présents dans un dictionnaire bilingue donné. Par exemple : (mots-langue-2 '((chat cat)
(chien dog)))
-+
(cat dog)
135
Exercices corrigés
Question 4 - Écrire une fonction qui, étant donné un dictionnaire bilingue, rend le dictionnaire inverse (par exemple, si le dictionnaire donné est un dictionnaire français-anglais, cette fonction rend un dictionnaire anglais-français). Question 5- Écrire une fonction, traduction, qui, étant donnés un dictionnaire bilingue et une phrase (implantée par une liste de mots) écrite dans la première langue du dictionnaire, traduit cette phrase (mot à mot) dans la seconde langue.
Exercice 26 - Généralisation de map Question 1 - Écrire une fonction nommée map2 prenant une fonction 1 (acceptant deux arguments) et deux listes Ll et L2. Cette fonction construit une nouvelle liste dont les termes sont les résultats des applications de 1 aux termes successifs et de même rang deLl et L2. Si les listes diffèrent par la taille, les termes superflus ne sont pas pris en compte. Ainsi, (map2 - '(11 22 33) '(1 2 3)) ---+ (10 20 30) (map2 max '(1 2) '(10 1 12)) ---+ (10 2)
( (
Al
BI
f
(
A2
A4
A3
B2
B3
B4
f
Cl
)
A5
BS)
f
C2
C3
C4
cs)
Question 2- Écrire une fonction nommée associations prenant une liste de clefs et une liste de valeurs et construisant la liste d'associations associant ces clefs à ces valeurs. On supposera que ces listes ont même taille. Ainsi, (associations '(un deux trois) ((un 1) (deux 2) (trois 3))
'(1 2 3))---+
Question 3- Écrire une fonction nommée map-liste prenant pour données une fonction unaire et une liste de listes et rendant une liste de listes où chaque élément est l'application de 1 sur chaque élément des listes données. Par exemple : (map-liste even? '((1 3 5) (2 4 6 8) (1 2 3 4 56)))---+ ( (H #f #f) (#t #t #t #t) (#f #t #f #t #f #t))
Exercice 27- Sur les occurrences d'un élément Nous reprenons des exercices, vus dans le chapitre sur la récursion sur les listes, sur les occurrences d'un élément donné dans une liste. Mais, ici, nous utilisons des fonctionnelles pour écrire les définitions des fonctions demandées. Question 1 - En utilisant la fonction filtre, écrire une définition de la fonction moinsoccurrences qui, étant donnés un élément elt et une liste L, renvoie la liste privée de toutes les occurrences de cet élément. Par exemple : (moins-occurrences 3 (list 1 3 4 3 55 3)) ---+ (1 4 55) (moins-occurrences 3 (list 2 4 1 5)) ---+ (2 4 1 5)
Chapitre 5. Fonctionnelle
136 (moins-occurrences "ma"
{list "ma"
"me"
"ma"
"mo")}
~
("me"
"mo")
Indication : pour pouvoir utiliser filtre, il faut définir un prédicat unaire, interne à la fonction moins-occurrences, qui étant donné un élément x, renvoie vrai ssi x est différent de elt.
Question 2 - En utilisant la fonction redu ce, donner une définition de la fonction nombreoccurrences, qui rend le nombre d'occurences d'un élément donné dans une liste. Exercice 28 - Schéma de Homer Étant donné un polynôme a0 + a 1x + a2x 2 + ... + anxn, représenté par la liste de ses coefficients (aO al . . . an), on veut évaluer ce polynôme pour une certaine valeur de x (la liste vide et la liste (0) représentent toutes deux le polynôme nul). Pour que l'évaluation soit efficace, on utilisera le schéma suivant, dit schéma de Homer:
ao + a1x + a2x 2 + a3x3 + a4x4 = ao +x* (a1 +x* (a2 +x* (a3 +x* a4))) Avec le schéma de Homer, l'évaluation d'un polynôme de degré n nécessite n multiplications (et n additions). Lorsqu'une fonction calcule un résultat en un nombre d'opérations proportionnel à la taille des données, on dit que cette fonction est de complexité linéaire en temps. Ici la taille des données est n + 1 : les n coefficients et la valeur x. Le schéma de calcul de Homer permet donc d'évaluer un polynôme en temps linéaire.
Question 1 - Écrire une définition récursive de la fonction horner, qui reçoit un nombre et une liste de coefficients (aO al ... an), et calcule, par schéma de Homer, la valeur du polynôme en ce nombre. Question 2 redu ce.
Écrire une autre définition de la fonction horner en utilisant la fonction
Exercice 29 - Autour de reduce
Question 1 - Revenons sur le piège du cours (page 125) pour bien comprendre la différence dans l'ordre des évaluations, d'une part pour l'expression (reduce - 0 (list el e2 . . . eN) ) et d'autre part pour l'expression (- el e2 . . . eN). Étant donnée une listeL d'éléments el, e2 ... eN, il s'agit d'écrire des définitions récursives pour les fonctions moins-liste-vers-gauche etmoins-liste-vers-droi te, qui effectuent les opérations de soustractions respectivement de la droite vers la gauche et de la gauche vers la droite : - l'application de la fonction moins-liste-vers-gauche à la listeL= (el, ... , en) rend la même valeur que (reduce - 0 L), c'est-à-dire (el- (e2- (... (en-1 -en)))), - l'application de la fonction moins-liste-vers-droite à la listeL= (el, ... , en) rendlamêmevaleurque (- el e2 ... en),c'est-à-dire((((el-e2)- ... )-en-l)-
en)· Question 2- L'itérateur reduce correspond au schéma suivant : (reduce fe (list et e2 ••• ep)) = If et If e2 • • • If ev e) ... ) ) Écrire un itérateur, nommé ecuder, de schéma équivalent à: (ecuder fe (list et e2 ••• ep)) = If If .•• Ife ep) ••• e2) et) Écrire au moins deux tests (avec la forme verifier). Question 3- Écrire une définition de la fonction moins-liste-vers-droite qui utilise la fonction ecuder
137
Exercices corrigés
Exercice 30 - Fonction de tri générique Le tri fusion présenté en exercice (page 105) a pour argument une liste d'entiers et renvoie la liste triée en ordre croissant. ll s'agit ici d'adapter ce tri pour pouvoir trier des listes plus générales, selon différentes relations d'ordre.
Question 1- Écrire une définition de la fonction tri-fusion-generique, qui reçoit un prédicat inferieur? ainsi qu'une liste de termes comparables selon cet ordre, et renvoie la liste, triée en ordre croissant. Question 2- Application 1 : on considère qu'un point P du plan est représenté par un couple de nombres (x, y), où x représente l'abscisse de P et y son ordonnée. ll s'agit de trier une liste de points, soit selon les abscisses, soit selon les ordonnées. 1. Écrire les fonctions auxiliaires abscisse et ordonnee, qui renvoient respectivement l'abscisse et l'ordonnée d'un point.
2. Écrire les prédicats infAbscisse? et infOrdonnee?, qui, étant donnés deux points, renvoient vrai si, et seulement si, l'abscisse (respectivement l'ordonnée) du premier point est strictement inférieure à celle du second. 3. Écrire la fonction triParRapport, qui étant donnés un critère (absc ou ordo) et une liste de points, trie la liste en ordre croissant, selon les abscisses si le critère vaut absc et selon les ordonnées si le critère vaut ordo. Attention pour que les points de la liste soient comparables, il faut que leurs abscisses (resp. ordonnées) soient toutes différentes si l'on trie selon les abscisses (resp. selon les ordonnées).
Question 3 - Application 2 : on considère qu'une carte à jouer C est représentée par un couple (rg, coul), où rg représente le rang de C et coul représente sa couleur. Les couleurs appartiennent à l'ensemble {trefle, carreau, coeur, pique}, et les rangs appartiennent à l'ensemble {2, 3, 4, 5, 6, 7, 8, 9, 10, valet, dame, roi, as}. Pour comparer deux cartes, on les compare d'abord selon leur couleur: trefle -<(c carreau -<(c coeur -<(c pique (tout trèfle est strictement inférieur à tout carreau ; tout carreau est strictement inférieur à tout cœur et tout cœur est strictement inférieur à tout pique). Et si deux cartes ont la même couleur, on les compare selon leur rang, selon l'ordre suivant: 2 -<(r 3 -<(r 4 -<(r 5 -<(r 6 -<(r 7 -<(r 8 -<(r 9 -<(r 10 -<(r valet -<(r dame -<(r roi -<(r as. 1. Écrire les fonctions auxiliaires rang et couleur, qui renvoient respectivement le rang et la couleur d'une carte. 2. Écrire la fonction relationOrdreSelon qui, étant donnée une liste d'éléments L, rend la relation d'ordre (qui est un prédicat binaire) permettant de classer deux éléments selon cette liste. Plus précisément, la liste L sert d'échelle pour classer deux éléments x et y (que 1' on suppose apparaissant dans L) : la relation d'ordre, appliquée à x et à y rend #t si, et seulement si, x est situé avant y dans L. Par exemple ((relationOrdreSelon '(10 20 30 40 50 60 70)) 40 20) ((relationOrdreSelon '(10 20 30 40 50 60 70)) 20 40)
#f
-> ->
#t
En utilisant la fonction relationOrdreSelon, écrire des définitions des fonctions relationOrdre-Couleur et relationOrdre-Rang permettant de comparer deux cartes, d'une part selon leur couleur et, d'autre part, selon leur rang. Par exemple: ((relationOrdre-Couleur) 'trefle 'coeur) -> #t ((relationOrdre-Rang) 4 'valet) -> #t ((relationOrdre-Rang) 'valet 3) -> #f
138
Chapitre 5. Fonctionnelle
3. Écrire le prédicat infCarte? qui, étant données deux cartes, renvoie vrai si et seulement si la première carte est strictement inférieure à la seconde, selon l'ordre précisé dans la présentation du sujet. 4. Écrire la fonction tri Cartes qui, étant donnée une liste de cartes, la renvoie triée en ordre croissant.
6.2 Corrigés Exercice 25 - Un dictionnaire franco-anglais
Solution de la question 1 - Un dictionnaire à peine plus important que le dictionnaire donné en exemple: ; ; ; dico-french-anglais : --+ Dico ; ; ; (dico-french-anglais) rend un dictionnaire français-anglais (define (dico-french-anglais) '((chat cat) (chien dog) (souris meuse) (manger eat) (fromage cheese)))
Solution de la question 2 - La liste donnée en argument est une liste de couples. On peut définir la fonction avec l'itérateur map qui applique la fonction car sur chaque couple et construit ainsi la liste des premiers éléments de ces couples : ; ; ; mots-langue-1: Dico--+ USTE[Symbole] ; ; ; (mots-langue-] dico) rend la liste des mots de la première langue du dictionnaire «dico» donné (define (mots-langue-1 dico) (map car dico))
Solution de la question 3- On peut encore utiliser map (en itérant la fonction cadr) : ; ; ; mots-langue-2 : Dico --+ USTE[Symbole] ; ; ; (mots-langue-2 dico) rend la liste des mots de la seconde langue du dictionnaire «dico» donné (define (mots-langue-2 dico) (map cadr dico))
Solution de la question 4 - Encore une application de la fonctionnelle map, la fonction à itérer étant la fonction qui inverse un couple donné : ; ; ; assoc·inverse: COUPLE[a (3]--+ COUPLE[(3 a] ; ; ; (assac-inverse couple) rend le couple (ba) lorsque le couple donné est le couple (ab) (define (assac-inverse couple) (list (cadr couple) (car couple))) , , , dico-inverse : Dico ---+ Dico ; ; ; (dico-inverse dico) rend le dictionnaire inverse du dictionnaire donné ; ; ; (e.g. rend un dictionnaire anglais-français lorsque le dictionnaire donné est français-anglais) (define (dico-inverse dico) (map assac-inverse dico))
Solution de la question 5- Donnons tout d'abord un jeu d'essais: (verifier traduction (traduction (dico-french-anglais) '(souris manger fromage)) == ' (meuse eat cheese) (traduction (dico-inverse (dico-french-anglais)) '(dog eat cat)) == ' (chien manger chat) )
Exercices corrigés
139
Écrivons maintenant la fonction traduction. ll suffit de faire un map avec la fonction qui traduit un mot. Nous avons défini, dans la section (page 95) sur les listes d'associations, la fonction valeur-de, mais cette dernière a deux arguments alors que map nécessite une fonction unaire. Pour résoudre ce (petit) problème, il suffit de définir une fonction interne à la définition de la fonction traduction: ; ; ; valeur-de: a *LISTE[COUPLE[a /3]] -> /3 + #f ; ; ; (valeur-de clef aliste) rend la valeur de la première association de «aliste» ; ; ; dont le premier élément est égal à «clef». Rend #fen cas d'echec. (define (valeur-de clef aliste) (let ((couple (assoc clef aliste))) (if couple (cadr couple) #f)) )
; ; ; traduction: Dico* LISTE[Symbole]--+ LISTE[Symbole] ; ; ; (traduction dico phrase) rend la liste de mots «phrase», traduite selon le dictionnaire «dico» ; ; ; HYPOTHESE: tous les mots de «phrase» appartiennent au dictionnaire (define (traduction dico phrase) ; ; traduction-mot : Symbole --+ Symbole ; ; (traduction-mot m) rend la traduction du mot «m» en utilisant le dictionnaire «dico» (define (traduction-mot rn) (valeur-de rn dico)) ; ; expression de (traduction dico phrase) : (map traduction-mot phrase))
Exercice 26 - Généralisation de map Solution de la question 1 : ; ; ; map2: (a* /3--+ 'Y) *LISTE[a]* LISTE[/3]--+ LISTE['"(] ; ; ; (map2f(list al a2 ... aP)(list bl b2 ... bQ)) rend (list (f al bl) ; ; ; (f a2 b2) ... (faR bR)) avec R = min(P, Q). (define (map2 f Ll L2) (if (and (pair? Ll) (pair? L2) ) (cons (f (car Ll) (car L2)) (map2 f (cdr Ll) (cdr L2)) )
.
()
)
)
Remarque : la fonction map de Scheme est en réalité de la forme : (map f-Naire listel liste2 • . . listeN)
où la fonctionf-Naire prend N arguments et les N listes présentes dans l'application doivent avoir la même longueur. Solution de la question 2 : ; ; ; associations: LISTE[a]* LISTE[/3]--+ LISTE[COUPLE[a {3]] ; ; ; (associations cles valeurs) rend la liste d'association associant les clés ; ; ; de «cles» aux valeurs de «valeurs». (define (associations cles valeurs) (map2 list cles valeurs) )
Solution de la question 3 - Une première définition pourrait être :
; ; ; map-liste :(a--+ /3) * LISTE[LISTE[a]]--+ LISTE[LISTE[/3]] ; ; ; (map-liste f LL) rend la liste des listes qui sont des images par «f» des termes des listes de «LL»
Chapitre 5. Fonctionnelle
140 (define (map-liste f LL) (if (pair? LL) (cons (map f (car LL))
'
(map-liste f
(cdr LL)))
() ) )
Autre solution de la question 3- Avec une fonction interne: (define (map-liste f LL) ; début de la définition interne ; ; map-int : LISTE[a] --+ LISTE[(3] ; ; (map-int L) rend l'application de «map» avec«/» sur la liste «L» (define (map-int L) (map f L) ) ; jin de la définition interne ; ; expression de la définition de map-liste (map map-int LL))
Exercice 27- Sur les occurrences d'un élément Solution de la question 1 - Dans la fonction interne, la variable elt est globalisée afin que cette fonction puisse être unaire. ; ; ; moins-occurrences : a *LISTE[a] --+ LISTE[a] ; ; ; (moins-occurrences elt L) rend la liste «L» privée de toutes les occurrences de «elt» (define (moins-occurrences elt L) ; début de la définition interne ; ; different-de-elt: a ---> boo/ ; ; (different-de-elt e) rend vrai ssi «e» est différent de «elt» (define (different-de-elt e) (not (equal? elt e))) ; jin de la définition interne ; ; début de l'expression de la définition de moins-occurrences (filtre different-de-elt L))
Solution de la question 2- Sie est l'élément à compter, une première solution consiste à construire une liste intermédiaire, de même taille que la liste à explorer, où chaque élément égal à e est remplacé par 1 et chaque élément différent, par O. En utilisant reduce, on fait alors la somme des nombres de la liste intermédiaire. ; ; ; nombre-occurrences : a * LISTE[a] --+ nat ; ; ; (nombre-occurrences eL) rend le nombre d'occurrences de «e» dans «L». ; ; ; Par convention on rend 0 lorsque la liste est vide (define (nombre-occurrences e L) ; début de la définition interne ; ; compte-1-ou-0 : a --+ nat ; ; (compte-1-ou-0 x) rend 1 si «X» est égale à «e» et 0 sinon (define (compte-1-ou-0 x) (if (equal? xe) 1 0)) ; jin de la définition interne ; ; début de l'expression de la définition de nombre-occurrences (reduce + 0 (map compte-1-ou-0 L)))
Autre solution de la question 2 - On peut ici encore améliorer la performance de la fonction (un seul parcours de liste au lieu de deux parcours) en définissant une fonction qui ajoute 0 ou 1 à une donnée n selon que la donnée x est égal ou non à e.
Exercices corrigés
141
(define (nombre-occurrences e L) ; début de la définition interne ; ; compte-e : a * nat ---+ nat ; ; (compte-e x n) rend (n+ 1) si «X» est égale à «e» et «n» sinon (define (compte-e x n) (+ (if (equal? xe) 1 0) n)) ; fin de la définition interne ; ; début de l'expression de la définition de nombre-occurrences (reduce compte-e 0 L))
Exercice 28 - Schéma de Homer Solution de la question 1 - Voici une définition de la fonction demandée, qui correspond au
schéma donné dans l'introduction : si la liste est vide on rend 0, et sinon on rend la sonnne du premier élément et du produit par x de l'application de la fonction au reste de la liste. ; ; ; homer: Nombre* USTE[Nombre]--+ Nombre ; ; ; (homer x liste-coeffs) rend l'évaluation, pour la valeur «X», du polynôme ; ; ; aO +al x+ a2 x 2 + ... +an xn, si «liste-coeffs» est la liste de ses coefficients (aO al ... an). (define (horner x liste-coeffs) (if (pair? liste-coeffs) (+ (car 1iste-coeffs) (*x (horner x (cdr liste-coeffs)))) 0) )
Solution de la question 2 - Le calcul « réduit >> la liste des coefficients par la fonction de nom a+xb qui associe à deux nombres a et b la valeur a+ x* b. Cette fonction a+xb sera donc utilisée connne argument de la fonction reduce. Pour obtenir l'argument base de la fonction reduce il suffit de« prolonger>> l'expression
ao+x*(al +x* (a2+x*(a3+xM4))) parao+x* (al +x*(a2+x* (a3 +x*(a4 +x*O)))) (define (horner x liste-coeffs) ; début de la définition interne ; ; a+xb : Nombre *Nombre --+ Nombre ; ; (a+xb ab) rend la valeur (a+ x* b) (define (a+xb a b) ( + a ( * x b) ) ) ; fin de la définition interne ; ; début de l'expression de la définition de homer (reduce a+xb 0 liste-coeffs))
Exercice 29 - Autour de reduce Solution de la question 1 : ; ; ; moins-liste-vers-gauche: USTE[Nombre]lnon vide/-+ Nombre ; ; ; (moins-liste-vers-gauche L) rend la méme valeur que (reduce- 0 L) (define (moins-liste-vers-gauche L) (if (pair? L) (- (car L) (moins-liste-vers-gauche(cdr L))) 0) )
(verifier moins-liste-vers-gauche (moins-liste-vers-gauche '(30 20 10 5)) == 15 (moins-liste-vers-gauche '(30 20 10 5)) == (reduce- 0 '(30 20 10 5)))
Chapitre 5. Fonctionnelle
142
; ; ; moins-liste-vers-droite : USTE[Nombre]lnon vide/-+ Nombre ; ; ; (moins-liste-vers-droite L) rend la même valeur que(- el e2 ... eN) ; ; ; «el», «e2», ... «eN» étant les éléments de la liste «L» (define (moins-liste-vers-droite L) (if (pair? (cdr L)) (if (pair? (cddr L)) (moins-liste-vers-droite (cons (- (carL) (cadr L)) (cddr L))) (- (carL) (cadr L))) (- (carL)))) (verifier moins-liste-vers-droite (moins-liste-vers-droite '(30 20 10 5)) == -5 (moins-liste-vers-droite '(30 20 10 5)) -(- 30 2010 5)) Solution de la question 2 : ; ; ; ecuder: ({3 *a-+ {3) * f3 * USTE[a]-+ f3 ; ; ; (ecuder feL) calcule (f(f ... (fe ep) ... e2) el) lorsque L =(list el e2 ... ep). (define (ecuder f e L) (if (pair? L) (f (ecuder f e (cdr L)) (car L)) e ) )
(verifier ecuder ( ecuder list ' () ' () ) == • () (ecuder * 1 '(2 3 4)) == 24 (ecuder- 10 '(1 2 3)) == (-
(- (-
10 3) 2) 1) )
Solution de la question 3 : (define (moins-liste-vers-droite L) (ecuder - (car L) (cdr L)))
Exercice 30 - Fonction de tri générique Solution de la question 1- Il suffit de réécrire la fonction de tri (et la fonction d'interclasse-
ment, que l'on définit ici à l'intérieur de la fonction de tri), en passant comme paramètre la relation d'ordre. On réutilise la fonction pas-paires-impaires qui avait été définie pour le tri fusion. ; ; ; tri-fusion-generique: (a *a-+ bool) * USTE[a]-+ USTE[a] ; ; ; (tri-fusion-generique inferieur? L) retourne la liste obtenue en rangeant ; ; ; les éléments de «L» dans l'ordre défini par le prédicat «inferieur?» (define (tri-fusion-generique inferieur? L) ; ; interclassement-generique : USTE[a]*USTE[a]-+ LISTE[a] ; ; (interclassement-generique L1 L2) rend la liste obtenue en interclassant ; ; les éléments de «Ll » et «L2» dans l'ordre défini par le prédicat «inferieur?» ; ; HYPOTIIÈSE: les listes «Li» et «L2» sont rangées en ordre croissant selon «iriferieur?» (define (interclassement-generique L1 L2) (cond ((not (pair? L1)) L2) ((not (pair? L2)) L1) ((inferieur? (car L1) (car L2)) (cons (car L1) (interclassement-generique (cdr L1) L2)))
Exercices corrigés
143
(el se (cons (car L2) (interclassement-generique Ll (cdr L2)))))) ; ; expression de la définition de la fonction tri-fusion-generique: (if (and (pair? L) (pair? (cdr L))) (let* ((Z (pas-paires-impaires L)) (Ll (car z)) ( L2 (ca dr Z) ) ) (interclassement-generique (tri-fusion-generique inferieur? Ll) (tri-fusion-generique inferieur? L2))) L) )
Solution de la question 2 :
1. Un point étant représenté par un couple, on définit l'abscisse comme étant le premier élément du couple et l'ordonnée le second. ; ; ; abscisse: Point--> Nombre, avec Point= COUPLE[Nombre Nombre] ; ; ; (abscisse point) rend l'abscisse du point «P» (define (abscisse P) (car P)) ; ; ; ordonnee: Point--> Nombre, avec Point= COUPLE[Nombre Nombre] ; ; ; (ordonnee point) rend l'ordonnée du point «P» (define (ordonnee P) (cadr P))
2. L'ordre selon les abscisses (ou ordonnées) compare les abscisses (ou ordonnées) des points. ; ; ; infAbscisse? : Point * Point --> bool ; ; ; (in/Abscisse? Pl P2) rend vrai ssi l'abscisse de «Pl» est strict. inférieure à celle de «P2» (define (infAbscisse? Pl P2) (< (abscisse Pl) (abscisse P2))) ; ; ; injVrdonnee? : Point * Point --> bool ; ; ; (injVrdonnee? Pl P2) rend vrai ssi l'ordonnée de «Pl» est strict. inf. à celle de «P2» (define (infOrdonnee? Pl P2) (< (ordonnee Pl) (ordonnee P2)))
3. ll ne reste plus qu'à utiliser la fonction générique de tri pour faire le tri selon les abscisses ou selon les ordonnées. ; ; ; triParRapport : Critere * USTE[POINT]-+ USTE[POINT] ; ; ; (triParRapport critere liste-points) rend la liste triée en ordre croissant, selon ; ; ; les abscisses si «critere» vaut 'absc et selon les ordonnées si «critere» vaut 'ordo ; ; ; HYPOTHÈSE : «critere» vaut 'absc ou 'ordo (define (triParRapport critere liste-points) (if (equal? critere 'absc) (tri-fusion-generique infAbscisse? liste-points) (tri-fusion-generique infOrdonnee? liste-points))) Solution de la question 3 :
1. Une carte étant représentée par un couple, on définit le rang comme étant le premier élément du couple et la couleur le second.
144
Chapitre 5. Fonctionnelle ; ; ; rang: Carte-+ Rang, avec Carte= COUPLE[Rang Couleur] ; ; ; (rang C) rend le numéro de la carte «C» (define (rang C) (car C)) ; ; ; couleur: Carte-+ Couleur, avec Carte= COUPLE[Rang Couleur] ; ; ; (couleur C) rend la couleur de la carte «C» (define (couleur C) (cadr C))
2. La fonction relationOrdreSelon, ayant pour argument une listeL, rend une relation, c'est-à-dire un prédicat binaire, qui, étant donnés deux éléments x et y, rend #t si, et seulement si, x est situé avant y dans L. ; ; ; relationOrdreSelon : USTE[01.] -+ ( 01. * 01. -+ bool) ; ; ; ( relationOrdreSelon L) rend le prédicat binaire qui, étant donnés deux éléments ; ; ; «X» et «y», rend #t si, et seulement si, «X» apparaît avant «y» dans la liste «L» (define (relationOrdreSelon L) ; ; infSelonL : 01. * 01. -+ bool ; ; (infSelonLx y) rend vrai ssi «X» apparaît avant «Y» dans la liste «L» ; ; ERREUR lorsque «X» n'est pas dans «L» (define (infSelonL x y) (if (member y (cdr (member xL))) #t #f)) ; ; expression de la fonction relationOrdreSelon : infSelonL)
Remarquer l'utilisation du semi-prédicat member qui, étant donnés un élément e et une liste L renvoie # f si e n'est pas dans la liste L, et sinon renvoie la première sous-liste de L qui commence en e. Noter que, voulant un prédicat- et non un semi-prédicat -, nous avons dû écrire (if (test ... ) #t #f), expression que nous avons proscrite lorsque test est un prédicat. Pour définir les fonctions relationOrdre-Couleur et relationOrdre-Rang, il suffit d'appeler relationOrdreSelon avec la liste servant d'échelle: ; ; ; relotionOrdre-Couleur: USTE[Couleur]-+ (Couleur* Couleur-+ USTE[Couleur] + #J) ; ; ; (relationOrdre-Couleur) compare selon l'échelle des couleurs (define (relationOrdre-Couleur) (relationOrdreSelon '(trefle carreau coeur pique))) ; ; ; relationOrdre-Rang : USTE[Rang]-+ (Rang *Rang -+ USTE[Rang] + #J) ; ; ; (relationOrdre-Rang) compare selon l'échelle des rangs (define (relationOrdre-Rang) (relationOrdreSelon (append (intervalle-croissant 2 10) '(valet dame roi as))))
Remarquer, dans la définition de relationOrdre-Rang, l'utilisation de la fonction intervalle-croissant vue dans l'exercice sur les intervalles d'entiers (page 102). 3. La fonction d'ordre sur les cartes peut maintenant être facilement définie: ; ; ; injCarte? : Carte * Carte -+ boo/ ; ; ; (in.fCarte? cl c2) rend vrai ssi «cl» inf à «C2» dans l'ordre des cartes (define (infCarte? cl c2) (or (and (equal? (couleur cl) (couleur c2))
Exercices corrigés
145
( (relationOrdre-Rang) (rang cl) (rang c2))) ( (relationOrdre-Couleur) (couleur cl) (couleur c2))))
4. Le tri d'une liste de cartes utilise la relation de comparaison des cartes dans la fonction générique de tri : ; ; ; triCartes: USTE[Carte]--+ USTE[Carte] ; ; ; (triCartes liste-cartes) rend la liste des cartes triée en ordre croissant (define (triCartes liste-cartes) (tri-fusion-generique infCarte? liste-cartes))
Chapitre 6
Modèle par substitution Pour utiliser correctement un langage de programmation, il est indispensable de comprendre sa sémantique. Le modèle par substitution se compose de quelques règles simples qui permettent d'expliquer ce que calcule un programme. La première de ces règles stipule que 1' on simplifie les expressions par leurs valeurs jusqu'à obtenir une expression qui ne peut plus être simplifiée: c'est celle que vous utilisez depuis toujours en mathématiques. Le modèle par substitution, exposé dans ce chapitre, permet de décrire simplement la sémantique de Scheme. Une autre façon de décrire Scheme est de présenter (chapitre 10) l'implantation d'un évaluateur pour ce langage.
1 Idée et problématique Soit la fonction : ; ; ; a-disque : Nombre ---t Nombre ; ; ; (a-disquer) rend la surface du disque de rayon «T» (define (a-disque r} (let ((pi 3.1416})
(*
pi r
r)))
On désire évaluer l'expression: (a-disque (+ 20 2)). Voyons ce qui se passe en détaillant et commentant l'évaluation pas à pas1 • L'expression à évaluer est constituée d'une application de fonction, il faut donc d'abord évaluer son argument : (a-disque .-1(_+_2_0_2_}---.,1}
La fonction+ est primitive et ses arguments sont des valeurs, l'expression est calculée« d'un coup»: 1
(a-disque
22 )
1
La fonction a-disque n'est pas une primitive, elle a été définie par le programmeur. Dans cette définition, on va se servir de deux éléments : - la liste des variables désignant les arguments attendus de la fonction, ici réduite à r ; 1En DrScheme existe
un outil intitulé stepper qui permet d'évaluer pas à pas des expressions. Pour utiliser cet outil, il faut paramétrer correctement le niveau de langage ainsi que le mode d'impression des résultats.
148
Chapitre 6. Modèle par substitution
- le corps de la définition, qui utiliser. Pour évaluer l'application de la fonction définie par l'utilisateur a-disque à la valeur 2 2, on évalue le corps de la définition dans lequel on remplace la variable r par la valeur de l'argument : (let ((pi 3 .1416)) {* pi 22 22)) 1 La définition de a-disque est une construction let qui introduit le nom et la valeur de la constante pi avant de l'utiliser dans l'expression principale qui définit la fonction. faut 1
n
dans un premier temps calculer cette valeur (ici c'est immédiat) : 3.1416))
l(let {{pi
{*pi2222))1
Puis, dans un second temps, on remplace le nom pi, dans le corps du bloc, par sa valeur : 1
(* 3.1416 22 22)
1
On trouve enfin une expression qui ne contient plus qu'une fonction primitive et des valeurs :elle est calculée d'un coup. C'est d'ailleurs ce qui fonde la qualité de« primitive»: une fonction primitive est une fonction prédéfinie (c'est-à-dire définie par quelqu'un avant vous) dont vous ignorez la définition précise. Elle opère donc de façon opaque en une seule étape indivisible. 1520.5344
L'exemple que nous venons de traiter montre qu'une simple règle de remplacement des variables par leur valeur permet d'expliciter les calculs qui ont été menés. La sémantique de Scheme, c'est-à-dire le sens à accorder à tout programme écrit en Scheme, est ainsi décrite comme l'application itérée d'une règle de calcul simple. Cette règle modélise le processus d'évaluation de Scheme et se nomme le modèle par substitution.
,
2 Etapes d'évaluation Dans les schémas précédents, à tout moment, le processus d'évaluation s'intéresse à une expression unique. Cette expression, cible de son attention, est celle qui figure dans une boite. L'évaluation métamorphose cette expression en une nouvelle expression qui apparatt sur fond grisé. Une expression grisée et une boite peuvent apparaître en même temps ce qui indique à la fois ce qui résulte d'une étape d'évaluation (en grisé) et sur quelle expression va se focaliser 1' évaluateur. Attention, à une expression Scheme se substitue non pas exactement sa valeur mais plutôt une nouvelle expression Scheme équivalente mais plus simple. Ainsi, (*
1
{+ 3 4}
1
(! 6 2))
est, après une étape d'évaluation, équivalent à: ( * 7 1 ( 1 6 2 ) 1> De la même manière, si l'on manipule des listes plutôt que des nombres, obtient-on: (cons
1
(car ' {a b c} }
1
{
cdr ' (a b c} ) )
est équivalent à: (cons
'a
r-I(_c_d_r_'-(a_b_c_}---,}1>
c'est-à-dire à: 1
(cons
1
a
et enfin: 1
{a b c)
1
(b c} l
Environnement
149
Noter, dans les trois derniers programmes de cette séquence d'évaluations, l'irruption de citations pour exprimer des résultats non numériques : ainsi (car ' (a b c} } est-il remplacé par 'a. À tout instant, l' évaluateur simplifie le programme initial et le transforme en un nouveau programme, plus simple, mais équivalent. C'est en ce sens qu'un programme ne crée pas d'information, il se contente de la révéler! Il n'y a pas de différence de sémantique entre le nombre 24, la factorielle du nombre 4 et la plus grande solution de l'équation x 2 - 25x + 24 = O.
3
Environnement
Le mot « environnement » est employé dans de multiples acceptions : - lorsque l'on travaille sur ordinateur (quoi qu'on fasse), on a besoin d'un certain ensemble de ressources. Par exemple, si l'on veut traiter des images, on a besoin d'un numériseur (ou scanner) et d'une (bonne) imprimante couleur- ressources matérielles - et si l'on veut retoucher sa photo avant de l'imprimer, on a besoin d'un atelier graphique, un programme de traitement des images - ressource logicielle. Ces éléments constituent l'environnement matériel et logiciel de l'ordinateur; - le système d'exploitation qui fournit des outils comme la possibilité d'imprimer et qui comporte des variables d'environnement, dont la valeur peut varier selon l'utilisateur, selon ce que fait l'utilisateur, etc. ; - à l'intérieur de ce système d'exploitation, différents environnements sont en jeu (plusieurs langages de commandes (shell), plusieurs environnements graphiques, plusieurs presse-papiers, divers curseurs dans des fenêtres différentes, etc.); - dans la plupart des logiciels, DrScheme en particulier, on peut être dans différents environnements (niveau d'apprentissage ... ); - En Scheme, comme dans tous les langages de programmation, l'évaluation d'une expression est effectuée dans un certain environnement. Ainsi, lorsque l'on définit une fonction, on ajoute cette définition à l'environnement et, dans ce nouvel environnement, on peut utiliser cette fonction. L'environnement est donc un mot désignant le contexte dans lequel on opère. Comme ces mots le suggèrent, l'environnement, le contexte, sont des concepts implicites mais importants. Que le contexte soit implicite est une excellente chose quand tout fonctionne bien car nul besoin de l'appréhender ou le comprendre. Cet état de fait se gâte en cas de problème! Lorsqu'une erreur survient dans un programme en Scheme, il s'agit probablement d'une erreur dans la définition de la fonction en cours. Il vous faut examiner précisément cette définition syntaxiquement puis sémantiquement. Si l'erreur persiste et que votre code vous semble correct alors peut être incriminé l'environnement. L'environnement de la fonction contient notamment les arguments : si la fonction est correcte et que le résultat n'est pas celui attendu alors ce sont les arguments qui doivent être incorrects. Inspectez donc la définition de la fonction ayant invoqué celle où vous avez remarqué un dysfonctionnement. Recommencez récursivement. Si tout votre code est correct et que l'erreur subsiste alors suspectez l'environnement global : vérifiez-le, redémarrez votre application. Si l'erreur subsiste, il s'agit peut-être d'une erreur d'environnement liée au système ou au compilateur ou interprète. Si l'erreur persiste
150
Chapitre 6. Modèle par substitution
encore alors peut-être s'agit-il d'une erreur matérielle ou d'une alimentation électrique incohérente ou d'un excès de température, etc. N'incriminez l'environnement (et ne dites que c'est la faute à l'informatique) que si vous avez épuisé l'analyse de votre code. En revanche, ce que cette digression illustrait est que seuls ceux au fait de l'existence de l'environnement et de ses caractéristiques peuvent espérer comprendre comment réparer en cas de problème. Les experts connaissent plus ou moins profondément leur environnement et un niveau de plus fait la différence ! Doubler une quantité, dans le contexte de Scheme, peut s'écrire (* 2 x). Ce faisant nous nous sommes aussi implicitement placés dans le contexte des nombres puisque nous avons utilisé une multiplication par 2. Doubler une image est une autre opération (d'ailleurs ambiguë car on peut doubler les tailles ou doubler la surface ou accoler deux images l'une à côté de l'autre). Évaluer l'expression ( * 2 x) nécessite un environnement indiquant quelle est la valeur de la variable x et, accessoirement, quelle est la valeur de la variable * (est-ce encore la multiplication des nombres ou la duplication des images ?). Bien qu'invisible, le contexte a un poids qui ne saurait être négligé. La multiplication dans le cadre des entiers, des flottants, des rationnels ou des complexes n'est pas la même. Doubler une image peut échouer si le disque dur n'a pas suffisamment de place disponible! Lire l'implantation du programme mis en œuvre ne donne aucune information pour résoudre ce problème de contexte. Ce qui est difficile c'est d'en savoir juste assez sur le contexte pour pouvoir résoudre son problème. On ne peut jamais supprimer le caractère implicite de l'environnement! TI y a toujours des assertions non spécifiées qui régissent tout texte. TI ne peut y avoir d'échanges (entre un ordinateur et vous, entre deux étudiants, entre deux étrangers, entre un humain et un extraterrestre2, etc.) que si un référentiel commun est a priori partagé.
4 Substitution Le modèle par substitution effectue en fait deux sortes de substitutions :
1. la substitution d'un nom par une valeur dans le corps d'une forme let ou d'une définition de fonction ;
2. la substitution du corps d'une définition à un appel de fonction. Dans ce cas, le corps de la fonction a, au préalable, été instancié (par substitution) avec la valeur des arguments. En fait, pour des raisons d'efficacité, les évaluateurs n'agissent souvnet pas ainsi. Un principe informatique usuel, dit de paresse, pour améliorer l'efficacité est de ne pas faire par avance ce qui n'est pas forcément nécessaire. En d'autres termes, on n'effectue que ce qu'il est inévitable de faire au dernier moment où il est encore possible de le faire. Ce principe est mis en application dans les langages dits paresseux où un calcul n'est effectué que s'il est utile. En ce qui concerne le modèle par substitution, les programmes ne sont pas réécrits en remplaçant les noms par des valeurs ou des définitions, les remplacements à effectuer sont juste conservés dans une sorte de << dictionnaire » dans lequel l' évaluateur retrouve les remplacements à opérer. En informatique, ce dictionnaire est connu sous le nom d'environnement et permet d'associer à des noms les valeurs par lesquelles ils auraient dû être substitués. 2 Pour
le cas des communications avec des extra-terrestres, nous renvoyons le lecteur vers les sites de la NASA http://spaceprojects.arc.nasa.gov/Space_]'rojects/ afin qu'il découvre les messages gravés sur les sondes Voyager.
Substitution
151
Reprenons l'exemple précédent en faisant apparaître l'environnement. Les différentes étapes de calcul apparaissent dans les tableaux suivants. Chacun de ces tableaux comporte : - en colonne de gauche, une représentation de l'environnement courant, - en colonne de droite, les étapes du processus d'évaluation du programme. Dans tout ce qui suit, nous omettons les commentaires qui ne sont utiles que pour les programmeurs et parfaitement inutiles pour l'évaluateur. Nous avons donc à calculer (a-disque (+ 20 2)) dans l'environnement initial. Cet environnement initial est constitué par toutes les primitives et fonctions prédéfinies ( *, +, ... • ? cons, car ... ) . pa1r.,
Environnement
* + • . . pair ? cons car ...
Évaluation (define (a-disque r) (let ((pi 3 .1516)) (*pi r r))) (a-disque 22)
L'évaluation d'une définition a pour effet d'enrichir l'environnement d'évaluation avec cette définition, ce qui, dans notre schéma revient à faire passer la définition de la colonne de droite à celle de gauche3 • Environnement
Évaluation
* + •.. pair? cons car (define (a-disque r) (let ((pi 3.1416)) (*pi r r)))
(a-disque 22)
Ici, plutôt que de remplacer r par sa valeur dans le corps de la fonction a-disque, on mémorise dans l'environnement que cette substitution reste à faire ce que l'on note commer = 2 2. Ce qui nous conduit à : Environnement
Évaluation
* + ... pair? cons car (define (a-disque r) (let ((pi 3.1416)) (*pi r r))) r
(let ((pi 3 .1416)) ( * pi r r) )
= 22
L'environnement est alors étendu par la prise en compte du bloc local définissant la variable pi. Là encore, on ne substitue rien, on mémorise seulement ce qu'il faudra éventuellement faire: 3En
fait, plus exactement, l'environnement mémorise que la variable globale fonction qui prend un argument r et calcule ( * 3 • 1416 r r) .
a-disque a pour valeur une
Chapitre 6. Modèle par substitution
152
Environnement
Évaluation
*+ ... pair? cons car
1
(define (a-disque r) (let ((pi 3 .1416)) (*pirr)))
= 22 pi = 3.1416 r
(* pi r
r)
1
( * 1 pi 1 r r) (*
3.1416
(* 3.1416
0 22
( * 3 .1416 22
r)
0> 22 )
1520.5344
Les substitutions ont alors lieu, telles que mémorisées, et mènent bien au résultat attendu.
n faut bien voir que les deux explicitations des calculs induits par l'évaluation de l'application (a-disque 22) sont équivalentes. La première nécessite de réécrire les programmes à évaluer à chaque substitution ce que ne fait pas la seconde qui est donc plus efficace. En revanche, la première est conceptuellement la plus simple car elle ne nécessite pas que soit introduite la notion d'environnement (autre que global). Vous pouvez penser en termes de l'une ou de l'autre de ces deux visions.
4.1
Extension et rétraction d'environnements
L'environnement est constitué de toutes les variables disponibles, à une étape donnée, accompagnées de leur valeur. L'environnement croît et décroît, ce que va exhiber 1' évaluation de 1' expression suivante : (+
(let
( (n 10))
(* 2 (let
n) ) ((n 15))
(!
n 3) ) )
ll s'agit d'une addition dont il faut calculer les arguments. Scheme ne définit pas dans quel ordre sont calculés les arguments, nous supposerons, pour simplifier, qu'ils sont calculés « de la gauche vers la droite» c'est-à-dire par ordre d'apparition: le premier argument est évalué en premier, etc. Ainsi:
Environnement *+ ... pair? cons car ...
Évaluation (+
lnet ((n
10))
(let ((n 15}}
(* 2
n>>l
(! n 3}))
V évaluation du premier argument conduit à étendre l'environnement pour incorporer la variable locale net sa valeur. Noter que nous accélérons le processus d'évaluation et sautons les étapes triviales.
Environnement pair ? cons car ... * + n
=
10
Évaluation (+
1 (
*
2 n}
1
(let ((n 15}}
(! n 3}))
L'état instantané de l'évaluateur apparaît sur le schéma précédent. V expression en cours d'évaluation est ( * 2 n), l'environnement dans lequel cette expression est évaluée contient notamment que n vaut 10. Enfin la valeur qui sera calculée (comme le montre le schéma suivant) deviendra le premier argument de l'addition en cours.
Substitution
153
Environnement
Évaluation 20
(+
*+ ... pair? cons car ...
1 (let
( (n 15) )
(!
n 3) )
1>
Lorsque le calcul du premier argument est achevé, l'environnement revient à ce qu'il était au moment où l'on a débuté l'addition. En effet, la variable n de la fonne (let ( (n 10)) ( * 2 n)) n'est utilisable que pendant ce calcul et pas après. L'évaluation du second argument débute donc et enrichit 1' environnement comme le montre le schéma suivant :
Environnement pair? cons car ... * +
Évaluation (+ 20
n
1
(!
n 3)
1 )
= 15
La variable n qui se trouve maintenant dans l'environnement n'a rien à voir avec la précédente variable qui se nommait aussi n. D'ailleurs, le nom des variables locales n'a aucune importance puisque l'on peut toujours en changer (mais on ne peut pas utiliser n'importe quel nom!). Ainsi, les expressions suivantes sont-elles équivalentes: (+(let ((n 10)) (* 2 n)) (+(let ((u 10)) (* 2 u)) (let ((n 15)) (/ n 3 ) ) } (let ((v 15)) ( / v 3)) En revanche, les expressions suivantes ne sont pas équivalentes car le remplacement de p par n corrompt la structure du programme. Parmi l'infinité de noms par lesquels on peut remplacer la variable p, il faut écarter les noms net+. (let ((n 3)) (let ((n 3)) (let ((n 3)) (define (plus-n +) (define (plus-n n) ~ (define (plus-n p) ~ ( + n n)
)
(plus-n 4) )
(+ n p) --+
8
(+ n +}
}
(plus-n 4) }
-+
7
)
(plus-n 4) )
-+
~
L'évaluation pas à pas montre que les environnements sont enrichis par les définitions de fonctions ou les blocs locaux (ou formes spéciales let). Lorsqu'un bloc local est quitté, l'environnement avec lequel son corps fut évalué est lui aussi abandonné.
4.2 Environnement et portée À tout instant et tout endroit de l'évaluation, l'environnement contient l'ensemble des variables (et leur valeur) utilisables. La portée d'une variable est la zone du programme où elle est utilisable. L'environnement est une notion qui existe lors de l'évaluation, la portée n'est qu'une notion syntaxique. Toutefois, bien qu'intervenant à des moments différents, ces deux notions sont fortement liées et, d'une certaine manière, duales: les variables de l'environnement présentes en un point du programme sont celles dans la portée desquelles est ce point. La portée est qualifiée de syntaxique car elle n'existe pas à l'évaluation, elle se déduit du texte du programme sans nécessiter que soit évalué ce programme. C'est ce qu'illustre le bouton check syntax dans DrScheme qui montre les associations entre les variables et les formes spéciales les introduisant lorsque l'on passe la souris au-dessus d'elles:
154
Chapitre 6. Modèle par substitution
À
Untitle(j- DrSctleme
Fi 1e Ed it
(+
Wi n dow s Show
U
File
(+
Edit Windows
Show
1
(let ((n 3))
(let ( (\ 3) ) (defi
Untitleei- DrScHeme
À
(plus-n p )
(define
( p~l)
(+ ~
p) )
(plus-n 4 ) )
(plus-n 4 ) )
5 )
Dans le programme suivant, on peut compter 7 variables : foo, n, *, p, +, - et q. Quatre sont globales : foo, *, + et- ce qui signifie qu'elles peuvent, a priori, être partout utilisées. Les trois autres variables sont locales. La variable n est locale à la fonction foo : elle a pour portée le corps de la fonction foo c'est-à-dire l'expression ( * 2 n). La variable p est également locale et a pour portée, le corps du bloc local c'est-à-dire l'expression ( + p ( let ( ( q p) ) ( - q) ) ) . Enfin la variable q est locale et a pour portée le corps du bloc l'introduisant à savoir (- q). (define (foo n)
:-(-; --2 --~) : ) L----------
J
(let ( (p (foo 3))) r-----------------------------, :(+p (let ((qp))
1
1
:
:-(-.=_--q-)-: ) )
)
--tO
L.------- -·
Portée et environnement sont des notions duales : - L'environnement qui existe lors de la référence à la variable * dans le corps de la fonction f oo est formé de l'environnement global (partout disponible et contenant la fonction foo) auquel s'ajoute la variable n (la variable de la fonction englobante foo). - L'environnement présent autour de la référence à la variable+ est formé de l'environnement global auquel s'ajoute la variable locale p. - L'environnement présent autour de la référence à la variable- est formé de l'environnement global auquel s'ajoutent les variables locales pet q. Les portées obéissent à ce que l'on nomme une « discipline de bloc » autrement dit, les portées doivent être bien parenthésées. Une portée est soit incluse dans une autre, soit complètement disjointe. Ainsi les portées de n et de p sont disjointes mais la portée de q est incluse dans celle de p qui est elle-même incluse dans celle de foo: en effet, foo est globale et les variables globales sont globalement visibles partout : elles ont une portée globale et forment l'environnement global. Attention : une variable locale peut masquer une variable de même nom et de portée plus grande. Ainsi si l'on renomme q en p ne change-t-on pas le sens du programme. On ne le change pas non plus en renommant q ou p par n ou quand on renomme pou q par foo ce qui donne, dans ce dernier cas, le programme complètement équivalent mais totalement illisible :
Substitution
155
(define (foc n)
(*
2 n)
(define (foc foc) ( * 2 foc) ) (let ((foc (foc 3))) (+foc (let ((foc foc)) (- foc) ) ) ) --+ 0
)
(let ((p (foc 3))) (+ p (let ((q p)) (- q)
) )
)
--+ 0
4.3 Fonctionnelles Le modèle par substitution rend particulièrement claire l'évaluation des fonctionnelles. Voici une définition de l'itérateur map à l'aide de reduce: (define (reduce f end L) (if (pair? L) (f (carL) (reduce fend (cdr L))) end ) ) (define (map f L) (define (g carL reste) (cons (f carL) reste) (reduce g (list) L) ) Que vaut (map zero? (list 2 o 3) ) ? Si l'on utilise la version prédéfinie de map, la valeur est ( # f # t # f ) car le prédicat zero? est unaire et vérifie que son argument est égal
au nombre zéro. Si l'on utilise les définitions précédentes, esquissons les principales étapes d'évaluation : -
-
(map zero? (list 2 0 (map zero? ' ( 2 0 3) ) (reduce g (list) ' (2 avec (de fine (g carL (if (pair? ' (2 0 3) ) (g (car ' ( 2 0 3) ) ' ()
3) ) 0 3) )
reste)
(cons (zero? carL) reste) )
(reduce g '()
(cdr ' ( 2 0 3))))
)
avec (de fine (g carL reste) (cons (zero? carL) reste) ) (g 2 (reduce g ' ( ) (cdr ' (2 0 3)))) avec (de fine (g carL reste) (cons (zero? carL) reste) ) (g 2 (reduce g ' ( ) '(0 3)))
-
-
-
-
-
avec (de fine (g carL reste) (cons (g 2 (if (pair? '(03)) (g (car '(03)) (reduce g '() ) ) avec (de fine (g carL reste) (cons (g 2 (g 0 (reduce g ' ( ) '(3)))) avec (de fine (g carL reste) (cons (g 2 (g 0 (g 3 '()))) avec (de fine (g carL reste) (cons (g 2 (g 0 (cons (zero? 3) '()))) (g 2 (g 0 (cons #f '()))) (g 2 (g 0 '(#f))) (g 2 (cons (zero? 0) '(#f))) (g 2 '(#t #f)) (cons (zero? 2) ' ( #t #f)) '(#f #t #f)
(zero? carL) reste) )
'()
(cdr ' ( 0 3 ) ) ) )
(zero? carL) reste) ) (zero? carL) reste) ) (zero? carL) reste))
Chapitre 6. Modèle par substitution
156
La clé de cette évaluation est de bien voir que g est une fonction locale qui, à sa création (lorsque map est invoquée), a capturé la valeur de f à savoir zero?.
5 Récursion et portée globale Lorsqu'une variable est globale, elle est visible partout : après sa définition, dans sa définition (sauflorsque masquée), avant même sa définition! C'est ce qu'illustrent les définitions suivantes (reprises de l'exercice 24) : ; ; ; pos-impaires: USTE[alpha]--> USTE[alpha] ; ; ; (pos-impaires L) rend la liste des éléments en position impaire ; ; ; dans la liste «L» (le premier élément a la position 0) (define (pas-impaires L) (if (pair? L) (pas-paires (cdr L)) (list))) ; ; ; pos-paires: USTE[alpha]--> USTE[alpha] ; ; ; (pos-paires L) rend la liste des éléments en position paire ; ; ; dans la liste «L» (le premier élément a la position 0) (define (pas-paires L) (if (pair? L) (cons (car L) (pas- impaires ( cdr L) ) ) (list)))
On y voit que ces fonctions sont en récursion mutuelle : l'une appelle l'autre et réciproquement. On y voit également que pas-paires est référencée avant même sa définition.
6 Récursion et portée locale La définition suivante de la factorielle est précautionneuse car elle teste la nature de son argument ce qu'atteste sa signature. ; ; ; fact: Valeur --->Nombre ; ; ; (fact v) rend la factorielle de v. ; ; ; ERREUR si v n'est pas un entier. (define (fact v) ; ; factnum: Nombre ---> Nombre ; ; (factnum n) rend la factorielle den. (define (factnum n) (if (> n 0) (* n (factnum (- n 1))) 1 )
)
(if (integer? v) (factnum v) (error 'fact "Pas un entier" v) ) )
Une définition interne a pour portée le corps dans lequel elle apparaît. La portée de la fonction factnum est donc le corps entier de la fonction fact.
Pour en savoir plus
157
La portée des définitions internes permet la mise en récursion mutuelle de plusieurs fonctions internes. Ainsi la définition suivante introduit-elle deux prédicats internes qui sont en récursion mutuelle : est-pair? utilise est-impair? et vice-versa. Cette récursion mutuelle est possible car les portées de est-pair? et de est-impair? sont identiques à celle de i et englobe donc les définitions de ces deux prédicats. Noter que si ces portées n'étaient pas telles, les deux prédicats ne sauraient se référencer l'un l'autre (dans certains langages, le second prédicat pourrait utiliser le premier mais le premier, ne voyant pas le second, ne pourrait l'utiliser !). ; ; ; nombre-pair?: nat ---> bool ; ; ; (nombre-pair? i) vérifie que « i » est pair. (define (nombre-pair? i) ; ; est-pair?: nat---> bool ; ; (est-pair? n) vérifie que« n »est pair. (define (est-pair? n) (or (= n 0) (est-impair? (- n 1)) ) ) ; ; est-impair?: nat---> bool ; ; (est-impair? n) vérifie que« n »est impair. (define (est-impair? n) (and (> n 0) (est-pair? (- n 1)) ) )
(est-pair? i)
)
Les définitions internes ont pour portée le corps dans lequel elles sont définies et c'est pourquoi les définitions internes ne peuvent apparaître que dans des corps : corps de définition ou corps de bloc local.
7 Pour en savoir plus La portée est une notion essentielle. Elle peut paraiÙ'e simpliste, elle est néanmoins primordiale pour la compréhension de tous les langages de programmation et de tous les systèmes de modules où l'on combine des fragments de programmes indépendamment développés. La programmation à grande échelle (plusieurs centaines de milliers de lignes de programmes) repose en grande partie sur le typage et la mail:rise des portées. Nous n'avons présenté et utilisé dans cet ouvrage que des portées globales ou locales (on dit également « lexicales »). Les portées locales sont introduites par les formes spéciales 1 et (ou bloc local) et de fine pour la définition de fonctions. D'autres portées existent également dans d'autres langages de programmation. Nous allons illustrer différents types de portées dans divers langages tels que PHP, sh et Perl. Ces exemples illustrent des portées possibles mais ne sont pas nécessairement représentatifs des meilleurs styles de programmation possibles dans ces langages.
7.1
PHP
Prenons un exemple à la mode, PHP, dans lequel nous avons écrit ce fragment de programme pour calculer le volume d'un cylindre :
111 11
f~t
de. l'Ill'
function volum"-c:ylilldra ($r, $hl (
retur.n $h •
eire_dia~e($rlt
1
tunction aire_4iaque ($ri { globù $pi;
retur.n $pi • Sr • sr, 1
N'eD!mlls pa~ dans lN cMtrl• ~ua- impœlliilU COIIIDIII: - fonrtiou et11011-foDctioDa (ooocm'u vaàable& ec l'HP) 11011111(par6ea; - lei variable~ SOllt pœm.cu par Ill à p dollar maïa pu 1111 ~"' linn•; - lei fllllctioua 10111 Mflnjs par IIIIIIOt-elef function; - Je 1151alw da fJOIIc1icms at slp•'!ll par retuzn. La ponte d'Bile fOZ!CÔQ!J dE!Iuje par l:unction est globale (c'est polll'qJJOi la fonct!oD aire_disqlle peut en 1!dl! 61 daiiS volume_cylindr:e 18111 prob»me et avW mer .. d'avoir~ d81nie). Ce n'estpourlllttpas le cas cie la \'lll'lable s'obtle pi qui, pœrêlre nt!l!•!e daDa la f0Jic1i01l aire,_CÜIIqlle doit J fue s!IJ!aJ6f> M@oelfllllt globale Meel'op!rateur gloœl.
P&tiDWII UD aR & 6 ••q•le avec aa ''"PP de omnmandeas sh (ptODODI:tTZ Wl/), qui Olt \ID laq:qe IPfcialiM (CGtœ IUirel) danJJo ]euwuMf cJo JWilih 11 iliN ct Jcrur coaliGie~ L'e• •tt• e1t ilhiJ1z6 awla çç«aœ d'!!am lllli9anlti. La 'VIriablti PI till d!!finïe dlœ 1lll8 M•i&•o Wlelllllo 11116valualeur do sh pjk:e ll'ÏD14111C!ÎCD export PI~3 .141ti, piiÏI11110 II01MDo &•!81te tilt llŒ6e (oomm•nde xterm) l qui l'œ clemando de lmœr un 1101ml 6vaJualnr de sb. On CCIDitltO. daJui co demier ll'ai.de de l'illllnlclion echo $PI, que la 'f'lrill)le PI at bUa vilihle. La port!lle de la 'flliable &pOtiN PI aem"lo dCIIIc s'6tledre art progr•mme.s ec, dewsi,&•....,t.l œœt que ceu
bambou$ echo $Pl 3,1416 bambou$ 0
1
ED.lt19111Cho, li l'on~- llllr la f.etre jnjtjaJe l pachD et que l'on nuljfi.& la wl.eur do PI avec PI=2. 78. oa CO"etMe, cm ftiYeCill1 sur la re..,_ de clml.œ et cm pdermmd•m la ftleur do PI, que la l'Uiable PI 11'1 pu ~ modjfl6e T.~ /iMJhiJ.:.d
export Pl=3,1416 xterlllS.
[2] 8415
1~:::~~~! 01=2.78
echo $Pl
'
111 Si OD modifie l DI.JIM:&U la m•I!Je Pl: &WC export l'I....agique c:t que l'OD reloumc demchef -la fal!tre de clroiœ, OD OOI!Ibte DOD moÙII dacclld que la wriable PI n'a pu ~ ...Miffi6e On a doŒ 1IDI: pmvr: que œ a'm pu la !:~!!me l'llliablc dontœ pule dam ks deux li • '!tes 1llD fiù.t, les 'YIIriabks espmt6:& IDD1 copiées el DOD pu~B&éa.
7.3 Perl ~pu 1111 dm!er temple, eu Perl.lllumaltt la llllliOD de « pclllie dJDIII!lque "· Lel.foDclioDa som délhdee pu le mot-del IN]) (pour~), .ëU:c l"'""" (COIIhll"" en ah) d'ob!enir le prr.m!r:t cleurglUIII'.ZIIS. La variab'•• 1011t pn'fb' par \Ill clolhr <Wmlms
eaœœ mali cJe faÇOil cryp1lque le volume d'lill cyJIDdœ : .W. faobar (
local $p1 • 3.1,161 ret~ aire_diaquel221; )
.W. ai:re_dillque ( lfM $r ~ .uhift: ret~ $pi • $.: • $r: ) ~iDt
fool:>ar; Comme ~ l'illdiq= pu le moklcf lcx:.l. danlla follelioD foobtar,la 'YIIriable pi '\'OÎt aa pwtfe •npnœtfe llml& ks c:alcula qui 1'e1fcclw:roat laD1 que la fmll:lion faabar D'am& pu lemlial6:. Celle pcitâ: e.t qna!ifite de« clyuamiqœ"' par oppocitiODIWlt portta J~çal...,. Aimi la œrtm~~:e l p! clms la !oD:IiOil aire_disque ck'é&n' t-dk la yariWJc pi (UDtfe $pi) claua foobtar 1ede pOfll"mmatjon est dangcnueo car la fi .. i:li IJI! aire_disque 110 peut etœ ~tillvoq1lt.cpiÎJql&'illui.fut III!C milhkpi ct que rien daDa .. clt4Diliœ D.'mdiqno cO peut eo trouver œue 'YIIriahJe : eDo doit tiper, _ , peiuo d'~ ela. le Ce style D'Mt plua Ulilill! mi"KI cloplia la 'YIDioD5 lill profit dM pmi S 1'1MïcaJ•
n aÎlllc - ,III1IDde dinailli de pei'' , , wmpiwdn: œtœ ,ne;nn pour œiDlllllzi- ka popDaQ elit ulilc pl1ll' dm!lj!a D'importe quell""&'&ge de piijp*mmaljcm La pmtée ]r:D. Û 1:81 la plwl ;juqJ.: et celle "WDIIiqiiC:& ll:ndent la plnpert des lmpp ID fil de Jeum ré..mcm I'JCœiiÏYeL
160
Chapitre 6. Modèle par substitution
8 Conclusion Le modèle par substitution est d'une simplicité désarmante et pourtant il fonde le modèle d'exécution de Scheme et de nombre d'autres langages de programmation. Ce modèle est général mais s'accompagne de règles particulières correspondant aux formes spéciales (define, if, let) et aux fonctions prédéfinies (comme +, cons, equal ?, etc.). Dans les faits, il constitue une excellente spécification de ce qu'est un évaluateur de Scheme à charge pour cet évaluateur de respecter cette spécification tout en étant le plus efficace possible. Une telle implantation fait l'objet du chapitre 10.
9 Exercices corrigés 9.1
Énoncés
Exercice 31- Arcane Soit la définition suivante : (define (arcane v) (define (f g n) (if (> n 0)
(* n 1 ) (f
f v)
(g g
(- n
1)))
)
)
Question 1 - Quelles sont les variables ? Question 2 - Quelles sont les variables globales ? Question 3- Quel est l'environnement présent lors de la multiplication apparaissant en quatrième ligne de la définition d'arcane? Question 4 - Quelle est la portée de v, n, f, g ? Question 5- Que vaut (arcane
9.2
4)
?
Corrigés
Exercice 31 -Arcane Solution de la question 1 - Les variables sont v, f, g, n, >, * et - ainsi que arcane qui fait l'objet de la définition. Solution de la question 2- Les variables globales sont >, *, - ainsi que arcane. Solution de la question 3 - Autour de la multiplication sont visibles, outre les variables globales, les variables v, f, g et n. Solution de la question 4 - La variable v a pour portée le corps de la fonction arcane tout comme la fonction interne f. Les variables n et g ont pour portée le corps de la fonction interne f. Solution de la question 5 soit 24.
L'expression (arcane 4) a pour valeur la factorielle de 4
Chapitre 7
Structuration des données Un programme informatique étant un traitement automatique sur des données, nous avons abordé dans les chapitres précédents des connaissances qui portent à la fois sur le traitement automatique et sur les données. Pour le traitement automatique nous avons d'abord fourni les outils de base en Scheme puis montré un mécanisme puissant, la récursion, permettant d'exprimer la répétition des opérations dans un programme et enfin détaillé un modèle du processus d'évaluation d'une expression Scheme, le modèle par substitution. Pour les données 1 sur lesquelles s'effectue le traitement automatique, nous avons d'abord présenté les types de bases des données en Scheme pour ensuite travailler sur un type de données structurées, constituées de plusieurs informations, le type LISTE [al, le type des listes homogènes d'éléments. Dans ce chapitre, nous approfondissons tout d'abord la notion de structure de données avec ses constructeurs, ses accesseurs et ses reconnaisseurs pour, ensuite, présenter des structures de données de Scheme : les listes, les vecteurs et, incluant ces deux dernières, les S-expressions. Ensuite nous proposons des règles de programmation pour construire sa propre structure de données, c'est-à-dire pour définir et implanter la barrière d'abstraction qui lui est associée et, ainsi, pouvoir effectivement utiliser cette nouvelle structure de données. Ainsi, qu'il s'agisse de traitement ou de données, le processus est similaire : avoir à sa disposition des éléments de base (outils de base ou types de base), des possibilités de composition (fonction ou structure de données) et de nommage de ces compositions (afin de ne plus avoir à revenir sur leur définition) pour, chaque fois, construire les concepts, les objets, les mots permettant de mieux exprimer les problèmes que l'on souhaite résoudre.
1 Structures de données en Scheme Rappelons tout d'abord qu'un type est un ensemble de valeurs associé à des opérations que l'on effectue sur ces valeurs. Les types qui ont été vus dans les chapitres précédents sont nat, int, float, Nombre (qui contient les types précédents), string, boel et symbol. 1
Attention : les données peuvent êtte aussi des résultats.
162
Chapitre 7. Structuration des données
Pour utiliser une donnée de base dans une expression Scheme, il n'est pas besoin de passer explicitement par une fonction constructeur; il suffit d'écrire la dounée en respectant la syntaxe d'écriture de son type (par exemple, si l'on écrit 13 dans une expression, il y aura alors transformation du texte 13 en une valeur 13 de type Nombre). Mais l'on peut aussi obtenir une donnée de base en passant par une opération. Par exemple, dans l'application {+ 13 12}, il y a transformation du texte 13 en une valeur 13 de type Nombre, transformation du texte 12 en une valeur 12 de type Nombre et l'opération+« construit» le nombre 25. ll y a aussi des traductions moins triviales comme {or #t #f}.
1.1
Structure de données et barrière d'abstraction
Nous avons déjà abordé au chapitre 4 la notion de structure de données : lorsque l'on souhaite manipuler un agrégat d'informations formant un tout, on doit pouvoir construire cet agrégat constitué de plusieurs informations, le manipuler comme un tout et retrouver les différentes informations qui le composent. On dit alors que l'on crée une structure de données (ou type de données structurées). On utilise par la suite indifféremment agrégat ou donnée structurée. Une structure de données est un ensemble d'agrégats manipulables au travers d'une barrière d'abstraction, ensemble de fonctions de base de la structure de données comportant essentiellement des constructeurs, des accesseurs et des reconnaisseurs : - un constructeur permet de fabriquer un agrégat, une donnée structurée ; le type du résultat est donc la structure de données ; une barrière d'abstraction possède au minimum un constructeur mais il peut y en avoir plusieurs, qui peuvent alors construire des agrégats de sous-types différents ; - un accesseur permet de retrouver une information présente dans une donnée structurée, sous réserve que la donnée contienne une telle information ou qu'on puisse la déduire ; le type de l'argument d'un accesseur est la structure de données ; il y a autant d'accesseurs que d'informations constituant les agrégats; - un re connaisseur est un prédicat, son résultat est donc un booléen, le type de son argument est la structure de données ; il permet, en général, de savoir si la valeur passée en argument a été construite par le constructeur dont le nom est évoqué par celui du reconnaisseur (par exemple, {vector? v} indique si oui ou non v est un vecteur); dans une barrière d'abstraction, il doit exister, au minimum, un nombre de reconnaisseurs égal au nombre de sous-types créés par les constructeurs, moins un. La barrière d'abstraction peut aussi contenir quelques fonctions supplémentaires pour tester les nouvelles fonctions utilisant la barrière d'abstraction (fonction d'affichage), pour augmenter l'efficacité des traitements ou encore, tout simplement, pour faciliter la manipulation des données de la structure. ll existe des structures de données de base en Scheme : celles que nous présenterons dans ce chapitre sont les listes, les vecteurs et les S-expressions (qui inclut les listes et les vecteurs). L'implantation en Scheme d'une nouvelle barrière d'abstraction se fera à l'aide d'une ou de ces deux structures Scheme (les vecteurs et les listes) ou à l'aide d'une autre barrière d'abstraction déjà implantée. In fine, toutes les nouvelles structures de données sont implantées avec cette structure de données de base en Scheme, les S-expressions.
Structures de données en Scheme
163
1.2 Liste en Scherne Au chapitre 4, nous avons introduit la structure de données LISTE [a], liste homogène (tous les éléments de la liste sont de type a). Effectivement, dans un premier temps, pour des raisons pédagogiques, nous ne désirions manipuler que des listes homogènes (liste de nombres, liste de chaînes de caractères, liste de symboles ... ). Considérons maintenant l'expression Scheme, (- ( + (- 9 5 l 5 l ( + 1 o 3 l l . Cela ressemble à une liste (de trois éléments), le premier élément étant -, le second élément étant (+ (- 95) 5) et le dernier élément étant (+ 10 3 l ; à nouveau, ( + (- 95 l 5) ressemble à une liste de trois éléments, le premier élément étant +, le second élément étant (- 9 5 l et le dernier élément étant 5 ; à nouveau, (- 9 5 l ressemble à une liste de deux éléments ... Mais ce ne sont pas des listes de type LISTE [a] puisque les éléments ne sont pas de même type. Cependant, si l'on considère le type valeur comme ensemble des données calculables en Scheme, l'expression que nous venons d'étudier est bien une liste homogène de type LISTE [Valeur].
1.2.1 Spécification des fonctions primitives des listes Les fonctions de base, des primitives de Scheme, qui permettent de manipuler les listes, sont en fait les fonctions que nous avons déjà vues dans le chapitre 4, mais les signatures que nous avions alors données étaient plus restrictives puisque nous ne travaillions que sur des listes homogènes dont les éléments étaient des types de base. Nous retrouvons donc ici ces fonctions avec des signatures plus complètes : - les constructeurs cons et list: ; ; ; cons: Valeur* LISTE[Valeur]---> LISTE[Valeur] ; ; ; (cons v L) crée une nouvelle liste telle que son car est «V» et son cdr est «L» ; ; ; list: Valeur * ... ---> LISTE[Valeur1 ; ; ; (list v... ) crée une liste dont les éléments sont les arguments. (list) rend la liste vide - les accesseurs car et cdr ; ; ; ; car: LISTE[Valeur1 ---> Valeur ; ; ; (carL) rend le premier élément de la liste «L» ; ; ; ERREUR lorsque «L» ne satisfait pas pair? ; ; ; cdr: LISTE[Valeur1 ---> LISTE[Valeur1 ; ; ; (cdr L) rend la liste formée de tous les éléments de «L» sauf son premier. ; ; ; ERREUR lorsque «L» ne satisfait pas pair? - le reconnaisseur pair? permet de savoir si une donnée contient ou non un car et un cdr et donc permet de faire, pour une liste donnée, la partition entre liste non vide et
liste vide; ; ; ; pair?: Valeur---> booZ ; ; ; (pair? v) vérifie que «V» a un car et un cdr
1.3 Vecteur en Scherne Les listes, que nous venons de voir, permettent de rassembler plusieurs valeurs avec la possibilité d'extraire immédiatement la première valeur (en utilisant car) ; mais si 1' on veut
164
Chapitre 7. Structuration des données
extraire le ième élément de la liste (i étant variable), on doit écrire une fonction qui parcourt la liste (en utilisant la fonction cdr) :le coût est linéaire, il augmente avec i. Cette fonction se nomme list-ref et par exemple: (1ist-ref '(10 20 30 40) 3)
-+
40
vecteurs2
Les sont des structures de dounées Scheme qui permettent d'agréger plusieurs valeurs -les composants du vecteur- avec la possibilité d'extraire en temps constant l'une quelconque de ces valeurs spécifiée par son numéro d'ordre. Un vecteur est caractérisé par une longueur, des indices, et des composants : - la longueur du vecteur est le nombre de ses composants et elle ne peut pas varier une fois le vecteur créé ; - ses composants sont indicés par les entiers naturels compris entre 0 (inclus) et sa longueur (exclue) ; - le premier composant du vecteur est celui d'indice 0, le deuxième composant est celui d'indice 1... et le dernier composant est celui ayant comme indice la longueur du vecteur moins un ; chaque composant peut être de type quelconque. Noter qu'il existe un vecteur particulier, le vecteur vide, dont la longueur est nulle et qui ne contient aucun composant. Remarque: l'affichage des vecteurs n'est pas normalisé en Scheme. Ainsi sous DrScheme, l'affichage de la valeur d'un vecteur commence par le caractère 41, continue par la longueur du vecteur et se termine par, entre parenthèses, la liste des valeurs des composants du vecteur. Ainsi 413 ( 1 o 2 o 3 oJ correspond à l'affichage du vecteur contenant trois composants de type Nombre : 10, 20 et 30. Mais sous d'autres interprètes comme sous Bigloo (sous lequel sont effectuées, dans ce livre, les sorties des évaluations), on obtient, pour le même vecteur, l'affichage 41 ( 1 0 2 0 3 0 ) , sans l'affichage de la longueur. Les deux syntaxes sont permises en lecture.
1.3.1 Spécification des fonctions primitives des vecteurs Les fonctions de base qui permettent de manipuler les vecteurs, sont des primitives de Scheme et l'on y retrouve, entre autres, un constructeur et des accesseurs. ~
Constructeur
La fonction vector, qui peut avoir un nombre quelconque d'arguments, rend un vecteur dont les composants sont ses arguments, dans l'ordre où ils sont donnés; nous noterons vecteur le type correspondant et VECTEUR [a] un vecteur dont tous les composants sont de type a: ; ; ; vector : Valeur * ... -+ Vecteur ; ; ; (vector v ... ) rend le vecteur dont les composants sont les arguments ; ; ; (vector) rend le vecteur vide, de longueur 0 et qui ne contient aucune valeur
Exemples: (vector 3 4 5) -+ 41(3 4 5) (vector (+ 3 5) 4) -+ 41(8 4) (vector 'a 'b 'c) -+ 41(a b c) Et voici un exemple de citation d'un vecteur : '41(a b cl -+ 41(a b cl 2 0n parle
de tableaux dans d'autres langages de programmation (avec une nuance que nous ne verrons pas ici).
Structures de données en Scheme ~
165
Accesseurs
La fonction vector-length permet d'accéder directement à la longueur d'un vecteur. On a vu, d'autre part, que la caractéristique d'un vecteur était de pouvoir accéder immédiatement à un composant d'un indice donné. Ceci se fait grâce à la fonction vector-ref. ; ; ; vector-length : Vecteur---> nat ; ; ; (vector-length V) rend la longueur de «V», c'est-à-dire son nombre de composants ; ; ; vector-ref: Vecteur *nat ---> Valeur ; ; ; (vector-refV k) rend le composant d'indice «k» du vecteur «V» ; ; ; ERREUR lorsque «k» n'appartient pas à l'intervalle [0 .. (vector-length V)[
Exemples: (vector-length (vector 'a (vector-ref (vector 'a 'b (vector-ref (vector 'a 'b Mais(vector-ref (vector (vector-ref: index 3 out
'b 'c) 'c) 'a of
'c))
---> 3
a ---> c 'b 'cl 3)renduneerreur: range [0, 2] for vector: #(abc)) 0) 2)
--->
1.4 Fonctions de conversion Les listes et les vecteurs représentent, les unes et les autres, des séquences d'éléments, mais leurs fonctions de base respectives sont très différentes. Aussi existe-t-il deux fonctions, vector->list et list->vector, qui font passer d'une structure de données à l'autre: ; ; ; vector->list: "'cteur--+ LISTE[Valeur] ; ; ; (vector->list V) rend la liste des composants du vecteur «V» ; ; ; list->vector: LISTE[Valeur]-> Vecteur ; ; ; (list->vector L) rend le vecteur ayant comme premier composant le premier ; ; ; élément de la liste «L», comme deuxième compasant le deuxième élément de «L»...
Exemples: (vector->list (vector 'a 'b 'c)) ---> (list->vector '(abc)) ---> #(a b c)
(a b c)
1.5 S-expression en Scheme Toutes les expressions Scheme sont en fait des S-expressions (« S » signifiant « Symbolic » et non « Scheme » ; les S-expressions ont été inventées en 1958 par Mc Carthy pour le langage Lisp, prédécesseur de Scheme). Plus généralement tout programme Scheme est représenté par une S-expression. En fait, la S-expression est LA structure de données de Scheme, qui regroupe toutes les structures de données et types de données de Scheme.
1.5.1 Définition Une S-expression est soit une liste de S-expressions soit un atome. Les S-expressions ont été inventées pour manipuler les listes (et un atome, par opposition aux listes, a été défini historiquement comme insécable par car et cdr) et c'est pour cette raison que souvent, par l'abus de langage, on parle deS-expressions même lorsqu'il s'agit uniquement de listes.
166
Chapitre 7. Structuration des données
Formellement, on définit les S-expressions par les règles syntaxiques suivantes : <S-expression> ---> { <S-expression> *
--->
NOMBRE CHAÎNE SYMBOLE
<J;ecteur>
--->
#t #f
# { <S-expression> * NOMBRE, CHAÎNE, SYMBOLE désignent tous les nombres, chaînes de caractères et symboles de Scheme. Noter qu'uneS-expression peut donc se réduire à un atome; par exemple 3 ou #t sont des S-expressions. Noter aussi qu'un vecteur est un atome au sens où il est insécable par car et <J;ecteur>
--->
cdr.
1.5.2 Spécification des fonctions primitives des S-expressions Les primitives qui permettent de manipuler les S-expressions ont été en grande partie déjà expliquées en amont de cette section et, pour les constructeurs et les accesseurs, nous nous contentons juste de les récapituler ici. En revanche, nous développons plus la partie des reconnaisseurs qui permettent de choisir un traitement approprié à chaque type de données et structures de données que comportent les S-expressions. - les constructeurs : - pour construire des listes, les fonctions cons et list; - pour construire les atomes des types de base, il suffit de les écrire dans le texte d'une expression Scheme en respectant leur syntaxe d'écriture, comme nous l'avons déja vu; - pour construire un vecteur, la fonction vector. - les accesseurs : - pour les listes, les fonctions car et cdr ; - pour les vecteurs (seule donnée atomique composite), les fonctions vector-ref et vector-length.
- les reconnaisseurs : - 1 i s t? permet de savoir si une valeur est ou n'est pas une liste, ce qui rend ensuite possible un traitement spécifique pour les listes et un autre pour les atomes ; ; ; ; list?: Valeur---> bool ; ; ; (list? v) rend #t ssi «V» est une liste (éventuellement vide) - pair? permet de savoir si une donnée contient ou non un car et un cdr et donc permet de faire, pour une liste donnée, la partition entre liste non vide et liste vide ; ; ; ; pair?: Valeur ---> boo[ ; ; ; (pair? v) vérifie que «V» a un car et un cdr - il existe aussi au moins un reconnaisseur pour chaque type d'atome : numher?, integer?, boolean, string?, symbol? et vector? dont toutes les signatures
167
Structures de données en Scheme
sont : valeur --> bool ; ces reconnaisseurs indiquent si la valeur passée en argument est ou non un nombre, un entier, un booléen, une chaîne de caractères, un symbole ou un vecteur, ce qui permet ensuite de pouvoir appliquer à cette valeur, les fonctions et opérateurs propres à ce type. Nous schématisons les aiguillages que permettent les reconnaisseurs dans la structure de données des S-expressions dans le dessin suivant : (list? v)
#f
#t v est une liste
v est un atome (number? v) (integer? v)
(pair? v)
A
v est une liste
avec un car et
(boolean? v) (symbol? v)
(vector? v)
#t/\#f #t/\#f #t/\#f #t/\#f #t/\#f
v est la liste vide
uncdr
Mais, parce que la structure de pair? est plus large, on peut aussi se servir de l'aiguillage suivant, si besoin est : v est une valeur (pair? v)
A
v est la liste vide ou un atome
v est une liste non vide avec un car et uncdr
1.6
Cohérence de l'implantation d'une barrière d'abstraction
Avant de livrer une barrière d'abstraction à ses utilisateurs potentiels, il faut vérifier la cohérence entre l'implantation des constructeurs, reconnaisseurs et accesseurs. Cette cohérence obéit à quelques lois siruples : Cohérence contructeurslreconnaisseurs : toute valeur construite à l'aide d'un constructeur d'une barrière d'abstraction doit être reconnue par le reconnaisseur correspondant. Par exemple: (vector? (vector 4 5 6}} --> *T (list? (list)} --> #T (list? (list 12 3}} --> #T (pair? (cons 10 '(}}} --> #T
Cohérence constructeurs/accesseurs : toute valeur construite à l'aide d'un constructeur d'une barrière d'abstraction doit pouvoir être décomposée par les accesseurs correspondants. Par exemple : (car (cons 10 (cons 20 '(}}}} (cdr (cons 10 (cons 20 '()}}}
-
10 (20}
168
Chapitre 7. Structuration des données
2 Construire sa structure de données De la même façon qu'il est possible, pour répondre à un besoin de calcul particulier, de définir sa propre fonction, il est aussi possible de créer sa propre structure de données. Pour cela, on doit définir une barrière d'abstraction, ensemble de fonctions qui permettent de construire ses nouvelles données structurées et de les manipuler sans se soucier de la façon dont elles sont représentées. On s'abstrait ainsi, d'une part, de leur représentation à l'aide des structures de données sous-jacentes pré-existantes et, d'autre part, de la façon dont ces fonctions sont implantées.
2.1 Structure de données COUPLE Au chapitre 4, nous avons aussi introduit la structure de données COUPLE [a {31 pour désigner une séquence de deux éléments, le premier de type a et le second de type (3. Là encore, sans le dire alors, nous avons représenté COUPLE [a {31 à l'aide de la structure de données pré-existante des listes des S-expressions. Ainsi vous pouviez facilement, avec vos connaissances du moment, construire un couple à l'aide de list ou de cons et extraire chaque élément du couple à l'aide de car et de cadr. Connaissant maintenant les vecteurs, vous devez vous rendre compte qu'il est aussi possible, et tout aussi efficace, de représenter le couple à l'aide d'un vecteur, la construction se faisant à l'aide de la fonction vector et l'extraction des éléments du couple à l'aide de la fonction vector-ref.
2.2 Notion de NUPLET On peut généraliser la notion de COUPLE par la notion de NUPLET, notation pour désigner une structure de données qui comporte n éléments. On dit aussi qu'un n-uplet est un enregistrement, constitué de n champs, chacun étant d'un type bien défini. Nous noterons NUPLET [at a, . . . ak 1 la structure de données constituée de k éléments, dont le premier est de type a 1 , le deuxième de type a 2 et le kième de type ak. Un COUPLE [a (3] est en fait un NUPLET [a (3] . Par exemple, pour définir le type Identi teEtudiant regroupant les informations sur l'identité d'un étudiant, on peut utiliser un NUPLET [string string] regroupant son nom et son prénom sous forme de chaînes de caractères. Et pour définir le type FicheEtudiant permettant d'organiser les informations concernant un étudiant (son numéro de dossier, son identité et la liste de ses notes) on peut utiliser un NUPLET [nat IdentiteEtudiant LISTE [Note]], où Note est un nombre compris entre 0 et 20. Une autre structuration aurait pu être prise : ce qui nous intéresse ici c'est l'illustration pédagogique pour la construction d'une structure de données et non l'efficacité qui peut résulter de la structure choisie. Attention: il ne faut pas confondre les notions de LISTE et de NUPLET. Elles diffèrent par: - le nombre d'éléments: - deux listes de type LISTE [a] peuvent avoir des nombres d'éléments différents (et c'est pour cette raison, pour permettre un nombre quelconque de notes, que nous avons choisi le type LISTE [Note]);
Construire sa structure de données
169
- deux n-uplets de type NUPLET [ a1 a2 . . . ak 1 ont exactement le même nombre d'éléments à savoir k ; - le type des éléments : - tous les éléments d'une liste de type LISTE [a] sont de même type (à savoir a); - dans un NUPLET [ 0<1 a2 . . . ak l , les types a1, a2 ... ak des éléments sont en général différents ; - l'implantation : - un type LISTE [a] est implanté à l'aide des listes de Scheme; - un n-uplet de type NUPLET [ ... 1 n'est pas forcément implanté à l'aide de listes mais peut l'être à l'aide de vecteurs comme nous le verrons un peu plus loin.
2.3 Définition de la structure de données Identi teEtudiant Dans la suite de cette section, nous travaillerons sur le type nommé Iden ti teEtudiant puis sur FicheEtudiant permettant de regrouper des informations concernant un étudiant. Sur cet exemple nous illustrerons comment la barrière d'abstraction d'une structure de données permet de s'abstraire de sa représentation et de l'implantation de ces fonctions.
Jl
Pour chaque barrière d'abstraction, nous préfixerons chaque nom de fonction par l'abré~ viation de la barrière (ie pour Identi teEtudiant et fe pour FicheEtudiant) afin d'avoir toujours présent à l'esprit à quelle barrière d'abstraction appartient n'importe quelle fonction. 2.3.1 Spécification de la barrière d'abstraction Nous avons déjà vu que pour travailler sur une structure de données, il faut pouvoir construire une donnée structurée et retrouver les différentes informations présentes dans cette donnée. Ce rôle est celui des constructeurs et des accesseurs de la barrière d'abstraction. - Constructeur de Identi teEtudiant: on doit pouvoir construire une identité à partir de la donnée d'un nom et d'un prénom : ; ; ; ie-identite : string *string --+ JdentiteEtudiant ; ; ; (ie-identite nom prenom) construit l'identité d'un étudiant formée ; ; ; de «nom» et de «prénom»
Remarque : les chaînes de caractères, plutôt que les symboles, ont été prises pour représenter le nom et le prénom, afin d'autoriser les noms composés (un symbole ne peut comporter d'espace). - Accesseurs de IdentiteEtudiant :on doit pouvoir accéder à chacun des éléments (extraire la valeur d'un champ) de IdentiteEtudiant. On définit donc deux fonctions qui extraient respectivement le nom et le prénom. - Accès au nom : ; ; ; ie-nom : JdentiteEtudiant --+ string ; ; ; (ie-nom id) rend le nom de «id»
- Accès au prénom : ; ; ; ie-prenom : IdentiteEtudiant --+ string ; ; ; (ie-prenom id) rend le prénom de «id» L'ensemble des trois fonctions (ie-identite, ie-nom et ie-prenom), dont on ne
connaît pour l'instant que les spécifications, forment la barrière d'abstraction de la structure de données Identi teEtudiant.
170
Chapitre 7. Structuration des données
2.3.2 Implantation de la barrière d'abstraction Nous allons donner deux implantations différentes de la barrière d'abstraction du type Identi teEtudiant, c'est-à-dire deux séries d'implantations pour ses trois fonctions de base. Implantation de IdentiteEtudiant avec des listes: Si l'on utilise les listes pour représenter la structure Identi teEtudiant, nous obtenons une première série de définitions : (define (ie-identite nom prenom) (list nom prenom)) (define (ie-nom id) (car id)) (define (ie-prenom id) (cadr id)) Implantation de IdentiteEtudiant avec des vecteurs: Si l'on utilise les vecteurs pour représenter le type Identi teEtudiant, nous obtenons
une deuxième série de définitions : (define (ie-identite nom prenom) (vector nom prenom)) (define (ie-nom id) (vector-ref id 0)) (define (ie-prenom id) (vector-ref id 1))
Noter que dans l'implantation, tant par les listes que par les vecteurs, nous avons fait le choix arbitraire de placer le nom avant le prénom. Ces choix arbitraires sont très fréquents dans la construction de structure de données et il est indispensable d'en garder la trace pour faciliter la maintenance.
2.3.3 Ne pas franchir une barrière Le schéma suivant représente la barrière d'abstraction de IdentiteEtudiant et ses trois fonctions :
ie-identite
~
~
ie-nom
~
ie-prenom
UTILISATION
Construire sa structure de données
171
La barrière, dont il est question, délimite« l'espace» entre - l'utilisation des fonctions (on a besoin, pour les utiliser, de ne connaître que leur spécification), - l'implantation de ces fonctions. TI est d'un intérêt fondamental en génie logiciel de permettre une modification ultérieure de la représentation sous-jacente de la structure de données et donc une modification de l'implantation d'une fonction l'utilisant. Cette modification doit non seulement être possible, mais doit se faire, aussi, en limitant la propagation en cascade des modifications. Ainsi, côté utilisateur, est-il indispensable (il faudrait se l'imposer) d'utiliser les fonctions fournies sans utiliser la connaissance des détails que l'on pourrait avoir de leur implantation. Ainsi, dans l'exemple de Iden ti teEtudian t, nous voulons définir une nouvelle fonction identite->chaine qui, étant donné l'identité d'un étudiant, rend une chaîne de caractères constitué de son nom, suivi d'un espace et du prénom, avec la spécification suivante : ; ; ; identite->chaine: ldentiteEtudiant --t string ; ; ; (identite->chaine id) rend le nom de «id» suivi d'une espace et du prénom de «id» Pour écrire la définition de identite->chaine nous pouvons être tentés d'écrire, si
nous savons que l'implantation est faite à l'aide des listes : (define (identite->chaine id) (string-append (car id) " " (cadr id))) Si nous changeons l'implantation de IdentiteEtudiant et utilisons plutôt des vecteurs, nous serons obligés de modifier non seulement les trois fonctions (ie-identi te, ie-nom et ie-prenom), mais aussi la définition de identite->chaine, alors qu'en uti-
lisant uniquement les fonctions de la barrière d'abstraction, sans franchir la barrière, la définition de identi te->chaine qui suit, est valable quelle que soit l'implantation du type : IdentiteEtudiant: (define (identite->chaine id) (string-append (ie-nom id) " " (ie-prenom id)))
2.3.4 Affichage d'une donnée structurée En fait, le besoin de créer la fonction identite->chaine vient du besoin d'afficher une donnée structurée. En effet, pour pouvoir tester des fonctions utilisant une structure de données, pour pouvoir mettre au point (« déboguer ») de telles fonctions il est très utile de visualiser (même sommairement du point de vue de l'esthétisme mais très explicitement du point de vue du contenu) les données de la structure. Ainsi est-il préférable de prévoir dans la barrière d'abstraction une ou des fonctions d'affichage, qui ont pour objectif la visualisation d'une donnée, et doivent, bien sûr, être indépendantes de l'implantation de la structure. Par exemple: ; ; ; nl : Rien --> string ; ; ; (nl) rend une chaîne contenant un retour à la ligne (define (nl)
") ; ; ; tab : Rien --> string ; ; ; (tab) rend une chaîne contenant une tabulation (define (tab) ")
172
Chapitre 7. Structuration des données
; ; ; ie-affichage : ldentiteEtudiant --+ string ; ; ; (ie-affichage id) affiche l'identité «id» (define (ie-affichage id) (string-append "Identité : " (nl) (tab) "nom : " (ie-nom id) (nl) (tab) "prénom : " (ie-prenom id))) Les fonctions nl et tab sont deux fonctions utilitaires pour faire une nouvelle ligne et
une tabulation et qui, utilisées dans la fonction d'affichage, permettent de distinguer la mise en forme pour l'affichage et l'indentation propre de la fonction. Voici une application de ie-affichage : (ie-affichage (ie-identite "BORDEAUX" "Emilien"))--+ "Identité : nom : BORDEAUX prénom : Emilien ••
2.4 Définition de la structure de données FicheEtudiant Dans la partie qui suit, nous traitons de la barrière d'abstraction de la structure de données FicheEtudiant qui regroupe des informations concernant un étudiant. Nous ne nous préoccupons toujours pas des traitements automatiques que nous pourrions effectuer sur cette structure (comme le calcul de la moyenne d'un étudiant), ui des traitements automatiques que nous pourrions effectuer sur une base de données de type LISTE [Fi cheEtudiant 1 (comme la liste des noms des étudiants de la base de données, la moyenne globale des étudiants ou la liste des reçus). En revanche, nous allons nous intéresser, ici, à la structure de données elle-même. 2.4.1 Spécification de la barrière d'abstraction - Constructeur: on doit pouvoir construire une nouvelle fiche de type FicheEtudiant à partir du numéro de dossier et de l'identité de l'étudiant, et aussi ajouter une note à une fiche d'étudiant : ; ; ; fe-fiche : nat * IdentiteEtudiant --+ FicheEtudiant ; ; ; (fe-fiche nu id) construit la fiche d'un étudiant,formée d'un numéro ; ; ; de dossier «nu», d'une identité «id» et d'une liste vide de notes. ; ; ; fe-ajout-note : Note * FicheEtudiant--+ FicheEtudiant ; ; ; (fe-ajout-note x un-etudiant) ajoute la note «X» à la liste des notes de «Un-etudiant»
Par exemple, pour tester les défruitions de fonctions que nous écrirons par la suite, nous utiliserons les fonctions fiche-exemple et fiche-exemple-avec-notes : (define (fiche-exemple) (fe-fiche 23100 (ie-identite "BORDEAUX" "Emilien"))) (define (fiche-exemple-avec-notes) (fe-ajout-note 10 (fe-ajout-note 12 (fiche-exemple))))
Attention: c'est volontairement que nous n'avons pas mis, ici, le résultat de l'application de fe-fiche, ni celle de fe-ajout-note afin que n'apparaisse pas la structure de données sous-jacente utilisée (liste ou vecteur); nous visualiserons ce résultat dès
Construire sa structure de données
173
que nous aurons mis à notre disposition une fonction d'affichage d'une fiche dans la barrière d'abstraction. - Accesseurs: on doit pouvoir accéder à chacune des composantes de FicheEtudiant; on spécifie donc trois fonctions qui extraient respectivement les trois différentes informations présentes dans la dounée structurée (le numéro de dossier, l'identité et la liste de notes). - Accès au numéro de dossier : ; ; ; fe-numero-dossier : FicheEtudiant ---> nat ; ; ; (fe-numero-dossier un-etudiant) rend le numéro de dossier de «un-etudiant»
- Accès à l'identité : ; ; ; fe-identite : FicheEtudiant ---> IdentiteEtudiant ; ; ; (fe-identite un-etudiant) rend l'identité de «un-etudiant»
- Accès aux notes : ; ; ; fe-notes: FicheEtudiant---> USTE[Note] ; ; ; (fe-notes un-etudiant) rend la liste des notes de «un-etudiant»
2.4.2 Utilisation de la barrière d'abstraction Voici quelques exemples de l'utilisation des fonctions de la barrière d'abstraction de FicheEtudiant : (fe-numero-dossier (fiche-exemple)) ---> 23100 (ie-nom (fe-identite (fiche-exemple))) --->"BORDEAUX" (ie-prenom (fe-identite (fiche-exemple))) -+"Emilien" (fe-notes (fiche-exemple)) -+ () (fe-notes (fiche-exemple-avec-notes)) ---> (10 12) Et c'est encore volontairement que nous n'avons pas mis, ici, le résultat de l'application de fe-identite afin que n'apparaisse toujours pas la structure de dounées sous-jacente utilisée (liste ou vecteur). Mais nous pouvons le visualiser par : (ie-affichage (fe-identite (fiche-exemple)))-+ "Identité : nom : BORDEAUX prénom : Emilien" Nous disposons maintenant des fonctions nécessaires à la fonction d'affichage d'une fiche étudiante. Par exemple : ; ; ; fe-affichage : FicheEtudiant ---> string ; ; ; (fe-affichage un-etudiant) affiche la fiche de «un-etudiant» avec la liste de ses notes
(define (fe-affichage un-etudiant) (string-append (nl) "Fiche de l'étudiant :" (nl) (ie-affichage (fe-identite un-etudiant)) (nl) "numéro de dossier : " (nl) (tab) (->string (fe-numero-dossier un-etudiant)) (nl) nnotes :
Il
(nl)
(tab) (->string (fe-notes un-etudiant)))) La fonction ->string permet de construire une chaîne représentant l'argument (cf. la
carte de référence, page 327). Voici une application de fe-affichage :
174
Chapitre 7. Structuration des données
(fe-affichage (fiche-exemple-avec-notes))-+ " Fiche de l'étudiant Identité : nom : BORDEAUX prénom : Emilien numéro de dossier : 23100 notes : (1012)"
L'ensemble des quatre fonctions fe-fiche, fe-numero-dossier, fe-identite, fenotes, dont on ne conmuî: pour l'instant que les spécifications, auxquelles on ajoute les trois fonctions de la structure de données Identi teEtudiant, et les deux fonctions d'affichage ie-affichage et fe-affichage forment la barrière d'abstraction de la structure de données Fiche-etudiant. Avec ces fonctions, on devrait pouvoir définir des fonctions (pour l'ajout d'une suite de notes d'un étudiant, la moyenne d'un étudiant, la liste des noms des étudiants de la base de données, la moyenne globale des étudiants ou la liste des reçus ... ) sans avoir à se soucier de la façon dont sont implantées les sept fonctions de base. Que l'on prenne l'une quelconque des implantations que nous allons présenter dans la paragraphe suivant (ou d'autres encore), la moyenne globale d'une promotion ou la liste des reçus doivent être identiques!
2.4.3 Implantation de la barrière d'abstraction Nous allons donner trois implantations différentes des quatre fonctions de base de la barrière d'abstraction de la structure de données Fiche-etudiant. ~
Deux implantations différentes avec des listes de FicheEtudiant
Si l'on utilise les listes, pour représenter la structure FicheEtudiant nous obtenons une première série de définitions : (define (fe-fiche nu id) (list nu id ; la liste des notes est vide lors de la création d'une fiche
'
() ) )
(define (fe-ajout-note x un-etudiant) (list (car un-etudiant) (cadr un-etudiant) (cons x (caddr un-etudiant)))) (define (fe-numero-dossier un-etudiant) (car un-etudiant)) (define (fe-identite un-etudiant) (cadr un-etudiant)) (define (fe-notes un-etudiant) (caddr un-etudiant))
Construire sa structure de données
175
Noter que le caddr d'une telle fiche donne la liste de notes de l'étudiant, mais on aurait pu aussi faire en sorte que ce soit le cddr d'une fiche qui doune la liste de notes, par une autre implantation : (define (fe-fiche nu id) (list nu id)) (define (fe-ajout-note x un-etudiant) (cons (car un-etudiant) (cons (cadr un-etudiant) (cons x (cddr un-etudiant))))) (define (fe-numero-dossier un-etudiant) (car un-etudiant)) (define (fe-identite un-etudiant) (cadr un-etudiant)) (define (fe-notes un-etudiant) (cddr un-etudiant))
~
Implantation de FicheEtudiant avec des vecteurs
Si l'on utilise les vecteurs, pour représenter la structure FicheEtudiant, nous obtenons une deuxième série de définitions : (define (fe-fiche nu id) (vector nu id ' () ) ) (define (fe-ajout-note x un-etudiant) (vector (vector-ref un-etudiant 0) (vector-ref un-etudiant 1) (cons x (vector-ref un-etudiant 2)))) (define (fe-numero-dossier un-etudiant) (vector-ref un-etudiant 0)) (define (fe-identite un-etudiant) (vector-ref un-etudiant 1)) (define (fe-notes un-etudiant) (vector-ref un-etudiant 2))
Noter que le troisième composant du vecteur représentant une fiche étudiant est une liste représentant la liste de notes de l'étudiant.
2.4.4 Ne pas franchir la barrière L'expression- (fe-fiche 23100 (ie-identite BORDEAUX Emilien)) -dela définition de la fonction fiche-exemple dounée précédemment, est une application de la fonction fe-fiche. Les fonctions utilisées sont indiquées dans le schéma suivant:
176
Chapitre 7. Structuration des données
ie-identite
i!i
1 1:§
6
ie-nom
~
;.=
ie-prenom
UTIIJSATION (fe-fiche 23100 ie-identite BORDEAUX Emilien))
fe-fiche fe-ajout-note fe-identite fe-numero-dossier
fe-notes
Sur ce tout simple exemple, nous constatons que fiche-exemple peut être implanté de quatre manières différentes pour le choix des structures de données sous-jacentes (sans oublier les deux façons différentes d'implanter FicheEtudiant avec les listes) : - liste pour Identi teEtudiant et liste pour FicheEtudiant; - liste pour Identi teEtudiant et vecteur pour FicheEtudiant; - vecteur pour Identi teEtudiant et liste pour FicheEtudiant; - vecteur pour Identi teEtudiant et vecteur pour FicheEtudiant. Cela fait beaucoup et, comme chacune de ces possibilités a ses avantages, et ses inconvénients, il se peut que l'on soit amené à réviser le choix initial. Aussi, est-il impératif, pour maîtriser la fiabilité, la maintenance et l'évolution d'une structure de données de séparer, autant qu'il est possible, la spécification et l'implantation de la barrière d'abstraction associée en utilisant les fonctions fournies sans utiliser la connaissance que l'on peut avoir de leur implantation. C'est pour cette raison, que dans le chapitre suivant sur les arbres (page 193), lorsque le résultat d'une fonction est une donnée structurée de type arbre (arbre binaire ou arbre général), il y a uniquement l'affichage du texte lf afin d'imposer l'utilisation de cette donnée-résultat, et des informations qui la composent, en passant obligatoirement par les fonctions de la barrière d'abstraction. Si l'on désire uniquement visualiser cette donnée, il faut la passer en argument d'une fonction d'affichage.
Pour en savoir plus
177
3 Pour en savoir plus 3.1
Type Valeur
Les S-expressions sont une structure de données extrêmement adaptable. On les utilise en Scheme pour représenter à la fois les données et les programmes. Un programme est donc matériellement représenté par un texte (une suite de caractères) ayant la structure d'une Sexpression (donc bien parenthésée, nous parlons ici des listes) dont le sens est de décrire un processus de calcul (à l'intention de l'évaluateur). Que les programmes soient représentables par des donnés permet d'écrire des programmes qui engendrent des programmes (les fameuses macros de Scheme) ouvrant ainsi de nouvelles possibilité d'extensions linguistiques permettant d'adapter le langage aux problèmes traités. La définition des S-expressions (page 165) a été présentée sous la forme d'une grammaire régissant comment peuvent s'écrire de telles S-expressions. Les S-expressions sont toutefois plus riches que ne laisse l'entendre cette grammaire car il faut y ajouter les S-expressions qui peuvent être obtenues par calcul. Ce que l'on peut écrire n'est qu'un sous-ensemble strict de ce que l'on peut calculer. En d'autres termes, un élément de Valeur n'a pas nécessairement de représentation écrivable par le programmeur. L'expression (cons 'a (list)) a pour valeur (a) et est donc équivalente au programme (une citation) (quote (a)). L'expression (list car cdr) n'a pas de citation équivalente ! Sa valeur est une liste de deux fonctions que DrScheme affiche ainsi : > (list car cdr) (#<primitive:car> #<primitive:cdr>)
L'évaluateur DrScheme sait transcrire une valeur fonctionnelle telle que car en une suite de caractères indiquant sa nature mais cette graphie n'est pas réversible : vous ne pouvez la faire relire par Scheme. Les valeurs fonctionnelles ne peuvent être citées, elles ne peuvent qu'être calculées. La fonction cons a en fait (une fois de plus) une signature encore plus large qu'annoncé. Sa véritable signature est : ; ; ; cons: Valeur * Valeur ---> PairePoint ; ; ; (cons a d) crée une paire pointée dont le car est «a» et le cdr «d». Cette signature permet d'écrire (cons 2000 4) dont la valeur est la paire pointée combinant les arguments 2000 et 4. La paire pointée tire son nom de sa graphie (réversible car le programmeur peut l'utiliser) : ( 2 0 0 0 . 4) où la séquence blanc-point-blanc sépare les deux valeurs combinées. La paire pointée a été la structure de données historique au-dessus de laquelle ont été implantées les listes et S-expressions. On la trouve souvent évoquée dans les livres sur Lisp ou Scheme mais nous ne nous en servons aucunement dans tout cet ouvrage. Si d'aventure certains de vos programmes mènent à des valeurs contenant de telles paires pointées, vérifiez-les au plus vite, ils ne sont pas solutions de nos énoncés !
3.2 Qu'est-ce qu'une donnée? Dans le chapitre 5, nous avons vu que les fonctions Scheme pouvaient avoir des fonctions comme donnée (argument) ou comme résultat, ce qui relativise la distinction entre fonction et donnée. Mais nous avons tout de même gardé cette distinction en parlant de fonctionnelle, les fonctions données en argument étant vues comme des fonctions que la fonctionnelle applique à des données « normales ». Or cette distinction est encore plus ténue ! Par exemple, on
178
Chapitre 7. Structuration des données
peut implanter la barrière d'abstraction de IdentiteEtudiant, sans structure de données Scheme, en utilisant uniquement des fonctions : (define (ie-identite nom prenom) (define (ident rn) (cond ( (equal? rn 'nom) nom) ((equal? rn 'prenom) prenom) (else (erreur 'ie-identite "la fonctionnelle ne prend pas en compte" rn)))) ident) (define (ie-nom id) (id 'nom)) (define (ie-prenom id) (id 'prenom)) Cette implantation est bien correcte car, quelles que soient les valeurs de n et de p, (ie-nom (ie-identite np)) n (ie-prenom (ie-identite n p)) p En effet, pour la première équivalence (la seconde se démontre de la même façon) : - (ie-identite n p) rend une fonction qui rend n lorsque sa donnée est le symbole
==
nom,
- ie-nom est une fonction qui applique sa donnée, ici (ie-identite n p), au symbole nom. Ainsi, (ie-nom (ie-identite n p)) rend bien la valeur den. Remarque : cette implantation permet de rendre totalement étanche la barrière d'abstraction : (ie-identite "Toto" "Jean") --+ #
4 Exercices corrigés 4.1
Énoncés
Exercice 32- Fiche d'un étudiant La première partie de cet exercice est consacrée à la manipulation des barrières d' abstraction des types Identi teEtudiant et FicheEtudiant (définis dans la partie cours). Dans la deuxième partie, nous apporterons quelques modifications à ces types et nous étudierons les conséquences de ces modifications sur les barrières d'abstraction et les fonctions définies sur ces types. ~
Manipulation des barrières d'abstraction
Question 1- Définir une fonction sans paramètre, nommée f iche-abelin, qui renvoie une fiche pour l'étudiant Jean ABELIN, dont le numéro de dossier est 3670 et dont les notes sont 13 et 16. L'affichage de cette fiche produira :
(fe-affichage (fiche-abelin))--+ "
Fiche de l'étudiant :
179
Exercices corrigés Identité : nom : ABELIN prénom : Jean numéro de dossier 3670 notes : (13 16)"
Question 2- Écrire une définition de la fonction fe-efface-notes qui prend en argument une fiche et qui rend la fiche obtenue en supprimant toutes les notes de la fiche initiale. Question 3- Écrire une définition de la fonction fe-ajout-liste-notes qui prend en arguments une liste de notes et une fiche et qui rend la fiche obtenue en ajoutant la liste de nouvelles notes à la fiche initiale. Ainsi : (fe-notes (fe-ajout-liste-notes '(7 10 19) (7 10 19 13 16)
(fiche-abelin)) )->
Question 4- Écrire une définition de la fonction fe-fiche-avec-notes qui prend en arguments un numéro de dossier, une identité d'étudiant et une liste de notes et qui construit la fiche correspondante. Question 5- Écrire une définition de la fonction ie-modif-nom qui prend une identité et une chaîne de caractères nouveau-nom et qui rend l'identité obtenue en remplaçant le nom par nouveau-nom. Écrire une définition de la fonction fe-modif-nom qui prend une fiche et une chaîne de caractères nouveau-nom et qui rend la fiche obtenue en remplaçant le nom par nouveau-nom. Par exemple : (fe-affichage ( fe-modif-nom ( fiche-abelin) " Fiche de l'étudiant Identité : nom : ABELAIN prénom : Jean numéro de dossier 3670 notes : (13 16)" ~
"ABELAIN") ) ->
Modification des types
Question 6- Dans cette question, on enrichit le type Identi teEtudiant en lui ajoutant une information, le deuxième prénom de l'étudiant. Que faut-il modifier dans les barrières d'abstraction Identi teEtudiant et FicheEtudiant et dans les fonctions définies sur ces types?
Question 7- Dans cette question, on enrichit le type FicheEtudiant en lui ajoutant une information : la situation de l'étudiant (primant ou doublant). Que faut-il modifier dans les barrières d'abstraction et dans les fonctions définies sur les types Identi teEtudiant et FicheEtudiant?
180
Chapitre 7. Structuration des données
Exercice 33 - Multi-ensembles dénombrés Le but de cet exercice est de manipuler une structure de données au travers de sa barrière d'abstraction puis de proposer plusieurs implantations pour cette barrière d'abstraction. Nous appellerons ici multi-ensemble dénombré de type a, et nous noterons MED[a] le type ainsi obtenu, la donnée : - d'un multi-ensemble fini d'éléments de type a, c'est-à-dire un ensemble dans lequel un même élément peut apparaître plusieurs fois, - du nombre d'éléments de ce multi-ensemble, chaque élément étant compté autant de fois qu'il apparaît. La figure ci-contre montre un
multi-ensemble ayant sept éléments. Nous nommerons D 1 le multi-ensemble dénombré ainsi défini. ~
1
8
•
•
•
3
•
5 3
•8
13
•
•
Spécification de la barrière d'abstraction - Constructeurs : ils sont au nombre de deux. L'un permet de construire le multiensemble dénombré vide (i.e ayant zéro élément) : ; ; ; med-vide:--> MED[a] ; ; ; (med-vide) renvoie le mu/ti-ensemble dénombré ayant zéro élément
L'autre permet d'ajouter un élément à un multi-ensemble dénombré: ; ; ; med-ajout: a* MED[a]--> MED[a] ; ; ; (med-ajout xE) renvoie le mu/ti-ensemble dénombré obtenu en adjoignant ; ; ; l'élément «X» au mu/ti-ensemble dénombré «E»
- Accesseurs : ils sont au nombre de deux. L'un permet d'accéder au nombre d'éléments d'un multi-ensemble dénombré : ; ; ; med-nombre : MED[a] --> nat ; ; ; (med-nombre E) renvoie le nombre d'éléments de «E»
L'autre permet d'extraire un élément d'un multi-ensemble dénombréE, c'est-à-dire de calculer un couple formé d'un élément x choisi dansE et du multi-ensemble dénombré F obtenu lorsque l'on retire x de E med-extraction : MED[a] --> COUPLE[a MED[a]] (med-extraction E) renvoie un couple fonné d'un élément «X» clwisi dans «E» et du mu/ti-ensemble dénombré obtenu lorsque l'on retire «X» de «E» HYPaTHÈSE: «E» n'est pas vide Remarquer que la spécification de med-extraction ne précise pas comment est ; ; ; ;
; ; ; ;
; ; ; ;
choisi l'élément x. Si l'on veut faire un choix particulier, il faut définir un autre type, voisin de celui-ci, dans lequel la spécification de med-extraction explicite le choix (voir le dernier paragraphe de cet exercice). Aux deux accesseurs que nous avons définis pour le type MED [a] , il faut ajouter deux accesseurs pour le type COUPLE [a MED [al l , de façon à pouvoir accéder à l'élément choisi et au multi-ensemble dénombré obtenu lorsque l'on retire l'élément choisi : ; ; ; ;
; ; ; ;
; ; ; ;
couple-] : COUPLE[ 01. (3] --t 01. (couple-] C) renvoie le premier élément du couple «C» couple-2 : COUPLE[ 01. (3] --t (3 (couple-2 C) renvoie le deuxième élément du couple «C»
181
Exercices corrigés
- Reconnaisseurs: puisqu'il y a deux constructeurs, il doit y avoir (au moins) un reconnaisseur: ; ; ; med-vide?: MED[a]---> bool ; ; ; (med-vide? E) rerwoie vrai ssi le mu/ti-ensemble dénombré «E» est vide
Ce reconnaisseur peut être défini à l'aide des autres fonctions de la barrière d'abstraction : c'est l'objet de la première question. ~
Manipulation de la barrière d'abstraction
Question 1- Écrire une définition du reconnaisseur med-vide? qui reconmu"t si un multiensemble dénombré est vide. Question 2- Écrire une définition (récursive) de la fonction list->med qui, étant donnée une liste L, renvoie un multi-ensemble dénombré qui a les mêmes éléments que L (et dont le nombre d'éléments est égal à la longueur deL). Utiliser cette fonction pour définir la fonction med-Dl qui construit le multi-ensemble dénombré D 1 donné dans l'introduction de l'exercice. Question 3- Écrire une définition (récursive) de la fonction med->list qui, étant donné un multi-ensemble dénombréE, renvoie une liste qui a les mêmes éléments que E (et dont la longueur est égale au nombre d'éléments de E). Par exemple : (med->list (med-Dl)) ---> (8 53 1 3 8 13) Remarquer que la fonction med->list permet de visualiser les multi-ensembles dénombrés, ce qui n'était pas possible auparavant. Question 4- Écrire une définition de la fonction med-moyenne qui, étant donné un multiensemble dénombré non vide de nombres, renvoie la moyenne de ces nombres. Par exemple : (med-moyenne (med-Dl)) ---> 41/7
Question 5- Écrire une définition de la fonction med-union qui, étant donnés deux multiensembles dénombrés E 1 et E 2 , renvoie l'union de E 1 et E 2 , c'est-à-dire le multi-ensemble dénombré contenant tous les éléments de E1 et tous les éléments de E2 (avec éventuellement des répétitions). Par exemple, si on appelle med-D2 la fonction qui construit le multiensemble dénombré ayant pour éléments 3 et 19 : (med->list (med-union (med-Dl) (3 19 8 5 3 1 3 8 13)
(med-D2)))--->
Question 6- Écrire une définition de la fonction med-moities qui, étant donné un multiensemble dénombré E, renvoie un couple de multi-ensembles dénombrés E 1 et E 2 dont l'union estE et ayant le même nombre d'éléments, à une unité près. Ainsi, (med-moities (med-Dl) ) est un couple dont le premier élément est le multi-ensemble dénombré ayant pour éléments, par exemple, 8, 5 et 3, et dont le second élément est le multi-ensemble dénombré ayant pour éléments 1, 3, 8 et 13. ~
Implantations de la barrière d'abstraction n s, agit ici d, implanter, de plusieurs façons différentes, la barrière d, abstraction des multiensembles dénombrés.
Question 7- Donner une implantation des multi-ensembles dénombrés au moyen de couples dont le premier composant est un entier naturel donnant le nombre d'éléments du multiensemble et dont le second composant est une liste formée des éléments du multi-ensemble.
182
Chapitre 7. Structuration des données
Dans cette implantation, le multi-ensemble D 1 est représenté par le couple ayant pour éléments 7 et, par exemple, la liste ( 1 8 13 3 8 5 3 ) . Question 8- Donner une implantation des multi-ensembles dénombrés au moyen de listes dont le premier élément est un entier donnant le nombre d'éléments du multi-ensemble et dont les autres éléments sont les éléments du multi-ensemble. Dans cette implantation, le multiensemble D1 peut être représenté par la liste (7 3 8 1 5 13 8 3) (c'est volontairement que nous écrivons les éléments dans nn ordre différent de celui de la question précédente). Question 9 - Donner une implantation des multi-ensembles dénombrés au moyen de listes d'associations, la clef de chaque association étant un élément du multi-ensemble et sa valeur étant le nombre d'occurrences de la clef dans le multi-ensemble (toutes les clefs sont différentes). Dans cette implantation, le multi-ensemble D 1 peut être représenté par la liste d'associations ( (3 2) (5 1) (13 1) (8 2) (1 1)). ~
Types voisins La spécification de l'accesseur med-extraction ne donne aucun renseignement sur la manière dont est choisi un élément dans l'ensemble. Nous proposons dans ce paragraphe de définir deux types voisins du type MED [a] : ils diffèrent de celui-ci par la spécification et l'implantation de l'accesseur med-extraction. Nous nous baserons sur la première implantation (par des couples) pour donner quelques indications sur l'implantation de med-extraction. - Un choix au hasard: le choix de l'élément x dans le multi-ensemble est fait au hasard et tous les éléments ont la même probabilité d'être tirés. Remarque: on voit ici qu'il est important que med-extraction renvoie un couple : en même temps que l'élément tiré au hasard, il faut déterminer le multi-ensemble restant (sinon on ne saura pas comment le calculer). Idée pour l'implantation : définir une fonction qui, étant donnés un entier k et une liste L, renvoie le couple formé de l'élément en position k dans L et de la liste L privée de cet élément ; puis appliquer cette fonction à un entier tiré au hasard (parmi les positions possibles) et à la liste des éléments du multi-ensemble. - Le choix du minimum: ici, les éléments sont des nombres et l'élément choisi est le minimum des éléments du multi-ensemble. Remarque : ici encore, il est important que med-extraction renvoie un couple, mais pour d'autres raisons : il serait possible de déterminer le multi-ensemble restant indépendamment du calcul du minimum, mais il est plus efficace de calculer en une seule fois le minimum et le multi-ensemble restant. Idée pour l'implantation : définir une fonction qui, étant donnée une liste L, renvoie le couple formé de l'élément minimum deL et de la listeL privée de cet élément. Exercice 34 - Séquences cycliques Une suite, u 0 u 1 u 2 ••• , est dite périodique, de période p, si V i, up+i = u;. Dans cet exercice, on souhaite simuler une telle suite à l'aide d'une séquence cyclique. Une séquence cyclique est fabriquée à partir d'une liste homogène de base, de longueur égale à la période de la séquence cyclique. Comme la séquence est cyclique, le terme qui suit le dernier terme de la liste ayant servi de base est en fait le premier de cette même liste. On n'accepte de ne construire de séquence cyclique que sur des listes de base contenant au moins un terme. On dispose donc d'une nouvelle structure de données (Cycle [a]):
183
Exercices corrigés
- on fabrique une séquence cyclique à l'aide d'une liste homogène quelconque grâce au constructeur cy-circulaire; - on accède à son premier terme grâce à cy-car ; - on accède à tous les suivants avec cy-cdr. Voici les spécifications des fonctions composant la barrière d'abstraction : ; ; ; cy-circu/aire: USTE[a]lnon vide/---> Cycle[ a] ; ; ; (cy-circulaire L) construit une séquence cyclique avec les termes de «L». ; ; ; cy-car: Cycle[ a]---> a ; ; ; (cy-car c) rend le premier terme de la séquence cyclique. ; ; ; cy-cdr: Cycle[ a]---> Cycle[a] ; ; ; (cy-cdr c) rend une séquence cyclique dont les termes et la période sont identiques à ; ; ; ceux de «c» mais dont le terme de départ est décalé de un par rapport à celui de «c».
Et voici quelques exemples d'emploi de ces fonctions: (let ((c (cy-circulaire (list 'gauche 'droite)))) (list (cy-car c) (cy-car (cy-cdr c)) (cy-car (cy-cdr (cy-cdr c)))))--t (gauche droite gauche) Cette séquence cyclique correspond à la « liste infiuie >> (gauche droite gauche droite ... ) que l'on peut aussi voir comme une liste enroulée sur elle-même.
Question 1 - Écrire une fonction, nommée les-premiers, prenant une séquence cyclique et un entier n et calculant la liste des n premiers termes de la séquence cyclique. On veillera à n'utiliser bien sûr que les fonctions de la barrière d'abstraction décrite plus haut. Ainsi: (les-premiers (cy-circulaire '(abc)) 2) (les-premiers (cy-circulaire '(abc)) 5)
---> -+
(ab) (abc ab)
Question 2 - Pour pouvoir tester la fonction les-premiers, il nous faut implanter les fonctions cy-circulaire, cy-car et cy-cdr de la barrière d'abstraction. On suppose, pour cela, qu'une séquence cyclique de base Lest représentée par un COUPLE [LISTE [al, nat] contenant la liste de base L et un index indiquant le rang du terme de L à considérer comme premier terme de la séquence cyclique (celui que rend cy-car). L'index peut-être supérieur ou égal à la longueur de L. Écrire les définitions des trois fonctions. Question 3 - Peut-on faire mieux? On se propose maintenant de représenter une séquence cyclique avec un couple ne contenant plus d'index mais deux listes : la liste de base et la liste suffixe de la liste de base - le premier terme de cette liste suffixe est le terme de la liste de base à considérer comme premier terme de la séquence cyclique. Voici une implantation : (define (cy-circulaire L) ; la liste de base est en seconde position (list L L) ) (define (cy-car c) (caar c) ) (define (cy-cdr c) ; la liste suffixe a-t-elle plus d'un terme ?
184
Chapitre 7. Structuration des données
(if (pair? (cdar c)) ; (liste suffixe décalée d'un terme, liste de base) (list (cdar c) (cadr c)) ; (liste suffixe égale à liste de base, liste de base) (list (cadr c) (cadr c)) ) )
Voici une visualisation de cette implantation : (let ((c (cy-circulaire '(abc d)))) (list c (cy-car c) (cy-cdr c) (cy-cdr (cy-cdr c))))-+ ( ( (a b c d)
(a b c d) )
a ( (b c d)
((cd)
(a b c d) )
(abcd)))
Cette implantation vous semble-t-elle plus efficace que celle(s) de la question précédente?
4.2
Corrigés
Exercice 32 - Fiche d'un étudiant ~
Manipulation des barrières d'abstraction
Solution de la question 1- On utilise les constructeurs fe-fiche et fe-ajout-note : ; ; ; fiche-abelin : -+ FicheEtudiant ; ; ; (fiche-abe lin) construit la fiche de l'étudiant Jean ABEUN (define (fiche-abelin) (let ((ficheO (fe-fiche 3670 (ie-identite "ABELIN" "Jean")))) (fe-ajout-note 13 (fe-ajout-note 16 ficheO))))
Solution de la question 2 - On utilise le constructeur fe-fiche et les accesseurs fenumero-dossier et fe-identite : ; ; ; fe-efface-notes : FicheEtudiant -+ FicheEtudiant ; ; ; (fe-efface-notes un-etudiant) rend la fiche obtenue en supprimant toutes les notes de «un-etudiant» (define (fe-efface-notes un-etudiant) (fe-fiche (fe-numero-dossier un-etudiant) (fe-identite un-etudiant)))
Solution de la question 3 - Si la liste n'est pas vide on ajoute son premier élément à la fiche obtenue en ajoutant le reste de la liste à la fiche initiale ; sinon on renvoie la fiche initiale. ; ; ; fe-ajout-liste-notes : USTE[Note] * FicheEtudiant -+ FicheEtudiant ; ; ; (fe-ajout-liste-notes L un-etudiant) rend la fiche obtenue en ajoutant la ; ; ; liste de notes «L» à la liste des notes de «un-etudiant» (define (fe-ajout-liste-notes L un-etudiant) (if (pair? L) (fe-ajout-note (car L) (fe-ajout-liste-notes (cdr L) un-etudiant)) un-etudiant))
185
Exercices corrigés
On aurait pu suivre un autre algorithme : si la liste n'est pas vide on ajoute le reste de la
liste à la fiche obtenue en ajoutant le premier élément de la liste à la fiche initiale : (define (fe-ajout-liste-notes-autre L un-etudiant) (if (pair? L) (fe-ajout-liste-notes-autre (cdr L) (fe-ajout-note (carL) un-etudiant)) un-etudiant))
mais le résultat aurait été différent : (fe-notes (fe-ajout-liste-notes-autre ' (7 10 19) ~(19
(fiche-abel in) ) )
10 7 13 16)
Solution de la question 4- Nous donnons deux définitions de la fonction fe-fiche-avecnotes. La première utilise fe-ajout-liste-notes : ; ; ; fe-fiche-avec-notes ; ; ; (fe-fiche-avec-notes nu id L) (define (fe-fiche-avec-notes nu id L) (fe-ajout-liste-notes L (fe-fiche nu id)))
et la seconde ne l'utilise pas : (define (fe-fiche-avec-notes nu id L) (if (pair? L) (fe-ajout-note (carL) (fe-fiche-avec-notes nu id (cdr L))) (fe-fiche nu id)))
Remarque (l'œuf ou la poule?) : on pourrait inverser l'ordre des questions : demander de définir d'abord fe-fiche-avec-notes puis fe-ajout-liste-notes. On donnerait alors la définition suivante de fe-ajout-liste-notes : (define (fe-ajout-liste-notes L un-etudiant) (fe-fiche-avec-notes (fe-numero-dossier un-etudiant) (fe-identite un-etudiant) (append L (fe-notes un-etudiant))))
Solution de la question 5 : ; ; ; ie-modif-nom : ldentiteEtudiant *string --+ ldentiteEtudiant ; ; ; (ie-modif-nom ids) rend l'identité obtenue en remplaçant le nom de «id» par «S» (define (ie-modif-nom id s) (ie-identite s (ie-prenom id))) ; ; ; je-1nodij-nom : FicheEtudiant * string --+ FicheEtudiant ; ; ; (fe-modif-nom un-etudiant s) rend la fiche obtenue en remplaçant le ; ; ; nom de «un-etudiant» par «s» (define (fe-modif-nom un-etudiant s) (let ((nu (fe-numero-dossier un-etudiant)) (id (fe-identite un-etudiant)) (L (fe-notes un-etudiant))) (fe-fiche-avec-notes nu (ie-modif-nom ids) L)))
186 ~
Chapitre 7. Structuration des données
Modification des types
Solution de la question 6 : 1> Le type !denti teEtudiant
- Barrière d'abstraction - il faut modifier la spécification du constructeur ie-identi te ; ; ; ie-identite : string * string *string --+ ldentiteEtudiant ; ; ; (ie-identite nom prenom] prenom2) construit l'identité d'un étudiant formée ; ; ; de «nom» de «prénom]» et de «prénom2»
et son implantation, - avec des listes : (list nom prenoml prenom2) - avec des vecteurs : (vector nom prenoml prenom2) - il faut définir une fonction ie-prenom-deux, permettant d'accéder au deuxième prénom, de spécification: ; ; ; ie-prenom-deux : IdentiteEtudiant --+ string ; ; ; (ie-prenom-deux id) rend le prénom de «id»
et d'implantation, - avec des listes : (caddr id) - avec des vecteurs : (vector-ref id 2) - il n'y a pas à modifier les autres accesseurs, compte-tenu du choix qui a été fait pour la nouvelle implantation de ie-identi te. - Fonctions d'affichage : il faut modifier!' affichage d'une identité (laissé au lecteur) 3 • - Autres fonctions: il faut modifier la fonction ie-modif-nom (laissé au lecteur). 1> Le type FicheEtudiant
ll n'y a pas à modifier les fonctions définies sur le type FicheEtudiant car l'identité d'un étudiant y est toujours manipulée au travers de la barrière d'abstraction du type Identi teEtudiant. Remarquer l'intérêt de la fonction ie-modif-nom, qui évite d'avoir à modifier fe-modif-nom. Solution de la question 7 : 1> Le type IdentiteEtudiant
n n'y a rien à modifier. 1> Le type FicheEtudiant - Barrière d'abstraction - il faut modifier la spécification du constructeur fe-fiche
; ; ; fe-fiche : nat * ldentiteEtudiant *string --+ FicheEtudiant ; ; ; (fe-fiche nu ids) construit la fiche d'un étudiant, formée d'un numéro de dossier «nu», ; ; ; d'une identité «id», d'une situation «sit» et d'une liste vide de notes
et son implantation, - avec des listes : (list nu id s ' () ) - avec des listes (seconde implantation): (list nu id s) - avec des vecteurs : (vector nu id s ' () ) - il faut modifier le constructeur fe-ajout-note (laissé au lecteur), 3Les
fonctions de cet exercice qui sont laissées au lecteur ne présentent aucune difficulté, il est conseillé au lecteur débutant de les écrire.
Exercices corrigés
187
- il faut définir une fonction permettant d'accéder à la situation (primant ou doublant) de l'étudiant (laissé au lecteur), - il n'y a pas à modifier les accesseurs fe-numero-dossier et fe-identite, mais il faut modifier l'accesseur fe-notes (laissé au lecteur). - Fonctions d'affichage: il faut modifier l'affichage d'une fiche (laissé au lecteur). - Autres fonctions : il faut modifier les définitions des fonctions fe-efface-notes, fe-fiche-avec-notes et fe-modif-nom (laissé au lecteur).
Exercice 33 - Multi-ensembles dénombrés ~
Manipulation de la barrière d'abstraction
Solution de la question 1 : (define (med-vide? E) (= 0 (med-nombre E))) Solution de la question 2 - Si la liste n'est pas vide, il faut ajouter son premier élément au multi-ensemble dénombré obtenu à partir du reste de la liste ; si la liste est vide, on construit le multi-ensemble dénombré vide. ; ; ; list->med: USTE[a] ---t MED[a] ; ; ; (list->med L) renvoie le mu/ti-ensemble dénombré qui a les m2mes éléments que «L» (define (list->med L) (if (pair? L) (med-ajout (carL) (list->med (cdr L))) (med-vide)) )
On peut construire D1 à partir de n'importe quelle liste contenant les éléments 8, 5, 3, 1, 3, 8 et 13, par exemple : ; ; ; med-Dl: USTE[Nombre] ---t MED[Nombre] ; ; ; (med-Dl) renvoie le mu/ti-ensemble dénombré «D1» (define (med-D1) (list->med (list 8 53 1 3 8 13))) Solution de la question 3 - Si E n'est pas vide, il faut choisir un élément x dans E et ajouter
cet élément en tête de la liste obtenue à partir de E privé de x ; sinon on construit la liste vide. ; ; ; med->list: MED[a] ---t USTE[a] ; ; ; (med->list E) renvoie la liste qui a les m2mes éléments que «E» (define (med->list E) (if (med-vide? E) (list) (let ((C (med-extraction E))) (cons (coup1e-1 C) (med->1ist (coup1e-2 C)))))) Solution de la question 4- Pour calculer la moyenne des nombres d'un multi-ensemble
dénombréE, il suffit de diviser la somme des nombres de Epar le nombre d'éléments de E. ; ; ; med-moyenne: MED[Nombre]lnon vide/---> Nombre ; ; ; (med-moyenne E) renvoie la moyenne des nombres de «E» (define (med-moyenne E) (/ (med-somme El (med-nombre E)))
La fonction med-somme est définie récursivement: ; ; ; med-somme: MED[Nombre]---> Nombre ; ; ; (med-somme E) renvoie la somme des nombres de «E»
188
Chapitre 7. Structuration des données
(define (med-somme E) (if (med-vide? E) 0
(let ((C (med-extraction E))) (+ (couple-1 C) (med-somme (couple-2 C))))))
Solution de la question 5- La fonction med-union est définie sur le modèle de la fonction append: on ajoute un à un tous les éléments d'un des deux multi-ensembles à l'autre multiensemble. La fonction med-union diffère cependant de la fonction append : append ajoute un à un tous les éléments de E1 alors que med-union ajoute un à un tous les éléments du multi-ensemble ayant le plus petit nombre d'éléments (E1 ou E 2 ). ; ; ; med-union: MED[a] * MED[a] ---t MED[a] ; ; ; (med-union El E2) renvoie l'union de «El» et «E2» (define (med-union El E2) ; ; aux: MED[a] * MED[a] ---t MED[a] ; ; (aux El E2) renvoie l'union de «E1» et «E2» (define (aux Ea Eb) (if (med-vide? Ea) Eb (let ((C (med-extraction Ea))) (med-ajout (couple-1 C) (aux (couple-2 C) Eb))))) ; ; expression de la définition de med-union : (if (< (med-nombre El) (med-nombre E2)) (aux El E2) (aux E2 El)))
Solution de la question 6- On définit d'abord une fonction med-partage qui, étant donnés un entier naturel pet un multi-ensemble dénombré E, renvoie un couple de multi-ensembles dénombrés E1 et E2 tels que E1 contient p éléments de E et E2 contient les autres éléments deE: ; ; ; med-partage: nat* MED[a] ---t COUPLE[MED[a] MED[a]] ; ; ; (med-partage p E) renvoie un couple de mu/ti-ensembles dénombrés dont le premier élément ; ; ; contient «P» éléments de «E» et dont le second élément contient les autres éléments de «E» ; ; ; HYPŒHÈSE: «p» est inférieur ou égal au nombre d'éléments de «E» (define (med-partage p E) (if (= p 0) (list (med-vide) E) (let* ((C (med-extraction E)) (D (med-partage (- p 1) (couple-2 C)))) (list (med-ajout (couple-1 C) (car D)) (cadr D)))))
Puisque l'on connaît le nombre n d'éléments de E et que l'on dispose maintenant de la fonction med-partage, la définition de med-moi ti es est très simple : ; ; ; med-moities: MED[a] ---t COUPLE[MED[a]] ; ; ; (med-moities E) renvoie un couple de mu/ti-ensembles dénombrés dont l'union ; ; ; est «E» et ayant le même nombre d'éléments, à une unité près (define (med-moities E) (med-partage (quotient (med-nombre E) 2) E))
189
Exercices corrigés ~
Implantations de la barrière d'abstraction
Solution de la question 7 : C>
Implantation des multi-ensembles dénombrés au moyen de couples
(define (med-vide) (couple 0 (list))) (define (med-ajout xE) (couple (+ 1 (car E)) (cons x (cadrE)))) (define (med-nombre E) (couple-1 E)) (define (med-extraction E) (couple (car (couple-2 E)) (couple(- (couple-1 E) 1) (cdr (couple-2 E))))) Cette implantation utilise le constructeur couple, de spécification :
; ; ; couple: a * /3---+ COUPLE[ a /31 ; ; ; (couple a b) renvoie le couple dont le premier élément est «a» et dont ; ; ; le deuxième élément est «b» etles accesseurs couple-1 et couple-2.
Barrière d'abstraction pour les couples - Au moyen de listes : C>
(define (couple a b) (define (couple-1 C) (define (couple-2 C)
(list a b)) (car C)) (cadr C))
- Au moyen de vecteurs : (define (couple ab) (define (couple-1 C) (define (couple-2 C)
(vector ab)) (vector-ref C 0)) (vector-ref C 1))
Solution de la question 8 : C>
Implantation des multi-ensembles dénombrés au moyen de listes
(define (med-vide) (list 0)) (define (med-ajout x E) (cons (+ 1 (car E)) (define (med-nombre E) (car E)) (define (med-extraction E) (couple (car (cdr E)) (cons (- (car E) 1)
(cons x (cdr E))))
(cddr E))))
Remarque : dans les implantations du type MED [a 1 que nous avons présentées dans cette question et dans la question précédente, le calcul du nombre d'éléments d'un multi-ensemble ne nécessite pas un nouveau parcours de ce multi-ensemble. Ceci assure l'efficacité des fonctions med-union et med-moi ti es. Solution de la question 9- Le multi-ensemble vide est représenté par la liste d'associations vide: (define (med-vide)
(list))
Pour ajouter un élément à un multi-ensemble, il faut d'abord le chercher dans le multiensemble : s'il y est, il faut augmenter de 1 son nombre d'occurrences; sinon il faut créer une nouvelle association : (define (med-ajout x E) (define (chercher al) (if (pair? al) (if (equal? x (caar al)) (cons (list x (+ 1 (cadar al))) (cdr al) )
190
Chapitre 7. Structuration des données
{cons {car al) {chercher {cdr al))) {list {list x 1)) ) ) {chercher E) )
)
Pour obtenir le nombre d'éléments d'un multi-ensemble, il faut additionner les nombres d'occurrences de tous les éléments : {define {med-nombre E)
{reduce + 0 {map cadrE))
)
Pour extraire un élément d'un multi-ensemble, il faut soit diminuer de 1 son nombre d'occurrences (si ce nombre est supérieur à 1) soit supprimer l'association ayant cet élément pour clef: {define {med-extraction E) {let {{x {caar E)) {n {cadar E)) ) {if {> n 1)
{list x (cons {list x {- n 1)) {cdr E) ) ) {list x {cdr E ) ) ) ) )
Remarque: dans cette implantation, le calcul du nombre d'éléments d'un multi-ensemble nécessite un nouveau parcours de ce multi-ensemble. Exercice 34 - Séquences cycliques
Solution de la question 1- Voici la fonction les-premiers, en tout point comparable à une fonction qui travaillerait sur des listes sauf qu'ici on travaille sur des séquences cycliques. ; ; ; les-premiers: Cycle[a]* nat---> USTE[a] ; ; ; (les-premiers c n) rend la liste des «n» premiers termes de «C». {define (les-premiers c n) {if
{> n 0)
{cons {cy-car c) {les-premiers {cy-cdr c)
'
{)
)
{- n 1)) )
)
Solution de la question 2 - Voici une première implantation où le COUPLE [LISTE [a], nat] est représenté à l'aide d'une liste (où le premier élément est l'index et le second la liste de base (mais nous aurions pu aussi bien faire l'inverse) : ; ; ; cy-circulaire: USTE[a]/non vide/---> Cycle[ a] ; ; ; (cy-circulaire L) construit une séquence cyclique avec les termes de «L». {define (cy-circulaire L) {list 0 L) ) ; ; ; cy-car: Cycle[ a] ---> a ; ; ; (cy-car c) rend le premier terme de la séquence cyclique. {define (cy-car c) {let* ({index {car c)) {L {cadr cl) {ln {length L)) {list-ref L {remainder index ln)) ) ) ; ; ; cy-cdr: Cycle[ a]---> Cycle[a] ; ; ; (cy-cdr c) rend une séquence cyclique dont les termes et la période sont identiques à ; ; ; ceux de «C» mais dont le terme de départ est décalé de un par rapport à celui de «C».
191
Exercices corrigés
(define (cy-cdr c) (let* ((index (car c)) (L (cadr c)) ) (list (+ index 1) L) ) ) Le constructeur cy-circulaire ne fait que construire le couple suggéré par l'énoncé pour représenter la séquence cyclique. L'accesseur cy-cdr ne fait que construire une nou-
velle séquence cyclique décalée d'un terme. Cet effet se réalise simplement en incrémentant l'index se trouvant dans le couple représentant la séquence cyclique. L'accesseur cy-car extrait le terme courant de cette liste mais doit, pour cela, traiter le cas d'index plus grands que la longueur de la liste de base. Voici une deuxième implantation où le couple est représenté comme un vecteur (encore une fois, l'index a été mis en première position mais il aurait pu être tout aussi bien en seconde position). (define (cy-circulaire L) (vector 0 L) ) (define (cy-car c) (let* ((index (vector-ref c 0)) (L (vector-ref c 1)) (ln (length L)) ) (list-ref L (remainder index ln))
) )
(define (cy-cdr c) (let* ((index (vector-ref c 0)) (L (vector-ref c 1)) ) (vector (+ index 1) L) ) )
La définition précédente n'est guère intéressante mais elle ouvre la route vers une troisième implantation n'usant plus que de vecteurs et qui donc assure que les accès seront rapides et en temps constant. Deux améliorations y figurent : la liste de base est convertie en un vecteur (à l'aide d'un appel à list->vector). Enfin, plutôt que d'effectuer l'opération modulo à chaque accès à la liste circulaire, il est préférable d'assurer que l'index est toujours correct c'est-à-dire dans les limites du vecteur: il faut donc calculer ce modulo dans cy-cdr ! Mais il est inutile, pour recalculer ce modulo de recalculer sempiternellement la longueur de la liste de base puisqu'une fois la séquence cyclique créée, cette longueur ne change jamais. Cette longueur n'est donc calculée qu'une seule fois et stockée dans le NUPLET représentant la séquence cyclique. Ainsi le type de la liste circulaire est-il NUPLET[nat, nat, VECTEUR[ a]]. (define (cy-circulaire L) (vector 0 (length L) (list->vector L))
)
(de fine (cy-car c) (let* ((index (vector-ref c 0)) (L (vector-ref c 2)) (vector-ref L index) ) ) (define (cy-cdr c) (let ((index (vector-ref c 0)) (ln (vector-ref c 1)) (L (vector-ref c 2)) (vector (rernainder (+ index 1) ln) ln L)
) )
192
Chapitre 7. Structuration des données
Solution de la question 3- Nous ne répondrons à cette question qu'en comparant l'implantation proposée dans l'énoncé de la question, n'utilisant que des listes, à la dernière implantation que nous avons présentée dans la question précédente, avec que des vecteurs. La fonction cy-car, dans le cas de l'implantation par des listes, fait deux appels à car, dans l'autre cas fait trois appels à vector-ref. Un appel à car étant comparable à un appel à vector-ref, la version utilisant des listes est donc plus efficace. En ce qui concerne cy-cdr, l'analyse est plus délicate à effectuer et une meilleure solution est de mesurer le temps mis à faire ces opérations. On construit alors un banc d'essai (ou benchmark). La fonction duree de la bibliothèque MIAS prend une fonction sans argument, l'invoque et renvoie le temps (en millisecondes) mis par cette fonction pour calculer ce résultat4 . Pour réitérer suffisamment un calcul afin qu'il ait une durée perceptible, nous introduisons un utilitaire fois qui prend en argument un entier net une fonction calcul et renvoie une fonction qui itère n fois le calcul calcul : ; ; ; fois: nat/>01 * (---> alpha) ---> (---> alpha) ; ; ; (fois n calcul) construit une fonction qui fera «n» fois le «calcul» demandé. (define (fois n calcul) (define (iteration n v) (if
(> n 1)
(iteration (- n 1)
(calcul))
#t))
(define (les-calculs) (iteration n (calcul))) les-calculs)
Voici donc le banc d'essai: (let ((c (cy-circulaire • (1 2 3 4 56 7 8 9 10 11 12 13 14 15)))) (define (un-calcul) (les-premiers c 1000)) (duree (fois 100 un-calcul)))-> 1001.509
Et voici les durées obtenues sur une machine dotée d'un Pentium 4 à 2 GHz :
Implantation 1 2 3 4
Caractéristiques nuplet naïf suggéré avec des vecteurs et des listes que des vecteurs que des listes
Temps(ms)
5193 5136 4703
3633
La dernière semble donc la plus efficace! Mais elle n'est efficace que sur ce banc d'essai. Sur un autre banc d'essai, où le nombre d'appels à cy-cdr surpasserait grandement le nombre d'appels à cy-car, ce ne serait pas forcément la meilleure implantation!
4 Clairement,
duree n'est pas une fonction au sens mathématique car elle renvoie un résultat dépendant de la machine, de sa vitesse, de sa charge, etc.
Chapitre 8
Structures arborescentes Ce chapitre porte sur les arbres, structure omniprésente en informatique pour représenter des organisations hiérarchiques. Arbres binaires ou généraux, ces structures, fondées sur la décomposition d'un arbre en sous-arbres, sont intrinsèquement récursives et les fonctions sur les arbres sont donc par nature récursives : pour traiter un arbre on compose les traitements de ses sous-arbres. Pour les arbres binaires, constitués d'une racine et de deux sous-arbres, le traitement nécessite en général deux appels récursifs (récursion diadique ou binaire). Pour les arbres généraux, constitués d'une racine et d'un nombre quelconque de sous-arbres, le traitement général repose sur un nombre quelconque d'appels récursifs (récursion polyadique ou généralisée), que l'on résout soit par récursion mutuelle soit par utilisation d'itérateurs. Les arbres sont des types abstraits de données : la description de la structure (aussi bien arbres binaires que généraux) est accompagnée de sa barrière d'abstraction, qui est un ensemble de fonctions primitives permettant de manipuler les objets sans se préoccuper de leur représentation. Tous les traitements sur les arbres se font au travers de leur barrière d' abstraction, ce qui permet de se concentrer sur les mécanismes de récursion. C'est seulement en fin de chapitre, après avoir mené l'étude sur les arbres abstraits, que l'on présente différentes implantations des barrières d'abstraction, à 1' aide de listes ou de vecteurs. Le chapitre est illustré par trois paradigmes d'arbres, qui doivent être familiers à tout informaticien : les arbres binaires de recherche pour gérer des ensembles dynamiques, l' arborescence des fichiers dans un système d'exploitation et les arbres cardinaux pour représenter des images.
1 Structure d'arbre Les structures arborescentes permettent de représenter les organisations hiérarchiques : organigramme des responsables d'un service, table des matières d'un livre, structuration d'un programme ou d'un ensemble de fichiers ... En informatique, un arbre est un ensemble de nœuds organisés de façon hiérarchique à partir d'un nœud distingué qui est la racine, les autres nœuds étant eux-mêmes structurés sous forme d'arbres. Ainsi dans un arbre, tout nœud a un unique père, sauf la racine qui n'en
Chapitre 8. Structures arborescentes
194
a pas. On peut aussi définir un arbre récursivement, comme la donnée d'une racine et d'un ensemble d'arbres (les sous-arbres de la racine). On nomme étiquette l'information attachée aux nœuds. Le vocabulaire concernant les arbres en informatique est souvent emprunté à la botanique ou à la généalogie : racine, père, fils (ou sous-arbre), frères (pour deux nœuds ayant le même père), feuille (pour un nœud sans fils) ... ll existe différentes structures d'arbres, mais nous n'étudierons ici que les arbres binaires et les arbres généraux (les arbres cardinaux seront traités dans les exercices). Dans les arbres binaires chaque nœud a deux fils, et l'on distingue entre le fils (sous-arbre) gauche et le fils (sous-arbre) droit. La figure suivante est un arbre binaire qui représente l'ensemble des ancêtres du dénommé « Frédéric » : chaque nœud représente un individu, le lien gauche, qui en part, pointe vers la lignée du père, et le lien droit vers la lignée de la mère. Ainsi chaque nœud porte comme étiquette un prénom, la racine de son sous-arbre gauche porte le prénom de sa mère et la racine de son sous-arbre droit porte le prénom de son père. Lorsque le prénom d'un ancêtre est inconnu on laisse l'étiquette vide et l'on arrête le développement de l'arbre à cet endroit, ce qui laisse un lien vide.
\1 Louise
\1 Eric
"
\1
\1 Catherine
/
Agat~ / Jeanne
Élis\ / Sébastien
/
"
Benoît
"--..
/
Frédéric
Dans les arbres généraux chaque nœud a un nombre quelconque de sous-arbres (0, 1,2 ... ), qui sont ordonnés (premier sous-arbre, deuxième sous-arbre... ). La figure suivante est un arbre général qui représente l'ensemble des descendants de « Dominique » : chaque nœud a comme suite de sous-arbres la suite (par exemple par ordre de naissance) des enfants de la personne dont le prénom est en étiquette. Chaque individu pointe donc vers chacun de ses enfants et un individu sans enfant clôt une branche de l'arbre.
---- " ---Dominique
Paul
/ Marie
"
/ Pierre
Jean
"
Claude
Jacques
1 Lucie
1 Pierre
1
Line
Thomas
........
/
Alphonse
Albert Élodie/
"
----/
Noémie
Louise
1
Mathieu
Berthe
"~ Émile Yves
Arbres binaires
195
2 Arbres binaires Un arbre binaire est une structure de données qui permet de rassembler en un unique agrégat une hiérarchie binaire de valeurs. Les arbres binaires que nous manipulerons dans ce livre sont typés : toutes les valeurs des étiquettes sont de même type. On présente ici la définition récursive des arbres binaires, qui induit leur barrière d'abstraction et leur schéma général de traitement.
2.1
Définitions
Dans un arbre binaire non vide, chaque nœud porte une valeur (étiquette) de type a, et a exactement deux sous-arbres (éventuellement vides). Le type d'un arbre binaire dont toutes les valeurs sont de type a se note: ArbreBinaire [a].
Définition récursive: un arbre binaire de type ArbreBinaire [a] est - soit vide, - soit formé d'un nœud portant une étiquette de type a, d'un sous-arbre gauche et d'un sous-arbre droit, qui sont tous deux de type ArbreBinaire [a]. La figure qui suit montre des schémas d'arbres binaires non vides. Ces arbres ont une racine et deux sous-arbres :lorsqu'un sous-arbre est non vide il est dessiné par un triangle. Dans le premier schéma les sous-arbres gauche et droit sont non vides ; dans le deuxième, le sous-arbre gauche est non vide et le sous-arbre droit est vide ; dans le troisième, le sous-arbre gauche est vide et le sous-arbre droit est non vide ; enfin dans le dernier schéma, les deux sous-arbres sont vides (on dit dans ce cas que l'arbre est une feuille).
2.2 Barrière d'abstraction La définition récursive des arbres binaires induit les fonctions de la barrière d'abstraction: on doit disposer de deux constructeurs, pour fabriquer 1' arbre vide et un arbre non vide, de trois accesseurs pour récupérer les différentes parties d'un arbre non vide (étiquette, sousarbre gauche et sous-arbre droit), ainsi que d'un reconnaisseur pour distinguer l'arbre vide des autres arbres binaires. Les traitements sur les arbres binaires se feront au travers des fonctions de la barrière d'abstraction, dont on a besoin de connmî:re uniquement la spécification (on étudiera en fin de chapitre différentes implantations de ces fonctions). A priori on ne sait donc rien de la représentation des arbres que l'on manipule. Pour pouvoir les « montrer » autrement que graphiquement, on adjoint, à la barrière d'abstraction, la spécification d'une fonction d' affichage.
Chapitre 8. Structures arborescentes
196
2.2.1 Spécification des constructeurs ; ; ; ab-vide: ___, ArbreBinaire[a] ; ; ; (ab-vide) rend l'arbre binaire vide. ; ; ; ab-noeud: a * ArbreBinaire[a] * ArbreBinaire[a]-t ArbreBinaire[a] , , , (ab-noeud e Bl B2) rend l'arbre binairefonné de la racine d'étiquette ; ; ; «e», du sous-arbre gauche «Bi» et du sous-arbre droit «82». Par exemple pour construire l'arbre suivant, de type ArbreBinaire [Nombre], que l'on appellera par la suite abE x,
JO
5
/
/\
1\
"
15
4/ "3
1\ 1\
on peut écrire l'expression (ab-noeud lD (ab-noeud 5 (ab-noeud 3 (ab-vide) (ab-vide)) (ab-vide)) (ab-noeud 15 (ab-noeud 4 (ab-vide) (ab-vide)) (ab-noeud 3 (ab-vide) (ab-vide)))) ou alors l'expression (let* ((bD (ab-noeud 3 (ab-vide) (ab-vide))) (bl (ab-noeud 4 (ab-vide) (ab-vide))) (b2 (ab-noeud 5 bD (ab-vide))) (b3 (ab-noeud 15 bl bD))) (ab-noeud lD b2 b3)) La valeur de cette expression est un objet de type ArbreBinaire [Nombre] que l'on manipule au travers des accesseurs de la barrière d'abstraction.
2.2.2 Spécification des accesseurs Ces fonctions permettent d'accéder aux différentes parties d'un arbre non vide: i i i ab-etiquette: ArbreBinaire[a]-t a (ab-etiquette B) rend l'étiquette de la racine de l'arbre «B» ' '' i ; i ERREUR lorsque «B» est l'arbre vide ' '' i i i
' '' ; i i ; i i
i i i
ab-gauche : ArbreBinaire[01.] ___, ArbreBinaire[01.] (ab-gauche B) rend le sous-arbre gauche de «B» ERREUR lorsque «B» est l'arbre vide ab-droit: ArbreBinaire[a]--> ArbreBinaire[a] (ab-droit B) rend le sous-arbre droit de «B» ERREUR lorsque «B» est l'arbre vide
197
Arbres binaires
Les accesseurs permettent de « défaire » ce qui a été construit avec le constructeur ab-noeud, et inversement le constructeur permet de fabriquer un arbre à partir de ses composants. On exprime ces propriétés par les règles algébriques suivantes : - Pour tout couple d'arbres binaires G et D, et toute valeur v: {ab-etiquette {ab-noeud v GD)) {ab-gauche {ab-noeud v GD)) {ab-droit {ab-noeud v GD))
v G
D
- Pour tout arbre binaire non vide B : {ab-noeud {ab-etiquette B) {ab-gauche B) {ab-droit B))
B
2.2.3 Spécification du reconnaisseur On choisit de prendre comme reconnaisseur un prédicat qui rend vrai pour un arbre non vide. Ce prédicat, nommé ab-noeud?, rend donc vrai lorsque l'arbre est un nœud, c'est-àdire pour les quatre schémas d'arbres non vides présentés en page 195. ; ; ; ab-noeud?: ArbreBinaire[a.]--+ bool ; ; ; (ab-noeud? B) rend vrai ssi «B» n'est pas l'arbre vide. ; ; ; ERREUR lorsque «B» n'est pas un arbre binaire
2.2.4 Utilitaire: une fonction d'affichage Pour représenter les objets de type ArbreBinaire [a.] on a, pour l'instant, usé de différents moyens : dessin graphique, description par des phrases ou expression Scheme explicitant leur construction. Toutes ces façons permettent de s'adresser au lecteur pour décrire et montrer des arbres. Mais pour programmer des traitements d'arbres, en particulier pour tester des fonctions dont le résultat est un arbre, il faut adjoindre à la barrière d'abstraction une fonction d'affichage qui permet de visualiser les arbres évalués. Ainsi la barrière d'abstraction est véritablement « étanche » : les objets sont construits, traités et visualisés de façon abstraite, c'est-à-dire sans montrer leur implantation. Nous avons choisi ici la fonction ab-affichage qui a comme spécification: ; ; ; ab-affichage : ArbreBinaire[a.] --+ string ; ; ; (ab-affichage B) rend un affichage préfixe de «B».
Noter que le résultat de l'affichage d'un arbre n'est pas un objet de type arbre: c'est une chaîne de caractères. Cette chaîne décrit l'arbre binaire de façon non ambiguë: à partir d'une chaîne donnée on peut reconstruire un unique arbre binaire. Cette fonction d'affichage peut elle-même être définie en utilisant la barrière d' abstraction; c'est ce qui est fait dans l'exercice« Affichage des arbres binaires>>. Nous donnons ici seulement un exemple pour expliciter l'affichage choisi : l'application de la fonction d'affichage à l'arbre abE x donne pour résultat la chaîne • [ 1 o [ 5 3 @] [ 15 4 3] ] ".
2.3 Récursion sur les arbres binaires On présente ici le schéma récursif fondamental sur les arbres binaires, ainsi que différents exemples d'applications de ce schéma.
198
Chapitre 8. Structures arborescentes
2.3.1 Schéma récursif Le schéma de traitement récursif des arbres binaires, qualifié de récursion binaire, découle de leur définition : on traite le cas général d'un arbre non vide par deux appels récursifs sur les sous-arbres gauche et droit, combinés avec un traitement de la racine de 1' arbre ; puis on traite le cas de base, l'arbre vide. ; ; ; ab-fonction: ArbreBinaire[alpha]--+ ... (define (ab-fonction B) (if (ab-noeud? B) (combinaison (ab-etiquette B) (ab-fonction (ab-gauche B)) (ab-fonction (ab-droit B) ) ) cas-de-base) )
Ce principe de récursion repose sur le fait que les sous-arbres gauche et droit sont strictement plus petits 1 que l'arbre tout entier (on a ainsi décomposé la donnée en données plus petites), et la combinaison exprime une relation de récurrence entre l'arbre et ses sous-arbres gauche et droit; cette relation n'est pas vérifiée pour l'arbre vide et il faut déterminer la valeur de la fonction pour ce cas de base. Le schéma récursif est à rapprocher de l'induction structurelle :pour prouver une propriété ou une fonction sur les arbres binaires on raisonne par récurrence sur la structure de l'arbre: - Cas de base: la propriété est vraie pour l'arbre vide. - Étape récursive : soit B un arbre non vide, on suppose que la propriété est vraie pour ses sous-arbres gauche et droit et on montre alors qu'elle est vraie pour B. 2.3.2 Exemples d'application On calcule ici quelques caractéristiques des arbres binaires : la taille, la profondeur et la liste infixe. Exemple 1 : le nombre de JUEuds, ou taille d'un arbre binaire se calcule simplement : si l'arbre n'est pas vide c'est la somme des tailles des sous-arbres gauche et droit, augmentée de 1, et la taille de l'arbre vide est O. Par exemple, le nombre de nœuds de l'arbre abEx est 6. La définition de la fonction ab-nombre-noeuds suit le schéma récursif des arbres binaires, avec 0 comme cas de base, la fonction + comme combinaison, et on ajoute 1 pour tenir compte de la racine. ; ; ; ab-nombre-noeuds : ArbreBinaire[a] --+ nat ; ; ; (ab-nombre-noeuds B) rend le nombre de noeuds de «B» (la taille de l'arbre) (define (ab-nombre-noeuds B) (if (ab-noeud? B) (+ 1
(ab-nombre-noeuds (ab-gauche B)) (ab-nombre-noeuds (ab-droit B))) 0))
Exemple 2 : la profondeur d'un arbre binaire correspond à la longueur du plus long chemin de la racine jusqu'à un arbre vide. On peut aussi donner une définition récursive : la profondeur d'un arbre non vide est égale au maximum des profondeurs de ses deux sous-arbres 10n ne précise pas
selon quel critère on mesure les arbres, mais que l'on prenne la taille, ou la hauteur ou ... , tout sous-arbre d'un arbre B est plus petit que B; et il a un seul élément minimal, qui est l'arbre vide
Arbres binaires de recherche
199
immédiats, augmenté de 1 ; et la profondeur de l'arbre vide est O. Par exemple la profondeur de l'arbre abEx est 3. La définition de la fonction ab-profondeur se dédnit immédiatement de la définition récursive : ; ; ; ab-profondeur: ArbreBinaire[a]---> nat ; ; ; (ab-profondeur B) rend la profondeur de «B» (define (ab-profondeur B) (if (ab-noeud? B) (+ 1 (max (ab-profondeur (ab-gauche B)) (ab-profondeur (ab-droit B)))) 0) )
Exemple 3 : la liste infixe des étiquettes d'un arbre binaire B est définie comme suit : si B est un arbre non vide sa liste infixe est égale à la concaténation de la liste infixe du sous-arbre gauche deB, de l'étiquette de la racine de B et de la liste infixe du sous-arbre droit de B ; et si B est l'arbre vide sa liste infixe est la liste vide. Par exemple, la liste infixe de l'arbre abE x est (3 5 10 4 15 3) ; ; ; ab-liste-infixe : ArbreBinaire[a] ---> USTE[a] ; ; ; (ab-liste-infixe B) rend la liste infixe de «B» (define (ab-liste-infixe B) (if (ab-noeud? B) (append (ab-liste-infixe (ab-gauche B)) (cons (ab-etiquette B) (ab-liste-infixe (ab-droit B)))) ' () ) )
3 Arbres binaires de recherche Un arbre binaire de recherche est un type de données permettant de représenter, et de gérer efficacement, un ensemble d'informations sur lequel on fait des opérations de recherche (un élément donné appartient-il à l'ensemble?), d'ajout et de suppression d'un élément à l'ensemble. Dans cette section nous étudions les arbres binaires de recherche comme exemple d'utilisation des arbres binaires, et implantons les fonctions de base des arbres binaires de recherche à l'aide des fonctions de la barrière d'abstraction des arbres binaires.
3.1
Définition et propriété
Définition : un arbre binaire de recherche est soit un arbre binaire vide, soit un arbre binaire qui possède les propriétés suivantes : - l'étiquette de sa racine est - supérieure à toutes les étiquettes de son sous-arbre gauche, - inférieure à toutes les étiquettes de son sous-arbre droit, - ses sous-arbres gauche et droit sont aussi des arbres binaires de recherche. Par la suite, pour que les opérations de comparaison (< et >) soient définies, nous considèrerons que les étiquettes sont des nombres. On supposera aussi que les étiquettes d'un arbre binaire de recherche sont toutes distinctes (car on veut représenter des ensembles). On note ArbreBinRecherche le type des arbres binaires de recherche définis ci-avant.
200
Chapitre 8. Structures arborescentes
Propriété caractéristique : Soit B un arbre binaire, B est un arbre binaire de recherche si, et seulement si, la liste infixe des étiquettes de B est en ordre croissant. La preuve suivante est donnée pour montrer l'analogie entre l'induction structurelle et la récursion ; elle peut être sautée en première lecture. Preuve :rappelons que la liste infixe des étiquettes d'un arbre binaire B, non vide, est égale à la concaténation de la liste infixe du sous-arbre gauche de B, de 1' étiquette de la racine de B et de la liste infixe du sous-arbre droit de B. Prouvons tout d'abord que la liste infixe d'un arbre binaire de recherche est croissante. La preuve se fait par induction structurelle sur les arbres : - la propriété est vraie pour 1' arbre vide ; - soit Bun arbre binaire de recherche non vide; nommons e l'étiquette de la racine de B, G son sous-arbre gauche et D son sous-arbre droit. Supposons que les listes infixes des arbres binaires de recherche G et de D soient croissantes. Puisque par définition e est supérieure (respectivement inférieure) à toutes les étiquettes de G (respectivement D), on peut donc conclure que la liste infixe de B est croissante. Réciproquement prouvons, encore par induction structurelle, que si la liste infixe d'un arbre binaire est croissante alors cet arbre est un arbre binaire de recherche : - la propriété est vraie pour l'arbre vide ; - soit Bun arbre binaire non vide, formé d'une étiquette e, d'un sous-arbre gauche G et d'un sous-arbre droit D. Sa liste infixe est de la forme L 8 = (La,e,Ln), où La et Ln sont les listes préfixes des sous-arbres G et D. Si LB est croissante alors La et Ln le sont aussi. Supposons que la propriété soit vérifiée pour G et D : leurs listes infixes étant croissantes ce sont des arbres binaires de recherche. Enfin, puisque l'étiquette e est comprise entre La et Ln, on déduit que B est aussi un arbre binaire de recherche. La figure suivante montre un exemple d'arbre binaire de recherche, dont la liste infixe est (2 3 4 5 6 8 10 12 14 15 19 20) :
/JO~ 6 2
/
1\ 3/
"1\ 8
'-s
J5 12
/
1 \14
""'JI \ 20
1\ 1\
1\ 1\ j\ Un même ensemble d'étiquettes peut être représenté par différents arbres binaires de ~ recherche. En fait un ensemble de n étiquettes peut être « porté » par n'importe quel arbre binaire de taille n : il suffit que la liste infixe soit en ordre croissant. Sur la figure suivante les deux arbres binaires de recherche portent le même ensemble d'étiquettes. Ils sont de formes très différentes : celui de gauche est bien équilibré (il est même complet, chaque nœud ayant deux sous-arbres non vides ou aucun), et celui de droite est dégénéré (chaque nœud a au plus un sous-arbre non vide). Ces deux arbres sont équivalents
201
Arbres binaires de recherche
du point de vue de l'ensemble qu'ils représentent, mais ils ne sont pas du tout équivalents du point de vue des traitements algorithmiques, comme l'explique le paragraphe suivant. « bien équilibré »
11
JO /
13
/
"
12
~
« dégénéré »
20
18
14 /
](: \
"
1
20
1\ 1\ 1\ 1\
'n
1
\12
1
\8
131 \
1
\14
1\ 3.2 Algorithmes et complexité Les arbres binaires de recherche sont adaptés à la gestion d'ensembles dynamiques, c'està-dire d'ensembles sur lesquels on veut effectuer les traitements suivants : rechercher si un élément x appartient à l'ensemble, ajouter ou supprimer un élément x à l'ensemble. Les algorithmes de recherche, ajout ou suppression d'un élément x dans un arbre binaire de recherche reposent sur le principe qu'une unique comparaison entre x et l'étiquette d'un arbre non vide permet d'aiguiller la suite du traitement soit dans le sous-arbre gauche, soit dans le sous-arbre droit. Ainsi le schéma de traitement des arbres binaires de recherche peut se définir récursivement comme suit : étant donnés un nombre x et un arbre binaire de recherche AB R, si l'arbre est non vide, on compare x avec l'étiquette e, et selon le résultat de la comparaison on appelle récursivement le traitement sur le sous-arbre gauche (si x< e) ou le sous-arbre droit (si x > e); le résultat de cet appel récursif est éventuellement combiné avec d'autres parties de l'arbre pour terminer le traitement. Le cas où l'arbre est vide et le cas où x = e sont à traiter séparément. ; ; ; abr-fonction : Nombre * ArbreBinRecherche --+ ... (define (abr1onction x ABR) (if
(ab-noeud? ABR) (let ((e (ab-etiquette (cond ( ( = x e) cas-égalité)
( (< x e )
ABR)))
(une-combinaison (abr1onction x
(ab-gauche ABR))
. -. ) ) (else (une-combinaison
(abr1onction x
. -. ) ) cas-arbre-vide) )
(ab-droit ABR))))
202
Chapitre 8. Structures arborescentes
Le principe d'aiguillage, qui permet de « laisser tomber » un sous-arbre entier (gauche ou droit), et de recommencer récursivement le traitement sur un seul sous-arbre, fonde l'efficacité des algorithmes sur les arbres binaires de recherche. En effet, si chaque sous-arbre contient à peu près la moitié des nœuds de son arbre (on parlera alors d'arbre équilibré), le nombre de nœuds restant à examiner est divisé par deux (dichotomie) à chaque appel récursif. Dans ce cas, partant d'un arbre de taille n, après 1 comparaison on se ramène récursivement à un arbre de taille n + 2. Ainsi en log 2 n appels récursifs on atteint 1' arbre vide. La complexité du traitement est alors logarithmique : le temps d'exécution est en O(logn). Mais si l'arbre est dégénéré (chaque nœud a au plus un sous-arbres non vide) le nombre de nœuds restant à examiner est seulement diminué de 1 à chaque appel récursif. Dans ce cas, partant d'un arbre de taille n, on se ramène récursivement en 1 comparaison à un arbre de taille n -1. La complexité du traitement est alors linéaire: le temps d'exécution est en O(n). lllustrons cela sur les arbres de la figure précédente. La recherche se fait par comparaison à partir de la racine. Dans le pire des cas, le nombre de comparaisons pour trouver un élément est égal à la profondeur de l'arbre. Ici la recherche de l'élément 14, parmi les 7 éléments de l'ensemble, nécessite 3 comparaisons dans l'arbre bien équilibré (3 = 1Zog27l) et 7 comparaisons dans 1' arbre dégénéré. Les différences de performance entre arbre dégénéré et arbre équilibré sont encore plus contrastées lorsque le nombre d'éléments est grand: par exemple si l'arbre contient de l'ordre du million d'éléments, la recherche pourra nécessiter jusqu'à un million de comparaisons dans le premier cas, alors qu'elle se fera toujours en une vingtaine de comparaison dans le second cas (sin ~ 106 alors log 2 n ~ 20, puisque 106 ~ 220 ).
3.3 Spécification des opérations Nous donnons ici la spécification des opérations de recherche, ajout et suppression dans les arbres binaires de recherche. La fonction de recherche est un semi-prédicat, qui renvoie faux lorsque l'élément cherché x n'est pas dans l'arbre, et sinon renvoie le sous-arbre dont l'étiquette est x (on suppose que les étiquettes d'un arbre sont toujours deux à deux distinctes). ; ; ; abr-recherche : Nombre *ArbreBinRecherche ---> ArbreBinRecherche + #f ; ; ; (abr-recherche x ABR) rend l'arbre de racine «X», lorsque «X» est dans «ABR» ; ; ; et renvoie #f si «X» n'apparait pas dans «ABR»
La fonction d'ajout rend comme résultat un arbre binaire de recherche qui contient l'élément à ajouter. , ; ; ;
, ; ; ;
, ; ; ;
abr-ajout: Nombre * ArbreBinRecherche --+ ArbreBinRecherche (abr-ajout x ABR) rend l'arbre «ABR» /orque «X» apparait dans «ABR» et, lorsque «X» n'apparait pas dans «ABR», rend un arbre binaire de recherche qui contient «X» et toutes les étiquettes qui apparaissent dans «ABR»
La fonction de suppression rend comme résultat l'arbre binaire de recherche privé de l'élément à supprimer. ; ; ; ;
; ; ; ;
; ; ; ;
abr-suppression : Nombre *ArbreBinRecherche ---> ArbreBinRecherche (abr-suppression xABR) rend l'arbre «ABR» /orque «X» n'apparait pas dans «ABR» et. lorsque «X» apparait dans «ABR», rend un arbre binaire de recherche qui contient toutes les étiquettes qui apparaissent dans «ABR» hormis «X».
Les spécifications de l'ajout et de la suppression ne précisent ni l'algorithme à mettre en œuvre, ui la forme de l'arbre résultant. Par exemple les deux arbres suivants résultent bien
203
Arbres binaires de recherche
de l'ajout de la valeur 3 à l'arbre [ 2 5 6] . Sur l'arbre de gauche la valeur ajoutée est une feuille et sur l'arbre de droite elle est à la racine.
5
2
/".
3
6
/\ /\ 1\
2
/" 5
1\ /\ 1\
Et de même la suppression de la valeur 5 dans chacun des deux arbres précédents pourrait donner comme résultat l'un quelconque des deux arbres suivants
3 2
/'6
1\ 1\
6
/\ /\ 1\
Dans les paragraphes suivants, nous précisons les algorithmes choisis et donnons des implantations des trois opérations, en utilisant la barrière d'abstraction du type ArbreBinaire.
3.4 Implantation de la recherche et de l'ajout L'idée récursive de l'implantation de la recherche est très simple : - si 1' arbre est vide le résultat est #f, - sinon, lorsque l'élément recherché est égal à l'étiquette de la racine, on 1' a trouvé et le résultat est l'arbre lui-même; sinon, on doit rechercher l'élément dans le sous-arbre gauche ou dans le sous-arbre droit selon qu'il est inférieur ou supérieur à l'étiquette de la racine. D'où la défiuition: (define (abr-recherche x ABR) (if (ab-noeud? ABR) (let ((e (ab-etiquette ABR))) (cond ( (= x e) ABR) ((<xe) (abr-recherche x (ab-gauche ABR))) (else (abr-recherche x (ab-droit ABR))))) #f))
On a vu précédemment que la spécification de la fonction abr-aj out ne précise pas à quel endroit de l'arbre il faut ajouter l'élément. Différentes méthodes sont possibles; nous choisissons ici d'ajouter l'élément à l'endroit où sa recherche se serait terminée en échec, c'est-à-dire au uiveau d'un arbre vide. Ainsi l'élément ajouté devient une feuille de l'arbre. (On traite en exercice un algorithme d'ajout à la racine.) L'idée récursive de l'implantation de l'ajout est donc similaire à celle de la recherche, à la différence près qu'il faut ici (re)construire au fur et à mesure (avec le constructeur ab-noeud) l'arbre binaire de recherche qui est rendu en résultat.
204
Chapitre 8. Structures arborescentes
...JI
Dans le langage purement fonctionnel que nous utilisons, une fonction prend un (ou ~ plusieurs) argument(s) et rend un résultat, mais les arguments ne sont pas modifiés. Pour la recherche (avec succès), le résultat est un sous-arbre de l'arbre initial. Mais pour l'ajout le résultat est une modification de l'arbre initial. On ne peut pas modifier l'argument (arbre initial), il faut donc calculer l'arbre résultat en le construisant complètement. Pour ajouter l'élément x dans un arbre non vide ABR, d'étiquette e, de sous-arbre gauche G et de sous-arbre droit D, le raisonnement récursif est le suivant : si x < e il faut ajouter x dans le sous-arbre gauche G de ABR; cet ajout renvoie récursivement un arbre G' (il faut y croire !) ; et l'on fabrique l'arbre résultat avec e en racine, G' en sous-arbre gauche et D en sous-arbre droit. Le cas x > e se traite de manière analogue. (define (abr-ajout x ABR) (if (ab-noeud? ABR) (let ((e (ab-etiquette ABR))) (cond ( (= x e) ABR) ( (< x e) (ab-noeud e (abr-ajout x (ab-gauche ABR)) (ab-droit ABR))) (else (ab-noeud e (ab-gauche ABR) (abr-ajout x (ab-droit ABR)))))) (ab-noeud x (ab-vide) (ab-vide))))
3.5 Implantation de la suppression Pour définir la fonction abr-suppression il y a aussi différentes méthodes possibles; celle que nous présentons a l'avantage de ne jamais augmenter la profondeur de l'arbre en effectuant la suppression. La structure de la définition de cette fonction est analogue à celle de abr-aj out. Toute la spécificité est dans la fonction moins-racine, que nous explicitons après. (define (abr-suppression x ABR) (if (ab-noeud? ABR) (let ((e (ab-etiquette ABR))) (cond ( (= x e) (moins-racine ABR)) ; supprimerlaracine ( (< x e) (ab-noeud e (abr-suppression x (ab-gauche ABR)) (ab-droit ABR))) (else (ab-noeud e (ab-gauche ABR) (abr-suppression x (ab-droit ABR)))))) (ab-vide))) La spécification de la fonction moins-racine est la suivante: moins-racine : ArbreBinRecherche --+ ArbreBinRecherche ' '' ; ; ; (moins-racine ABR) rend un arbre binaire de recherche qui contient toutes ; ; ; les étiquettes qui apparaissent dans «ABR» hormis l'étiquette de sa racine. ; ; ; HYPOTHÈSE: l'arbre «ABR» n'est pas vide
La suppression de la racine est simple si l'élément à supprimer n'est pas relié à deux sous-arbres : il suffit alors de « court-circuiter >> l'élément à supprimer. Voici donc une version incomplète de cette fonction, qui traite uniquement les cas simples :
Arbres binaires de recherche
205
(define (moins-racine ABR) (cond ((not(ab-noeud? (ab-gauche ABR))) (ab-droit ABR) ) ((not(ab-noeud? (ab-droit ABR))) (ab-gauche ABR) ) (el se i la racine a deux sous-arbres )) )
...
Si la racine a effectivement un sous-arbre gauche et un sous-arbre droit, la suppression est plus complexe: le résultat est l'arbre obtenu en remplaçant la racine par maxinf, le plus grand des éléments qui lui sont inférieurs Où se trouve l'élément maxinf? Il est dans le sous-arbre gauche de la racine (puisqu'il est inférieur), et il est à l'extrémité de la branche droite (suite des nœuds obtenus à partir de la racine, en suivant toujours le sous-arbre droit) du sous-arbre gauche, puisque c'est le plus grand des inférieurs. De plus le sous-arbre droit de maxinf étant par définition vide, on peut remplacer l'arbre de racine maxinf par son sous-arbre gauche. Cette opération de remplacement est illustrée par la figure suivante :
(Noter qu'on aurait pu tout aussi bien« remplacer» la racine par le plus petit des éléments qui lui sont supérieurs, c'est-à-dire par l'élément situé à l'extrémité de la branche gauche du sous-arbre droit.) Il s'agit donc maintenant de « défaire» un arbre binaire de recherche, ici le sous-arbre gauche de la racine, pour renvoyer son maximum et l'arbre privé de son maximum. On trouvera en exercice une fonction qui rend le maximum d'un arbre binaire de recherche, et une autre fonction qui rend un arbre binaire de recherche privé de son maximum. Pour ne pas faire deux fois le même travail, on propose ici de « regrouper » ces deux résultats et d'écrire une fonction qui étant donné un arbre binaire de recherche non vide rend le couple formé du maximum et de l'arbre privé de son maximum. Pour calculer ce couple sur l'arbre ABR, on calcule récursivement le couple correspondant à son sous-arbre droit D ; on obtient ainsi un maximum max, qui est bien le maximum de l'arbre ABR, et un arbre D-max; le résultat est le couple dont le premier élément est max, et le second est l'arbre formé de l'étiquette et du sous arbre-gauche de ABR et qui a pour sous-arbre droit l'arbre D-max. 1
1
1
1
1
1
1
1
1
1
1
1
max-sauf-max: ArbreBinRecherche---+ Couple[Nombre ArbreBinRecherche] (max-sauf-max ABR) rend le couple formé de la plus grande étiquette présente dans l'arbre «ABR» et d'un arbre binaire de recherche qui contient toutes les étiquettes qui apparaissent dans «ABR» hormis ce maximum. HYPOTHÈSE: l'arbre «ABR» est non vide
206
Chapitre 8. Structures arborescentes
(define (max-sauf-max ABR) (if (ab-vide? (ab-droit ABR)) (list (ab-etiquette ABR) (ab-gauche ABR)) (let ((res-pree (max-sauf-max (ab-droit ABR)))) (list (car res-pree) (ab-noeud (ab-etiquette ABR) (ab-gauche ABR) (cadr res-pree))))))
On peut maintenant écrire la définition complète de la fonction qui supprime la racine : (define (moins-racine ABR) (cond ((ab-vide? (ab-gauche ABR)) (ab-droit ABR)) ((ab-vide? (ab-droit ABR)) (ab-gauche ABR)) (else (let ((res-pree (max-sauf-max (ab-gauche ABR)))) (ab-noeud (car res-pree) (cadr res-pree) (ab-droit ABR))))))
3.6
Conclusion
Dans les algorithmes précédents, la recherche, l'ajout et la suppression dans un arbre binaire de recherche, nécessitent uniquement l'examen (et éventuellement la modification) d'une branche de l'arbre, à partir de la racine. La complexité de ces algorithmes est donc proportionnelle à la hauteur de la branche suivie. On a vu que la hauteur d'un arbre binaire de recherche contenant n éléments peut varier entre log n (pour les arbres bien équilibrés) et n (pour les arbres dégénérés). Une étude plus poussée, qui dépasse le cadre de ce livre, montre que la hauteur des arbres binaires de recherche est « en général >> en 0 (log n) (il s'agit plus exactement d'une moyenne, sous certaines hypothèses d'uniformité). Ainsi les arbres binaires de recherche sont une structure efficace en moyenne mais pas dans le cas le pire. Pour éviter les cas de dégénérescence, il existe des techniques de rééquilibrage qui permettent de maintenir, en temps logarithmique, un arbre de recherche à peu près équilibré (c'est-à-dire de profondeur O(logn)). On obtient alors des arbres de recherche pour lesquels les traitements se font toujours en O(logn)): ce sont par exemple les arbres AVL ou les arbres 2-3-4, qui sont utilisés dans de nombreux contextes, comme par exemple les systèmes de gestion des bases de données.
4 Arbres généraux La structure d'arbre général permet de représenter une hiérarchie de valeurs, que nous supposons ici de même type. Cette section présente la définition des arbres généraux, leur barrière d'abstraction et quelques exemples de traitement.
207
Arbres généraux
4.1 Définition Dans un arbre général, chaque nœud porte une information (étiquette de type a), et a un nombre quelconque de sous-arbres. Le type correspondant est noté ArbreGeneral [a]. Définition (récursive): un arbre général de type ArbreGeneral [a] est formé - d'un nœud portant une étiquette de type a - et d'une suite de sous-arbres immédiats, chacun de type ArbreGeneral [a] Une suite (ordonnée) d'arbres généraux est appe1éefor€t. Noter qu'il n'y a pas d'arbre général vide; en revanche une forêt peut être vide. Le« plus petit>> arbre général est donc formé d'un nœud et d'une forêt vide. On appellera feuille un tel arbre. ll faut aussi remarquer que lorsque la forêt des sous-arbres immédiats d'un arbre général a un seul élément, cet élément est le sous-arbre de la racine (et il n'est pas question ici de notion gauche/droite).
4.2 Barrière d'abstraction La définition récursive des arbres généraux induit les fonctions de la barrière d' abstraction : on dispose du constructeur ag-noeud pour construire un arbre général, et des accesseurs ag-etiquette et ag-foret pour accéder aux parties d'un arbre général. La forêt des sous-arbres immédiats de la racine est une suite d'arbres. Nous considérons ici que cette suite est représentée par une liste et nous introduisons donc le type Foret[a] _ LISTE[ArbreGeneral[a]].
jl Ainsi le type Foret [al n'est pas un type abstrait et il n'y a donc pas, dans la barrière ~ d'abstraction, de fonctions spécifiques pour manipuler les forêts ; pour traiter les forêts on utilisera les fonctions sur les listes. En particulier, une forêt se décompose en son premier arbre (fonction car) et le reste de la forêt (fonction cdr) qui est une forêt. Et l'on pourra aussi utiliser sur les forêts les fonctionnelles map et reduce.
4.2.1 Spécification du constructeur La construction d'un arbre général se fait par le constructeur ag-noeud. ; ; ; ag-noeud: a* Foret[a]--tArbreGeneral[a] ; ; ; (ag-noeud e F) rend l'arbreformé de la racine d'étiquette «e» et, ; ; ; comme sous-arbres immédiats, les arbres de la forêt «F».
Par exemple l'expression {ag-noeud 3 ' {J J construit la feuille d'étiquette 3. L'arbre suivant, appellé par la suite ag Ex, est de type ArbreGeneral [Nombre] 6
1/.1~5
2
/1""3 4
2 1 4
/\ 3
208
Chapitre 8. Structures arborescentes
il peut être construit par 1' expression (ag-noeud 6 (list (ag-noeud 1 (list (ag-noeud 2 '()) (ag-noeud 3 ' ()) (ag-noeud 4 ' ( ) ) ) ) (ag-noeud 4 ' ()) (ag-noeud 3 ' ()) (ag-noeud 5 (list (ag-noeud 2 (list (ag-noeud 4 '()))) (ag-noeud 3 ' () ) ) ) ) )
ou alors par l'expression (let* ((g1 (ag-noeud 2 (g2 (ag-noeud 3 (g3 (ag-noeud 4 (g4 (ag-noeud 1 (g5 (ag-noeud 2 (g6 (ag-noeud 5 (ag-noeud 6 (list g4
'())) '())) '())) (list (list (list g3 g2
g1 g2 g3))) g3))) g5 g2)))) g6)))
La valeur de cette expression est un objet de type ArbreGeneral [Nombre] que l'on manipule au travers des accesseurs de la barrière d'abstraction. Remarque : lorsque l'on utilise une barrière d'abstraction, les objets manipulés sont tous « fabriqués >> en utilisant les constructeurs. Pour définir une fonction sur un tel objet on a besoin de savoir avec quel constructeur il a été fabriqué. C'est le rôle des reconnaisseurs d'aiguiller les définitions selon le constructeur qni a fabriqué l'objet. Mais lorsque la barrière d'abstraction ne comporte qu'un constructeur, tout objet est« fabriqué>> en utilisant ce constructeur et l'on n'a pas besoin de recounaisseur si l'on reste dans l'ensemble des objets construits. 4.2.2 Spécification des accesseurs
i i
Les accesseurs permettent d'accéder à l'étiquette et à la forêt d'un arbre général. i ag-etiquette : ArbreGeneral[a.] --+ a. (ag-etiquette G) rend l'étiquette de la racine de l'arbre «G».
' '' ; ; ; ag-foret: ArbreGeneral[a.]--+ Foret[a.] ; ; ; (ag-foret G) rend /a forêt des sous-arbres immédiats de «G».
Par exemple l'application de la fonction ag-etiquette à l'arbre ag Ex rend la valeur 6, et l'application de la fonction ag-foret à l'arbre agE x rend une forêt de quatre arbres. Les accesseurs permettent de défaire ce qui a été fabriqué avec le constructeur, plus précisément on a les propriétes algébriques suivantes : - pour toute forêt d'arbres généraux F, et toute valeur v (ag-etiquette (ag-noeud v F)) (ag-foret (ag-noeud v F)) = F
=
v
- pour tout arbre général G (ag-noeud (ag-etiquette G) (ag-foret G))
G
Arbres généraux
209
4.2.3 Utilitaire: une fonction d'affichage On adjoint à la barrière d'abstraction une fonction d'affichage qui permet de visualiser les arbres généraux évalués par Scheme. La fonction ag-affichage a comme spécification : ; ; ; ag-affichage : ArbreGeneral[a] - t string ; ; ; (ag-affichage G) rend un affichage préfixe de «G».
Le résultat de l'affichage d'un arbre est une chaîne qui décrit l'arbre général de façon non ambiguë. Par exemple l'application de la fonction ag-affichage à l'arbre agEx doune pourrésultatlachaîne • [6 [1 2 3 41 4 3 [5 [2 41 311 ".Cette fonction d'affichage peut être définie en utilisant la barrière d'abstraction (voir exercices).
4.3 Récursion sur les arbres généraux On présente des schémas de récursion croisée sur les arbres généraux et les forêts, ainsi qu'un schéma de traitement des arbres qni utilise les fonctionnelles sur les listes. Différents exemples d'applications sont ensuite proposés.
4.3.1 Schéma récursif Récursion croisée : les définitions récursives sont plus compliquées sur les arbres généraux que sur les arbres binaires, car le nombre de sous-arbres de la racine n'est pas limité à deux, et de plus on ne peut pas atteindre directement tous les sous-arbres de la racine, puisque la forêt est représentée par une liste. En fait, souvent, pour définir une fonction ayant comme donnée un arbre général, on a besoin de définir une fonction compagnon ayant comme donnée une forêt, ces deux fonctions étant mutuellement récursives (l'une appelle l'autre et inversement). On parle alors de récursion croisée. Le schéma suivant montre deux telles fonctions :la fonction ag- fonction prend comme dounée un arbre général G et lui applique une combinaison de l'étiquette de G et du résultat de l'application de la fonction foret-fonction à la forêt des sous-arbres de G. En retour, la fonction foret-fonction, qui prend comme donnée une forêt (liste) d'arbres, combine le traitement du premier arbre par la fonction ag-fonction et le traitement récursif du reste de la forêt, qni est une forêt; il faut bien sftr aussi traiter le cas de base de la forêt vide. Dans le schéma on a placé les deux fonctions au même niveau, mais la fonction principale est celle sur les arbres ; la fonction sur les forêts, qui est auxiliaire, peut être définie à l'intérieur de ag- fonction. ; ; ; ag-fonction: ArbreGeneral[alpha] - t ... (define (agjonction G) (une-combinaison (ag-etiquette G) (foret-fonction (ag-foret G)))) ; ; ; foretjonction: LISTE[ArbreGeneral[alpha]] - t ... (de fine (foret-fonction F) (if {pair? F) (combi (ag-fonction (car F) ) (joretjonction ( cdr F) ) ) casjoret-vide) )
210
Chapitre 8. Structures arborescentes
...JI
Ce schéma peut être légèrement modifié pour particulariser le traitement des feuilles : ~ dans la fonction ag-fonction on fait alors apparaître comme cas de base le cas où l'arbre est une feuille, et on appelle la fonction foret-fonction uniquement si l'arbre n'est pas réduit à une feuille. Dans ce cas la fonction foret-fonction est toujours appelée avec une forêt non vide et l'on peut donc prendre comme cas de base la forêt formée d'un seul arbre Oe test est donc alors (pair? (cdr F)) ). La récursion sur les arbres généraux est fondée sur le fait que la forêt des sous-arbres immédiats d'un arbre A est constitué d'arbres toujours strictement plus petits que A, quel que soit le critère de mesure. Et cette décomposition récursive aboutit ultimement à une liste vide de sous-arbres. Le schéma de récursion croisée exprime une relation de récurrence entre un arbre et ses sous-arbres immédiats. ll est analogue (particulièrement dans la version modifiée présentée en remarque) à la méthode de preuve par induction structurelle sur les arbres généraux : - Cas de base : la propriété est vraie pour l'arbre réduit à une feuille. - Étape récursive : soit G un arbre qui n'est pas une feuille, on suppose que que la propriété est vraie pour tous ses sous-arbres immédiats et on montre alors qu'elle est vraie pour G. Utilisation de fonctionnelles : au lieu d'écrire explicitement la récursion, on peut utiliser les fonctionnelles map et reduce, en exploitant le fait qu'une forêt est une LISTE d'arbres. Pour traiter un arbre G, il faut traiter chacun de sous-arbres immédiats (map ArbreRec (ag-foret G)), réduire la liste ainsi obtenue et la combiner avec l'étiquette de G. La définition de la fonction sur les forêts peut ainsi s'exprimer par : (de fine (foret-fonction F) (reduce (combi base (map ag-fonction (ag-foret
G)))))
Mais en fait il est inutile de définir explicitement la fonction sur les forêts : dans la définition de la fonction sur les arbres, il suffit de substituer son appel par l'expression qui la définit, en adaptant les arguments de l'appel. On obtient donc la définition suivante : (define (ag-fonctionG) (une-combinaison (ag-etiquette G) (reduce combi base (map ag-fonction (ag-foret G))))
Ce schéma dissimule la récursion derrière les deux appels de fonctionnelles. ll faut aussi noter qu'il est éventuellement moins efficace que la récursion croisée. En effet ici, en l'absence d'optimisation, la liste des sous-arbres est traitée deux fois (d'abord par l'application de map puis par l'application de reduce), alors que dans le schéma précédent les deux traitements sont rassemblés en une seule étape. 4.3.2 Exemples d'application On calcule ici quelques caractéristiques des arbres généraux : la profondeur et la liste préfixe.
Exemple 1 : la profondeur d'un arbre général peut être définie comme étant égale à la profondeur de son arbre le plus profond, augmentée de un (et elle est nulle pour la forêt vide). Par exemple, la profondeur de l'arbre agE x est 4.
211
Arbres généraux
La fonction ag-profondeur, qui rend la profondeur d'un arbre, est accompagnée de la fonction foret-profondeur qui rend la profondeur d'une forêt. Cette dernière n'étant qu'une fonction auxiliaire, elle est définie à l'intérieur de la définition de ag-profondeur. La profondeur d'une forêt se calcule récursivement sur la liste des arbres qui la composent : elle est égale au maximum de la profondeur du premier arbre de la forêt et de la profondeur de la forêt obtenue en supprimant le premier arbre. Cette relation de récurrence n'étant définie que pour une liste non vide, il faut extraire de l'appel récursif le cas où la forêt est vide. ; ; ; ag-profondeur: ArbreGeneral[a] -+ nat ; ; ; (ag-profondeur G) rend la profondeur de l'arbre «Œ> (define (ag-profondeur G) ; ; foret-profondeur : Foret[a] -+ nat ; ; (foret-profondeur F) rend la profondeur de «F», c.-à-d. le maximum des profondeurs ; ; des arbres de «F» (rend 0 lorsque laforet est vide). (define (foret-profondeur F) (if (pair? F) (max (ag-profondeur (car F)) (foret-profondeur (cdr F))) 0) )
, , expression de (ag-profondeur G) : (+ 1 (foret-profondeur (ag-foret G))))
Pour calculer la profondeur d'une forêt on peut aussi utiliser la fonctionnelle map pour rendre la liste des profondeurs de ses arbres ; puis calculer le maximum des éléments de cette liste en lui appliquant la fonctionnelle reduce, avec la fonction max et la valeur initiale O. (define (ag-profondeur G) (+ 1 (reduce max 0 (map ag-profondeur (ag-foret G)))))
Exemple 2 : la liste préfixe des étiquettes d'un arbre général est égale à la concaténation de l'étiquette de sa racine et de la liste préfixe des étiquettes de chacun de ses sous-arbres immédiats. Par exemple, la liste préfixe de ag Ex est ( 6 1 2 3 4 4 3 s 2 4 3) .
Ici aussi on peut donner deux définitions de la fonction qui calcule la liste préfixe d'un arbre général, la première utilise la récursion croisée : ; ; ; ag-prefixe : ArbreGeneral[a] -+ LISTE[a] ; ; ; (ag-prefixe G) rend la liste préfixe des étiquettes de l'arbre «G» (define (ag-prefixe G) : : foret-prefixe : Foret[a] -+ LISTE[a] rend la concaténation ; ; des listes préfixes des étiquettes des arbres de F (define (foret-prefixe F) (if (pair? F) (append (ag-prefixe (car F)) (foret-prefixe (cdr F))) ' () ) ) , , expression de (ag-prefixe G) : (cons (ag-etiquette G) (foret-prefixe (ag-foret G))))
et la seconde utilise les fonctionnelles sur les listes : (define (ag-prefixe G) (cons (ag-etiquette G) (reduce append '()
(map ag-prefixe (ag-foret G)))))
212
Chapitre 8. Structures arborescentes
5 Systèmes de fichiers On a déjà cité les arbres généraux pour représenter les arbres généalogiques de descendance, les tables des matières des documents, les organigrammes de sociétés ; on étudie ici la représentation arborescente des systèmes de fichiers.
5.1
Notion de descripteur de fichier
Dans un tel système, fichiers et répertoires sont décrits à l'aide de descripteurs qui, dans la réalité, comportent le nom du fichier ou du répertoire, la date de création, la date de modification ... ainsi que des informations pour situer le fichier sur le disque. Dans notre modèle, le descripteur ne comportera que les informations suivantes : - le nom du fichier ou du répertoire, - est-ce un fichier ou un répertoire ? - pour un fichier, la taille du fichier. ~
Spécification de la barrière d'abstraction Descripteur Nous manipulerons les descripteurs à l'aide des fonctions suivantes : 1>
' '' i ; i i ; i ; i i
Constructeurs
desc-jichier : string * nat --+ Descripteur (desc-.fichier nom taille) rend le descripteur du fichier de nom «nom» et de taille «taille» desc-repertoire : string --+ Descripteur (desc-repertoire nom) rend le descripteur du répertoire de nom «nom» 1>
Reconnaisseurs
; ; ; desc-.fichier? : Descripteur --+ bool ; ; ; (desc-fichier? desc) rend #t ssi «desc» est le descripteur d'un fichier ; ; ; desc-repertoire? : Descripteur--+ bool ; ; ; (desc-repertoire? desc) rend #t ssi «desc» est le descripteur d'un répertoire 1> i ; i ; i i
Accesseurs
desc-nom : Descripteur --+ string (desc-nom desc) rend le nom du répertoire ou du fichier dont le descripteur est «desc»
desc-taille : Descripteur --+ nat , , , (desc-taille desc) rend la taille du fichier dont le descripteur est «desc» i i i ERREUR lorsque la description donnée est la description d'un répertoire ; i i
~
Implantation de la barrière d'abstraction Descripteur
Parmi de nombreuses possibilités, nous avons choisi d'implanter cette barrière d'abstraction en utilisant des vecteurs (de longueur deux pour les répertoires et trois pour les fichiers) : - le premier composant contient le symbole D (D comme Directory, lorsque c'est un répertoire) ou le symbole F (F comme File, lorsque c'est un fichier), - le second composant contient le nom du répertoire ou du fichier, - pour les fichiers, le troisième composant contient la taille du fichier.
Systèmes de fichiers
213
Remarque: dans l'exemple, les nombres d'informations à mémoriser étant différents pour les fichiers et pour les répertoires, le premier composant du vecteur est inutile. On a tout de même préféré la solution donnée ci-dessus, car on pourrait éventuellement être amené à ajouter, dans le modèle, une autre information pour les répertoires (par exemple, le nombre d'éléments qu'il contient). L'implantation est alors très simple et se passe de commentaires. 1> Constructeurs
; ; ; desc-fichier : string • nat ..... Descripteur ; ; ; (desc-.fichier nom taille) rend le descripteur du fichier de nom «nom» et de taille «taille» (define (desc-fichier nom taille) (vector 'F nom taille)) ; ; ; desc-repertoire : string ..... Descripteur ; ; ; (desc-repertoire nom) rend le descripteur du répertoire de nom «nom» (define (desc-repertoire nom) (vector 'D nom) ) 1> Reconnaisseurs
; ; ; desc-fichier? : Descripteur ..... bool ; ; ; (desc-.fichier? desc) rend #t ssi «desc» est le descripteur d'un fichier (define (desc-fichier? desc) (equal? (vector-ref desc 0) 'F)) ; ; ; desc-repertoire? : Descripteur ..... bool ; ; ; (desc-repertoire? desc) rend #t ssi «desc» est le descripteur d'un répertoire (define (desc-repertoire? desc) (equal? (vector-ref desc 0) 'D)) 1> Accesseurs
; ; ; desc-rwm : Descripteur ~ string ; ; ; (desc-nom desc) rend le nom du répertoire ou du fichier dont le descripteur est «desc» (define (desc-nom desc) (vector-ref desc 1)) ; ; ; desc-taille : Descripteur ..... nat , , , (desc-taille desc) rend la taille du fichier dont le descripteur est «desc» ; ; ; ERREUR lorsque la description donnée est la description d'un répertoire (define (desc-taille desc) (vector-ref desc 2))
5.2
Représentation d'un système de fichiers
Un système de fichiers peut être représenté par un arbre général dont les étiquettes sont des descripteurs. Noter que tout arbre ainsi défini ne représente pas un système de fichiers valide : dans un système de fichiers réel, tout nœud qui a comme étiquette un descripteur de fichier doit être une feuille et les noms des différents éléments d'un répertoire doivent être tous différents.
214
Chapitre 8. Structures arborescentes
Nous nommerons Systeme le type des éléments de ArbreGeneral [Descripteur] qui vérifient ces deux propriétés. Dans la suite, nous utiliserons l'exemple suivant (les rectangles représentent des répertoires, les ovales représentent des fichiers, leur taille étant écrite à l'intérieur de l'ovale, et, dans les deux cas le nom étant écrit juste en-dessous) :
D G 0 rms
fins
finp rab
G)
G Qc:! 0 fab
fins® 0f 5 ab
fab
fmp
finp Jja
rab
rmp
En ne mettant que le nom du fichier ou du répertoire comme étiquette, ce système peut être représenté par l'arbre suivant (comme en Unix, par convention, la racine a un point comme étiquette) :
~·~ rab
/ /\ rab
rms
fmp
fms
fab
~ /1""'
rmp
rja
fms
fab
fmp
fms
/l"" fab
fmp
On peut écrire deux constructeurs pour le type Systeme, le premier, sys-fichier, qui
construit un système de fichiers ne comportant qu'un fichier et le second, sys-repertoire, qui construit un répertoire : ; ; ; sys-jichier : string * nat ---> Systeme ; ; ; (sys-fichier nom taille) rend le système de fichiers qui ne comporte qu'unfichier, ; ; ; de nom «nom» et de taille «taille» (define (sys-fichier nom taille) (ag-noeud (desc-fichier nom taille) '())) ; ; ; sys-repertoire: string * USTE[Systeme]---> Systeme ; ; ; (sys-repertoire nom L) rend le système de fichiers qui comporte le répertoire, ; ; ; de nom «nom», qui a comme sous-systèmes les systèmes de «L» (define (sys-repertoire nom L) (ag-noeud (desc-repertoire nom) L))
215
Systèmes de fichiers
La fonction systl rend alors la représentation, dans le type Systeme, de l'exemple dessiné précédemment : (define (systl) (sys-repertoire (list (sys-repertoire 11 rab'' (list (sys-repertoire nrab (list (sys-repertoire "rms" '()) (sys-fichier "fmp" 45))) (sys-repertoire 11
nrjan
(list (sys-fichier "fms" 99) (sys-fichier "fab" 54) (sys-fichier "fmp" 65))) (sys-fichier "fms" 66))) (sys-fichier "fab" 564) (sys-repertoire ''rm.p''
(list (sys-fichier "fms" 99) (sys-fichier "fab" 54) (sys-fichier "fmp" 65))))))
5.3 ~
Implantation de quelques commandes gérant un système de fichiers
Définition de la fonction du-s
Soit à définir la fonction du-s, correspondant à la commande du -s d'Unix, qui rend la somme des tailles de tous les fichiers présents dans le système donné : ; ; ; du-s : Systeme ---+ nat ; ; ; (du-s systeme) rend la quantité d'espace disque utilisée par les fichiers du système «systeme» Parexemple: (du-s (systl)) --+ 1111
La définition de cette fonction est une définition « classique >> pour les arbres généraux : - si l'étiquette de l'arbre est le descripteur d'un fichier, le résultat est la taille de ce fichier, - si c'est un répertoire, il faut sommer les quantités d'espace disque utilisées par les différents éléments du répertoire, cette sommation pouvant être effectuée en utilisant les fonctionnelles map et reduce : (define (du-s systeme) (if (desc-fichier? (ag-etiquette systeme)) (desc-taille (ag-etiquette systeme)) (reduce + 0 (map du-s (ag-foret systeme))))) ~
Définition de la fonction 11
Traitons à présent la fonction 11 qui correspond à la commande ls -1 d'Unix: ll : Systeme --+ string (Il systeme) rend la chaîne de caractères contenant, pour chaque élément de «systeme» ' '' i i i (on ne considère que les sous-éléments immédiats), une ligne formée: i i i
216
Chapitre 8. Structures arborescentes
; ; ; pour un fichier, de sa taille et de son nom (séparés par une tabulation), ; ; ; pour un répertoire, de 0, d'une tabulation, de son nom immédiatement suivi du caractère ''/"
Par exemple : (11 (systl) )---> " 0 rab/ 564 fab 0 rmp/
" Pour calculer le résultat de cette fonction, on peut :
1. extraire la liste des descripteurs de tous les sous-arbres du système (en utilisant la fonctionnelle map appliquée à la forêt des sous-répertoires et à la fonction ag-etiquette), 2. fabriquer la liste des lignes recherchées (toujours à l'aide de la fonctionnelle map, appliquée à la liste des descripteurs obtenue dans le point précédent et à une fonction -à définir et que nous nommerons desc-11- qui rend la ligne pour un descripteur donné), 3. et il n'y a plus qu'à concaténer toutes ces lignes en utilisant l'itérateur reduce. Voici la définition Scheme correspondante : (define (11 systeme) , , desc-ll : Descripteur --+ string ; ; (desc-ll descripteur) rend l'image de «descripteur», i.e. ligne formée: ; ; pour un fichier, de sa taille et de son nom (séparés par une tabulation), pour un , , répertoire, de 0, d'une tabulation, de son nom immédiatement suivi du caractère "/" (define (desc-11 descripteur) (string-append (n1) (->string (if (desc-fichier? descripteur) (desc-tai11e descripteur) 0) )
(tab) (desc-nom descripteur) (if (desc-fichier? descripteur) "" "/"))) , , expression de (Il systeme): (reduce string-append (n1) (map desc-11 (map ag-etiquette (ag-foret systeme))))) ~
Définition de la fonction find
Remarque : cet exemple, plus complexe que les précédents, peut être passé en première lecture. Sous Unix, la commande find permet de rechercher des fichiers ou des répertoires dans une hiérarchie de répertoires. On souhaite ici définir la fonction find, cas particulier de la commande Unix : ; ; ; find : string * Systeme ---> string ; ; ; (find ident syst) rend les noms complets des fichiers ou répertoires du système «syst» ; ; ; dont le nom est «ident» (avec un nom complet par ligne).
Systèmes de fichiers
217
Par exemple : (find "fms" (systl) )---+ " ./rab/rja/fms . /rab/fms . /rmp/fms "
D> Idées
Étant donnés un système, syst, et un identificateur, ident, les fichiers ou répertoires du système syst dont le nom est ident sont :
1. le système syst lui-même si son nom est ident ; le nom complet est alors ident tout simplement; 2. si le système syst est un répertoire, les fichiers ou répertoires des sous-systèmes de syst dont le nom est ident; le nom complet est alors obtenu en préfixant par le nom du système syst suivi du caractère « 1 » le nom complet du fichier dans le sous-système où il a été trouvé. Ainsi, après avoir obtenu récursivement les noms complets dans les sous-systèmes, il faut préfixer chacun d'eux par le nom du système syst suivi du caractère << 1 ». Pour que cette opération soit aisée, il est préférable que la fonction qui est appelée récursivement rende la liste des noms complets et non la chaîne de caractères obtenue en concaténant ces noms complets, séparés par des retours à la ligne. D'autre part, on peut noter que la valeur de l'argument<< ident >>(le nom à chercher) sera inchangée pour tous les appels récursifs : cet argument peut (on verra par la suite qu'il doit) être mis en variable globale dans la fonction qui rend une liste de noms, cette dernière devant alors être une fonction interne à la définition de la fonction find demandée. D> Structure de la définition de fi nd
À partir de la liste des noms complets, il faut obtenir la chaîne de caractères des noms complets séparés par des retours à la ligne. Pour ce faire, on peut appliquer l'itérateur reduce à la fonction string-append-nl spécifiée et définie par : ; ; ; string-append-nl : string * string --t string ; ; ; (string-append-nl sl s2) rend la chaîne de caractères obtenue en concaténant un retour à la ; ; ; ligne puis «Si» et «S2». (define (string-append-nl sl s2) (string-append (nl) sl s2)) D'où la structure de la définition de la fonction find: (define (find ident syst) ; ; find-liste-ident : Systeme ---+ LISTE[string] ; ; (find-liste-ident syst) rend la liste des noms complets des fichiers ou répertoires ; ; du système «syst» dont le nom est «ident» ... définition à écrire ... définition à écrire ; ; expression de (find ident syst) : (reduce string-append-nl (nl) (find-liste-ident syst)))
Chapitre 8. Structures arborescentes
218 [>
Définition de la fonction (interne) find-liste-ident
Au vu de ce qui a été dit dans le paragraphe « Idées », la structure de la définition de find-liste-ident pourrait être: (if [le nom de «syst » est «ident»] (cons ident [liste des noms complets des fichiers issus des éventuels sous-systèmes de «syst»]) [liste des noms complets des fichiers issus des éventuels sous-systèmes de «syst»])
Mais, la définition du calcul de la liste des noms complets des fichiers issus des éventuels sous-systèmes étant complexe, nous préférons la structure (équivalente, mais plus détaillée car elle prend en compte la nécessité de tester si le système est un répertoire) suivante : (append (if [le nom de «syst » est «ident»] (list ident) ' () ) (if [ «syst » est un répertoire] [liste des noms complets des fichiers issus des sous-systèmes de «syst»] ' () ) )
Reste à écrire l'expression qui calcule, lorsque le système est un répertoire, la liste des noms complets des fichiers issus de ses sous-systèmes : - on calcule, pour chacun des sous-systèmes, la liste des noms complets des fichiers ou répertoires ayant comme nom ident ; pour ce faire, on applique l'itérateur map à la fonction find-liste-ident -noter la nécessité de la globalisation de la variable iden t - et à la liste des sous-systèmes ; - le résultat précédent est une liste de listes de chaînes de caractères et on doit avoir la liste de toutes ces chaînes de caractères ; on l'obtient en appliquant l'itérateur reduce à la fonction append et à la liste de listes ; - pour avoir le résultat attendu, on doit préfixer chacun des éléments de la liste précédente par le nom du système suivi du caractère« 1>>;il suffit d'appliquerl'itérateur map à une fonction ad-hoc, fonction interne à la définition de la fonction find-liste-ident. [>
Définition complète de la fonction find
Voici la définition complète de la fonction find (en plus des explications précédentes, dans la définition de find-liste-ident, on nomme, en utilisant un let, le système de fichiers donné) : ; ; ; find : string * Systeme -> string ; ; ; (find ident syst) rend les noms complets des fichiers ou répertoires du système «syst» ; ; ; dont le nom est «ident» (avec un nom complet par ligne). (define (find ident syst) ; ; ftnd-liste-ident : Systeme -> LISTE[string] ; ; (jind-liste-ident syst) rend la liste des noms complets des fichiers ou répertoires ; ; du système «syst» dont le nom est «ident» (define (find-liste-ident syst) (let ((nom-syst (desc-nom (ag-etiquette syst))))
219
Représentation des arbres ; prefixee-par-nom-syst : string --+ string ; (prefixee-par-nom-syst s) rend la chaîne de caractères obtenue en ; concaténant le nom du système «syst», le caractère 1 et «S» (define (prefixee-par-nom-syst s} (string-append nom-syst "/" s}} ; expression de (jind-liste-ident syst) : (append (if (equal? ident nom-syst} (list ident} ' (} } (if (desc-repertoire? (ag-etiquette syst}} (map prefixee-par-nom-syst (reduce append
'(} (map find-liste-ident (ag-foret syst}}}} '(}}}}}
; ; expression de (jind ident syst) : (reduce string-append-nl (nl}
(find-liste-ident syst}}}
6 Représentation des arbres Cette section est consacrée à l'implantation des barrières d'abstraction des arbres binaires et des arbres généraux, par des S-expressions d'une part et des vecteurs d'autre part.
6.1
Implantation d'une barrière d'abstraction
Tous les programmes que l'on a écrits jusqu'ici sur les arbres (binaires ou généraux) utilisent comme primitives les fonctions des barrières d'abstraction (des arbres binaires ou généraux), en s'appuyant sur les spécifications fournies pour ces fonctions. On se propose ici de passer de l'autre côté de la barrière, pour définir les fonctions de la barrière d'abstraction, en respectant leur spécification. ll s'agit de programmer ces fonctions à partir d'autres fonctions, plus primitives, c'est-à-dire qui appartiennent à une barrière d'abstraction plus enfouie dans le langage. Dans l'optique structuration des données, cela veut aussi dire que l'on va représenter les arbres en utilisant des structures de données de Scheme. D'un point de vue opérationnel, développer une application au travers d'une barrière d'abstraction permet d'écrire du code robuste et réutilisable. D'un point de vue conceptuel, cela permet aussi de se concentrer sur les difficultés propres à l'application. Ici c'est sur le mécanisme de récursion que nous avons voulu insister : la récursion binaire (deux appels récursifs pour les arbres binaires) et la récursion générale (suite d'appels récursifs pour les arbres généraux) sont mises en valeur par les fonctions des barrières d'abstraction. Les façons d'implanter une barrière d'abstraction sont multiples et peuvent être comparées suivant différents critères: rapidité d'accès, mémoire utilisée ... et, selon l'application, on pourra donc préférer telle implantation à telle autre. Pour les arbres on montre une implantation par des S-expressions et une implantation par des vecteurs. Nous avons étudié, au chapitre précédent, les différentes caractéristiques des Sexpressions et des vecteurs mais nous ne comparerons pas les performances de ces différentes
220
Chapitre 8. Structures arborescentes
implantations plus avant, car cela dépasse le programme de ce livre. Ce qui nous importe ici, c'est de respecter les spécifications données dans les barrières d'abstraction.
6.2 Arbres binaires La barrière d'abstraction des arbres binaires est donnée en début de chapitre; nous ne rappelons pas ici les spécifications des fonctions, mais donnons seulement leur définition. 6.2.1 Implantation par des listes La notion générale de liste permet d'implanter facilement les arbres binaires. En effet lorsque l'arbre est vide, on peut le représenter par la liste vide; et lorsque l'arbre n'est pas vide, on peut le représenter par une liste contenant l'étiquette de la racine et les deux S-expressions qui représentent ses sous-arbres immédiats.
j\ Noter que l'on a six ordres possibles pour représenter cette liste de trois éléments ~ (d'abord l'étiquette puis le sous arbre gauche et enfin le sous-arbre droit ou alors le sous-arbre gauche puis l'étiquette et enfin le sous-arbre droit...). Ces six possibilités sont aussi valables les unes que les autres. ll faut en choisir une et s'y tenir pour toutes les fonctions qui opèrent sur les arbres non vides. Nous donnons une implantation écrite en mettant systématiquement l'étiquette de la racine en premier et le sous-arbre gauche avant le sous-arbre droit. ; ; ; ; Constructeurs (define (ab-noeud e Bl B2) (liste Bl B2)) (define (ab-vide) ' ()) ; ; ; ; Reconnaisseur (define (ab-noeud? B) (pair? B)) ; ; ; ; Accesseurs (define (ab-etiquette B) (car B)) (define (ab-gauche B) (cadr B)) (define (ab-droit B) (caddr B))
6.2.2 Implantation par des vecteurs On peut aussi implanter les arbres binaires en utilisant des vecteurs : l'arbre vide est représenté par le vecteur vide ; un arbre non-vide est représenté par un vecteur de longueur trois ayant comme composants l'étiquette de la racine de l'arbre- par exemple en indice 0-, la représentation du sous-arbre gauche - par exemple en indice 1 - et la représentation du sous-arbre droit- par exemple en indice 2. Les constructeurs sont implantés en utilisant la fonction vector, avec ou sans argument. Pour savoir si un arbre est vide il suffit de comparer la longueur du vecteur à O. Pour les
221
Représentation des arbres
accesseurs, on utilise la fonction vector-ref, l'étiquette étant en indice 0, le sous-arbre gauche en indice 1 et le sous-arbre droit en indice 2. ; ; ; ; Constructeurs
(define (ab-noeud e Bl B2) (vector e Bl B2)) (define (ab-vide) (vector)) ; ; ; ; Reconnaisseur
(define (ab-noeud? B) (> (vector-length B) 0)) ; ; ; ; Accesseurs
(define (ab-etiquette (vector-ref B 0)) (define (ab-gauche B) (vector-ref B 1)) (define (ab-droit B) (vector-ref B 2))
B)
6.3 Arbres généraux On ne rappelle pas ici les spécifications des fonctions de barrière d'abstraction, données en début de chapitre ; on donne seulement leurs définitions, dans trois implantations différentes, à l'aide de listes et de vecteurs.
6.3.1 Implantation par des listes Un arbre général sera représenté par une liste dont le premier élément est l'étiquette de la racine et dont les éléments suivants constituent la forêt de ses sous-arbres immédiats. Pour construire un arbre à partir de 1' étiquette e de la racine et de la forêt F de ses sousarbres, il suffit d'utiliser la fonction cons. Inversement pour les accesseurs, l'étiquette est le premier élément de la liste qui représente l'arbre et la forêt des sous-arbres est constituée par la liste qui représente l'arbre privé de son premier élément. ; ; ; ; Constructeur
(define (ag-noeud e (cons e F))
F)
; ; ; ; Accesseurs
(define (ag-etiquette g) (car g)) (define (ag-foret g) (cdr g))
6.3.2 Implantation par des vecteurs Un arbre général sera représenté par un vecteur, le premier composant du vecteur contenant l'étiquette de la racine de l'arbre et les composants suivants contenant les représentations des sous-arbres immédiats de l'arbre.
222
Chapitre 8. Structures arborescentes
Pour construire un vecteur dont on connaît le premier composant et la liste des autres composants, on peut mettre ce premier composant en tête de la liste, puis transformer cette liste en vecteur à l'aide de la fonction list->vector. Pour accéder aux composants, l'étiquette étant le premier composant (celui d'indice 0) du vecteur qui représente l'arbre, il suffit d'appliquer la fonction vector-ref; et la forêt des sous-arbres étant la liste des arbres contenus dans les composants (hormis le premier) du vecteur, on utilise la fonction vector->list. ; ; ; ; Constructeur (define (ag-noeud e F) (list->vector (cons e F)) ; ; ; ; Accesseurs (define (ag-etiquette g) (vector-ref g 0)) (define (ag-foret g) (cdr (vector->list g)))
6.3.3 Implantation mixte Dans cette dernière implantation, on représente un arbre général par un vecteur, de longueur deux, dont le premier composant est l'étiquette de la racine et dont le second composant est la liste de ses sous-arbres immédiats (c'est donc la forêt de ses sous-arbres immédiats). ; ; ; ; Constructeur (define (ag-noeud e F) (vector e F)) ; ; ; ; Accesseurs (define (ag-etiquette g) (vector-ref g 0)) (define (ag-foret g) (vector-ref g 1))
7 Exercices corrigés 7.1
Énoncés
Exercice 35- De la feuille à l'arbre Cet exercice est un exercice d'assouplissement, dans lequel on s'entraîne à manipuler la barrière d'abstraction des arbres binaires.
Question 1- Écrire une définition de la fonction ab-feuille qui prend en argument une étiquette et renvoie 1' arbre binaire contenant cette seule étiquette. Écrire une définition du prédicat ab-feuille? qui reconna11 si un arbre binaire est une feuille. On supposera que l'arbre est non vide (ce prédicat ne sera appliqué qu'à des arbres qui satisfont le prédicat ab-noeud?). La figure suivante montre deux arbres binaires, nommés B1 et B2.
223
Exercices corrigés
Arbre binaire B1 :
Arbre binaire B 2 a
h
e~~g
v
/"
f
s
1\ 1\
1
/ \
:
/\ ............. t
u
r
1\
1\ 1\
'-.....d
c/
1\
e g
/
/
\
"1 \ f
1\ Question 2 - Écrire les définitions des fonctions ab- Bl et ab-B2, qui construisent les arbres B 1 et B 2 de la figure précédente. Exercice 36- Affichage des arbres binaires Dans l'exercice précédent, nous avons défini deux arbres B 1 et B 2 et nous avons écrit des fonctions ab-Bl et ab-B2 qui doivent construire ces deux arbres. Mais comment savoir si les fonctions ab-Bl et ab- B2 font bien ce que l'on attend d'elles, puisque l'on ne peut pas afficher les objets de type ArbreBinaire? Le but de cet exercice est de visualiser les arbres binaires que nous construisons, en définissant une fonction ab-affichage qui associe à un arbre binaire une chaîne de caractères qui le décrit de manière non ambiguë. On définit récursivement l'affichage préfixe d'un arbre binaire B comme suit : - si l'arbre B est vide, son affichage préfixe est la chaîne "@" 2 - si l'arbre B est réduit à une feuille, son affichage préfixe est la chaîne de caractères qui représente l'étiquette de la racine de B - sinon l'affichage préfixe de l'arbre B est égal à la concaténation d'un crochet ouvrant ''[",de la chaîne représentant l'étiquette de la racine de B, d'une espace" ",de l'affichage préfixe du sous-arbre gauche de B, d'une espace" ",de l'affichage préfixe du sous-arbre droit deBet d'un crochet fermant"]". Par exemple, les affichages préfixes des arbres B 1 et B 2 sont respectivement : "[h [ev s] [g [f@ u] [t r @]]]" "[a [b c [d [e g @] f]] @] •
Remarque: l'affichage préfixe d'un arbre binaire le décrit de manière non ambiguë: il suffit de connaître l'affichage préfixe d'un arbre pour reconstruire cet arbre. Écrire une définition de la fonction ab-affichage qui, étant donné un arbre binaire, renvoie son affichage préfixe. Exercice 37- Listes des nœuds dans les arbres binaires Le but de cet exercice est de lister, de différentes façons, les nœuds d'un arbre binaire (dans la partie cours, a déjà été vue la liste infixe). Dans tout cet exercice, on se réfèrera aux arbres binaires de l'exercice « De la feuille à l'arbre >>, qui sont construits par les fonctions ab-Blet ab-B2. Dans la première question, nous définissons la liste préfixe et nous verrons en quoi elle diffère de 1' affichage préfixe vu dans l'exercice précédent. 2 Nous
avons choisi de représenter l'arbre vide par"@" car c'est un caractère qui est affichable à l'écran et qui n'apparaît pas comme étiquette dans les arbres que nous définissons; nous aurions pu choisir n'importe quel autre caractère répondant à ces critères.
224
Chapitre 8. Structures arborescentes
Question 1 - On définit récursivement la liste préfixe d'un arbre binaire B : si B n'est pas l'arbre vide alors sa liste préfixe est égale à la concaténation de l'étiquette de la racine de B, de la liste préfixe du sous-arbre gauche de B et de la liste préfixe du sous-arbre droit de B ; sinon la liste préfixe de B est vide. Par exemple, les listes préfixes des arbres B 1 et B 2 sont respectivement : (h e v s g f u t r) (a b c d e g f)
Écrire une définition de la fonction ab-prefixe qui, étant donné un arbre binaire, retourne la liste de ses étiquettes en ordre préfixe.
Question 2 - On définit récursivement la liste suffixe d'un arbre binaire : si B n'est pas l'arbre vide alors sa liste suffixe est égale à la concaténation de la liste suffixe du sous-arbre gauche de B, de la liste suffixe du sous-arbre droit de B et de l'étiquette de la racine de B ; sinon la liste suffixe de B est vide. Par exemple, les listes suffixes des arbres B1 et B2 sont respectivement : (v s e u f r t g h) (c g e f d b a)
Écrire une définition de la fonction ab-suffixe qui, étant donné un arbre binaire, retourne la liste de ses étiquettes en ordre suffixe.
Question 3- On définit récursivement la branche droite d'un arbre binaire: si B n'est pas l'arbre vide alors sa branche droite est égale à la concaténation de l'étiquette de la racine de B et de la branche droite du sous-arbre droit de B ; sinon la branche droite de B est la liste vide. Par exemple, la branche droite de B 1 est (h g t ) et la branche droite de B 1 est (a) . Écrire une définition de la fonction ab-branche-droite qui, étant donné un arbre binaire, retourne sa branche droite. Le lecteur pourra, s'ille désire, définir la branche gauche d'un arbre binaire.
Exercice 38 - Nombre de nœuds à niveau k dans un arbre binaire Le but de cet exercice est de calculer le nombre de nœuds à niveau k d'un arbre binaire : la racine d'un arbre non vide est à niveau 1, et le niveau d'un nœud autre que la racine est égal au niveau de son père augmenté de 1. Par définition, il y a 0 nœuds à niveau k dans l'arbre vide, quel que soit k. Écrire une définition de la fonction ab-nombre-noeuds-niveau qui, étant donnés un entier positif k et un arbre binaire B rend le nombre de nœuds étiquetés à niveau k dans B. Par exemple, si l'on considère les arbres (ab-Bl) et (ab-B2 ) (ab-nombre-noeuds-niveau 3 (ab-Bl)) (ab-nombre-noeuds-niveau 4 (ab-B2)) (ab-nombre-noeuds-niveau 7 (ab-B2))
4 2 -+ 0 -+ -+
Exercice 39- Maximum d'un arbre binaire de recherche Question 1 - Écrire une définition de la fonction max-abr qui rend le maximum d'un arbre binaire de recherche non vide. Question 2- Écrire une définition de la fonction abr-sauf-max qui, étant donné un arbre binaire de recherche non vide ABR rend un arbre binaire de recherche contenant toutes les étiquettes qui apparaissent dans AB R, sauf son maximum.
225
Exercices corrigés
Exercice 40- Ajout à la racine d'un arbre binaire de recherche Le but de cet exercice est de réaliser l'ajout d'un élément dans un arbre binaire de recherche, en l'insérant à la racine. Nous supposerons ici que les étiquettes d'un arbre binaire de recherche sont toujours deux à deux distinctes. Étant donnés un arbre binaire de recherche ABR et un nombre x, on appelle coupure de ABR selon x tout couple d'arbres binaires de recherche U et V tels que U est composé des éléments de ABR strictement inférieurs à x et V est composé des éléments de ABR strictement supérieurs à x. Une fois que l'on a déterminé une coupure (U, V) de ABR selon x, il est très simple d'insérer x à la racine de ABR: il suffit de construire l'arbre de racine x, de sous-arbre gauche U et de sous-arbre droit V. TI s'agit donc de définir une fonction abr-coupure, avec la spécification suivante: ; ; ; abr-coupure : Nombre * ArbreBinRecherche ; ; ; ---> COUPLE[ArbreBinRecherche ArbreBinRecherche] ; ; ; (abr-coupure x ABR) renvoie une coupure de «ABR» selon «X» Pour un arbre binaire de recherche AB R et un nombre x fixés, il y a plusieurs coupures possibles, plus ou moins coûteuses à calculer : une façon naïve (et coûteuse) consiste à calculer la liste infixe (ou préfixe ou suffixe) de l'arbre AB R, puis à insérer chaque élément de cette liste dans l'arbre U ou dans l'arbre V, selon qu'il est inférieur ou supérieur à x. Nous proposons ici un algoritlune plus efficace qui calcule une coupure en travaillant seulement sur une seule branche : - si l'arbre ABR n'est pas vide, nommons e l'étiquette de sa racine, a son sous-arbre gauche et D son sous-arbre droit ; trois cas peuvent se présenter : - x = e : la coupure est le couple formé de et D - x < e: on calcule la coupure de a selon x, c'est un couple formé de deux arbres strictement inférieurs à x) et 2 de recherche : 1 (composé des éléments de (composé des éléments de a strictement supérieurs à x); on construit l'arbre A de racine e, de sous-arbre gauche 2 et de sous-arbre droit D (A est un arbre de recherche et tous ses éléments sont strictement supérieurs à x); la coupure de ABR selon x est le couple formé de 1 et A - x > e: on calcule la coupure de D selon x, c'est un couple (D1, D2), on construit l'arbre A de racine e, de sous-arbre gauche a et de sous-arbre droit D 1 ; la coupure de ABR selon x est le couple formé de A et D2 - si l'arbre ABRest vide, la coupure est le couple formé de deux arbres vides. La figure suivante montre un arbre binaire de recherche, nommé ex-ABR, qui nous servira d'exemple dans la suite de cet exercice :
a
a
a
a
a
a
77 66/ 14
/
'-........ 81
/ ' 84 1\ / \3
'-..... 69
1 / . . . . . . . 46 1 \ 39 / ......... 62 1\ 51/ ,63 1\ 1\
9/ \ 1\
226
Chapitre 8. Structures arborescentes
Question 1 - Écrire une définition de la fonction abr-coupure qui, étant donnés un nombre x et un arbre binaire de recherche ABR, renvoie la coupure de ABR selon x, en suivant l'algorithme précédent. Par exemple, (abr-coupure 60 ex-ABR) renvoie le couple:
1
./
1\
14
77
.........
46
'si
39/
1\
62
1\
1
/
66 /
\3
'
69
"""1
1\
81
\84 1 \93
9/ "\
1\
1\ Question 2- En déduire une définition de la fonction abr-aj out-racine qui, étant donnés un nombre x et un arbre binaire de recherche AB R renvoie : - soit l'arbre dans lequel x a été ajouté à la racine de ABR, si x n'apparaissait pas dans
ABR, - soit un nouvel arbre (en général différent de l'arbre initial), dans lequel x est maintenant à la racine, si x était déjà dans ABR. Par exemple l'ajout à la racine de l'élément 60 dans ex-ABR renvoie l'arbre
60 14-------1 ./......... 46
1\
/""" /' l'
- - - - 77 66
/ ' 51 1\ 1\ 39
62
1
\3
1\
81
69
1\
1
84
\93
9/ "\ 1\
Exercice 41 - Feuilles, arbres, forêts ••• Dans cet exercice, on s'entraîne à manipuler les arbres généraux à travers leur barrière d'abstraction. On utilisera comme exemple les deux arbres généraux suivants : Arbre général A 2 Arbre général A 1 :
:
s
a d
----7 \:----
/ le " f
a
u
/' b
Question 1 - Écrire une fonction ag-feuille qui prend en argument une étiquette et renvoie l'arbre général contenant cette seule étiquette.
Exercices corrigés
227
Question 2- Écrire un prédicat ag-feuille? qui reconnaît si un arbre général est une feuille. Question 3- Écrire les fonctions ag-Al et ag-A2, qui construisent les arbres A1 et A2 de la figure précédente.
Exercice 42- Affichage des arbres généraux Le but de cet exercice est de visualiser les arbres généraux, en définissant une fonction ag-affichage qui associe à un arbre général une chaîne de caractères qui le décrit de manière non ambiguë. On définit récursivement l'affichage préfixe d'un arbre général A comme suit : - si 1' arbre A est réduit à une feuille, son affichage préfixe est la chaîne de caractères qui représente l'étiquette de sa racine - sinon l'affichage préfixe de l'arbre A est égal à la concaténation d'un crochet ouvrant ''['', de la chaîne représentant l'étiquette de la racine de A, d'une espace " ", de l'affichage préfixe de la forêt de B et d'un crochet fermant '']". il faut aussi définir l'affichage préfixe d'une forêt F : c'est la concaténation des affichages préfixes des arbres de F, ces affichages étant séparés par des espaces " ". Par exemple, les affichages préfixes des arbres (ag-Al) et (ag-A2 J construits dans l'exercice précédents sont respectivement : "[s a [t d e f] c [u a b]]" "[a [b c] [d [e g]] f]"
Remarque: l'affichage préfixe d'un arbre général le décrit de manière non ambiguë: il suffit de connru"tre l'affichage préfixe d'un arbre pour reconstruire cet arbre. Écrire une définition de la fonction ag-affichage qui, étant donné un arbre général, renvoie son affichage préfixe.
Exercice 43 - Listes des nœuds des arbres généraux Le but de cet exercice est de lister les étiquettes d'un arbre général, selon différents critères : liste suffixe, liste des feuilles, liste des étiquettes de la branche gauche, liste des étiquettes de la branche droite (la liste préfixe a été vue dans la partie cours, et la notion de liste infixe n'a pas de sens pour un arbre général). On reprend comme exemples les arbres construits par ag-Al et ag-A2 dans l'exercice << Feuilles, arbres, forêts ... ».
Question 1 - On définit récursivement la liste suffixe d'un arbre général : si A est une feuille alors sa liste suffixe contient un unique élément : son étiquette ; sinon la liste suffixe de A est égale à la concaténation des listes suffixes des sous-arbres de A, et enfin de l'étiquette de la racine de A. Par exemple, les listes suffixes des arbres (ag-Al) et (ag-A2J sont respectivement: (a d e f t c a b u s) (c b g e d f a) Écrire une définition de la fonction ag-suffixe qui, étant donné un arbre général, retourne la liste de ses étiquettes en ordre suffixe.
Question 2- Écrire une définition de la fonction ag-liste-feuilles qui, étant donné un arbre général, retourne la liste - de gauche à droite - de ses feuilles. Par exemple : (ag-liste-feuilles (ag-Al)) -. (ade f cab) (ag-liste-feuilles (ag-A2)) -. (cg f)
228
Chapitre 8. Structures arborescentes
Question 3 - On définit récursivement la branche gauche d'un arbre général : si A est une feuille alors sa branche gauche est la liste contenant son étiquette ; sinon la branche gauche de A est égale à la concaténation de l'étiquette de la racine de A et de la branche gauche du premier sous-arbre de A. Par exemple, la branche gauche de (ag-Al} est (s a} et la branche gauche de ( ag-A2} est (a b c} . Écrire une définition de la fonction ag-branche-gauche qui, étant donné un arbre général, retourne sa branche gauche.
Question 4- On défiait récursivement la branche droite d'un arbre général : si A est une feuille alors sa branche droite est la liste contenant son étiquette ; sinon la branche droite de A est égale à la concaténation de 1' étiquette de la racine de A et de la branche droite du dernier sous-arbre de A. Par exemple, la branche droite de (ag-Al} est (s u b} et la branchedroitede (ag-A2} est (a f}. Écrire une définition de la fonction ag-branche-droite qui, étant donné un arbre général, retourne sa branche droite. Exercice 44 - Nombre de nœuds à niveau k dans un arbre général Le but de cet exercice est de calculer le nombre de nœuds à un niveau donné dans un arbre général : la racine est à niveau 1, et un nœud dont le père est à niveau k, est à niveau
k+l. Écrire une définition de la fonction ag-nombre-noeuds-niveau qui, étant donnés un entier k et un arbre général A, rend le nombre de nœuds à niveau k dans A. Par exemple, en reprenant les arbres construits par ag-Al et ag-A2 dans l'exercice « Feuilles, arbres, forêts ... >> : (ag-nombre-noeuds-niveau 3 (ag-Al}} (ag-nombre-noeuds-niveau 3 (ag-A2}}
-> ->
5 2
Exercice 45 -Arbre général à fils ordonnés Dans cet exercice, on considère des arbres généraux dont les étiquettes sont toutes des nombres ou bien toutes des chaînes de caractères. On dit qu'un arbre général est à fils ordon-
nés si: - la forêt sous-jacente est rangée dans l'ordre croissant des étiquettes de ses arbres - les arbres de la forêt sous-jacente sont eux-mêmes à fils ordonnés. Étant donné un arbre général, on peut lui associer un arbre général à fils ordonnés en ordonnant les fils à tous les niveaux. Par exemple, si l'on considère l'arbre des descendants de Dominique (vu dans la partie cours), l'arbre à fils ordonnés associé est l'arbre obtenu en rangeant dans l'ordre alphabétique les enfants d'un même parent, c'est-à-dire:
Dominique Claude
--
Jacques 1
1
1
Lucie
Pierre
1
Albert /
Elodie
Berthe
......
Jean Line
Marie
......
Pierre
........ Alphonse
----/
'Noémie
Paul
/ /
Thomas /
---
Mathieu
Emile
......Louise ~ Yves
229
Exercices corrigés
Remarque : en toute rigueur, il faudrait écrire les prénoms entre guillemets car l'ordre alphabétique est défini sur les chaînes de caractères et non sur les symboles, mais cela alourdirait le dessin de l'arbre. Écrire une définition de la fonction ag-fils-ordonnes qui, étant donné un arbre général A, renvoie l'arbre obtenu en ordonnant les fils à tous les niveaux. Exercice 46- Arbres cardinaux Les arbres cardinaux sont une structure largement utilisée en géométrie algorithmique pour représenter des images « bitmap ». Un arbre cardinal est : - soit formé d'un nœud racine (sans étiquette) et de quatre sous-arbres : nord-ouest, nordest, sud-ouest et sud-est, qui sont eux-mêmes des arbres cardinaux - soit réduit à une feuille, pleine ou vide. La figure suivante montre deux arbres cardinaux, que l'on nommera par la suite C 1 et C 2 (les feuilles pleines sont représentées par des • et les feuilles vides par des D) : Arbre cardinal cl
:
• D
•
D
D
D
D
0
D
Arbre cardinal c2 0
•
D
D
D
:
D 0
D
0
0
•
•
D
•
D
0
Le but de cet exercice est de construire des arbres cardinaux et de les manipuler à travers une barrière d'abstraction. Le type des arbres cardinaux se note Arbrecard. La barrière d'abstraction permettant de manipuler des arbres cardinaux est formée : - des constructeurs ac- f -pleine (resp. ac- f -vide), pour construire un arbre cardinal réduit à une feuille pleine (respectivement vide) et ac-noeud pour construire un arbre à partir de quatre arbres cardinaux no ne sa se - des reconnaisseurs ac-noeud? pour reconnaître si un arbre cardinal est formé d'un nœud racine et de quatre sous-arbres, ac-f-pleine?, respectivement ac-f-vide? pour reconmurre si un arbre cardinal est une feuille pleine, respectivement vide - des accesseurs ac-no, respectivement ac-ne, ac-sa, ac-se, pour accéder au sousarbre nord-ouest, respectivement nord-est, sud-ouest, sud-est, d'un arbre donné. Par exemple C 1 est l'arbre obtenu par la fonction cardExl : ; ; ; cardExl : ---t ArbreCard ; ; ; (cardExl) rend l'arbre «C1» (define (cardExl) (let* ((ne (ac-noeud (ac-f-vide) (ac-f-vide) (ac-f-pleine) (ac-f-vide))) (sa (ac-noeud (ac-f-vide) (ac-f-pleine) (ac-f-vide) (ac-f-vide) ) )
230
Chapitre 8. Structures arborescentes
(se (ac-noeud (ac-f-pleine) (ac-f-vide) (ac-f-vide) (ac-f-vide)))) (ac-noeud (ac-f-pleine) ne so se))) On se contente de donner ici les signatures des fonctions de la barrière d'abstraction, les descriptions ayant été données ci-dessus. ; ; ; ; ; ; ; ; ; ;
; ; ; ; ; ; ; ; ; ;
; ; ; ; ; ; ; ; ; ;
ac-f-pleine : --+ ArbreCard ac-f-vide: --+ ArbreCard ac-noeud : ArbreCard * ArbreCard *Arbre Gard * ArbreCard --+ ArbreCard ac-noeud? : ArbreCard--+ booZ ac-f-pleine? : ArbreCard--+ boo/ ac-f-vide? : ArbreGard --+ boo/ ac-no : ArbreCard --+ ArbreCard ac-ne : ArbreCard --+ ArbreCard ac-so : ArbreCard --+ ArbreCard ac-se : ArbreCard--+ ArbreCard
Remarque : la donnée des trois reconnaisseurs -ac-noeud?, ac-f-pleine? et ac-fvide? -est redondante. En effet, il suffit d'en connru."tre deux pour définir le troisième (par exemple, une feuille vide est un arbre cardinal qui n'est ni un nœud ni une feuille pleine). ~
Exemples de construction d'arbres cardinaux
Question 1 - Écrire une définition de la fonction cardEx2 qui construit l'arbre cardinal C 2 de la figure précédente. ~
Visualisation des arbres cardinaux
Question 2 - Le but de cette question est de visualiser les arbres cardinaux, en définissant une fonction qui associe à un arbre cardinal une chaîne de caractères le décrivant de manière non ambiguë. On définit récursivement le codage préfixe d'un arbre cardinal comme suit : - si l'arbre n'est pas réduit à une feuille, son codage préfixe est égal à la concaténation de la chaîne "n" ("n" comme nœud) et des codages préfixes des sous-arbres nord-ouest, nord-est, sud-ouest et sud-est, dans cet ordre - sinon, si l'arbre est réduit à une feuille pleine (respectivement vide), son codage préfixe est la chaîne "1" (resp. "0") Écrire une fonction ac-codage-prefixe qui, étant donné un arbre cardinal, retourne son codage préfixe. Par exemple : (ac-codage-prefixe (cardExl)) --+ "nln0010n0100n1000" (ac-codage-prefixe (cardEx2)) --+ "nlDnlDllnDnllOlll" Question 3- On peut aussi représenter les arbres cardinaux au moyen d'images. On dessine un arbre cardinal dans un carré de côté c de la façon suivante : - si l'arbre est formé des quatre sous-arbres no, ne, so et se: - on partage le carré initial en quatre carrés de côté c/2 :un carré nord-ouest (en haut à gauche), un carré nord-est (en haut à droite), un carré sud-ouest (en bas à gauche) et un carré sud-est (en bas à droite) - on dessine les sous-arbres no, ne, so et se dans les carrés nord-ouest, nord-est, sud-ouest et sud-est, respectivement. si l'arbre est réduit à une feuille pleine, on le représente par un carré noir de côté c, si l'arbre est réduit à une feuille vide, on le représente par un carré blanc de côté c.
Exercices corrigés
231
On se propose de produire deux sortes de dessins, en rendant visibles, ou en les masquant, les traits de construction. Par exemple, voici les dessins des arbres C 1 et C 2 , avec traits de construction :
cardExl:
et voici les dessins des arbres
cardEx2:
cl et c2. sans traits de construction:
cardExl:
cardEx2:
Écrire une définition de la fonction ac-dessin qui dessine l'image associée à un arbre cardinal dans un carré de côté 2. Cette fonction aura deux paramètres : une chaîne de caractères prenant la valeur "etude" ou "joli" qui permet de tracer ou non les traits de construction, et un arbre cardinal. ~
Une méthode de construction d'arbres cardinaux Construire un arbre cardinal au moyen du constructeur ac-noeud est souvent fastidieux. Le but de la question suivante est de définir une fonction, réciproque de la fonction ac-codage-prefixe, qui permette de construire des arbres cardinaux à partir d'une chaîne de caractères. Par exemple, si l'on veut construire l'arbre cardinal correspondant aux dessins ci-dessous (la figure de droite montre les traits de construction afin de faciliter le découpage en quarts nord-ouest, nord-est, sud-ouest, sud-est) :
on détermine son codage préfixe : "nn0n0n00110nll010lnnn00110nlll00010n0ln000n00llnl010nl0n0101n00n00110',
et il ne reste plus qu'à appliquer notre fonction de décodage à cette chaîne.
Question 4- Écrire une définition de la fonction ac-decodage-prefixe qui, étant donnée une chaîne de caractères s, renvoie l'arbre cardinal dont s est le codage préfixe. On supposera que la chaîne s est bien le codage préfixe d'un arbre cardinal. Par exemple, on peut appliquer la fonction ac-decodage-prefixe à la chaîne "n1n0 01 OnO 1 0 On1 0 0 0 " (et on obtient l'arbre cardinal ( cardEx1) ) ou à la chaîne "n10n1011n0n110111" (et on obtient l'arbre cardinal ( cardEx2) ), mais on ne peut pas l'appliquer à la chaîne "n1n0010n0100n100 ". ~
Simplification des arbres cardinaux Le constructeur ac-noeud peut renvoyer un arbre dont les quatre fils sont des feuilles de même nature (pleine ou vide): il serait plus élégant de remplacer cet arbre par une seule feuille de même nature que les quatre précédentes. L'objet de ce paragraphe est de simplifier des arbres déjà construits.
232
Chapitre 8. Structures arborescentes
Question 5- Écrire une définition de la fonction ac-contraction qui, étant donnés quatre arbres cardinaux no, ne, so et se, renvoie : - un arbre cardinal réduit à une feuille pleine si no, ne, so et se sont des feuilles pleines, - un arbre cardinal réduit à une feuille vide si no, ne, so et se sont des feuilles vides, - un arbre cardinal dont les quatre sous-arbres sont no, ne, so et se, dans les autres autres cas. Question 6- Écrire une définition de la fonction ac-simplification qui, étant donné un arbre cardinal a, renvoie le simplifié de a, c'est-à-dire l'arbre cardinal obtenu en remplaçant récursivement tous les nœuds de a dont les quatre fils sont des feuilles de même nature par une feuille de cette nature. Par exemple, les deux figures ci-dessous montrent un arbre cardinal c3 et son simplifié : Arbre cardinal c3
: Simplifié de C3 :
D D
• •
D D
D
D
D
• • •
• •
D
D
• •
D
Les codages préfixes de C3 et de son simplifié sont respectivement : "n0n00n0n0000000nlllnlllll" et "n0011". ~
Opérations booléennes sur les arbres cardinaux
Nous voulons maintenant implanter des opérations booléennes sur les arbres cardinaux: complément, union, intersection.
Question 7- Le complément d'un arbre cardinal A est l'arbre cardinal dont l'image associée est obtenue en remplaçant le noir par du blanc et inversement dans l'image associée à l'arbre A. Écrire une défiuition de la fonction ac-complement qui, étant donné un arbre cardinal a, renvoie le complément de a. Par exemple :
et son complément :
cardExl:
Question 8- I.:union de deux arbres cardinaux A et B est l'arbre cardinal dont l'image associée est composée de tous les pixels noirs de l'une ou l'autre des images associées aux arbres A et B. Écrire une défiuition de la fonction ac-union qui, étant donnés deux arbres cardinaux a et b, renvoie l'union de a et b. Par exemple :
cardExl:
cardEx2:
et leur union :
._ •
Remarque : l'union d'un arbre quelconque et de son complément est un arbre réduit à une feuille pleine. Ceci montre l'importance de la fonction ac-contraction .
Exercices corrigés
233
!:intersection de deux arbres cardinaux A et B est l'arbre cardinal dont l'image associée est composée des pixels qui sont noirs dans l'une et l'autre des images associées aux arbres A et B. Le lecteur est invité à définir la fonction Scheme correspondante. ~ Implantation de la barrière d'abstraction Question 9- TI s'agit ici d'implanter, de deux façons différentes, la barrière d'abstraction des arbres cardinaux : - donner une implantation des arbres cardinaux à l'aide de listes, - donner une implantation des arbres cardinaux à l'aide de vecteurs.
7.2
Corrigés
Exercice 35- De la feuille à l'arbre Solution de la question 1 : ; ; ; ab-feuille : a --> ArbreBinaire[a] ; ; ; (ab-feuille e) renvoie l'arbre binaire contenant la seule étiquette «e» (define (ab-feuille e) (ab-noeud e (ab-vide) (ab-vide))) ; ; ; ab-feuille?: ArbreBinaire[a]/non vide/-+ boo/ ; ; ; (ab-feuille? B) rend vrai ssi l'arbre «B» est réduit à une feuille (define (ab-feuille? B) (and (not (ab-noeud? (ab-gauche B))) (not (ab-noeud? (ab-droit B)))))
Solution de la question 2 : ; ; ; ab-BI : __. ArbreBinaire[Symbole] ; ; ; (ab-BI) rend l'arbre «Bt» (define (ab-Bl) (ab-noeud 'h (ab-noeud 'e (ab-feuille 'v) (ab-feuille 's)) (ab-noeud 'g (ab-noeud 'f (ab-vide) (ab-feuille 'u)) (ab-noeud ' t (ab-feuille 'r) (ab-vide))))) ; ; ; ab-B2: --> ArbreBinaire[Symbole] ; ; ; (ab-B2) rend l'arbre «B2» (define (ab-B2) (let* ((GG (ab-feuille 'c)) (GDGG (ab-feuille 'g)) (GDG (ab-noeud 'e GDGG (ab-vide))) (GDD (ab-feuille 'f)) (GD (ab-noeud 'd GDG GDD)) (G (ab-noeud 'b GG GD))) (ab-noeud 'aG (ab-vide))))
Dans le nommage des différents sous-arbres, G signifie gauche et D signifie droit (par exemple, GDGG se lit gauche-droit-gauche-gauche). Noter que l'on aurait tout aussi bien pu construire l'arbre B 1 avec la méthode utilisée pour construire l'arbre B2 (et l'arbre B2 avec la méthode utilisée pour construire l'arbre B1).
234
Chapitre 8. Structures arborescentes
Exercice 36- Affichage des arbres binaires ; ; ; ab-affichage: ArbreBinaire[a]---> string ; ; ; (ab-affichage B) renvoie l'affichage préfixe de «B» (define (ab-affichage B) (if (ab-noeud? B) (if (ab-feuille? B) (->string (ab-etiquette B)) (string-append " [" (->string (ab-etiquette B)) (ab-affichage (ab-gauche B)) (ab-affichage (ab-droit B)) "]")) Il
Il
"@ .. ) )
Exercice 37- Listes des nœuds dans les arbres binaires Solution de la question 1 - La fonction reproduit la définition récursive : ; ; ; ab-prefixe: ArbreBinaire[a]---> USTE[a] ; ; ; (ab-prefixe B) rend la liste préfixe de toutes les étiquettes de «B» (define (ab-prefixe B) (if (ab-noeud? B) (cons (ab-etiquette B) (append (ab-prefixe (ab-gauche B)) (ab-prefixe (ab-droit B) )))
' ()
) )
j\ Remarquer les différences entre 1' affichage préfixe de l'exercice précédent et la liste ~ préfixe obtenue ici : - le premier est une chaîne de caractères alors que la seconde est une liste ; - l'affichage préfixe est non ambigu (à un affichage préfixe correspond un seul arbre) alors que deux arbres différents peuvent avoir la même liste préfixe. Par exemple, considérons les arbres B 3 et B4 : Arbre binaire B3 : a b~ ............... e
c/
1\
'-d
1\
1' '/'g 1 \
Arbre binaire B4:
b
./"'a .............
c
d / ............. g e / '1 1 \ 1 \ / \
1 \
lls sont différents, ont des affichages préfixes différents : "[a [b c dl [e [f@ g] @]]"pourB3 "[a b [c [d e f] g]]" pour B4 mais ont la même liste préfixe : (a b c d e f g).
Solution de la question 2 - La fonction reproduit la définition récursive : ; ; ; ab-suffixe : ArbreBinaire[a] ---> USTE[ a] ; ; ; (ab-suffixe B) rend la liste suffixe de toutes les étiquettes de «B» (define (ab-suffixe B) (if (ab-noeud? B) (append (ab-suffixe (ab-gauche B))
235
Exercices corrigés
(ab-suffixe (ab-droit B} (list (ab-etiquette B}}}
'
(} } }
Solution de la question 3- Ici, dans le cas d'un arbre non vide, il suffit d'appeler récursivement la fonction sur le sous-arbre droit (et non sur les deux sous-arbres). ; ; ; ab-branche-droite: ArbreBinaire[a:]---> USTE[a:] ; ; ; (ab-branche-droite A) rend la liste des éléments de la branche droite de «B» (define (ab-branche-droite B} (if (ab-noeud? B} (cons (ab-etiquette B} (ab-branche-droite (ab-droit B}}} ' (} } }
Exercice 38 - Nombre de nœuds à niveau k dans un arbre binaire
Si B n'est pas vide, le nombre de nœuds à niveau 1 dans B est 1; et, pour k > 1, le nombre de nœuds à niveau k dans B est égal à la somme des nombres de nœuds à niveau k- 1 dans les deux sous-arbres (gauche et droit) de B. ; ; ; ab-nombre-noeuds-niveau: nat x ArbreBinaire[a:]---> nat ; ; ; (ab-nombre-noeuds-niveau k B) rend le nombre de nœuds à niveau «k» dans «B» (define (ab-nombre-noeuds-niveau k B} (if (ab-noeud? B} (if
(> k 1}
(+
(ab-nombre-noeuds-niveau (- k 1} (ab-nombre-noeuds-niveau (- k 1}
(ab-gauche B}} (ab-droit B}}}
1}
0}}
Remarquer que la récursion se fait à la fois sur l'entier k et sur l'arbre B. Exercice 39 - Maximum d'un arbre binaire de recherche
Solution de la question 1- Le maximum d'un arbre binaire de recherche est à l'extrémité de sa branche droite. ; ; ; max-abr : ArbreBinRecherchelnon vide/---> Nombre ; ; ; (max-abr ABR) rend le maximum de l'arbre binaire de recherche «ABR» ; ; ; HYP(JTHESE : «ABR» est non vide (define (max-abr ABR} (if (ab-noeud? (ab-droit ABR}} (max-abr (ab-droit ABR}} (ab-etiquette ABR}}}
Solution de la question 2- Si le sous-arbre droit de ABR n'est pas vide, on construit un nouvel arbre, obtenu en gardant le sous-arbre gauche de ABR et en remplaçant le sous-arbre droit de ABR par ce sous-arbre privé de son maximum. Sinon, le maximum est à la racine et l'on rend simplement le sous-arbre gauche. ; ; ; abr-sauf-max : ArbreBinRecherchelnon vide/---> ArbreBinRecherche ; ; ; (abr-sauf-max ABR) rend un arbre binaire de recherche qui contient toutes ; ; ; les étiquettes qui apparaissent dans «ABR», hormis le maximum. (define (abr-sauf-max ABR} (if (ab-noeud? (ab-droit ABR}} (ab-noeud (ab-etiquette ABR}
Chapitre 8. Structures arborescentes
236
{ab-gauche ABR) {abr-sauf-max {ab-droit ABR))) {ab-gauche ABR)))
Exercice 40- Ajout à la racine d'un arbre binaire de recherche Solution de la question 1- Il suffit d'implanter l'algorithme: {define {abr-coupure x ABR) {if {ab-noeud? ABR) {let {{e {ab-etiquette ABR)) {G {ab-gauche ABR)) {D {ab-droit ABR))) {cond { {= x e) {list G Dl J { {< x e) {let {{coupe {abr-coupure x G) J J {list {car coupe) {ab-noeud e {cadr coupe) D)))) {else {let {{coupe {abr-coupure x D))) {list {ab-noeud e G {car coupe)) {cadr coupe) J J J J J {list {ab-vide) {ab-vide) J J J
Remarque: le calcul de la coupure est ici en O(h) où h est la hauteur de l'arbre alors que les méthodes naïves présentées au début de l'exercice donnent lieu à des calculs en O(n) où n est la taille de l'arbre. De plus, les arbres résultants ont une hauteur inférieure ou égale à celle de l'arbre initial. Solution de la question 2- Il suffit de couper ABR selon x, puis de construire l'arbre qui
a pour racine x, et pour sous-arbres gauche et droit les arbres binaires de recherche résultant de la coupure. ; ; ; abr-ajout-racine : Nombre --+ ArbreBinRecherche --+ ArbreBinRecherche ; ; ; (abr-ajout-racine x ABR): renvoie l'arbre dans lequel «X» a été ajouté à la racine de «ABR»; ; ; ; lorsque «X» est déjà dans «ABR», renvoie un autre arbre, dont «X» est racine. {define {abr-ajout-racine x ABR) {let {{coupe {abr-coupure x ABR))) {ab-noeud x {car coupe) {cadr coupe)))) L'ajout de 46 (qui figure déjà dans ex-ABR) dans ex-ABR fabrique l'arbre- différent de ex-ABR - dessiné ci-dessous. Noter que toutes les étiquettes de cet arbre sont bien deux à
deux différentes (l'élément 46 n'y apparcu"t pas deux fois).
14------' 46
1
/
1\
39
66
1\
62 / 51/
1\
'63
1\
/
"
77
69
1\
"'-.. 81
1
's4
1
\3
9/\ 1\
237
Exercices corrigés
Exercice 41 - Feuilles, arbres, forêts ... Solution de la question 1 : ; ; ; ag-feuille: a.---> ArbreGeneral[a.] ; ; ; (ag-feuillee) rend l'arbre général réduit à lafeuille d'étiquette «e» (define (ag-feuille el (ag-noeud e ' ()))
Solution de la question 2 : ; ; ; ag-feuille?: ArbreGeneral[a.]---> bool ; ; ; (ag-feuille? A) rend vrai ssil'arbre «A» est réduit à une feuille (define (ag-feuille? A) (not (pair? (ag-foret A))))
Solution de la question 3 : ; ; ; ag-Al : ---> ArbreGeneral[a.] ; ; ; (ag-Al) rend l'arbre «Ap (define (ag-Al) (ag-noeud 's (list (ag-feuille 'a) (ag-noeud ' t (list (ag-feuille (ag-feuille (ag-feuille (ag-feuille 'c) (ag-noeud 'u (list (ag-feuille (ag-feuille
'd) 'e) ' f) ) )
'a) 'b))))))
; ; ; ag-A2 : --+ ArbreGeneral[a.] ; ; ; (ag-A2) rend l'arbre «A2» (define (ag-A2) (let* ((ss-A-1-1 (ag-feuille 'c)) (ss-A-1 (ag-noeud 'b (list ss-A-1-1))) (ss-A-2-1-1 (ag-feuille 'g)) (ss-A-2-1 (ag-noeud 'e (list ss-A-2-1-1))) (ss-A-2 (ag-noeud 'd (list ss-A-2-1))) (ss-A-3 (ag-feuille 'f))) (ag-noeud 'a (list ss-A-1 ss-A-2 ss-A-3))))
Remarquer que l'on aurait tout aussi bien pu construire l'arbre A 1 avec la méthode utilisée pour construire l'arbre A 2 (et l'arbre A 2 avec la méthode utilisée pour construire l'arbre A 1 ). Exercice 42- Affichage des arbres généraux ll faut traiter à part le cas où l'arbre est réduit à une feuille puisque l'affichage d'un tel arbre est différent de l'affichage d'un arbre qui n'est pas réduit à une feuille. ; ; ; ag-affichage : ArbreGeneral[a.]---> string ; ; ; (ag-affichage A) renvoie l'affichage préfixe de l'arbre général «A» (define (ag-affichage A) (if (ag-feuille? A) (->string (ag-etiquette A)) (string-append " ["
238
Chapitre 8. Structures arborescentes
{->string {ag-etiquette A)) Il
Il
{foret-affichage {ag-foret A)) "]")))
La fonction foret-affichage est une fonction récursive qui n'est appelée que lorsque la forêt n'est pas vide. La récursion s'arrête donc lorsque la forêt se réduit à une liste d'un seul arbre, auquel cas on affiche 1' arbre. ; ; ; foret-affichage: USTE[ArbreGeneral[a]]/non vide/---+ string ; ; ; (foret-affichage F) renvoie /"affichage préfixe de laforét «F»
{define {foret-affichage F) {if {pair? {cdr F)) {string-append {ag-affichage {car Il
F))
Il
{foret-affichage {cdr F))) {ag-affichage {car F)))) Remarque: on peut bien sûr placer foret-affichage à l'intérieur de ag-affichage.
Exercice 43 - Listes des nœuds des arbres généraux Solution de la question 1 - On définit deux fonctions, mutuellement récursives : l'une calcule
la liste suffixe d'un arbre et l'autre calcule la liste suffixe d'une forêt (obtenue par concaténation des listes suffixes de ses arbres). Pour calculer la liste suffixe d'un arbre, il suffit de concaténer sa racine à la fin de la liste (éventuellement vide) de sa forêt. ; ; ; ag-suffixe: ArbreGeneral[a]---+ USTE[a] ; ; ; (ag-suffixe A) rend la liste suffixe de toutes les étiquettes de «A» {define {ag-suffixe A) {append {foret-suffixe {ag-foret A)) {list {ag-etiquette A))))
La fonction foret-suffixe est une fonction récursive qui est appelée dans tous les cas (que la forêt soit vide ou non), et donc la récursion s'arrête lorsque la forêt est vide. ; ; ; foret-suffixe: Foret[ a]---+ USTE[a] ; ; ; (foret-suffixe F) rend la liste résultant de la concaténation des listes suffixes des arbres de «F»
{define {foret-suffixe F) {if {pair? F) {append {ag-suffixe {car F)) {foret-suffixe {cdr F))) ' {) ) ) Autre solution de la question 1 - On calcule, par un rnap la liste des listes suffixes des sous-
arbres de A, puis on concatène ces listes en les réduisant (reduce) par la fonction append, avec pour base la liste formée de 1' étiquette de la racine de A. {define {ag-suffixe A) {reduce append {list {ag-etiquette A)) {rnap ag-suffixe {ag-foret A)))) Solution de la question 2 - On définit deux fonctions mutuellement récursives : l'une calcule
la liste des feuilles d'un arbre et l'autre calcule la liste des feuilles d'une forêt (obtenue par concaténation des listes des feuilles de ses arbres).
239
Exercices corrigés
Pour calculer la liste des feuilles d'un arbre, il faut tester si l'arbre est réduit à une feuille : dans ce cas, la liste des feuilles est la liste ayant cette feuille comme seul élément ; sinon la liste des feuilles de A est égale à la liste des feuilles des arbres de sa forêt (et dans ce cas, l'étiquette de la racine n'apparru."t pas dans la liste). ; ; ; ag-liste-feuilles: ArbreGeneral[01.]--+ LISTE[01.] ; ; ; (ag-liste-feuilles A) rend la liste (de gauche à droite) des feuilles de «A»
(define (ag-liste-feuilles A) (if (ag-feuille? A) (list (ag-etiquette A)) (foret-liste-feuilles (ag-foret A)))) La fonction foret-suffixe est une fonction récursive qui n'est appelée que lorsque l'arbre n'est pas réduit à une feuille: la récursion s'arrête lorsque la forêt a un seul élément ; ; ; foret-liste-feuilles : Foret[01.]/non vide/-+ LISTE[01.] ; ; ; (foret-liste-feuilles F) rend la liste résultant de la concaténation ; ; ; des listes des feuilles des arbres de «F» (define (foret-liste-feuilles F) (if (pair? (cdr F)) (append (ag-liste-feuilles (car F)) (foret-liste-feuilles (cdr F))) (ag-liste-feuilles (car F))))
Remarque : les feuilles sont énumérées de gauche à droite, tout comme dans la liste préfixe ou dans la liste suffixe (c'est la place des nœuds internes qui fait la différence entre liste préfixe et liste suffixe).
Autre solution de la question 2- Dans le cas où l'arbre n'est pas réduit à une feuille, on calcule (par un map) la liste des listes des feuilles des sous-arbres de A, puis on concatène ces listes en les réduisant (reduce) par la fonction append avec pour base la liste vide. (define (ag-liste-feuilles A) (if (ag-feuille? A) (list (ag-etiquette A)) (reduce append
.
()
(map ag-liste-feuilles (ag-foret A)))))
Solution de la question 3 - Pour cette fonction, dans le cas d'un arbre non réduit à une feuille, il suffit d'appeler récursivement la fonction sur le premier sous-arbre (et non sur tous les arbres de la forêt). ; ; ; ag-branche-gauche: ArbreGeneral[01.]-+ LISTE[01.] ; ; ; (ag-branche-gauche A) rend la liste des éléments de la branche gauche de «A»
(define (ag-branche-gauche A) (cons (ag-etiquette A) (if (ag-feuille? A) '()
(ag-branche-gauche (car (ag-foret A))))))
Solution de la question 4- C'est un peu plus compliqué que pour la branche gauche, car il faut travailler récursivement dans le dernier arbre de la forêt sous-jacente. On commence donc par définir une fonction dernier qui rend le deruier élément d'une liste non vide. ; ; ; dernier : LISTE[01.]/non vide/--+ 01. ; ; ; (dernier L) renvoie le dernier élément de la liste «L»
240
Chapitre 8. Structures arborescentes
(define (dernier L) (if (pair? (cdr L)) (dernier (cdr L)) (car L) ) ) La définition de ag-branche-droite est presque identique à celle de ag-branchegauche: ; ; ; ag-branche-droite : ArbreGeneral[a] - t liSTE[ a] ; ; ; (ag-branche-droite A) rend la liste des éléments de la branche droite de «A» (define (ag-branche-droite A) (cons (ag-etiquette A) (if (ag-feuille? A) ' () (ag-branche-droite (dernier (ag-foret A))))))
Exercice 44 - Nombre de nœuds à niveau k dans un arbre général On définit deux fonctions mutuellement récursives : l'une calcule le nombre de nœuds à niveau k d'un arbre et l'autre calcule le nombre de nœuds à niveau k d'une forêt, (c'est-à-dire la somme des nombres de nœuds à niveau k de ses arbres). Pour définir ag-nombre-noeuds-niveau, il suffit de remarquer que, pour k > 1, le nombre de nœuds à niveau k de l'arbre est égal au nombre de nœuds à niveau k- 1 de la forêt (et que le nombre de nœuds à niveau 1 de l'arbre est égal à 1). ; ; ; ag-nombre-noeuds-niveau: nat x ArbreGeneral[a]--+ nat ; ; ; (ag-nombre-noeuds-niveau kA) rend le nombre de nœuds à niveau «k» dans «A» (define (ag-nombre-noeuds-niveau k A) (if
(> k 1)
(foret-nombre-noeuds-niveau (- k 1)
(ag-foret A))
1) )
Si k > 1, la fonction récursive foret-nombre-noeuds-ni veau est appelée dans tous les cas (que la forêt soit vide ou non) :la récursion s'arrête donc lorsque la forêt est vide. ; ; ; foret-nombre-noeuds-niveau : nat x Foret[ a] --+ nat ; ; ; (foret-nombre-noeuds-niveau k F) rend le nombre de nœuds à niveau «k» de «F» (define (foret-nombre-noeuds-niveau k F) (if (pair? F) (+ (ag-nombre-noeuds-niveau k (car F)) (foret-nombre-noeuds-niveau k (cdr F))) 0) )
Autre solution- On utilise un map pour calculer le nombre de nœuds à niveau k -1 de chacun des arbres de la forêt et un reduce pour effectuer la somme des nombres ainsi obtenus. Pour appliquer le map, il faut définir une fonction interne qui prend comme seul paramètre un arbre général et qui calcule le nombre de nœuds à niveau k - 1 de cet arbre. (define (ag-nombre-noeuds-niveau k A) ; ; aux : ArbreGeneral[a] - t nat ; ; (aux A) calcule le nombre de nœuds à niveau k - 1 dans «A» (define (aux A) (ag-nombre-noeuds-niveau (- k 1) A)) ; ; expression de la définition de ag-nombre-noeuds-niveau : (if
(> k 1)
(reduce + 0 (map aux (ag-foret A))) 1) )
Exercices corrigés
241
Exercice 45 -Arbre général à fils ordonnés C'est une application du tri générique (vu dans l'exercice« Fonction de tri générique>> du chapitre 5), en utilisant le prédicat ag- inferieur? qui compare les étiquettes des racines de deux arbres généraux et qui est ainsi défini : ; ; ; ag-inferieur?: ArbreGeneral[a] * ArbreGeneral[a]-> bool ; ; ; (ag-iriferieur? Al A2) rend vrai si l'étiquette de la racine de «Al» est iriférieure ; ; ; à l'étiquette de la racine de «A2» et faux sinon ; ; ; HYPOTHÈSE: les étiquettes des racines de «Al» et «A2» sont toutes deux des ; ; ; nombres ou bien toutes deux des chaînes de caractères (define (ag-inferieur? Al A2) (if (number? (ag-etiquette Al)) (< (ag-etiquette Al) (ag-etiquette A2)) (string (ag-etiquette Al) (ag-etiquette A2)))) Soit A un arbre général, e l'étiquette de sa racine et F sa forêt sous-jacente. Pour calculer l'arbre à fils ordonnés associé à A, il faut construire l'arbre dont la racine a pour étiquette e et dont la forêt est obtenue en appliquant ag-fils-ordonnes à chaque arbre de F, puis en faisant le tri générique avec le prédicat ag-inferieur? de la liste ainsi obtenue. ; ; ; ag-fils-ordonnes: ArbreGeneral[a]->ArbreGeneral[a] ; ; ; (ag-fils-ordonnes A) rend l'arbre à .fils ordonnés associé à l'arbre général «A» ; ; ; HYPOTHÈSE: les étiquettes de «A» sont toutes des nombres ou bien toutes des ; ; ; chaînes de caractères (define (ag-fils-ordonnes A) (ag-noeud (ag-etiquette A) (tri-fusion-generique ag-inferieur? (map ag-fils-ordonnes (ag-foret A)))))
Exercice 46- Arbres cardinaux ~
Exemples de construction d'arbres cardinaux
Solution de la question 1 : ; ; ; cardEx2 : ---> ArbreCard ; ; ; (cardEx2) rend l'arbre «C2» (define (cardEx2) (let* ( (so (ac-noeud (ac-f-pleine) (ac-f-vide) (ac-f-pleine) (ac-f-pleine))) (sene (ac-noeud (ac-f-pleine) (ac-f-pleine) (ac-f-vide) (ac-f-pleine))) (se (ac-noeud (ac-f-vide) sene (ac-f-pleine) (ac-f-pleine)))) (ac-noeud (ac-f-pleine) (ac-f-vide) so se))) ~
Visualisation des arbres cardinaux
Solution de la question 2- La définition de la fonction ac-codage-prefixe est une simple traduction de l'énoncé : ; ; ; ac-codage-prefixe : ArbreCard ---> string ; ; ; (ac-codage-prefixe a) renvoie le codage préfixe de l'arbre cardinal «a» (define (ac-codage-prefixe a) (if (ac-noeud? a)
242
Chapitre 8. Structures arborescentes
(string-append ••n" (ac-codage-prefixe (ac-codage-prefixe (ac-codage-prefixe (ac-codage-prefixe (if (ac-f-pleine? a) "l" "0")))
(ac-no (ac-ne (ac-so (ac-se
a)) a)) a)) a)))
Pour définir la fonction ac-dessin, il faut définir une autre fonction (interne et récursive) qui permet de dessiner un arbre cardinal a dans un carré C de côté c et de coin bas-gauche (x, y). On pourra alors dessiner le sous-arbre nord-ouest de l'arbre a (respectivement nord-est, sud-ouest, sud-est) dans le quart nord-ouest (respectivement nord-est, sud-ouest, sud-est) du carré C, i.e dans le carré de côté c/2 et de coin bas-gauche (x, y+ c/2) (respectivement (x+ c/2, y+ c/2), (x, y), (x+ c/2, y)). Afin de ne pas tester la valeur de la chaîne à chaque appel récursif, nous définissons deux fonctions récursives internes :aux-etude etaux-joli. Nous définissons aussi deux fonctions utiles, l'une pour dessiner des carrés noirs et l'autre pour dessiner les contours des carrés blancs. Solution de la question 3 -
; ; ; carre-plein : Coordonnée * Coordonnée *Nombre/positif/ --t Image ; ; ; (carre-plein x y c) rend l'image d'un carré noir de coin bas-gauche (x, y) et de c{)té «C» ; ; ; HYPOTHÈSE: x+c et y+c sont inférieurs ou égaux à 2
(define (carre-plein x y c) (let ((xl(+ xc)) (yl (+y c))) (overlay (filled-triangle x y xl y x yl) (filled-triangle xl yl xl y x yl)))) La fonction carre-vide a la même spécification que la fonction carre-plein, en remplaçant carré noir par carré à bord noir. (define (carre-vide x y c) (let ((xl(+ xc)) (yl (+y c))) (overlay (line x y xl y) (line x y x yl) (line xl yl xl y) (line xl yl x yl)))) ;;; ;;; ;;; ; ;; ;;;
ac-dessin : string * ArbreCard --t Image (ac-dessins a) rend l'image carrée représentant l'arbre cardinal «a», en dessinant les traits de construction si la chaîne «S» est égale à "etude" et en ne les dessinant pas si elle est égale à ''joli" ERREUR si «s» n'est ni "etude'' ni ''joli"
(define (ac-dessin s a) ; ; aux-etude : ArbreCard * Coordonnée * Coordonnée*Nombrelpositif/--> Image ; ; (aux-etude a x y c) rend une image carrée de ctJté «C» et de coin bas-gauche (x,y ), ; ; qui représente l'arbre cardinal «a» en laissant visibles les traits de construction
(define (aux-etude a x y c) (if (ac-noeud? a) (let* ((c (! c 2)) (xl (+xc)) (yl (+yc))) (overlay (aux-etude (ac-no a) x yl c) (aux-etude (ac-ne a) xl yl c) (aux-etude (ac-so a) x y c) (aux-etude (ac-se a) xl y c))) ((if (ac-f-pleine? a) carre-plein carre-vide) x
y
c)))
; ; aux-joli : ArbreCard * Coordonnée * Coordonnée *Nombre/positif/-> Image ; ; (aux-etude a x y c) rend une image carrée de c{)té «C» et de coin bas-gauche (x,y), ; ; qui représente l'arbre cardinal «a» sans montrer les traits de construction
Exercices corrigés
243
(define (aux-joli a x y c) (if (ac-noeud? a) (let* ((c (/c 2)) (xl (+xc)) (yl (+yc))) (overlay (aux-joli (ac-no a) x yl c) (aux-joli (ac-ne a) xl yl c) (aux-joli (ac-sa a) x y c) (aux-joli (ac-se a) xl y c))) (if (ac-f-pleine? a) (carre-plein x y c) (image-vide) ) ) ) (cond ((equal? s "etude") (aux-etude a -1-1 2)) ((equal? s "joli") (aux-joli a -1 -1 2)) (else (erreur 'dessin ''le premier argument doit être soit " "la chaine etude soit la chaine joli")))) ~
Une méthode de construction d'arbres cardinaux
Solution de la question 4- On définit d'abord deux fonctions utiles pour manipuler les chaînes de caractères : ; ; ; string-tete : string ---> string ; ; ; (string-tete s) renvoie la chaîne de longueur 1 qui contient uniquement le premier caractère de «S» (define (string-tete s) (substring s 0 1)) ; ; ; string-queue : string ---> string ; ; ; (string-queues) renvoie la chaîne «S» privée de son premier caractère (define (string-queue s) (substring s 1 (string-length s)))
Puis une fonction qui permet de décoder les chaînes "1" et "0" : ; ; ; ac-decodage-feuille : string ---> ArbreCard ; ; ; (ac-decodage-feuilles) renvoie lafeuille dont «S» est le codage préfixe ; ; ; HYPŒHÈSE: «S» est égal à la chaîne "1" ou à la chaîne "0" (define (ac-decodage-feuille s) (if (equal? s "l") (ac-f-pleine) (ac-f-vide)))
Et enfin la fonction de décodage : ; ; ; ac-decodage-prefixe : string ---> ArbreCard ; ; ; (ac-decodage-prefixes) renvoie l'arbre cardinal dont «S» est le codage préfixe ; ; ; HYPŒHÈSE: «S» est le codage préfixe d'un arbre cardinal (define (ac-decodage-prefixe s) ; ; aux: String ---> NUPLET[ArbreCard string] ; ; (aux s) renvoie un couple dont le premier élément est l'arbre cardinal dont le codage préfixe est ; ; le plus long mot «81» tel que «S» peut s'écrire «8182» et dont le deuxième élément est «82» ; ; HYParHÈSE: «s» peut s'écrire «B182», avec «B1» codage préfixe d'un arbre cardinal (define (aux s) (let ((cl (string-tetes)) (sl (string-queues))) (if (equal? cl "n") (let* ((Ll (aux sl))
244
Chapitre 8. Structures arborescentes
(L2 (aux (cadr Ll) ) ) (L3 (aux (cadr L2) ) ) (L4 (aux (cadr L3) ) ) (no (car Ll)) (ne (car L2)) (so (car L3)) (se (car L4) ) ) (list (ac-noeud no ne so se) (cadr L4) (list (ac-decodage-feuille cl) sl)))) (car (aux s))) ~
))
Simplification des arbres cardinaux
Solution de la question 5 : ; ; ; ac-contraction : ArbreCarcr --+ ArbreCard ; ; ; (ac-contmction no ne so se): si les quatre arbres «no», «ne», «SO» et «Se» sont ; ; ; des feuilles de même couleur, rend une feuille de cette couleur et sinon rend un arbre ; ; ; cardinal dont les quatre sous-arbres sont «no», «ne», «SO» et «Se» (define (ac-contraction no ne so se) (if (or (and (ac-f-pleine? no) (ac-f-pleine? ne) (ac-f-pleine? so) (ac-f-pleine? se)) (and (ac-f-vide? no) (ac-f-vide? ne) (ac-f-vide? so) (ac-f-vide? se))) no (ac-noeud no ne so se)))
Solution de la question 6 : ; ; ; ac-simplification : ArbreCard--+ ArbreCard ; ; ; (ac-simplification a) renvoie le simplifié de «a» (define (ac-simplification a) (if (ac-noeud? a) (ac-contraction (ac-simplification (ac-no a)) (ac-simplification (ac-ne a)) (ac-simplification (ac-so a)) (ac-simplification (ac-se a))) a)) ~
Opérations booléennes sur les arbres cardinaux
Solution de la question 7- Le complément d'un arbre cardinal a est l'arbre obtenu en remplaçant les feuilles pleines de a par des feuilles vides et ses feuilles vides par des feuilles pleines: - si l'arbre a a quatre fils alors son complément a lui aussi quatre fils, qui sont les compléments des fils de a ; - si l'arbre a est réduit à une feuille pleine (respectivement vide) alors son complément est réduit à une feuille vide (respectivement pleine). D'où la définition : ; ; ; ac-complement : ArbreCard --+ ArbreCard ; ; ; (ac-complement a) rend l'arbre cardinal qui est le complément de «a» (define (ac-complement a)
245
Exercices corrigés
(cond ((ac-noeud? a)
(ac-noeud (ac-complement (ac-complement (ac-complement (ac-complement ( (ac-f-pleine? a) (ac-f-vide)) (else (ac-f-pleine))))
(ac-no (ac-ne (ac-so (ac-se
a)) a)) a)) a))))
Solution de la question 8- L'union de deux arbres cardinaux A et B est définie récursivement comme suit: si l'un des deux arbres est réduit à une feuille pleine alors l'union est une feuille pleine si 1' un des deux arbres est réduit à une feuille vide alors 1' union est égale à 1' autre arbre sinon l'union est l'arbre dont les quatre sous-arbres sont obtenus par union des sousarbres nord-ouest (respectivement nord-est, sud-ouest, sud-est) des arbres A et B. ; ; ; ac-union : ArbreCard *ArbreCard---> ArbreCard ; ; ; (ac-union ab) rend l'arbre cardinal qui est l'union de «a» et «h» (define (ac-union a b) (cond ((ac-noeud? a) (cond ((ac-noeud? b) (ac-contraction (ac-union (ac-no a) (ac-no (ac-union (ac-ne a) (ac-ne (ac-union (ac-so a) (ac-so (ac-union (ac-se a) (ac-se ((ac-f-pleine? b) (ac-f-pleine)) (else a))) ( (ac-f-pleine? a) (ac-f-pleine)) (else b))) ~
b)) b)) b)) b))))
Implantation de la barrière d'abstraction
Solution de la question 9- Implantation à l'aide de listes et des entiers 0 et 1 : (define (define (define (define (define (define (define (define (define (define
(ac-f-pleine) 1) (ac-f-vide) 0) (ac-noeud no ne so se) (list no ne so se)) (ac-noeud? a) (list? a)) (ac-f-pleine? a) (equal? a 1)) (ac-f-vide? a) (equal? a 0)) (ac-no a) (car a)) (ac-ne a) (cadra)) (ac-so a) (caddr a)) (ac-se a) (cadddr a)) Implantation à 1' aide de vecteurs et des booléens # f et # t : (define (ac-f-pleine) #tl (define (ac-f-vide) #f) (define (ac-noeud no ne so se) (vector no ne so se)) (define (ac-noeud? a) (vector? a)) (define (ac-f-pleine? a) a) (define (ac-f-vide? a) (not a)) (define (ac-no a) (vector-ref a 0))
246 (define (ac-ne a) (define (ac-so a) (define (ac-se a)
Chapitre 8. Structures arborescentes (vector-ref a 1)) (vector-ref a 2)) (vector-ref a 3))
Remarque : on peut maintenant montrer la raison pour laquelle on a choisi de mettre les trois reconnaisseurs dans la barrière d'abstraction. En effet, dans chacune des deux implantations, la définition du reconnaisseur ac-f-vide? ne demande qu'un seul test ( (equal? a 0) pour la première implantation et (not a) pour la seconde) alors que sa définition en fonction des deux autres reconnaisseurs demande deux tests ((not (ac-noeud? a)) et (not (ac-f-pleine? a))).
Chapitre 9
Du bon usage des grammaires La notion de grammaire a été introduite et utilisée dès le chapitre 1 qui présente les rudiments du langage de programmation Scheme. À chaque construction du langage Scheme (application de fonction, alternative, définition de fonction, etc.) correspond une règle de grammaire. Cette notion de grammaire a été reprise, par exemple, au chapitre 2 pour préciser le langage des types utilisés pour écrire les spécifications. Les grammaires ont donc servi à définir des langages, c'est-à-dire, des ensembles de« phrases>> ou« expressions>> auxquelles les humains savent donner un sens : valeur d'une expression Scheme - pour la grammaire de Scheme -, domaine d'application et de valeur d'une fonction- pour la grammaire des types. Notons qu'il existe des logiciels- DrScheme par exemple- qui évaluent le sens des expressions de Scheme alors, qu'à ce jour, il n'en existe pas pour notre langage de types. Ce chapitre, consacré à un bon usage des grammaires, présente comment celles-ci sont un guide pour la conception et l'écriture de programmes manipulant des expressions appartenant à un langage. C'est un prolégomène indispensable à la compréhension de l'ultime chapitre de cet ouvrage qui culmine par la présentation d'un programme chargé de« donner un sens>> aux expressions du langage Scheme : un interprète.
1 Motivation Pour le programmeur, à l'issue d'un processus intellectuel de conception et d'élaboration, un programme ou une simple expression Scheme est concrétisé par un texte, une suite de caractères. La tâche de l'interprète est de « lire >> ce texte et de le traiter de façon à en déterminer la valeur. Pour cela, un interprète met en œuvre trois types d'actions : - vérifier que le texte proposé est effectivement un programme - ou une expression Scheme; - construire sa propre représentation interne du programme ; - appliquer les règles d'évaluation, les règles sémantiques, du langage pour construire la valeur demandée. Le processus de vérification du texte consiste à s'assurer que celui-ci respecte les règles de grammaire qui définissent le langage Scheme. Lors de ce processus, le texte est décomposé en sous-parties hiérarchisées : par exemple, une définition peut contenir une expression
248
Chapitre 9. Du bon usage des grammaires
conditionnelle qui, elle-même, contient trois expressions (le test, la conséquence et l'alternant). Cette décomposition est appelée analyse syntaxique et n'est pas sans rappeler l'analyse grammaticale que vous pratiquiez naguère. La structure hiérarchique, ainsi obtenue, sert en fait de base à la construction de la représentation interne à l'interprète Scheme. En informatique, les structures hiérarchiques sont codées par des arbres tels que présentés au chapitre 8. Les programmes de traitement de langages, comme les interprètes, construisent des structures d'arbres particulières appelées arbres de syntaxe abstraite. La structure hiérarchique dégagée par l'analyse syntaxique est le reflet de celle exprimée par les règles de grammaire (puisque c'est sur ces règles que s'appuie l'analyse). Nous allons voir comment l'énoncé de la grammaire du langage à traiter donne un moyen quasi systématique de conception et de réalisation de la barrière d'abstraction des arbres de syntaxe abstraite. On parle alors de barrière syntaxique. Une grande partie de ce chapitre s'appuie sur un exemple constitué par un langage simple de contraintes arithmétiques. L'accent y est mis sur la conception de la grammaire du langage et son utilité pour concevoir et réaliser la barrière syntaxique. Le chapitre se termine par trois applications de traitement des expressions du langage, la première étant un exemple introductif, la deuxième pouvant être vue comme un interprète et la dernière, un normalisateur, qui calcule une expression équivalente à celle donnée en argument, mais qui possède une forme particulière.
2 Lire et comprendre une grammaire Un langage est un ensemble de phrases ou d'expressions bâties sur un lexique et une syntaxe. On parle d'unités lexicales ou de lexèmes pour désigner les éléments du lexique. La syntaxe est définie par un ensemble de règles dites de production qui édictent comment agencer correctement les unités lexicales pour composer ce que l'on appelle des unités syntaxiques. Une règle de grammaire est un couple constitué d'une unité syntaxique (à définir) et d'une suite d'unités syntaxiques ou lexicales (qui la définissent). Les règles de grammaires, permettent une définition incrémentielle des expressions du langage. En particulier, et souvent, une règle est récursive : l'unité définie apparaît aussi dans la suite qui la définit. En général, la totalité des possibilités de construction des phrases correspondant à une unité syntaxique est définie à l'aide de plusieurs règles. Par convention, les unités syntaxiques sont notées entre chevrons ( < et >). Les unités lexicales sont notées soit telles quel lorsqu'il s'agit d'un symbole particulier (comme le symbole + ou le mot-clef if) soit par un nom désignant un ensemble de suites de caractères (comme la notation décimale des nombres ou les noms de variables). Les deux éléments d'une règle sont séparés par une flèche : -+ suite d'unités syntaxiques ou lexicales ll est fréquent qu'une unité lexicale ou syntaxique soit répétée plusieurs fois dans un membre droit d'une règle. Pour indiquer cette répétition, on utilise le caractère * comme opérateur (dit opérateur de Kleene) de répétition. On écrit, par exemple, * pour désigner une suite, éventuellement vide, d' . En effet, l' étant le mot vide, * est équivalente à la règle (récursive) : * -+ * l'
Lire et comprendre une grammaire
249
Dans le formalisme des grammaires on parle de symboles terminaux pour les unités lexicales (elles ne demandent pas d'autre définition par une règle de grammaire) et de nonterminaux pour les unités syntaxiques. La grammaire qui définit un langage possède un nonterminal particulier appelé axiome. C'est lui qui détermine le « point d'entrée >> de la grammaire, l'unité syntaxique englobant toutes les expressions du langage. Par exemple, dans la grammaire de Scheme présentée dans la carte de référence (page 327) l'axiome est l'unité syntaxique <programme>.
2.1
Grammaire de Scheme (fragment)
À titre d'application des notions introduites ci-dessus, reprenons quelques éléments de la grammaire du langage Scheme (un énoncé plus complet est donné dans la carte de référence, page 327): --+ { let { *) ) --+ { <expression> ) <expression>
--+
--+
NOMBRE
--+
#t #f
--+ IDENTIFICATEUR --+ { <argument>* ) --+ * <expression> --+ {define { <nom-fonction> *) ) <nomjonction> --+ IDENTIFICATEUR --+ <expression> <argument> --+ <expression> Cefragrnentdegrammairecontienthuitterminaux: {,),let, NOMBRE, #t, #f, IDENTIFICATEUR et de fine. Parmi eux, six- {, ) , let, de fine, #t, #f- apparaissent tels quels dans les expressions du langage. Les terminaux NOMBRE et IDENTIFICATEUR désignent des ensembles de mots suceptibles d'apparaître dans les expressions du langage, respectivement: les notations pour les constantes numériques (0, -20, 1. 25, etc.) et les noms de variables ou fonctions (x, L, fib, liste-carres, existe-chiffre?, etc.). Ce fragment contient douze non terminaux: , , , , <expression>, , , , <nom-fonction>, , <argument>, . Certains de ces non terminaux ne sont pas réellement utiles à la définition du langage. Ils sont définis par un, et un seul, autre terminal ou non terminal. Par exemple, est défini comme étant le terminal IDENTIFICATEUR, <argument> est défini par le non terminal <expression>. Les concepteurs de la grammaire ont néanmoins choisi d'introduire ces non terminaux auxiliaires pour rendre la grammaire plus lisible à l'utilisateur humain du langage. Ainsi, une est donnée par --+ { <argument>* )
250
Chapitre 9. Du bon usage des grammaires
plutôt que par la règle équivalente mais moins intuitive : --> ( <expression> <expression>* ) D'autres non terminaux sont en revanche indispensables et édictent les règles de formation de certains éléments du langage. Ainsi, la règle définissant un précise qu'un doit être enclos entre parenthèses, qu'il débute, après la parenthèse ouvrante, par le mot-clef let et qu'il contient deux constituants :une suite de , mise entre parenthèses, et un .
2.2 Analyse Pour savoir si une phrase donnée appartient à un langage ou non, il suffit de répondre à la question : « existe-t-il une suite d'applications de règles de grammaires du langage qui, partant du non terminal visé (souvent, l'axiome), permet de produire cette phrase? >>. Pour répondre à cette question, on procède à l'analyse progressive de la phrase à traiter. À chaque étape de l'analyse, on se pose la question plus simple : « par quelle règle cette phrase a-t-elle pu être produite? >>. Si aucune règle n'a pu être utilisée, la réponse est négative : la phrase n'appartient pas au langage. Si une règle a pu être utilisée, on décompose la phrase en autant de constituants que l'on trouve dans le membre droit de la règle : on associe les terminaux aux terminaux correspondant dans la phrase et les non terminaux à des fragments de phrase qu'il faut récursivement analyser. Si, au bout du compte, on n'obtient plus de fragment à analyser récursivement, la réponse est positive et la phrase appartient au langage. Voyons comment se déroule l'analyse sur deux exemples d'une forme let. La première est correcte et la seconde non. On utilise une notation verticale des règles : la règle --> ( <expression> )
est notée
( <expression> )
On fait figurer les noms ou entiers utilisés pour les unités lexicales IDENTIFICATEUR et NOMBRE, comme dans :
IDENTIFICATEUR
x
.
Pour économiser de la place, on ahrège IDENTIFICATEUR en ID et en . ~
Forme correcte Soit l'expression Scheme (let ( (x3 ( * x x x) ) (y2 ( * y y) ) ) (+ (* x3 y2) x3 y2)))
Le premier pas de son analyse est le suivant :
( let (
*
(x3 ( * x x x) ) (y2 ( * y y) )
(+
(* x3
y2)
x3 y2)
Lire et comprendre une grammaire
251
Notez comment les parenthèses entourant la suite de liaison sont« consommées» par la règle du . L'analyse de la suite des deux liaisons suit les étapes suivantes (noter que * est décomposé en deux unités syntaxiques ) : *
(x3
( * x x x) l
(y2
( * y y))
L'analyse de la première liaison débute par :
,.....,..__._
<expression>
ID
x3
(* x x x)
Reste, pour la première liaison à traiter l'expression ( * x x x). C'est une application. Cela donne le schéma d'analyse suivant (noter que <JJrgument>* est décomposé en trois unités syntaxiques <JJrgument>) : <expression> <expression>
-
<argument>* <argument>
<argument>
<argument>
<expression>
<expression>
<expression>
......--"-..
......--"-..
,.........-....
ID
ID
ID
......--"-..
ID
--
*
x
x
x
L'analyse de la seconde liaison est tout à fait similaire. Elle ne sera donc pas détaillée ici mais le lecteur est invité à la faire à titre d'exercice. L'analyse du corps de la forme let prise en exemple illustre le caractère récursif de la défiuition de l'unité syntaxique <expression>. On y voit également une utilisation de la suite vide lors de l'utilisation de l'étoile- ici, une suite vide de . Voici quelques étapes de cette analyse (pour la mise en page, définition, expression et argument sont abrégés respectivement en déf, expr et arg) :
252
Chapitre 9. Du bon usage des grammaires
-
*
<expr>
<JJrg>*
~
<expr>
<JJrg>
<JJrg>
,...........-..
~
<expr>
,.....-.._.,
<expr>
- -
ID ~
+
~
(* x3 y2)
x3
y2
Une fonne incorrecte
Une erreur courante chez le programmeur deôutant en Scheme est d'oublier une paire de parenthèses lorsqu'il écrit une forme let ne comportant qu'une seu1e liaison. Par exemple : (let (x2 (*x x)) (+ x2 y)) Voici comment l'analyse syntaxique est capable de détecter cette erreur. La première règle à appliquer est celle du , ce qui donne le schéma :
( let (
*
x2
,.............. (+
( * x x)
x2 y)
Jusqu'ici tout va bien, mais l'analyse bloque, et donc échoue, lorsqu'elle tente de développer:
*
x2
( * x x)
On obtient en effet le schéma de décomposition suivant :
*
,..,....._
~
x2
( * x x)
ll est clair que l'analyse ne peut pas se poursuivre: x2, qui est une phrase qui ne commence pas par une parenthèse ouvrante - et ne se termine pas par une parenthèse fermante-, ne peut en aucun cas être considéré comme une .
Concevoir une grammaire ~
253
Pertinence des messages d'erreur
La pertinence des messages d'erreur est un critère important pour la qualité d'un compilateur ou d'un interprète. Bien sûr, un interprète doit rejeter tout programme qui n'est pas correct vis-à-vis de la grammaire, mais, en plus, le message d'erreur affiché doit aider le plus possible le programmeur dans la recherche de son erreur. Pour ce faire, dans son analyse, l'interprète doit détecter l'erreur au meilleur endroit possible, ce qui n'est pas toujours aisé. Ainsi, avec l'exemple précédent, DrScheme affiche« let: malfonned expression», ce qui est bien le cas ; mais, avec l'expression : (let
( (x2 * ( 3 4) ) ) (+x23))
il affiche le même message, ce qui est moins pertinent, l'erreur étant plutôt due à une écriture erronée de l'application ( * 3 4).
3 Concevoir une grammaire Le programmeur est nécessairement amené à lire des grammaires. Au premier chef celle du langage qu'il utilise pour programmer. n peut également être amené à concevoir une grammaire si l'application qu'il développe nécessite la conception d'un langage pour décrire, par exemple, des données, des formules, des préférences, etc. Cette section aborde le problème de la conception d'une grammaire.
3.1 Petit langage de contraintes Définissons un petit langage permettant d'exprimer des relations entre valeurs arithmétiques comme: (x= 0), (ET (0 <x) (x<= 10) ),etc. Unlangagepermettantdetelles expressions est appelé langage de contraintes. Pour rester simple1, les valeurs arithmétiques sont exprimées soit par des constantes numériques entières telles que 0, 1, 42, -37, etc., on désigne par NUM l'ensemble de ces constantes; soit par des variables telles x, y, xl, etc., on désigne par ID l'ensemble de ces identificateurs de variables. On ne donne pas de règles pour définir NUM et ID, on considère que ce sont des unités lexicales. On se donne des opérateurs de comparaison entre valeurs arithmétiques, =,<et<=. Puis, pour combiner les comparaisons, on considère les trois opérateurs booléens: NON, ET, ou. Pour faciliter la mise en œuvre des programmes de ce chapitre, les contraintes sont complètement parenthésées. La grammaire utilise donc les terminaux ( et ) . Ces éléments constituent le lexique de notre langage de contraintes. Les opérateurs booléens et de comparaison forment l'ensemble des symboles réservés du langage (qui ne peuvent pas être utilisés comme identificateur des variables). Pour ce qui est de sa syntaxe, on peut observer que le langage est composé de deux niveaux : les comparaisons qui forment les contraintes de bases et les combinaisons booléennes qui forment les contraintes composées. On peut expliciter cela en énonçant : - les comparaisons sont des contraintes ; 1Dans
tiques.
un vrai langage de contraintes, les valeurs arithmétiques sont exprimées à l'aide d'expressions arithmé-
254
Chapitre 9. Du bon usage des grammaires
- la négation d'une contrainte est une contrainte ; - la conjonction et la disjonction d'une suite de contraintes sont des contraintes. Cet énoncé se formalise par la donnée de deux unités syntaxiques - et - ainsi que l'ensemble de règles :
CoMPARAisoN ( NON ) NÉGATION (ET *) CoNJONCTION (OU *) DISJONCTION Une valeur arithmétique peut être soit une variable soit une constante. Pour factoriser l'écriture des règles de production des comparaisons, on introduit une unité syntaxique désignant l'une ou l'autre : . On définit cette unité par la règle :
--->
--->
NUM ID
On peut alors énoncer les règles de construction des comparaisons, ce qui achève la défi-
nition de la granunaire de notre langage de contraintes :
( =
) ( < ) ( < = )
--->
ÉGALITÉ
INFÉRIORITÉ STRICTE INFÉRIORITÉ ou ÉGALITÉ
3.2 Schémas simplifiés d'analyse et arbres de syntaxe abstraite Soit la contrainte (NON
(x< 0))
Le schéma de son analyse syntaxique, selon les règles de notre langage, est :
(NON
<
,......_,
ID
NUM
x
0
,........_._
Simplifions ce schéma en oubliant les non terminaux ainsi que les tenninaux On obtient la figure suivante :
ID
et NUM.
(NON
<
........................ x
0
En fait, on peut également oublier les parenthèses qui ne servaient qu'à regrouper dans le
255
Concevoir une grammaire
texte des composants qui sont maintenant isolés dans le schéma, pour obtenir : NON
<
,...,.._.,
,...,.._.,
x
0
En remplaçant les accolades par des traits reliant les éléments placés aux différents niNON veaux, on obtient la structure arborescente : 1
<
x
/\0
Onappellecetarbrel'arbredesyntaxeabstraitedel'expression (NON (x< 0) ).Nous expliciterons cette notion dans la section 4 après avoir donné, rapidement, un second exemple, la contrainte à analyser étant : (ET
(x< y)
(y= 10)
(x<= 20))
On obtient le schéma d'analyse suivant (en abrégeant ATOME en AT et NUM en NU) : ( ET
*
,.....,........
,.....,........
ID
ID
ID
NU
ID
NU
x
y
y
10
x
20
,.....,........
<
ll en résulte l'arbre :
< x
,.....,........
____-r~
/\
=
Y
Y
/\
JO
x
<=
..---.......
<=
/\
20
3.3 Variation syntaxique, invariance structurelle On peut donner d'autres règles syntaxiques pour écrire des contraintes. Par exemple, nous pouvons écrire les conjonctions et les disjonctions en notation infixe et en supprimant quelques parenthèses comme dans (x < y ET y = 10 ET x < = 2 0) . Cette syntaxe est décrite par les règles de granm1aire suivantes (les caractères [ et ] permettent de parenthéser une suite d'unités pour l'opérateur*) : COMPARAISON ---> NON NÉGATION ( [ET ]*) CoNJONCTION ( [OU ]*) DisJONcTION
256
Chapitre 9. Du bon usage des grammaires
--+
= <JJtome> < <JJtome> <JJtome> <= <JJtome>
--+
ÉGALITÉ INFÉRIORITÉ STRICTE INFÉRIORITÉ OU ÉGALITÉ
NUM
ID
Analysons la contrainte tions que précédemment):
(x < y
ET y = 10 ET x <= 20) (avec les mêmes abrévia-
ET
-.
<
ID
x
ET
-.
-.
ID -y
=
-.
,......--.. NU 10
ID -y
<=
,......--.. NU 20
ID
x
Procédons aux mêmes simplifications que précédemment (oubli des non terminaux et suppression des parenthèses) : ET
,....,....._ x
<
,....,....._
ET
,....,....._
y
=
,....,....._
y
,....,....._ x
10
<=
,....,....._ 20
On obtient l'arbre :
~ < x
/\
Y
Y
El' ET
1
=
/\
....____ <=
JO
x
/\
20
Fait notable : l'arbre de syntaxe abstraite obtenu est quasiment identique à celui issu de l'analyse de (ET (x < y) (y = 10 J (x <= 2 o J J dans le premierlangage. Seule change l'étiquette qui représente la conjonction. Les structures d'arbres de syntaxe abstraite sont donc préférables lorsqu'il s'agit de manipuler, par programme, les expressions d'un langage. En effet ces structures donnent une information plus générale sur les expressions que leur donnée textuelle. De plus, cette vision arborescente de l'analyse syntaxique laisse à penser que les définitions des fonctions qui doivent analyser les phrases du langage suivront des schémas similaires à ceux que nous avons vus pour les arbres binaires et généraux. La section suivante applique ces méthodes dans le cadre de notre exemple de langage de contraintes.
Écrire la barrière syntaxique
257
,
4
Ecrire la barrière syntaxique
Une grammaire spécifie un langage et modélise ses expressions. Elle donne un procédé d'analyse effectif permettant de conclure à l'appartenance ou non d'une phrase au langage défini. Nous avons vu deux grammaires qui spécifient deux langages en apparence différents et qui produisent, après analyse, des structures abstraites similaires pour des expressions syntaxiquement différentes mais dont le sens (la sémantique) est commun. Ainsi, les grammaires et leur usage ont fait apparaître la structure abstraite sous-jacente2 des expressions concrètes d'un langage. Lorsqu'il s'agit d'écrire un programme qui manipule les expressions d'un langage, soit à fin d'exploration, soit pour produire des expressions, il est bien plus aisé de travailler au niveau des structures abstraites (arbres) qu'au niveau plus concret de l'écriture des expressions (avec ses détails de place des opérateurs, des parenthèses, etc.). Pour définir ce niveau d'abstraction, la grammaire, en ce qu'elle modélise les constructions du langage, est un guide précieux. Ce paragraphe en propose l'illustration, en suivant l'exemple des langages de contraintes. Il met en œuvre la notion de barrière !syntaxique proche de la notion déjà introduite de barrière d'abstraction (voir page 162).
4.1
Barrière syntaxique
Que nous choisissions l'une ou l'autre des grammaires proposées pour un langage de contraintes, on a quatre possibilités pour produire une (car la règle de grammaire correspondante possède quatre lignes) :
1. produire une . 2. nier une . 3. faire la conjonction de plusieurs s. 4. faire la disjonction de plusieurs s. À son tour, il y a trois possibilités pour produire une :
1. égalité. 2. infériorité stricte. 3. infériorité au sens large. Enfin, pour prodnire une comparaison, il faut savoir produire un , il y a deux possibilités pour cela :
1. constante entière. 2. variable. Voilà, grossièrement, ce qu'apprennent les grammaires sur les expressions d'un langage de contrainte. La grammaire est alors une description précise du langage que doit employer l'utilisateur d'un système informatique gérant des contraintes. Pour le programmeur d'un tel système informatique, les règles de grammaire sont un guide précieux pour reconnaître, parmi les phrases données par l'utilisateur, celles qui appartiennent ou n'appartiennent pas au langage et, lorsque la phrase donnée appartient au 2 Les linguistes parlent de structure profonde, concept introduit par Noarn CHOMSKI dans
années 50.
la seconde moitié des
258
Chapitre 9. Du bon usage des grammaires
langage, pour décomposer celle-ci en vue d'un traitement, en particulier pour donner un sens à une telle phrase. Traditionnellement, une telle décomposition est nommée analyse. Pour effectuer l'analyse automatique d'une expression: - On doit pouvoir déterminer quelle est la ligne de la règle qui a été utilisée pour produire l'expression analysée : pour chaque ligne de règle, on spécifie un reconnaisseur qui reconnaît si une expression donnée peut être produite par cette ligne de règle. - Chaque ligne de règle indique de quoi est constituée l'expression: par exemple, dans une négation, il doit y avoir une autre contrainte. La lecture des règles détermine quels sont les accesseurs nécessaires pour décomposer les expressions produites par une ligne de règle : pour décomposer une négation, il faut avoir une fonction d'accès à la contrainte niée -il y aura donc un accesseur dont le résultat est une contrainte. Dans une conjonction, il doit y avoir nn nombre quelconque de contraintes : pour décomposer une conjonction, il faut avoir un accesseur dont le résultat est une liste de contraintes (il en est de même pour les disjonctions). Le programmeur d'un tel système va donc créer une barrière syntaxique - sur le même principe qu'une barrière d'abstraction- composée de reconnaisseurs et d'accesseurs. ~
Réécriture de la grammaire pour faciliter l'écriture de la barrière syntaxique
On peut, pour énoncer la barrière syntaxique, suivre à la lettre les règles, ligne par ligne. On obtiendra ainsi un ensemble systématique et exhaustif de fonctions. Mais on peut également affiner l'analyse des règles pour en extraire des informations structurelles qui permettront de factoriser ou raccourcir certaines fonctionnalités et, éventuellement, de modifier les règles de grammaire pour simplifier la barrière syntaxique. Ainsi, n'est-il pas utile de distinguer les trois groupes d'accesseurs aux composants des différentes comparaisons selon leurrègle de formation (égalité, infériorité stricte... ) car toutes ont la même structure : un atome, un comparateur et un atome. Aussi, nous modifions la grammaire en utilisant, à la place de la règle qui définit l'unité syntaxique comparaison, la règle suivante :
--+ ( COMPARATEUR ) COMPARATEUR étant une nouvelle unité lexicale comportant les symboles =, < et<=. Avec une telle règle de grammaire, il n'y a qu'un accesseur pour extraire l'opérande gauche et un accesseur pour extraire l'opérande droit (et un accesseur pour extraire le comparateur). Remarque : on pourrait aussi vouloir factoriser les accesseurs aux composantes des conjonctions et disjonctions, mais il est préférable de s'en abstenir dans la mesure où une telle factorisation ne se généralise pas à toutes les contraintes composées (la négation a une structure unaire). Ainsi, la grammaire devient : --+ ( NON ) (ET *) (OU *)
--> --> NUM ID
coMPARAISON NÉGATION coNJONCTION DISJONCTION
( COMPARATEUR )
Écrire la barrière syntaxique ~
259
Types des données et des résultats des fonctions de la barrière syntaxique
La barrière syntaxique permet d'écrire des définitions de fonctions qui analysent des phrases fournies par un utilisateur. N'étant pas sûr qu'il ne donnera que des phrases correctes, on ne peut pas dire que ces données appartiennent au langage de contraintes et, a priori, elles peuvent être quelconques (autrement dit du type Valeur). Mais, le langage étant complètement parenthésé, nous pouvons nous restreindre aux S-expressions. Pour spécifier les accesseurs et les reconnaisseurs, il est utile d'identifier, par un nom de type, les valeurs attendues. Or chaque unité syntaxique définit un ensemble de phrases engendrées : nous nommerons le type de cet ensemble par l'identificateur de l'unité syntaxique, avec la première lettre en majuscule. Pour l'exemple des langages de contraintes, il y a trois unités syntaxiques et donc trois types : Contrainte, Comparaison et Atome. Pour le cas des atomes, on pourrait introduire deux types correspondant aux unités lexicales NUM et ID. Mais on peut également décider de mentionner directement le type Scheme des valeurs qui les représentent : respectivement, int et Symbole 3 • jl Rappelons que lorsqu'un type est donné dans une spécification, cela signifie que la fonc~ tion rend effectivement une valeur du type résultat si l'argument appartient effectivement au type d'argument spécifié. Si l'argument n'est pas du type attendu, en l'absence de précision dans la spécification, la valeur rendue peut être n'importe quoi: une erreur ou une valeur sans signification précise (donc source potentielle d'erreurs ultérieures ou de mauvaise interprétation d'un résultat). ~
Règles de nommage
Comme nous l'avons vu dans les chapitres précédent, pour faciliter la lecture - et l' écriture - des programmes, le choix des noms des fonctions est très important, tout particulièrement lorsque leur nombre est important, ce qui est le cas ici. Pour ce faire, quelques règles de nommage peuvent être très utiles. Ainsi, le préfixe des noms des reconnaisseurs et des accesseurs indique quelle est l'unité syntaxique considérée: cmp- et ctr- pour, respectivement, les unités syntaxiques et . La lecture de la grammaire permet de spécifier complètement les accesseurs de la barrière syntaxique, ce que nous faisons maintenant pour notre exemple.
4.2 Spécification de la barrière syntaxique ~
Spécification des accesseurs 1> Unité syntaxique
; ; ; cmp-gauche : Comparaison --->Atome ; ; ; (cmp-gauche c) rend l'opérande gauche de la comparaison «C». ; ; ; cmp-droit : Comparaison ---> Atome ; ; ; (cmp-droit c) rend l'opérande droit de la comparaison «C». 3Dans
un autre langage que Scheme on utiliserait plutôt le type des chaînes de caractères. Nous préférons ici la facilité offerte par les symboles Scheme.
260
Chapitre 9. Du bon usage des grammaires
; ; ; cmp-comparateur : Comparaison --t Symbole ; ; ; (cmp-comparateur c) rend le comparateur de la comparaison «C». C> Unité syntaxique
; ; ; ctr-negation-operande : Contrainte --> Contrainte ; ; ; (err-negation-operande c) rend l'opérande de «C». ; ; ; HYPOTHÈSE: «C» a la forme d'une négation. ; ; ; err-disjonction-operandes: Contrainte --t USTE[Contrainte] ; ; ; (err-disjonction-operandes c) rend la liste des opérandes de «C». ; ; ; HYPOTHÈSE: «C» a la forme d'une disjonction ; ; ; err-conjonction-operandes: Contrainte--> USTE[Contrainte] ; ; ; (err-conjonction-operandes c) rend la liste des opérandes de «C» ; ; ; HYPOTHÈSE: «C» a la forme d'une conjonction ~
Signatures des reconnaisseurs
La spécification des reconnaisseurs est un peu plus délicate car nous devons prendre en compte les cas où la donnée (l'expression à analyser) n'appartient pas au langage. Un exemple d'utilisation de cette barrière nous permettra de dégager les spécifications précises des reconnaisseurs ; dans un premier temps, nous ne donnons que leur signature. C> Reconnaisseurs des unités lexicales
Nous devons avoir un reconnaisseur par unité lexicale : ; ; ; numerique? : Sexpression ~ bool ; ; ; identificateur? : Sexpression --t boo/ ; ; ; comparateur? : Sexpression--> booZ C> Reconnaisseurs des unités syntaxiques
En reprenant ligne par ligne la règle de grammaire définissant une contrainte (page 258), on déduit qu'une contrainte peut être une comparaison, une négation, une disjonction ou une conjonction. D'où les reconnaisseurs: ; ; ; ;
; ; ; ;
; ; ; ;
err-comparaison?: Contrainte --t booZ ctr-negation?: Contrainte--+ booZ err-disjonction? : Contrainte -->booZ err-conjonction? : Contrainte --> booZ
Un atome étant une valeur numérique ou un identificateur, il n'y a pas besoin de nouveau reconnais seur. ~
Exemple d'utilisation de la barrière syntaxique
Dans ce paragraphe, nous voudrions écrire un analyseur du langage de contraintes défini ci-dessus, c'est-à-dire un prédicat qui vérifie si une expression donnée (une S-expression Scheme) appartient ou non au langage.
Écrire la barrière syntaxique
261
Attention : il faut bien faire la différence entre un reconnaisseur et un analyseur : - un reconnaisseur identifie une forme donnée par un membre droit de règle, il correspond donc à une ligne d'une règle; notons qu'il ne fait qu'un travail de surface; par exemple, nous verrons que ctr-comparaison? ne fait que vérifier que sa donnée est une liste ayant exactement trois éléments ; - un analyseur fait un travail en profondeur; par exemple, nous définirons l'analyseur comparaison? qui vérifie que sa donnée appartient au langage des comparaisons, c'est-à-dire que le premier élément de la liste est un atome (ce qui demande vérification), que le deuxième élément est un comparateur (ce qui demande vérification) et que le troisième élément est un atome (ce qui demande encore vérification). Les reconnaisseurs et accesseurs permettent la mise en œuvre du principe d'analyse illustré dans ce chapitre : lorsque l'on a reconnu une forme (c'est-à-dire une règle applicable) -grâce à un reconnaisseur -, on accède aux différents composants de l'expression traitée (c'est-à-dire aux fragments correspondant aux non terminaux)- grâce aux accesseurs- pour poursuivre l'analyse. Notons une conséquence importante de ce principe d'analyse: lorsque le reconnaisseur correspondant à une ligne de règle rend la valeur vrai, les accesseurs correspondants à cette ligne doivent pouvoir être appliqués. Ainsi, de façon purement abstraite, sans connaître encore l'implantation de la barrière syntaxique, il est possible de donner la définition d'un prédicat contrainte? qui vérifie si une S-expression donnée appartient ou non au langage. La définition de ce prédicat est, essentiellement, un aiguillage (défini à 1' aide des reconnaisseurs liés à la règle qui définie les contraintes) : - lorsque l'expression a la forme d'une négation (liste de deux éléments, le premier élément étant le symbole Non), elle appartient au langage si, et seulement si, son opérande est une contrainte ; - lorsque l'expression a la forme d'une disjonction (liste dont le premier élément est le symbole ou), elle appartient au langage si, et seulement si, chaque élément de la liste de ses opérandes est une contrainte ; comme nous l'avons vu pour les arbres généraux, nous utilisons une nouvelle fonction, liste-contraintes? qui effectue cette vérification; - il en est de même lorsque l'expression a la forme d'une conjonction; - lorsque l'expression a la forme d'une comparaison, nous vérifions, à l'aide du nouvel analyseur comparaison?, que c'est bien une comparaison; - sinon, l'expression n'est pas une contrainte. D'où la définition: ; ; ; contrainte? : Se;cpression ---> booZ ; ; ; (contrainte exp) rend #t si «exp» est une contrainte bien formée et #f sinon.
(define (contrainte? exp) (cond ((ctr-negation? exp) (contrainte? (ctr-negation-operande exp))) ((ctr-disjonction? exp) (liste-contraintes? (ctr-disjonction-operandes exp))) ((ctr-conjonction? exp) (liste-contraintes? (ctr-conjonction-operandes exp))) ((ctr-comparaison? exp) (comparaison? exp)) (else H l ) )
262
Chapitre 9. Du bon usage des grammaires
Pour pouvoir appliquer l'analyseur contrainte?- en plus, bien sftr, des fonctions de la barrière syntaxique-, deux fonctions restent à définir: la fonction liste-contraintes? et l'analyseur comparaison?. Mais quelle est la spécification de cet analyseur? Contrairement à l'analyseur con train te?, qui correspond à l'axiome de la grammaire, a priori, cet analyseur ne sera pas appliqué sur une S-expression quelconque, mais uniquement lorsque le reconnaisseur ctr-comparaison? rend la valeur vrai. Ce reconnaisseur fait un premier travail de surface et on doit alors choisir qui (entre le reconnaisseur et l'analyseur spécialisé) fait quoi (le but étant de reconnaître si une S-expression est une comparaison). Nous avons fait le choix suivant (mais nous aurions pu faire d'autres choix): le reconnaisseur garantit que la S-expression est une liste ayant exactement trois éléments, l'analyseur fait le reste du travail. D'où la spécification de comparaison? : ; ; ; comparaison? : Sexpression -+ bool ; ; ; (comparaison? exp) rend #t si «exp» est une comparaison bien formée, #[sinon. ; ; ; HYPOTHÈSE: «exp» est une liste ayant exactement trois éléments En utilisant un nouvel analyseur, atome?, qui vérifie si uneS-expression donnée est un atome, la définition de comparaison? est très simple (il suffit de suivre la règle) : (define (comparaison? exp) (and (atome? (cmp-gauche exp)) (atome? (cmp-droit exp)) (comparateur? (cmp-comparateur exp)))) La définition de l'analyseur atome? ne pose pas plus de problème (un atome est une
valeur numérique ou un identificateur) : ; ; ; atome? : Sexpression -+ bool ; ; ; (atome? exp) rend #t si «exp» est un atome au sens des comparaisons, #f sinon. (define (atome? exp) (or (numerique? exp) (identificateur? exp))) Ne reste plus que la défiuition de liste-contraintes?, définition similaire aux défi-
nitions que nous avons données pour les arbres généraux : ; ; ; liste-contraintes?: USTE[Sexpression]-+ bool ; ; ; (liste-contraintes? L) rend #t si «L» est une liste de contraintes bien formées et #f sinon. (define (liste-contraintes? L) (or (not (pair? L)) (and (contrainte? (carL)) (liste-contraintes? (cdr L)))))
Remarque : dans le cas des arbres généraux, au lieu de définir une fonction sur les listes d'arbres, nous avons souvent appliqué les itérateurs map et reduce. Nous ne pouvons pas le faire ici car l'opération à itérer est and qui n'est pas une fonction. C>
Remarque sur l'exemple
Dans la pratique, reconnaître si une expression appartient à un langage donné présente peu d'intérêt! On veut aller plus loin. Par exemple, pour un langage de programmation, le compilateur ou l'interprète signalent bien les erreurs éventuelles de syntaxe (l'expression n'appartient pas au langage) mais, surtout, lorsque l'expression appartient au langage, un compilateur génère du code et un interprète évalue le programme. Mais cet exemple est fondamental ! En effet, il constitue un canevas de tous les programmes qui ont comme donnée une expression, l'utilisateur espérant qu'elle appartient au langage, que ce soit un compilateur, un interprète, un formateur de sources ...
263
Écrire la barrière syntaxique ~
Spécification des reconnaisseurs ~
Remarques fondamentales sur les reconnaisse urs
Première remarque: comme nous l'avons déjà dit, les reconnaisseurs ne font qu'un travail de surface: par exemple, lorsque l'application (ctr-negation? exp), donne la valeur #t, cela ne signifie pas que exp est effectivement une contrainte négative. Cela signifie simplement que exp a pu être produite par la règle :
-->
( NON
)
C'est-à-dire que exp a la forme d'une négation. Outre les parenthèses, on y trouve deux constituants : le symbole NON et une expression niée. Mais rien ne dit (encore) que cette sous-expression est bien une contrainte. Pour s'en assurer, il faut descendre récursivement dans l'analyse du composant des négations que l'on obtient par appel à l'accesseur ctrnegation-operande. Et l'analyse récursive peut échouer; par exemple (NON (x ou y) ) a la forme d'une négation, mais (x ou y) n'est pas une contrainte correctement formée. De façon duale, la réponse #f ne signifie pas nécessairement que exp est une autre forme de contrainte. Ce peut tout aussi bien ne pas être une contrainte du tout : (ctr-negation? (list 1 2 3))
-->
#F
Deuxième remarque: comme nous l'avons également déjà dit, pour spécifier les reconnaisseurs, la difficulté est due au fait que nous voulons détecter les expressions qui n'appartiennent pas au langage. Si ce n'était pas le cas, la définition du prédicat contrainte? serait particulièrement simple puisqu'il rendrait toujours la valeur vrai ! Notons que le reconnaisseur ctr-comparaison? serait alors inutile (une contrainte qui n'aurait pas la forme d'une négation, d'une disjonction ou d'une conjonction serait obligatoirement une comparaison).
De même, pour le problème de l'évaluation que nous traiterons plus avant, nous voulons que la définition de la fonction signale une erreur lorsque 1' expression donnée n'est pas une contrainte. En effet, sinon, on serait dans la même situation qu'un interprète qui ne signalerait pas une erreur lorsque le programme Scheme est syntaxiquement incorrect. La difficulté est alors de spécifier les reconnaisseurs pour que toutes les erreurs soient détectées, sans tester plusieurs fois le même cas d'erreur, et au bon endroit dans l'analyse afin que les messages d'erreur soient pertinents.
Troisième remarque : dans tout cet ouvrage, nous avons beaucoup insisté sur le fait que l'on doit spécifier les fonctions avant de les implanter, la spécification étant indépendante de l'implantation. Ce n'est pas aussi simple! Par exemple une comparaison étant constituée de trois éléments, on peut implanter ctr-comparaison? en vérifiant que l'expression donnée est unelistedelongueurtrois.Maisalors, (ctr-comparaison? '(OR (x< 2) (y= 3)) J rend la valeur vrai : pour que cette implantation soit correcte vis-à-vis de la spécification, nous ajoutons dans celle-ci que l'expression n'a pas la forme d'une disjonction ou d'une conjonction. Pratiquement, cela implique que dans les aiguillages, l'application de ce prédicat sera dans une clause postérieure aux clauses qui utilisent les reconnaisseurs ctr-conj onction? et ctr-conjonction?.
264 ~
Chapitre 9. Du bon usage des grammaires
Spécification des reconnaisseurs C> Reconnaisseurs des unités lexicales
; ; ; numerique? : Sexpression -+ boo/ ; ; ; (numerique? a) rend #t si «a» est une valeur numérique, #f sinon. ; ; ; identificateur? : Sexpression --+ bool ; ; ; (identificateur? a) rend #t si «a» est un symbole non réservé, #[sinon. ; ; ; comparateur? : Sexpression--+ boo/ ; ; ; (comparateur? s) rend #t si «S» est un comparateur et#fsinon.
C> Reconnaisseurs des unités syntaxiques
; ; ; ctr-negation? : Contrainte --+ booZ ; ; ; (ctr-negation? c) rend #t si «C» a la forme d'une négation et #f sinon. ; ; ; ctr-disjonction? : Contrainte --+boo! ; ; ; (ctr-disjonction? c) rend #t si «C» a la forme d'une disjonction, #[sinon. ; ; ; ctr-conjonction? : Contrainte --+ boo! ; ; ; (ctr-conjonction? c) rend #t si «C» a la forme d'une conjonction, #[sinon. ; ; ; err-comparaison?: Contrainte-+ boo/ ; ; ; (ctr-comparaison? c) rend #t si «C» a la forme d'une comparaison et#fsinon. ; ; ; HYPOTHÈSE: «C» n'a pas la forme d'une conjonction ou d'une disjonction
4.3 Implantation de la barrière syntaxique ~
Fonctions de service
Dans les définitions des reconnaisseurs, nous utiliserons les deux fonctions suivantes (qui, bien sûr, ne font pas partie de la barrière syntaxique) : ; ; ; lg=2?: LISTE[alpha]-+ boo! ; ; ; (lg=2? L) rend vrai ssi la liste «L» a exactement 2 éléments (define (lg=2? L) (and (pair? L) (pair? ( cdr L) ) (not (pair? (cddr L))))) lg=3?: LISTE[alpha]-> boo/ ' '' ; ; ; (lg=3? L) rend vrai ssi la liste «L» a exactement 3 éléments (define (lg=3? L) (and (pair? L) (pair? ( cdr L) ) (pair? ( cddr L) ) (not (pair? (cdddr L)))))
Écrire la barrière syntaxique ~
Reconnaisseurs des unités lexicales
; ; ; numerique? : Sexpression --+ bool ; ; ; (numerique? a) rend #t si «a» est une valeur numérique, #f sinon. (define (numerique? a) ( integer? a) ) ; ; ; identificateur? : Sexpression - t bool ; ; ; (identificateur? a) rend #t si «a» est un symbole non réservé, #fsinon. (define (identificateur? a) (and (syrnbol? a) (not (member a '(NON OU ET=<=<))))) ; ; ; comparateur? : Sexpression--+ bool ; ; ; (comparateur? s) rend#tsi «s» est un comparateuret#fsinon. (define (comparateur? s) (if (member s (list '= '<= '<)) #t #f) )
~
Unité syntaxique 1>
Accesseurs
; ; ; cmp-gauche : Comparaison - t Atome ; ; ; (cmp-gauche c) rend l'opérande gauche de la comparaison «C». (define (cmp-gauche c) (car c)) ; ; ; cmp-droit : Comparaison - t Atome ; ; ; (cmp-droit c) rend l'opérande droit de la comparaison «C». (define (cmp-droit c) (caddr c)) ; ; ; cmp-comparateur : Comparaison - t Symbole ; ; ; (cmp-comparateur c) rend le comparateur de la comparaison «C». (define (cmp-comparateur c) (cadr c))
~
Unité syntaxique 1>