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.)