Rapport TP1

publicité
par:
Nicola Grenon GREN30077303
et
Jean-François Delisle DELJ02057809
mardi vingt-et-un février deux mille six
I – Le fonctionnement des fonctions.
ligne:
(define ligne
(lambda (depart arrivee)
(lambda (transf)
(map (lambda (segment)
(segm (transf (segm-depart segment))
(transf (segm-arrivee segment))))
(decoupe depart arrivee 1/10)))))
La fonction ligne reçoit les deux vecteurs qui sont en fait chacun une paire représentant les
coordonnées cartésiennes d'un point (normalisé). La sous fonction «decoupe» s'occupe de calculer
l'ensemble des points nécessaires pour séparer le segment allant du premier vecteur au second en
une liste de segments de longueurs équivalentes d'au maximum 1/10. Cette dernière effectue
l'opération en divisant récursivement la longueur de chaque sous segment en deux un nombre
suffisant de fois pour que chaque sous segment ne dépasse pas cette limite. Une fois que la liste des
segments a été déterminée, on bâti le corps de la fonction qu'est un «dessinateur» en insérant la liste
dans une lambda qui se chargera d'appliquer la transformation «transf» à chacun des vecteurs ci
avant déterminés.
parcours->dessinateur:
(define parcours->dessinateur
(lambda (parcours)
(lambda (transf)
(concat
(map (lambda (segment)
((ligne (segm-depart segment) (segm-arrivee segment)) transf))
(liste-vect->liste-segm parcours))))))
La fonction parcours->dessinateur reçoit une liste de vecteur. On va d'abord transformer cette
liste de vecteur en une liste de segments au moyen de la sous fonction liste-vect->liste-segm. Pour
appliquer les transformations du transformateur que recevra le dessinateur que l'on veut produire, on
va traiter au moyen de celui-ci chaque segment obtenu pour notre parcours au moyen de la fonction
ligne. Le résultat de cette opération nous donnant la liste complète des segments (de taille 1/10
maximum) qu'on va inclure dans une lambda qui sera prête à leur appliquer le transformateur dont
nous nous sommes servis et qu'elle recevra en paramètre lorsqu'elle jouera son rôle de dessinateur.
Les fonctions de transformation:
Les fonctions de transformation ont été implémentées de façon modulaire. Ainsi, dans un premier
temps on voit ici les fonctions de calcul des coordonnées. Elles seront appliquées à chaque
coordonnée de chaque vecteur reçu pour chaque segment représentant le dessin du dessinateur. À
noter ici que le paramètre «signe» n'est utiliser que dans le cas de la rotation, car avec cette fonction
on doit utiliser une opération légèrement différente pour chacune des coordonnées. Toutes les autres
fonctions de transformation appliquent la même formule aux deux coordonnées, mais on a quand
même accolé le paramètre signe à toutes les fonctions dans un esprit de modularité.
(define f-translation
(lambda (a b amplitude signe)
(+ a amplitude)))
(define f-reduction
(lambda (a b amplitude signe)
(* a amplitude)))
(define f-loupe
(lambda (a b amplitude signe)
(* a (/ (+ 1 amplitude) (+ 1 (* amplitude (+ (* a a) (* b b))))))))
(define f-rotation
(lambda (a b amplitude signe)
(signe (* a (cos amplitude)) (* b (sin amplitude)))))
Chacune de ces fonctions de transformation est, donc, appelée par une fonction principale qui
applique les changements de ladite fonction de transformation à tous les vecteurs du dessinateur.
Elle reçoit donc en paramètre la fonction à appliquer «f» et la transmet à une sous fonction qui
l'appliquera elle sur chacun des vecteurs, coordonnée par coordonnée.
(define mod-dessinateur
(lambda (f mod-x mod-y dessinateur)
(lambda (transf)
(map (lambda (segment)
(segm (mod-vect f (segm-depart segment) mod-x mod-y)
(mod-vect f (segm-arrivee segment) mod-x mod-y)))
(dessinateur transf)))))
(define mod-vect
(lambda (f vecteur mod-x mod-y)
(vect (f (vect-x vecteur) (vect-y vecteur) mod-x +)
(f (vect-y vecteur) (vect-x vecteur) mod-y -))))
On a finalement ici les fonctions d'appel de départ qui ne servent qu'à mettre en paire les bonnes
fonctions de transformation, l'amplitude de la modification à apporter et le dessinateur à transformer
avant de le retourner.
(define translation
(lambda (deplacement-x deplacement-y dessinateur)
(mod-dessinateur f-translation deplacement-x deplacement-y dessinateur)))
(define reduction
(lambda (facteur-x facteur-y dessinateur)
(mod-dessinateur f-reduction facteur-x facteur-y dessinateur)))
(define loupe
(lambda (facteur dessinateur)
(mod-dessinateur f-loupe facteur facteur dessinateur)))
(define rotation
(lambda (thetadeg dessinateur)
(let ((theta (* (/ thetadeg 360) 2 3.141596539)))
(mod-dessinateur f-rotation theta theta dessinateur))))
La superposition:
(define superposition
(lambda (dessinateur1 dessinateur2)
(lambda (transf)
(append (dessinateur1 transf) (dessinateur2 transf)))))
La superposition est effectuée très simplement en extrayant de chacun des deux dessinateurs la
liste des segments auxquels on aura appliqué la transformation reçue en paramètre par notre
dessinateur résultant et qu'on aura ensuite juxtaposés avec un append.
L'empilage:
(define pile
(lambda (prop dessinateur1 dessinateur2)
(lambda (transf)
(append
((translation 0 (- prop 1) (reduction 1 prop dessinateur1)) transf)
((translation 0 prop (reduction 1 (- 1 prop) dessinateur2)) transf)))))
On combine ici deux transformations de base. Pour chacun des deux dessinateurs reçus, on
réduit d'abord du facteur voulu (l'un étant le complément de l'autre) et il ne reste plus qu'à déplacer
(translation) les deux dessins à leur position finale. Comme on applique au passage à chaque
dessinateur les transformations que devra subir le dessinateur final, on obtient deux listes de
segments qu'il ne nous reste plus qu'à combiner et à remettre dans un lambda qui leur passera le
«transf» précité. On a d'ailleurs déjà utilisé cette technique pour la superposition.
La mise en côte-à-côte:
(define cote-a-cote
(lambda (prop dessinateur1 dessinateur2)
(lambda (transf)
(append
((translation (- prop 1) 0 (reduction prop 1 dessinateur1)) transf)
((translation prop 0 (reduction (- 1 prop) 1 dessinateur2)) transf)))))
La stratégie est ici calquée sur l'empilage. La fonction se comporte exactement comme la
précédente, mais selon l'autre axe. Il aurait toutefois été plus compliqué de vouloir mettre en commun
les opérations similaires. Il aurait fallu soit jouer avec une série de rotation (coûteux), soit ajouter
plusieurs paramètres spécifiques, tuant ainsi l'espoir d'une généralisation utile. Il eut aussi été
possible de déduire à partir des paramètres déjà existant laquelle des deux fonctions appelait une
fonction commune, mais il aurait alors fallu effectuer une lourde série d'opération mathématiques à
chaque itération, ce qui, somme toute, n'était pas intéressant.
Le dessinateur de nombres entier:
(define entier->dessinateur
(lambda (nombre)
(nombre->inserer-dessinateur nombre (floor (log10 nombre)))))
On a besoin de passer à la fonction qui fera vraiment le travail le nombre d'élément qu'on devra
mettre côte à cote. Ainsi on peut itérer avec les bonnes proportions. Pour ce faire nous avons bâti
une fonction de log en base 10 et qui a la particularité de retourner 0 lorsqu'on lui demande le log de
0 par soucis de sécurité puisqu'on doit accepter les appels sur toutes les valeurs entières non
négatives.
(define log10
(lambda (x)
(if (= x 0)
0
(/ (log x) (log 10)))))
La fonction qui effectue vraiment le travail est la suivante. Elle reçoit le nombre, et le nombre de
chiffres composant celui-ci. Elle peut donc alors s'appeler récursivement afin de réduire le problème à
un cas de base, celui où on n'a qu'un chiffre à afficher. Il ne nous reste donc alors qu'à créer le
dessinateur de ce chiffre, qui nous est fourni sous forme de vecteur, au moyen de la fonction
parcours->dessinateur définie plus haut.
(define nombre->inserer-dessinateur
(lambda (nombre prop)
(let ((chiffre (parcours->dessinateur
(vector-ref parcours-pour-chiffres (modulo nombre 10)))))
(if (< nombre 10)
chiffre
(cote-a-cote
(/ prop (+ prop 1))
(nombre->inserer-dessinateur (quotient nombre 10) (- prop 1))
chiffre)))))
À chaque itération la proportions passe donc de (nombre de chiffres)/(nombre de chiffres + 1) à
la fraction suivante. On place donc les deux premiers avec une relation de 1/2, auxquels on accole
les chiffres suivant avec une proportion de 2/3 pour le groupe déjà défini, puis ¾, etc. Chaque
itération travaille avec le résultat de la division entière par dix, enlevant de fait de la liste de travail le
dernier chiffre de droite.
Le dessinateur d'arbre:
(define arbre->dessinateur
(lambda (arbre)
(arbre-hauteur->dessinateur arbre (hauteur-arbre arbre))))
(define arbre-hauteur->dessinateur
(lambda (arbre hauteur)
(if (pair? arbre)
(pile (/ (- hauteur 1) hauteur)
(cote-a-cote 1/2 (arbre-hauteur->dessinateur (car arbre) (- hauteur 1))
(arbre-hauteur->dessinateur (cdr arbre) (- hauteur 1)))
division)
vide)))
On utilise ici le même principe que pour la fonction précédente. Pour pouvoir disposer
correctement les éléments obtenus (verticalement), on doit savoir à l'avance combien il y aura
d'étages. Comme on ne peut ici se servir d'un «log», voici la fonction de calcul de l'arbre utilisée, qui
très simplement va suivre chaque sous branche récursivement, en conservant la plus profonde à
chaque remontée récursive.
(define hauteur-arbre
(lambda (arbre)
(if (pair? arbre)
(+ 1 (max (hauteur-arbre (car arbre)) (hauteur-arbre (cdr arbre))))
0)))
Alors, une fois qu'on a la hauteur de l'arbre, on peut appeler récursivement (en diminuant la
hauteur de 1 à chaque appel) la fonction arbre->dessinateur. On empilera alors avec la fonction pile
les étages d'information.
Ces étages d'information, quant à eux, sont plus aisés à générer puisqu'on travaille avec un arbre
binaire. On utilise donc la fonction cote-a-cote, mais toujours avec un paramètre 1/2. Donc avant de
faire l'appel récursif pour l'étage, on n'aura eu qu'à placer côte à côte le résultat de l'appel récursif.
L'appel récursif pouvant, quant à lui, retourner un sous arbre ou le cas de base, c'est-à-dire une
case vide. Au retour de l'appel récursif, on empile simplement un dessinateur représentant
l'embranchement (division) et les deux sous arbres.
Le «splash screen»:
Il s'agit en fait d'une collection d'empilage et de mise en côte à côte, le tout passé au travers de la
loupe. Sympatique non?
:o)
(dessiner (loupe 1 splash))
II – Forces et faiblesses.
La syntaxe:
La syntaxe du langage Scheme en elle-même représente une difficulté au premier abord. En effet, en
étant limité (pour le cadre de ce travail) à une structure fonctionnelle, il fallait éviter de prévoir le
déroulement du travail à effectuer en tant qu'une séquence d'événements, mais plutôt comme une longue
expression unique dont il fallait prévoir tous les cas dans une seule formulation. C'est un peu comme
essayer d'exprimer une idée complexe de façon complète dans une seule phrase: il faut alors préparer
avec soin et inclure au fur et à mesure les définitions de chaque terme employé lors de l'expression
principale.
Même en étant limité au cadre plus «fonctionnel» du langage (en évitant les set! et les begin par
exemple), on peut toutefois «tricher» grâce à l'utilisation du let. En effet, le let permet de «préparer» à
l'avance des informations que l'on va ensuite utiliser dans le cœur de notre fonction. C'est donc une sorte
de séquençage qui mine (un peu) la structure fonctionnelle à laquelle nous étions restreint. Il faut dire que
c'est un net avantage du point de vue simplicité de programmation. Sans cet outil pour, entre autres,
clarifier le code, il aurait été réellement beaucoup plus ardu de trouver une formulation adéquate pour
effectuer les opérations demandées. Ceci dit, il appert que cet emploi du let pourrait facilement nous
entraîner dans une pente dangereuse. À savoir qu'il serait possible de pervertir complètement le but du
langage en utilisant le let de façon massive justement pour générer un environnement relativement
impératif.
Se concentrer sur la fonctionnalité plutôt que la structure des données:
La principale force de la programmation fonctionnelle, à notre avis, est sans doute la possibilité
d'utiliser une structure de fonctions préparées d'avance sur mesure, non dépendantes en taille ou en
forme des données qui y seront ensuite stockées. En d'autres mots, la modularité est exceptionnelle.
Dans la série de fonctions pour effectuer les transformations sur les dessinateurs par exemple
(rotation, translation, loupe et réduction), on voit clairement que le cœur de ces fonctions tient en une
toute petite ligne; une ligne qui peut se lire aisément (pour qui sait lire le préfixe bien sûr). On sait aussi
de manière équivalente qu'à partir de ce schéma, il serait facile d'implémenter une foule d'autres
fonctions de transformation sans que le travail soit complexe.
Dans un langage de programmation impératif tel le Java, auquel nous sommes beaucoup plus
habitués, il aurait fallu nous concentrer davantage sur la structure des données plutôt que sur les
fonctions. Ainsi la façon dont serait stockées les informations dicterait plus directement les choix de
programmation: pour de petits dessinateurs on pourrait utiliser des formes simples telles un tableau pour
stocker les segments du dessinateurs pour ensuite bâtir des méthodes de traitement de ces données.
Pour un grand volume d'information, on aurait plutôt penché pour des structures plus évoluées comme
des listes chaînées. Tandis qu'en programmation fonctionnelle, on peut s'intéresser d'abord à la
fonctionnalité recherchée et seulement ensuite adapter une structure de données. En fait, on bâti le
programme «en sens inverse». Au lieu de spécialiser une méthode jusqu'aux éléments de base, on fait
ce qui nous intéresse vraiment sur les éléments de base et on peut ensuite généraliser le processus au
moyen d'outils fonctionnels de plus en plus évolués.
En fait c'est là en même temps un point rendant l'utilisation du Scheme (en l'occurrence) plus lourds
dans un premier temps. En plus du manque d'expérience de départ (ou parlons plutôt de la moindre
habitude puisque c'est un langage dont l'utilisation est moins généralisée), il faut aussi apprendre à
penser autrement. Il est plus difficile de «se lancer dedans» pour programmer. (C'est peut-être une bonne
chose en fait, mais ça rend la tâche plus rebutante.)
Alors qu'il est plus instinctif de partir d'un problème large et d'en spécialiser la solution de plus en
plus, on doit d'abord s'arrêter à voir comment appliquer l'opération élémentaire, puis chercher la bonne
façon de généraliser. Le besoin d'abstraction à ce niveau est grand: il faut arriver à imaginer l'objet qu'on
veut créer, visualiser le travail qu'il va accomplir plutôt que ce qu'il contient. En Java, on peut se
permettre d'observer l'état de nos données, puis ajouter une par une des méthodes pour les modifier.
Cela semble moins intuitif en Scheme.
Les types et les listes:
Un avantage net du Scheme est sans doute la possibilité d'utiliser sans aucune forme de complication les
paires comme unité de structure de donnée universelle. On n'a pas à se préoccuper du type des données qui
y sont stockées. En Java, nous pouvons aussi dire que le type n'est pas une contrainte pour le stockage dans
les structures de données puisqu'on se sert du polymorphisme pour passer outre au type. Mais il persiste
qu'en Scheme, on n'a pas à se soucier de «caster» le type de l'information (que nous devons donc connaître
à priori). On peut donc simplement utiliser l'information sortie de la structure de donnée directement. Bien sûr
à l'interne le langage doit être capable de différencier les types, mais comme nous disposons en plus de
fonctions capables de façon générique de travailler indifféremment sur plusieurs types en même temps, la vie
du programmeur est hautement simplifiée. [Nous faisons ici référence, par exemple, au if qui acceptera
indifféremment que le résultat de ce qu'il teste soit booléen ou autre.] On peut donc, en définitive, exprimer en
bien moins de lignes de code la même idée (dans plusieurs cas où on teste des résultats) en beaucoup
moins de lignes de code.
Préfixe versus infixe et uniformité:
Le Scheme a une structure très uniforme. Bien sûr il y a quelques fonctions avec des formes spéciales,
mais de façon générale, une fois qu'on a compris la syntaxe, on peut difficilement se méprendre sur la façon
d'employer une fonction. Le fait de travailler e forme préfixe nous permet d'éviter beaucoup de confusion sur
le sens à donner aux expressions: on voit toujours (dans les formes normales) l'identifiant de la fonction suivi
de ses arguments. On sait donc d'un seul coup d'oeil comment interpréter ce qu'on lit. Il nous suffit de
déterminer les arguments au besoin (résolvant récursivement les sous fonctions au besoin) et 'ensuite
analyser les opérations effectuées par la fonction appelée. On obtient clairement un arbre déterministe de
sous résultats aboutissant à un résultat principal.
En Java on peut bien sûr visualiser l'arbre des résultats obtenus par les sous appels, mais le fait de
travailler en infixe, à notre avis, permet moins facilement de suivre le fil du déroulement. [Une boucle avec un
if-break(return) est un cauchemar en la matière pour ne citer qu'un exemple.] Évidemment ici nous
supposons que l'observateur a autant d'expérience avec un langage que l'autre!
Transformations de fonctions:
Une chose qui est plus difficile à travailler en Scheme, c'est l'intégration parallèle de deux fonctions. On
peut facilement composer deux fonctions (même très facilement, ce qui en fait un outil puissant) au sen
d'appliquer sur le résultat de l'une la fonctionnalité de l'autre. Encore plus intéressant c'est lorsque le type
sous-entendu du résultat et de l'entrée de plusieurs fonctions est le même. On peut alors, avec peu de travail,
générer une impressionnante diversité de résultats. (Par exemple nos outils de transformation dans ce
travail). Cependant, lorsque vient le moment de joindre deux fonctions au sens de leur résultats (Par exemple
la superposition de deux dessinateurs), il faut manipuler les fonctions pour en extraire les données
intrinsèques au moyen d'artifices (une lambda identité?) pour ensuite recomposer une fonction que nous
retournerons en sortie. Ce processus n'est certes pas élégant ni rentable.
III – Avantages et désavantages de Scheme dans ce contexte.
Dans notre contexte, à savoir des fonctions appliquées dans un cadre de dessins à générer sur une
grille standardisée, le Scheme permet dans un premier temps de modulariser grandement le travail à
accomplir. En conservant à presque chaque étape la forme des objets à travailler (nommément le
dessinateur) on peut intervertir l'ordre d'utilisation des fonctions et réutiliser celles-ci dans divers
contextes.
Il est également plus facile de séparer le travail en sections indépendantes puisqu'un sujet n'affecte
pas les autres. À ceci près que c'eut été plus évident pour un travail de plus grande ampleur, car ici il faut
admettre que pour pouvoir bien programmer les fonctions et atteindre un niveau de modularité
intéressant, il fallait déjà pouvoir assimiler ce qui rendait plusieurs fonctions similaires entre elles. Ainsi,
dans le cadre d'un travail où nous aurions à implémenter 30 fonctions de transformation, il est clair que la
création de celles-ci pourrait être déléguée sans mal. De la même façon, une fois un modèle accepté, les
fonctions de plus haut niveau (entier, arbre) ne sont que d'autres modules, plus gros (long), mais pas
forcément plus complexes.
Pour tout ce qui est de la manipulation de base, par contre, on est à la remorque de la forme à
conserver. On doit extraire l'information de force de certaines fonctions pour les recréer de toute pièce
ensuite (superposition). Aussi, les premières étapes demandent un niveau d'abstraction nécessitant de
l'expérience et une bonne idée du résultat final à obtenir. Elles sont aussi plus longues et complexes à
définir. (Le code d'une simple ligne prends beaucoup de code en comparaison à celui de disons un
empilage.)
Donc, somme toute, tout n'est pas noir ou blanc. Il est intéressant d'obtenir un code clair (à l'œil
averti), modulaire et réutilisable, mais d'un autre côté il est dommage de devoir utiliser des artifices quand
vient le temps de faire ce que ne prévoyait pas le modèle de base. (Superposition, lambda identité, etc.)
Téléchargement