greibach normal form example
Transcription
greibach normal form example
Analyse On passe doucement à l’analyse des programmes sources : l’analyse lexicale (scanning) et l’analyse syntaxique (parsing). Les résultats de l’analyse peuvent – si on le veut – être stockés dans un arbre syntaxique (un code intermédiaire nonexécutable), mais ceci n’est pas important, on peut générer le code de plus bas niveau plus directement. Ce qui compte c’est la sémantique du programme, la signification de toutes les constructions, la cohérence des « connaissances » du compilateur concernant le programme-source. Donc, nous n’allons jamais séparer l’analyse purement formelle, théorique, le traitement des chaînes et des arbres, de notre vision plus concrète, de la nécessité de générer un code exécutable par une machine physique ou virtuelle ! 109 Ceci dit, un peu de théorie sera nécessaire. Dans le champ de compilation les arbres poussent partout : structures syntaxiques, environnements hiérarchiques (lexicaux et dynamiques), intégration du programme à partir des modules, etc. Il est donc souhaitable de rappeler quelques techniques modernes de traitement des arbres, ainsi que leur réalisation dans le style fonctionnel. Arbres de recherche Les structures data Arbre a = Nil | Nd a (Arbre a) (Arbre a) permettent (dans le cas optimal) insérer et accéder à un élément en temps logarithmique. Rappelons que la technique d’insertion d’un élément consiste à descendre jusqu’au niveau des feuilles, en allant à gauche si l’élément inséré est plus petit que la racine (courante), sinon – à droite. 110 On remplace le Nil par le nouveau élément. Si on insère dans un arbre initialement vide les éléments de la liste [4,5,2,7,1,8,6,9,3], on obtient la structure 4 2 1 5 3 7 6 8 9 ce qui est facilement implémentable par le programme ci-dessous. 111 Ce programme définit l’insertion d’un élément, l’itérateur convertissant une liste en arbre, ainsi qu’une fonction inverse, qui « aplâtit la liste » selon le parcours inorder. ins x Nil = Nd x Nil Nil ins x (Nd y g d) | x < y = Nd y (ins x g) d | otherwise = Nd y g (ins x d) listree l = ls l Nil where ls [] t = t ls (x:xq) t = ls xq (ins x t) flat tr = flt tr [] where flt Nil l = l flt (Nd x g d) l = flt g (x : (flt d l)) 112 L’opération un peu plus délicate est la suppression d’un élément. Si l’objet à supprimer possède au maximum 1 fils, la solution est triviale, on fusionne le descendant unique avec l’encêtre. Si le noeud possède deux fils, il faut effectuer les opérations suivantes : – Localiser le successeur du noeud, l’élément suivant dans inorder. Il ne peut avoir aucun fils gauche, car c’est le minimum de la première branche à droite de ce noeud. – Effacer ce successeur, et remplacer le noeud supprimé par le successeur. Dans le cas général la recherche du successeur n’est pas triviale (il faut descendre à droite d’un ancêtre), mais dans le contexte ci-dessus, le successeur est le minimum de la branche droite tout court. Construisons une procédure (deltmin) qui récupère et qui efface le minimum d’un arbre (elle n’est pas protegée contre l’échec ; veuillez la compléter vous-mêmes) : 113 deltmin (Nd x Nil d) = (x,d) deltmin (Nd x g d) = let (y,p) = deltmin g in (y,Nd x p d) Pour effacer un noeud quelconque il suffit de lancer la procédure delt delt x (Nd y g d) | x==y = if g==Nil then d else if d==Nil then g else let (s,p) = deltmin d in Nd s g p | x<y = Nd y (delt x g) d | otherwise = Nd y g (delt x d) 114 Arbres équilibrés Les arbres de recherche dans la compilation peuvent jouer le rôle d’environnements. Nous avons déjà utilisée une liste linéaire, pour les opérateurs, et pour les variables. Dans la discussion d’un interprète de la forme lambda, nous avons vu que l’environnement agit comme une pile, et de nouvelles instances recouvrent les précédentes. Même avec des listes linéaires on peut optimiser la recherche : garder tout atome (identificateur, nom) en un seul exemplaire, mais attacher à son emplacement une pile (liste) de ses instances hiérarchiques). Ou, éventuellement, pour chaque module et chaque procédure compilée (ou interprétée) prévoir un environnement local, et chercher d’abord là. Si la recherche échoue, on cherche dans l’environnement englobant, etc. Les environnements peuvent alors constituer un arbre hiérarchique aussi. 115 En tout cas, recherche linéaire est lente, et des techniques d’optimisation, l’usage du hash-coding ou des arbres devient inéluctable. Mais pour assurer un bon comportement des algorithmes de recherche et de la mise à jour, il faut que les arbres soient équilibrés. Nous allons donc rappeler quelques outils de traitement des arbres balancés (équilibrés ; dont les branches possèdent à-peu-près la même hauteur, ce qui est bon pour la complexité des algorithmes qui les digèrent, on obtient le coût logarithmique dans le pire des cas). Les arbres AVL (Adelson, Velskiy & Landis) se caractérisent par la propriété que la différence de hauteur des deus sous-arbres ne dépasse √ jamais 1. On prouve que la hauteur maximale d’un arbre AVL est limitée par 2 fois la taille optimale (équilibre parfait), donc, toujours logarithmique en nombre de noeuds. L’insertion dans un arbre AVL commence comme d’habitude, on descend jusqu’aux feuilles. L’insertion peut produire un arbre déséquilibré, qui sera alors immédiatemment reconstruit. 116 Souvent on stocke dans des noeuds un drapeau qui dit laquelle de deux branches est plus longue (ou aucune). Si l’insertion a lieu dans la branche courte, c’est tout. Dans le cas contraire on effectue une rotation. Il existe deux variantes de rotation des arbres AVL : simple et double. L’arbre à droite est le résultat d’une rotation simple de l’arbre à gauche, visiblement déséquilibré. x y y A x z B C A z BC D D Une autre configuration mauvaise peut également être traitée. 117 x z y A z B x D A y BC D C Donc, pour équilibrer l’arbre AVL il faut chercher un noeud dont le « grand-père » est devenu déséquilibré, et effectuer la rotation. Le code Haskell a une dizaine de lignes, et son implantation se trouve sur l’Internet. Trouvez-le. 118 Digression : lambda-lifting Si une procédure accède uniquement à ses variables locales (c’est une fonction « pure »), on peut stocker toutes les données sur la pile. Si de plus on peut accéder aux variables globales, on prévoit un environnement global. Cela suffit pour C ou les premières version du Python. Mais Scheme, Haskell, et même Pascal permettent la définition des procédures dans des procédures. Ceci signifie l’existence des variables non-locales qui ne soient pas globales. Comment y accéder? Les techniques générales d’allocation de mémoire seront discutées plus tard. Il faudra distinguer soigneusement entre les liaisons dynamiques et lexicales ! Mentionnons ici une astuce (utilisée d’ailleurs en Python au niveau utilisateur pour simuler les fermetures. . . ). Étant donné une hiérarchie de deux fonctions imbriquées, avec le passage d’une variable non-locale : 119 f x = ... let ... g y = ... h x y ... ... g(x+1) ... on voit que la variable x liée par f est utilisée à l’intérieur de g. Cette fonction constitue une fermeture. On peut la compiler en dehors de f, et transformer le code ci-dessus en g_ y x_ = ... h x_ y ... f x = ... ... g_ (x+1) x ... Donc, on ajoute un paramètre supplémentaire pour chaque variable non-locale, on compile la fonction comme pure, et on l’applique sur les variables extérieures, traitées comme arguments. 120 Techniques de précédence On revient à l’analyse proprement dite. La première classe d’algorithmes de structuration de données très simplifiés est basée sur le concept dit de « grammaires d’opérateurs », mais nous éviterons toute référence au concept de grammaire, cela viendra plus tard. Supposons que les entités primitives appartiennent à une de deux classes : les données, ou atomes, et les opérateurs. (à cela il faudra ajouter des méta-objets : les parenthèses, même si en principe on pourrait les traiter comme opérateurs. . . ). Commençons paradoxalement non pas par l’analyse d’un texte (séquence linéaire d’entités), mais par un « anti-parseur », ou un pretty-printer, un module qui transforme une arborescence syntaxique en texte. On sait que Haskell dispose de la classe Show qui contient quelques fonctions (notamment show) qui convertissent un objet Haskell quelconque en chaîne. 121 Ces fonctions sont définies pour des structures de données standard, nombres, listes, tuples, etc. On peut demander – grâce à la clause deriving l’affichage par défaut des structures algébriques ayant quelques balises et variantes, mais si on veut afficher quelque chose de plus conséquent, il faut définir soi-même la fonction show correspondante dans l’instance de Show. Cette classe possède encore une autre fonction : showsPrec qui prend un paramètre entier supplémentaire. Dans le prélude standard de Hugs ceci n’est pas exemplifié. Rappelons que la fonction shows (définie globalement, et non pas comme un méthode) possède la définition shows = showsPrec 0 mais, indépendamment de cette définition nous l’avons utilisé pour optimiser la conversion des séquences. Au lieu d’écrire 122 f = show a ++ show a ++ show a + show d ++ ... ce qui provoque une recopie massive des données (la concaténation (++) recopie toujours son premier argument), nous avons utilisé la fonction shows, dont la sémantique était : convertir le premier argument, et concaténer le résultat avec le second (une chaîne). Sémantiquement shows x str = show x ++ str. Ceci nous permet d’écrire f = shows a (shows b (shows c ... "")) - ou : f = (shows a . shows b . shows c . ...) "" Mais, le showsPrec? Ceci nous permettra de jouer avec les précédences des opérateurs, et prendre en considération le parenthésage. 123 Regardons ces deux arbres : − × a − c b × a b qui correspondent aux expressions a × b − c et a − b × c. c 124 Cependant, l’arbre × − a c b représentant (a − b) × c, nécessite les parenthèses pour affichage ! Parfois cela arrive avec les opérateurs de même famille, avec l’associativité particulière. Les expressions a − b − c et a − (b − c) ne sont pas équivalentes. 125 Dans quelles circonstances faut-il parenthéser une sous-expression? (Considérons que l’idée générale de l’affichage textuel d’un arbre est complètement triviale, il s’agit d’un parcours in-order le plus classique. . . ) On voit que le parenthésage est nécessaire si la précédence de l’opérateur interne est plus faible que celle de l’opérateur-ancêtre. Commençons par affecter à chaque opérateur binaire deux attributs. – La précédence, un nombre entier positif ; valeur plus grande signifie l’opérateur « plus fort » (p. ex. la multiplication est plus forte que l’addition). – L’associativité : gauche (comme les 4 opérations arithmétiques), droite (comme la puissance, ou l’affectation en C), ou – la non-associativité. Ceci sera représenté par la structure de données 126 data Assop = Lft | Rgt | Noa (Un objet de ce type, purement symbolique et mnémonique, sera converti en -1, +1 ou zéro). Les opérateurs seront mis dans une liste associative de recherche, disons : infops = [(’+’,60,Lft),(’-’,60,Lft),(’/’,70,Lft),(’^’,90,Rgt), (’<’,40,Noa),(’:’,50,Rgt),...] qui peut, naturellement, être élargie comme on veut. Il est souhaitable que les précédences ne soient pas contiguës, qu’il y ait un espace entre ces entiers. Ensuite nous définissons la fonction findop qui cherche les propriétés d’un opérateur dans la liste infops, et nous pouvons passer au pretty-printer infixe des structures arborescentes : 127 data Expr = Dat String | Eop String Expr Expr où Dat balise une feuille, une donnée atomique, et Eop représente un noeud binaire, identifié par son opérateur infixe. La fonction findop ne présente aucune difficulté, c’est une fonction de recherche la plus classique, qui retourne les attributs de l’opérateur trouvé : findop st ((o,n,as):rst) | st==o = (n,asconv as) | otherwise = findop st rst findop st [] = error ("missing operator: " ++ show st) (s’il existe, bien sûr ; déclencher une exception dans le cas contraire est une possibilité, mais nous pouvons également avoir un opérateur « bidon ». 128 Accessoirement nous aurons asconv Lft = -1 asconv Rgt = 1 asconv Noa = 0 où asconv est une fonction auxiliaire qui transforme l’associativité symbolique en numérique. On va ajouter le résultat : ±1 ou 0 à la précédence, et obtenir ainsi la précédence gauche ou droite. Voici le Pretty-Printer. Si l’expression est une feuille (donnée atomique), on l’affiche. instance Show Expr where showsPrec _ (Dat x) = showString x 129 showsPrec p (Eop op g d) = let (np,as) = findop op infops dl = asconv as txt=showsPrec(np+dl)g . showString op . showsPrec(np-dl)d in if np<p then showString "(" . txt . showString ")" else if np>p then txt else error "non-assoc..." Sinon, on descend récursivement et on affiche la branche gauche, l’opérateur et la branche droite, comme prévu, mais en descendant on passe la précédence de l’ opérateur actuel, modifié par l’ajout ou la soustraction de 1, -1 ou zéro selon la branche et l’associativité déclarée. Enfin, on compare la précédence de l’opérateur actuel avec celle de son ancêtre, et si le père est plus fort, on ajoute des parenthèses. À présent on voit comment l’associativité est-elle gérée. Pour un opérateur associatif à gauche, dont la précédence est égale à 60, on passe 59 à gauche, et 61 à droite. Si la branche droite contient le même opérateur, les parenthèses sont nécessaires. 130 Si l’opérateur n’est pas associatif, les précédences corrigées entrent en collision, et l’afficheur déclenche une exception. Et la lecture? L’exemple précédant nous montre que la librairie standard de Haskell possède quelques utilitaires de sortie des données structurées. Pour l’entrée des données de types variés nous avons la classe Read, avec des fonctions comme read, ainsi que readsPrec, qui peut être exploité pour le parsing – assez primitif – des expressions infixes, selon le modèle du pretty-printer « à l’envers ». Nous allons établir un modèle plus général un peu plus tard, mais la classe standard Read va nous inspirer. La méthode de cette classe, read possède le type String -> a, où a est le type du résultat, l’instance de la classe Read. 131 Il existe également la fonction reads, du type String -> [(a,String)]. Concrètement : read "1.23" déclaré comme Double transforme l’argument en 1.23, mais que fait-on si la chaîne-source contient "1.23abc"? L’idée est : on lit et on convertit l’argument, mais si la chaîne-source n’est pas épuisée, on retourne également le reste. Ici (1.23,"abc"). Mais c’est faux, n’oubliez pas les crochets ! Parfois le parsing est non-déterministe, il peut rendre plusieurs résultats. Imaginons un parseur (stupide. . . ) défini sur les entiers, qui transforme une chaîne de chiffres en entier. Si nous ne précisons plus rien, l’analyser "1079 marquise" peut resulter en : [(1,"079 marquise"), (10,"79 marquise"), (107,"9 marquise"), (1079," marquise)]. Ce non-déterminisme parfois est plus difficile à enlever que dans ce cas artificiel où il suffit de dire : accepte la chaîne numérique la plus longue. Parsing ambigu d’habitude est une faute. Comme auparavant avec shows, la fonction reads est définie comme readsPrec 0. 132 N’oublions pas que read etc. sont des fonctions polymorphes, qui retournent les résultats ambigus au niveau typage, il faut préciser ce que nous voulons (l’instance concrète de la classe read !). Cependant, indépendamment du typage du résultat, on peut séparer un flot (une chaîne) de caractères quelconques en items selon notre intuition, p. ex. "12.54abc /=+>bbb(p+12.e)" après quelques itérations peut nous fournir la liste ["12.54", "abc", "/=+>", "bbb", "(", "p", "+","12", ".e", ")"]. Un tel séparateur de « tokens » est prédéfini dans la librairie standard de Haskell, et s’appelle lex ; profitez-en. Nous allons reconstruire un scanneur lexical plus sélectif que celui-là (adapté à une grammaire concrète), mais plus tard. En tout cas, à cause du typage, la construction des procédures de lecture est assez fatigante. La proposition ci-dessous est un « jouet » qui contient les ingrédients d’un parseur sérieux, mais qui est extrêmement simplifié. Nous allons parser les expressions du genre "a*(b+c-a/f)-g^a", composées d’atomes. 133 Ce sont des simples caractères alphabétiques, les 5 opérations arithmétiques, et les parenthèses. Pas d’espaces, et l’expression en principe doit être correcte. Ces éléments primitifs seront appelés tokens (jetons). On peut catégoriser les tokens en définissant un type de données spécial, symbolique, et construire une fonction de recherche. data Toks = Atom | Lpar | Rpar | Add | Sub | Mul | Div | Pow | Err | Fin toklist = [(’(’,Lpar),(’)’,Rpar),(’+’,Add),(’-’,Sub), (’*’,Mul),(’/’,Div),(’^’,Pow)] findtok x ((c,t):es) | c==x = t | isAlpha x = Atom | otherwise = findtok x es findtok _ [] = Err token (c:str) = let t = findtok c toklist in (t,[c],str) 134 Notez que la fonction de recherche findtok n’échoue pas avec une erreur de Haskell, mais rend un « token illégal » Err. Ceci constitue un ingrédient du module d’analyse qui ne se laisse pas tuer par un programme erroné (rappelons que la fonction de recherche dans la liste des opérateurs n’avait pas cette protection ; essayez de l’ajouter. Ceci est un bon sujet d’examen). Nous avons également opté pour que la fin de la chaîne-source soit considérée comme un token spécial Fin. Le caractère retourné par token est emballé dans une chaîne en accord avec notre définition précédante du type Expr. Nous allons lire à présent les expressions complètes, et construire les arborescences correspondantes. La solution n’est pas optimale (en particulier, quelques tokens sont lus deux fois). 135 Voici le code : instance Read Expr where readsPrec p str = let (t,ch,rst) = token str in case t of Atom -> readsOp (Dat ch) p rst Lpar -> let [(e,rst1)] = readsPrec 0 rst (r,_,rst2) = token rst1 in if r==Rpar then readsOp e p rst2 else error "Parens." _ -> error (str) Commentaires. On commence par la lecture du premier token qui doit être un atome, ou la parenthèse ouvrante ; dans le second cas on appelle readsPrec récursivement, et ensuite on force la lecture de la parenthèse fermante. 136 readsOp ctx p str = let (t,op,rst) = token str in if any (t==) [Rpar,Fin] then [(ctx,str)] else let (np,assc) = findop op infops (nl,nr) = (np+assc,np-assc) in if nl<p then [(ctx,str)] else let [(rgt,rst1)] = readsPrec nr rst nctx = Eop op ctx rgt in readsOp nctx p rst1 La suite est assurée par la fonction readsOp qui prend trois paramètres : la précédence d’entrée, et la chaîne, comme dans readsPrec, ainsi que le contexte gauche, le paramètre ctx. Cette fonction démarre avec la « tête de lecture » positionnée devant un opérateur infixe. Il va de soi qu’il est précédé par une ou plusieurs données. La partie de données déjà analysée constitue ce contexte gauche. La fonction s’arrête net si au lieu d’un opérateur elle trouve la fin de données ou la parenthèse fermante, en retournant le contexte gauche. 137 La fonction retourne aussi si le nouveau opérateur possède la précédence inférieure par rapport à l’argument p. Notez que p est comparé avec la précédence gauche de l’opérateur. C’est ici que l’inefficacité se manifeste ; un opérateur plus faible, ou la parenthèse fermante seront lus deux fois. Sinon, après avoir consommé l’opérateur, readsOp relance readsPrec, en lui passant comme p la précédence droite de l’opérateur. Après avoir récupéré le résultat le parseur forme un noeud de l’arbre-résultat, et le passe comme le nouveau contexte gauche à l’appel récursif terminal. Quelques techniques d’optimisation, notamment l’usage des piles (privées) d’opérateurs, seront discutées plus tard. Mais le squelette conceptuel est là, et il doit être assimilé. 138 Grammaires et parseurs Passons à la théorie générale (appliquée !) du parsing. Rappelons qu’un langage est un ensemble de phrases valides, et que la validité est établie par une grammaire. Nous n’envisageons pas répéter le cours Langages et Automates !, son contenu est considéré acquis. Une grammaire formelle (sans sémantique) est un ensemble qui contient 1. Un certain nombre de symboles terminaux ou constantes syntaxiques. Ce sont des « mots » concrets qui appartiennent au langage ; des littéraux. 2. Les non-terminaux ou variables syntaxiques. Ce sont des méta-mots, des descriptifs des catégories, comme « expression », « phrase verbale », « opérateur », « boucle », etc. 3. Un non-terminal spécifique est considéré le symbole initial du langage, par exemple la variable « programme ». 4. Un ensemble de règles ou productions qui définissent récursivement les nonterminaux. 139 On appliquera cette caractérisation également à analyse lexicale, où une phrase formelle est un mot normal, et les éléments, les mots formels sont des lettres (chiffres, caractères spéciaux, etc.) La grammaire peut donc posséder 2 ou plus niveaux. Mots composés de lettres. Phrases (sentences) composées de mots. Poèmes composés de phrases, etc. Dans le domaine de compilation de langages de programmation, les grammaires utilisées sont d’habitude non-contextuelles, ce qu’implique que toute production aura la forme : non-terminal → Séquence de terminaux et non-terminaux où la séquence peut contenir des symboles juxtaposés, ce qui signifie la concaténation, la barre verticale significant l’alternative, ou les parenthèses (comme méta-symboles, dont le rôle est évident). On ajoute à cet ensemble le symbole ∅ qui dénote la chaîne vide. 140 Attention. La grammaire peut être non-contextuelle, mais le processus du parsing, et en particulier plusieurs éléments d’analyse sémantique sont contextuels, et il faudra durant l’analyse maintenir des traces des choses faites, et passer « en aval » quelques paramètres qui détermineront les choix des futures stratégies. Quelques grammaires Voici une séquence itérative, une chaîne de caractères entre guillemets, ou – après quelques modifications de notation, sans changer la structure : les listes (Scheme/Lisp) composées d’atomes (considérés irréductibles) et entourées de parenthèses (les listes complètes seront traitées plus tard). Liste → Lpar Sequence Rpar Lpar → ’(’ Rpar → ’)’ Sequence → ∅ | Atom Sequence ou, peut-être : Sequence → ∅| Sequence Atom. 141 La concaténation est évidemment associative, et syntaxiquement les deux variantes sont identiques. Une chaîne : Chaine → ’"’ SeqChar ’"’ SeqChar → ∅ | Char SeqChar ou la variante récursive à gauche. Notons la similitude de ces deux grammaires. En construisant les parseurs nous allons exploiter les fonctions d’ordre supérieur pour mettre sous le même chapeau – différemment paramétré – de telles et d’autres structures linéaires, itératives. Nous avons vu également la grammaire qui décrivait les listes en Prolog. Les éléments pouvaient être des atomes, mais aussi d’autres listes (en fait, des termes Prolog quelconques, mais c’est un bon exercice personnel). La liste pouvait être « irrégulière », et se terminer par un item plutôt que par la liste vide. Ces listes sont légales : [], [a,b,c], [a,[b,c],d | e], [[],a,b | [c,d]]. 142 Les complications sont assez nombreuses : – Si les listes peuvent être imbriquées, la structure de la grammaire est récursive et non pas itérative. – La virgule entre les éléments constitue un élément non-trivial, elle se trouve toujours entre deux items, jamais au début ni à la fin. – L’irrégularité terminale doit être traitée soigneusement. La barre ne peut se trouver devant rien. Exercice. Modifiez la grammaire ci-dessous afin de rendre légal la construction [| x] (ceci était valide en micro-Prolog). On y va : Trm → Atom | Liste Liste → ’[’ Seq ’]’ Seq → ∅ | Trm Seq_ Seq_ → Fin | ’,’ Seq_ Fin → ∅ | ’|’ Trm 143 Deuxième classe d’exemples concerne le langage des expressions algébriques (arithmétiques), avec des atomes, les 4 (ou 5) opérations infixes de base, ainsi que les parenthèses. La grammaire est rigide. Au lieu de parler de précédence des opérateurs on divise les expressions parsées en – primaires : ce sont des atomes, ou des expressions parenthésées ; on les appelera également des facteurs ; – secondaires, ou termes (ne pas confondre avec aucun autre usage de ce terme. . . ) : ce sont des séquences de facteurs connectés par des opérateurs multiplicatifs (* ou /) ; – enfin, des expressions générales, des sommes de termes – séquences de termes liés par les opérateurs additifs (+ ou -, plus faibles que les multiplicatifs). Les atomes sont considérés comme primitifs. Voici la grammaire : 144 Expr → Terme | Expr OpAdd Terme Terme → Facteur | Terme OpMul Facteur Facteur → Atom | ’(’ Expr ’)’ OpAdd → ’+’ | ’-’ OpMul → ’*’ | ’/’ Notez la propriété essentielle des constructions ci-dessus. L’associativité gauche des opérateurs implique la récursivité à gauche des productions syntaxiques ; la « première chose faite » par Expr est de référencer Expr si le premier variant échoue. La récursivité à gauche est une affaire délicate, et elle sera discuté dans les détails. On peut inventer de dizaines d’autres exemples de grammaires utiles, disons : les commentaires, simples et imbriqués ; la structure de nombres flottants avec l’exposant (et la base variable). 145 Et encore : les grammaires qui décrivent les grammaires (pour la construction des méta-parseurs) ; les grammaires décrivant les expressions régulières (pour les générateurs des scanneurs lexicaux) ; la grammaire des structures en Haskell, avec des opérateurs « sectionnés » : (x ==) ou (* y) ; les blocs en Smalltalk qui mélangent la structure d’une liste avec une séquence d’instructions ; les déclarations en C++ (avec les pointeurs, tableaux, références procédurales, avec les modificateurs du genre const, etc.) Parfois on peut s’étonner que les langages déclaratifs (descriptifs : VRML, SVG ; les déclarations de données en Haskell, Java ou C++, ou . . . ) sont sur le plan syntaxique beaucoup plus riches que les langages « de programmation », mais c’est normal. Quelques exemples seront discutés en TD, et mentionnés ad hoc pendant notre travail de construction des parseurs. Essayons de préparer un cahier des charges conséquent, pour la construction des parseurs et les utilitaires qui vont avec. 146 1. L’analyse c’est la reconnaissance des structures, la validation de la source. Pour nous la compréhension sera active, et on doit prouver que le texte analysé est sain. Les parseurs seront donc équipés de procédures sémantiques qui assembleront les propriétés des objets analysés, et permettront de générer, par une séquence de transformations, un code potentiellement exécutable. 2. La construction des parseurs doit suivre de manière la plus fidèle possible la grammaire du langage source. On fait la publicité des générateurs de parseurs (p. ex. Yacc) en disant : vous définissez la grammaire, le générateur vous produit un programme de parsing. Nous voulons avoir le même comfort intellectuel. 3. Avec les générateurs la vie n’est pas tellement douce, car les procédures sémantiques doivent être écrites par l’utilisateur quand même. Ajouter des procédures sémantiques à un parseur écrit en Yacc n’est pas facile. Nous voulons intégrer – en accord avec la philosophie du point (1) – les procédures sémantiques de manière naturelle et universelle. Les fonctions d’ordre supérieur seront inestimables ! 147 4. Nous n’allons pas séparer – comme dans quelques autres approches à la compilation – l’analyse lexicale de l’analyse syntaxique. Notre approche, fonctionnelle et combinatoire exploitera les grammaires et les combinateurs à ces deux niveaux : lexical et phrasal, de manière homogène. Ceci n’implique pas que le parseur doit construire le résultat à partir des caractères individuels : l’analyse procédera en quelques étapes, la construction des tokens d’abord, l’analyse d’un flot de tokens ensuite etc. 5. L’integration de modules du compilateur passera par le traitement séquentiel des flots de données ; ici la sémantique paresseuse encore une fois s’avère très utile. 6. Nous allons traiter sérieusement les problèmes d’efficacité et les problèmes de débogage, même si les solutions proposées seront simplifiées par rapport aux compilateurs professionnels. Nos maquettes pourront servir comme le point de départ d’un projet plus ambitieux. 148 7. Finalement, si le temps nous le permet, nous allons aborder quelques éléments du parsing différent de notre approche, notamment les techniques ascendantes LR. Si possible, nous discuterons aussi les expressions régulières, mais sans y trop insister. Les techniques de construction s’appuyeront sur les monades en Haskell : les entités connectées ensemble par des opérateurs (>>=) (bind) et (>>), et la fonction return. Les monades dans ce contexte se ressemblent beaucoup à des continuations, et c’est normal : l’analyse consomme le flot de données, et à chaque moment il faut préciser ce que l’on fait ensuite, on continue la lecture, ou réduit les tokens à un sous-arbre, etc. Mais nous profiterons de cette occasion pour parler de monades dans un contexte plus général, comme d’une réalisation fonctionnelle des concepts sématiques liés au « calcul » général, évaluation des expressions, mais également des concepts impératifs et du non-déterminisme logique (utile pour savoir comment on compile Prolog). 149 Parseurs monadiques Les techniques fonctionnelles, combinatoires du parsing appartiennent à la catégorie des parseurs descendants, où on construit l’arbre syntaxique (physique ou conceptuel) à partir de la racine. Les non-terminaux de la grammaire seront transformés en fonctions d’analyse, et les paramètres de ces fonctions (ainsi que leurs résultats) permettront de transmettre l’information sémantique. Pour le contraste : le parseur construit auparavant, qui utilisait les opérateurs et leur précédences était un parseur ascendant, on voit clairement comment on récupère les feuilles et comment on les assemble en expressions. Nous allons suivre quelques règles générales répertoriées ci-dessous. Ceci doit permettre de construire des parseurs universels, de réagir aux situations exceptionnelles, et de réutiliser le code si on trouve des similarités entre plusieurs parseurs. 150 – Les parseurs consomment un flot de données d’entrée. Ceci peut être une chaîne (liste de caractères), mais aussi une liste de tokens, ou un autre flot, par exemple les tokens enrichis par des méta-informations comme la position (ligne et colonne) d’un token dans le texte. Il faudra donc paramétrer les parseurs par le type de son flot d’entrée. – Le parseur doit retourner un résultat ainsi que le flot restant. Mais on accepte aussi la possibilité d’échec, ou le parsing ambigu : plusieurs résultats possibles. Donc, on retourne une liste de paires (résultat,flot restant), comme dans notre parseur avec précédences. L’échec c’est la liste vide. – Sur le plan opérationnel le parseur est une fonction. Mais pour des raisons techniques nous préférons de le considérer comme une donnée, un objet typé spécifique. Pour qu’il agisse sur son flot d’entrée on utilisera un opérateur d’application spécial, disons (-*>). 151 Définissons donc : infix 0 -*> newtype Parser c a = Pa ([c] -> [(a,[c])]) Pa pfun -*> inpt = pfun inpt (Rappelons que newtype en Haskell peut être assimilé à data, mais son implantation ressemble plutôt à type ; la balise joue uniquement le rôle d’identification, la représentation interne du parseur est une fonction (et l’opérateur (-*>) tout simplement l’applique, comme le dollar). Le type a dénote le résultat ; les parseurs seront paramétrables et polymorphes. Le type c décrit les items élémentaires dans le flot d’entrée : les caractères pour le scanneur, les lexèmes pour le parseur, ou, p. ex. chunks de code intermédiaire pour un « parseur » qui en fait est un générateur de code. 152 Les lexèmes classiques sont – par exemple – data Lexem = I Integer | F Double | Lpar | Rpar | Comma | Semic | Colon | Idn String | Lbrack | Rbrack | Op String | Apo | Quot | Period | Str String Ils doivent être distincts et reconnaissables. Le scanneur lexical est un parseur universel restreint : type Scanner = Parser Char Lexem Ce scanneur doit s’appliquer à une chaîne, et fournir un lexème. Si nous voulons intégrer notre compilateur comme une séquence de « boîtiers » formant une « pipeline », il faudra placer les lexèmes dans une liste (paresseuse). 153 On devra donc construire une procédure qui lit un fichier textuel et produit une liste de lexèmes. Ceci viendra un peu plus tard. Désormais les espaces seront considérés comme séparateurs, coupant p. ex. les identificateurs, mais ils ne feront jamais partie d’un lexème, sauf dans les chaînes littérales "Belle marquise etc." La philosophie générale qui va nous guider tout le temps est : un parseur qui retourne un résultat de type a (avec tout le « bazar » : le input restant, et tout emballé dans une liste) est analogique à IO a, c’est une action qui engendre un objet de type a. On utilisera le terme token pour désigner un item primitif dans le flot d’entrée. Parseurs primitifs Commençons par quelques parseurs très simples. Voici un parseur qui découpe et retourne un token : 154 item = Pa (\(x:xs) -> [(x,xs)]) Attention. Cette définition est incomplète. Et si le flot d’entrée est vide? Le parseur doit échouer (au lieu de déclencher une exception Haskell. . . ) Si on est là, nous pouvons imaginer des parseurs encore plus simples que ça ! Voici deux parseurs primitifs, l’un échoue toujours, et l’autre retourne un résultat constant, sans lire quoi que ce soit. failure = Pa (\_ -> []) succeed x = Pa (\s -> [(x,s)] Les deux seront très utils comme éléments des compositions. Parlant de composition : comme il a été suggéré, la chaîne de parseurs qui mastique et digère le flot d’entrée, ressemble un peu les opérations d’entrée/sortie formulées dans le style monadique. 155 Donc, nous voudrons réutiliser les mécanismes de composition définies pour la monade IO. Concrètement, nous déclarons les parseurs comme des instances de la classe Monad. Attention : le constructeur, Parser c sans « a » peut être monadique. IO est une monade, non pas IO Char etc. instance Monad (Parser c) where fail s = failure return = succeed Nous connaissons déjà return, l’échec existe aussi dans IO, car la lecture ou écriture peuvent échouer. La définition est incomplète, il nous manque le compositeur (>>=), mais construisons d’abord notre item item = Pa itm where itm (x:xs) = [(x,xs)] itm [] = [] 156 La forme bind est intuitivement triviale. Un parseur produit un résultat. Une fonction récupère ce résultat, et en produit un autre parseur qui agira sur le flot d’entrée restant. Mais – une seconde. . . et si le premier parseur échoue, ou s’il retourne plusieurs résultats (et plusieurs flots restants possibles)? Alors il faudra appliquer la fonction à droite du bind à tous résultats possibles, et rendre la liste aplâtie. Sachant que la fonction produit un parseur générant une liste d’un résultat primaire, individuel, ensemble nous aurons une liste de listes qu’il faut mettre à plat. instance Monad (Parser c) where fail s = failure return = succeed (Pa prs) >>= fun = Pa (\inp -> concat [fun v -*> inp1|(v,inp1) <- prs inp]) 157 La fonction concat est prédéfinie : concat = foldr (++) [], elle transforrme [[a],[b,c],[d,e]] en [a,b,c,d]. Combinateurs élémentaires Tout langage est structuré. Les chiffres forment des nombres, les lettres – identificateurs, etc. Il faut équiper notre boite de quelques utilitaires de reconnaissance. Voici le parseur sat p où p est un prédicat Booléen, qui rend un item à condition qu’il satisfait ce prédicat, sinon le parseur échoue. sat p = item >>= \x -> if p x then return x else failure Notez que ce parseur n’est plus élémentaire, mais une combinaison. cette combinaison est construite en toute indépendance du flot de données. Ce parseur est la base de plusieurs autres outils de reconnaissance : lettres, chiffres, etc. 158 Voici un parseur (lit) qui vérifie l’identité d’un item (littéral), et quelques scanneurs lexicaux primitifs : lit x = sat (\y -> x==y) -- {\em ou } sat (x==) digit = sat (\x -> ’0’ <= x && x <= ’9’) lower = sat (\x -> ’a’ <= x && x<=’z’) upper = sat (\x -> ’A’ <= x && x<=’Z’) Mais comment définir une lettre tout court, majuscule ou minuscule? Nous aurons besoin d’un autre combinateur : l’alternative (comme dans le domaine de grammaires, n’est-ce pas?) 159 L’alternative dans le monde des parseurs existe en deux variantes : – Logique, inclusive. On retourne tous les résultats produits par l’un ou par l’autre parseur. – Opérationnelle, ordonnée. On lance le premier parseur. S’il retourne un résultat, on l’accepte. Sinon on lance le second, et on prend son résultat. Ceci est évidemment la solution privilégiée, plus efficace, à condition que les deux parseurs s’excluent réciproquement. Appelons le premier parseur, inclusif alt, et l’autre, séquentiel et plus efficace – (#), c’est un opérateur infixe de précédence assez basse (mais supérieure à celle du (-*>)). Voici leurs définitions. 160 infixl 1 # alt (Pa p) (Pa q) = Pa (\inp -> p inp ++ q inp) (Pa p) # (Pa q) = Pa (\inp -> let s=p inp in if s==[] then q inp else s) Le parseur alt sera utilisé très rarement. Nous pouvons à présent construire letter = lower # upper alphanum = letter # digit etc. On peut passer aux séquences qui permettront, p. ex. la construction des mots, des nombres, etc. Il faut qu’un parseur suit un autre, ce qui implique des définitions récursives, comme dans la définition syntaxique Word → Letter | Letter Word 161 Commençons cependant par un parseur concret, plus simple, qui accepte la chaîne « -*> ». La grammaire AppOp → ’-’ ’*’ ’>’ ne nous dit rien à propos du résultat retourné. On retourne alors tout simplement cette chaîne. Voici le parseur, qui accepte une chaîne de longueur quelconque, mais qui exige que les trois premiers caractères forment le symbole désiré. appop = lit ’-’ >>= \c1 -> lit ’*’ >>= \c2 -> lit ’>’ >>= \c3 -> return [c1,c2,c3] L’appel appop "-*>abc" rend [("-*>","abc")]. Mais – allons !. . . Si on sait a priori quels sont les caractères qui doivent être lus, on n’a pas besoin de les stocker. Nous pouvons les lire et oublier, et à la fin retourner le même résultat. 162 La définition ci-dessous serait plus compacte : appop = lit ’-’ >> lit ’*’ >> lit ’>’ >> return "-*>" Dans la classe Monad l’opérateur « ensuite » (>>) est définit de manière universelle m1 >> m2 = m1 >>= \_ -> m2 mais il est inutile de passer à la fonction à droite un argument jamais lu. Donc, nous augmentons notre instance monadique par la définition plus explicite, un peu plus efficace. (Pa pr1) >> (Pa pr2) = Pa (\inp -> concat [pr2 inp1 | (_,inp1) <- pr1 inp]) 163 Les mots composés de lettres seront assemblés par le parseur qui contient une séquence (récursive), et une alternative. Voici une solution word = (letter >>= \c -> word >>= \s -> return (c:s)) # (letter >>= \c -> return [c]) où on note immédiatemment une légère inefficacité. La dernière lettre sera lue deux fois, car la première clause alternative finalement échouera. Il serait bien d’accepter une règle d’or : l’optimisation d’un parseur commence souvent par l’optimisation de la grammaire : Word = Letter (Word | ∅) Notez d’ailleurs que l’ordre inverse dans la clause récursive produirait un résultat correcte, mais indésirable : le parseur lirait la première lettre et terminerait l’analyse, car la première alternative (vide) se termine bien et le parseur n’a aucune raison de continuer. 164 Par contre, la construction avec alt fournirait toutes les solutions : une lettre, deux, trois,. . . . Voici le parseur optimisé : nullp = return "" word = letter >>= \c -> (word # nullp) >>= \s -> return (c:s) Son application à "Belle marquise" retourne [("Belle"," marquise")], l’espace arrête l’analyse, car letter échoue. On pourra utiliser la technique décrite ici pour assembler toute simple séquence, par exemple les entiers composés des chiffres. L’itération suit exactement le même modèle. Cependant dans ce cas on n’est pas tellement intéressé par une chaîne de chiffres, on veut un nombre entier comme résultat. Nous allons donc construire un parseur d’ordre supérieur, un itérateur, qui applique séquentiellement un perseur plus primitif jusqu’à son échec, et qui lance une fonction spéciale d’assemblage : une concaténation pour les chaînes, l’assemblage numérique pour les entiers, etc. 165 Le parseur élémentaire et la fonction d’assemblage seront des paramètres de notre itérateur. Il manque tout de même un ingrédient : la valeur initiale du tampon d’assemblage, disons "" pour les chaînes et 0 pour les nombres. Voici une proposition avec une erreur !! many p cnstr ini = let loop = p >>= \x -> (loop # return ini) >>= \s -> return (cnstr x s) in loop word = many letter (:) "" integ = many digit (\d n -> 10*n+ord d - 48) 0 La fonction many est OK, et le parsing word -*> "Belle marquise" est correct. Cependant l’exécution de integ -*> "0102360X" produit [(632010,"X")]. . . 166 Notre fascination par la généralité de la solution nous a fait oublier que la règle récursive à droite n’est pas bonne pour un assemblage direct des nombres. Consultez vos notes de TD ! Une solution de ce dilemme est évidente. Le parseur assemble toujours une chaîne (une liste de tokens légaux), et à la fin, à l’extérieur de la boucle on lance une procédure séparée de conversion. Ceci est plutôt inefficace. Une autre possibilité, exploiter la récursivité à gauche, selon la grammaire Word → Letter | Word Letter = (Word | ∅) Letter etc., engendre un désastre imminent. Construisez le parseur. Le bind exécuté à l’intérieur du word relance à nouveau word avant de consommer quoi que ce soit. Le système boucle. . . Ce problème reviendra lors du parsing des expressions arithmétiques, où l’associativité gauche est inéluctable. 167 Rappelons-nous de la fonction construite en TD, qui construisait corretement un entier à partir d’une chaîne numérique. intf s = itf s 0 where itf "" t = t itf (c:s) t = itf s (10*t + ord(c)-48) Il suffisait d’ajouter un tampon. . . L’itérateur universel many ne prévoit pas cette possibilité. L’autre solution n’est pas plus compliquée que many, mais cette fois le parseur qui boucle sera parametré par le tampon. lmany p cnstr tmp = loop tmp where loop t = p >>= \x -> let y = (cnstr x t) in (loop y # return y) integ = lmany digit (\d n -> 10*n+ord d-48) 0 168 On peut modulariser un peu plus cette solution, car la séquence de deux ou plusieurs parseurs est un pattern commun, indépendamment de la récursivité (sans ou avec tampon). seqr p1 p2 cnstr ini = p1 >>= \x -> (p2 >>= \s -> return (cnstr x s)) # return (cnstr x ini) many p cnstr ini = loop where loop = seqr p loop cnstr ini seql p1 pp cnstr tmp = p1 >>= \x -> let y = (cnstr x tmp) in (pp y # return y) lmany p cnstr = loop where loop = seql p loop cnstr Les parseurs word et integ restent sans modification. 169 D’habitude il est utile de considérer les espaces comme des caractères qui n’apportent rien, et qui peuvent se trouver optionnellement devant un token sérieux. Construisons le parseur optspaces p qui « avale » un nombre quelconque d’espaces qui précèdent l’item reconnu par le parseur p (qui retourne le résultat). space = lit ’ ’ # lit ’\n’ optspaces p = optsp where optsp = (space >> optsp) # p Quelle est le résultat de cette expérience : wordlist = many (optspaces word) (:) [] res = wordlist -*> "Belle marquise vos 876 yeux d’amour" 170 Analyse d’un fichier Avant de passer aux parseur plus complexes, discutons l’usage de nos parseurs non pas pour analyser une chaîne donnée, mais pour lire et digérer un fichier complet. Rappelons-nous : quand on commence à lire un fichier, ou – en général – à s’engager dans les activités I/O, on passe à un protocole « impératif », séquentiel de Haskell, on « entre » dans la monade IO, et on ne peut pas en sortir « comme ça ». Si on commence par l’ouverture d’un fichier, cette manipulation produit un descripteur du fichier, de type IO Handle, Ensuite nous pouvons construire un flot paresseux de caractères qui correspond au contenu de ce fichier, mais toutes les opérations auront lieu dans ce « programme principal » IO. Essayons donc de découper en mots un fichier declar.txt, qui contient : Tous les etres humains naissent libres et égaux en dignité et en droits. Ils sont doués de raison et de conscience et doivent agir les uns envers les autres dans un esprit de fraternité. 171 L’affichage du résultat est obtenu en lançant la fonction filewords définie cidessous. filewords = do hndl <- openFile "declar.txt" ReadMode str <- hGetContents hndl let lst = wordlist -*> str print lst hClose hndl où nous avons ajouté à l’ensemble de lettres auusi le « é » accentué, et le point. Cette fonction ne retourne rien. Nous pouvons ajouter quelque chose, comme return lst, mais ceci ne changera rien, on ne pourra utiliser directement ce résultat, car il restera emballé dans la monade IO. 172 Vraiment, toutes les opérations sur le texte lu doivent rester dans le bloc do. Le résultat imprimé est [(["Tous","les","etres","humains","naissent","libres","et", "\233gaux","en","dignit\233","et","en","droits.","Ils","sont", "dou\233s","de","raison","et","de","conscience","et","doivent", "agir","les","uns","envers","les","autres","dans","un","esprit", "de","fraternit\233."]," ")] Notez que le système d’affichage standard de Haskell n’aime pas tellement les caractères hors ASCII. Comment y rémédier? En fait, en disant que tout doit se trouver alors dans le bloc do ci-dessus nous avons menti. . . 173 Dans un autre variante, la fonction filewords n’imprime rien, mais on lui ajoute comme sa dernière instruction est return lst. C’est un résultat monadique, donc une action, invisible. Ensuite on définit printwords = filewords >>= print et notre liste peut être affichée. Mais elle ne le sera pas pour des raisons techniques pertinentes à Haskell ! Le fait que Haskell est un langage paresseux doit être toujours présent à l’esprit ! Un langage paresseux ressemble un peu un langage logique, au sens d’être goal-oriented, orienté vers le but, vers le résultat final, qui déclenche les activités nécessaire à sa génération. Uniquement quand on exécute printwords, la fonction filewords est lancée. Toutes les instructions qui engendrent lst (p. ex. sa lecture) restent latentes jusqu’à la demande d’imprimer lst. 174 Mais si avant cette impression on ferme le fichier par hclose hndl, la lecture échoue. On doit le laisser ouvert, et fermer plus tard, donc dans un programme professionnel la fonction qui imprime, ou fait quelque chose avec des données lues, doit recevoir comme paramètres toutes les handles des fichiers ouverts, pour pouvoir dûment nettoyer le contexte du travail. Nous avons terminé – pour l’instant – la discussion des parseurs « lexicaux », relativement simples, itératives. Les parseurs syntaxiques peuvent être beaucoup plus élaborés, et nous verrons d’autres combinateurs universels. Cependant nous reviendrons encore une fois à la couche de base, la lecture du fichier et l’assemblage des tokens lexicaux, car il faudra répondre au moins à la question : comment diagnostiquer une faute dans le programme source? Comment tranmettre à l’utilisateur l’information où l’erreur a été découverte? 175 Parsing phrasal général Cette fois notre volonté d’avoir toujours une relation immédiate entre la grammaire et le parseur correspondant est encore plus forte. Commençons par un sousensemble très réduit d’expressions algébriques, avec l’addition, la multiplication et les parenthèses. Les opérateurs impliqués sont associatifs et symétriques, donc on pourra prétendre qu’ils soient associatifs à droite. On essaira de construire des arbres syntaxiques comme déjà vus : × − a à partir de la forme textuelle. c b 176 Cependant on passera d’abord par la couche lexicale, qui construit un flot de lexèmes définis auparavant. Il faut alors que le scanner soit capable de discerner et de retourner un Lexem quelconque : Lexem → Integer | Double | Ident | Comma | Lpar | ... D’abord, rappelons la structure de données qui représentera le résultat sortant : data Lexem = I Integer | F Double | Lpar | Rpar | Comma | Semic | Idn String | Lbrack | Rbrack | Op String | Apo | Quot | Period | Str String deriving(Eq,Show) où, naturellement, les symboles purs comme la parenthèse ouvrante se réduisent à la balise, mais les nombres ou les identificateurs doivent être transmis comme objets possédant un type et un contenu. (On devrait, d’ailleurs, déjà ici introduire un sous-ensemble très important des symboles parametrés : les mots-clefs, différents des identificateurs). 177 L’instance Show pour les lexèmes est redondante, uniquement pour des tests visuels. L’instance Eq peut être essentielle ! Le parseur des expressions ne lira pas directement des chaînes de caractères, mais des listes lexicales. Construisons le module correspondant. Scanneur lexical relativement complet Quelques caractères spéciaux se transforment en simples balises : tagch c t = sat (c==) >> return t lpar = tagch ’(’ Lpar rpar = tagch ’)’ Rpar comma = tagch ’,’ Comma apostr = tagch ’\’’ Apo quote = tagch ’"’ Quot 178 etc. On considère que les opérateurs sont formés uniquement des caractères spéciaux spécifiques. On introduit donc une séquence de caractères discriminés par l’appartenance à un ensemble précis (lettres, etc.), de manière plus générale qu’auparavant, et on ajoute un parseur-constructeur, qui retourne une chaîne balisée : charin l = sat (\y -> any (y==) l) chain p = many p (:) [] tagit = (return .) opchar = charin "+-*/=<>#%:!?" oper = chain opchar >>= tagit Op ident = seqr letter (chain alphanum) (:) "" >>= tagit Idn intnum = integ >>= tagit I 179 La notation est très compacte, et si quelqu’un pense à apprendre tout cela durant l’examen, je lui souhaite bon courage. . . Mais tout est simple. Rappelons que any est le « ou » logique itéré sur une liste ; on termine l’itération si on trouve le premier élément « vrai » par rapport au prédicat. Le parseur chain est un cas trivial de many, où la construction du résultat se réduit à la concaténation. Le parseur-baliseur tagit mérite quelques mots. Sa définition expansée est : tagit balise objet = return (balise objet) Ensuite, rappelons encore une fois le parseur integ. Il est un peu différent de la version précedente à cause du fait que ord retourne un Int, et nous avons besoin d’un Integer. 180 integ = lmany digit (\d n -> 10*n+toInteger(ord d)-48) 0 Finalement, nous construisons un lexème générique comme l’alternative des cas particuliers, en ajoutant le consommateur d’espaces. La dernière étape est l’itération de ce parseur, ce qui génère une liste de lexèmes. lexem = foldr1 ((#) . optspaces) [lpar,rpar,ident,intnum,oper,comma] lexlist = chain lexem et le test res0 = lexlist -*> "alph0 + (x+17/12)>=(x*6++d1) - beta" donne 181 [([Idn "alph0",Op "+",Lpar,Idn "x",Op "+",I 17,Op "/", I 12,Rpar,Op ">=",Lpar,Idn "x",Op "*",I 6,Op "++", Idn "d1",Rpar,Op "-",Idn "beta"],"")] La couche lexicale présentée ici n’est pas – évidemment – complète. Il faut faire quelque chose en TD, et réserver un peu du mystère pour l’examen. Cependant, tous les ingrédients de reconaissance et d’assemblage sont là. On n’a pas discuté la gestion des erreurs ni l’intéraction entre le scanneur et la table des symboles (pour ne garder les identificateurs qu’en un seul exemplaire). Tout ceci est à découvrir aussi à travers votre travail personnel. On passe aux structures syntaxiques générales. 182 Structures algébriques simplifiées Notre grammaire aura la forme suivante : Expr → Term | Term ’+’ Expr Term → Factor | Factor ’*’ Term Factor → Ident | ’(’ Expr ’)’ et elle sera la base d’un parseur qui construit des expressions du type data Expr = Dat String | Eop Ops Expr Expr deriving (Eq,Show) data Ops = Add | Sub | Mul | Div | Pow | Lth | Gth | Equ | Leq deriving (Eq,Show) 183 presque comme auparavant. Cette fois le noeud d’une expression au lieu de contenir la chaîne "+", etc, contient un opérateur symbolique, la balise Add, etc. On prolifère les balises, mais ainsi le processus de reconnaissance devient plus simple. Comme il a été dit, le parseur lira une liste de lexèmes balisés, générée par l’itérateur lexlist, p. ex. lexpr = lexlist -*> "a+b0*(a1+b2*psi)*(b*(c+x))" La seconde couche du parsing demandera quelques utilitaires comme la procédure de recherche des opérateurs dans un environnement : listop = [("+",Add),("*",Mul),("/",Div),("^",Pow), ("<",Lth),(">",Gth),("==",Equ),("<=",Leq)] 184 opfind st ((x,opt):rst) | st==x = opt | otherwise = opfind st rst opfind st [] = error ("operator: " ++ show st) ainsi que quelques abbréviations, facilitant l’identification des lexèmes. addop = lit (Op "+") >> return Add mulop = lit (Op "*") >> return Mul lparen = lit Lpar >> return (Dat "") rparen = lit Rpar >> return (Dat "") Les deux dernières définitions sont naives, on retourne une expression bidon uniquement pour satisfaire le système de types, et assurer le compilateur que lparen et rparen sont des parseurs de type : Parser Lexem Expr. 185 Le parseur principal est une traduction presque littérale de la grammaire ; la seule chose intéressante ce sont les éléments sémantiques qui assemblent le résultat final. La seule chose gênante c’est la lisibilité, toujours médiocre si un fragment de programme contient trop de lambdas. . . : atom = item >>= \x-> case x of (Idn y) -> return (Dat y) _ expr = term -> failure >>= \t -> ((addop >>= \o -> expr >>= \s -> return (Eop o t s)) # return t) 186 term = factor >>= \t -> ((mulop >>= \o -> term >>= \s -> return (Eop o t s)) # return t) factor = atom # (lparen >> expr >>= \e -> rparen >> return e) cependant, grâce aux combinateurs déjà connus comme seqr, on peut simplifier considérablement ces expressions. Le résultat du parsing est : [(Eop Add (Dat "a") (Eop Mul (Dat "b0") (Eop Mul (Eop Add (Dat "a1") (Eop Mul (Dat "b2") (Dat "psi"))) (Eop Mul (Dat "b") (Eop Add (Dat "c") (Dat "x"))))),[])] et il suffit d’appliquer notre pretty-printer pour voir si le parsing est correct. 187 Récursivité à gauche Avec les règles récursives à droite, ou avec les éléments imbriqués, comme les sous-expressions parenthésées ou des listes hiérarchiques, il n’y a plus de problèmes. Nous pouvons p. ex. analyser les listes en Lisp/Prolog, les séquences d’instructions avec des blocs, les segments balisés en HTML/XML, etc. Cependant pour la « vraie » arithmétique, la grammaire possède des productions récursives à gauche, à cause de l’associativité dans : Expr → Term | Expr Addop Term Comme nous avons déjà mentionné, la clause à droite (la plus longue) doit être essayée en premier, et l’opérateur (>>=) relance Expr avant de lire l’item suivant. La parseur ne progresse pas, il boucle. 188 La solution est simple, mais demande une très bonne connaissance de la sémantique du résultat final, ainsi qu’une connaissance des astuces formelles dans le domaine du parsing. Commençons par la syntaxe. Normalisation de Greibach Il a été démontré formellement que toute grammaire avec des productions récursives à gauche peut être transformée en grammaire récursive à droite équivalente (qui reconnaît le même langage ; nous parlons ici uniquement de la syntaxe, non pas de l’interprétation, qui demandera un peu de réflexion). Supposons que la grammaire analysée possède la règle suivante : N → a1 | a2 | ... | ak | N b1 | N b2 | ... | N bl Le raisonnement de Greibach est le suivant : 189 Toute instance du flot d’entrée reconnue par cette grammaire doit commencer par un symbole de la famille a (il peut être composite). Toutes les formes b doivent être non-nulles, sinon la règle est pathologique (N → N). On affirme, que la grammaire suivante est équivalente : N M → → a1 M | a2 M | ... | ak M b1 M | b2 M | ... | bl M | ∅ Par exemple : Expr → Term TermSeq TermSeq → Addop Term TermSeq | ∅ ce qui devrait nous rappeler des formes déjà vues. De même manière on transforme Term → Factor | Term OpMul Factor en 190 Term → Factor FactSeq FactSeq → OpMul Factor FactSeq | ∅ Le problème syntaxique a été résolu. Cependant un problème conceptuel se pose : dans la version précédente nous avions une correspondance entre les non-terminaux de la grammaire, et les noeuds de l’arborescence syntaxique résultante du parsing. Ici on a introduit un symbole artificiel, les variables TermSeq et FactSeq, même si intuitivement leur signification est claire, ne correspondent à aucun noeud. On voit cela un peu mieux si on re rend compte que la forme Addop Term TermSeq ... commence par un opérateur, alors c’est une expression incomplète ! Nous allons utiliser exactement le même astuce que dans notre modèle de parseur basé sur les précédences. La variable TermSeq aura besoin d’être complétée, alors le parseur termesq aura besoin d’un paramètre, de son contexte gauche. Voici le parseur résultant. : 191 expr = term >>= termseq termseq c = (addop >>= \o -> term >>= \t -> termseq (Eop o c t)) # return c term = factor >>= factseq factseq c = (mulop >>= \o -> factor >>= \t -> factseq (Eop o c t)) # return c factor = atom # (lparen >> expr >>= \e -> rparen >> return e) Il transforme l’expression "a+b0*(a1-b2*psi-c)*(b/(c+x+y)/f)" en [(Eop Add (Dat "a") (Eop Mul (Eop Mul (Dat "b0") (Eop Sub (Eop Sub (Dat "a1") (Eop Mul (Dat "b2") (Dat "psi"))) (Dat "c"))) (Eop Div (Eop Div (Dat "b") (Eop Add (Eop Add (Dat "c") (Dat "x")) (Dat "y"))) (Dat "f"))),[])] 192 Il serait peut-être utile de visualiser graphiquement un parseur-séquence résultant de la normalisation de Greibach. La partie encerclée du graphe ci-dessous est obligée de couper un arc, qui représente le paramètre, le contexte gauche.. + − a × b d f Nous avons terminé la description des parseurs combinatoires essentiels. On sait comment parser les structures itératives et récursives à gauche, et comment assembler le résultat. Nous avons exploité souvent le bind, mais il ne faut pas hésiter à utiliser les blocs do : 193 termseq c = (do o <- addop t <- term termseq (Eop o c t) ) # return c Ils sont utiles aussi en dehors de la monade IO. Parseurs positionnels Supposons que le parseur lexical passe à sa continuation, le parseur phrasal pas seulement les tokens, mais aussi – si telle est la volonté de l’utilisateur – la position : (ligne,colonne) de chaque token. Tout parseur primitif comme item, ou lit qui consomme un caractère doit permettre au mécanisme de lecture d’incrémenter les compteurs correspondants. Cependant, nous ne voulons pas que cette information soit « pompée » inutilement sur le flot de sortie. Nous voulons qu’elle soit accessible en cas de besoin. 194 Il faudra rédéfinir l’état de la machine qui consomme le flot d’entrée (la chaîne de caractères). Par exemple, nous pourrons définir un item dédié aux chaînes de caractères data Cstate c = Cs Integer Integer [c] newtype Parser c a = Pa (Cstate c -> [(a,Cstate c)]) itchar = Pa itm where itm (Cs col row (x:xs)) = [(x,Cs c1 r1,xs)] where (c1,r1) = if x==’\n’ then (0,row+1) else (col+1,row) itm (Is _ _ []) = [] mais un problème d’universalité se pose : pour les parseurs qui agissent sur un flot de lexèmes déjà construit, ou sur encore une autre structure, peut-être il n’est pas souhaitable de garder ces informations. Les modifications toucheront le reste du paquetage. 195 Nous ne pouvons discuter en détails toutes les modifications possibles. Quelques détails (sans commentaires) se trouvent dans le fichier newparse.hs, stocké dans le même endroit que ces notes. Voici le parseur qui récupère la position courante (sans lire quoi que ce soit, alors on peut le lancer même après avoir épuisé le flot d’entrée) : pos = Pa ppos where ppos ss@(Cs col row s) = [((col,row),ss)] Nous pouvons précéder le parser word etc. par pos, et ensuite utiliser cette information à notre guise. Nous reviendrons encore sur la problématique du parsing, en particulier dans le contexte de gestion des attributs qui constituent l’information sémantique du flot traité. Cependant, vu le flou autour du bind et autres concepts monadiques, il nous semble utile de parler un peu de la sémantique calculatoire en général 196 Intermezzo monadique Le « protocole » de la programmation fonctionnelle pure est très simple. Nous avons des expressions composées des données (primitives ou pas), auxquelles on applique des fonctions, en construisant d’autres expressions : x → f (x). La seule opération magique est l’application fonctionnelle (indépendamment de la possibilité de réaliser un système calculatoire fonctionnel dans le cadre du calcul lambda de Church. . . ). Dans la pratique on a vu déjà d’autres entités/catégories plus complexes : – Évaluation d’une expression peut échouer. Ceci peut se dérouler de manière « douce », on retourne un objet qui constate l’échec, et on interdit aux autres fonctions de s’y apopliquer. Ceci peut se dérouler de manière « forte », en déclenchant une exception, la rupture du flot de contrôle existant. Ceci peut être généralisé : 197 – Dans le cadre de programmation nondéterministe (Prolog ou parsing général), une expression peut résulter en plusieurs résultats possibles (ou aucun). – Une expression peut être « incomplète » ; elle ne sera évaluée que si on prévoit un mécanisme de récupération de la valeur – la continuation. Une donnée cesse d’être statique, mais devient un objet fonctionnel qui prend comme argument cette continuation. – Le concept de continuation peut être beaucoup plus élaboré que cela. Scheme et quelques autres langages permettent la construction des continuations de première classe ; on peut, au milieu d’évaluation d’une expression demander la création d’un objet qui représente le « futur » de ce calcul. Cet objet peut être transmis ailleurs, et exécuté, ce qui provoque la restauration du contexte de sa création. Le programme « redémarre » en complétant l’évaluation de l’expression d’origine. Ce mécanisme peut être utilisé pour créer des co-procédures, et organiser un système de simulation des processus parallèles. 198 – On peut imaginer qu’une expression ou une donnée possède un « état interne » qui est modifié par le programme. Ceci est le trait caractéristique le plus important de la programmation impérative, explicitement non-fonctionnelle. – Une version très concrète de ce contexte est spécifiée par les entrées/sorties. Quand on lit un fichier, quand on écrit quelque chose, on modifie « l’état du monde ». Sur le plan strictement fonctionnel c’est comme si on créait un nouveau monde avec les modifications, en « oubliant » le monde précedant. Bien sûr, ceci n’est pas réalisable dans la pratique, il faut savoir modifier le monde donné, qui n’existe qu’en un seul exemplaire. – (On peut aussi vouloir avoir des variables internes modifiables, pour des raisons d’efficacité.) – On peut vouloir ajouter à un programme fonctionnel des effets de bord, par exemple tracer le programme en processus du débogage. Encore une fois, il s’agit d’ajouter une couche impérative à un programme fonctionnel. 199 On peut continuer cette liste, mais on voit déjà que le monde de programmation est beaucoup plus riche que la vision de la programmation fonctionnelle enseignée en Licence. Pourtant, nous affirmons que la programmation fonctionnelle est suffisamment riche pour spécifier et implémenter ces catégories de calculs, et le mot-clé concerné est : la Monade, un concept qui appartient à la théorie des catégories, et qui a été introduite – relativement récemment – dans la théorie de programmation par Eugenio Moggi. Les monades dans le monde de compilation sont vraiment irremplaçables, on les voit partout ! Une Monade (au sens spécifique, exploité ici) est un constructeur de données qui « emballe » une donnée en quelque chose qui peut être appelée le « calcul » (ang. : computation). La Monade triviale correspond à la programmation fonctionnelle pure. Une expression « emballée » ou « liftée » au domaine des monades reste intacte. Les transformations des objets monadiques sont des simples fonctions. 200 Pour définir une monade m sur un type quelconque a il faut définir une fonction polymorphe return du type a -> m a qui effectue ce lifting. Le nom m est icipurement symbolique, générique. On peut avoir la monade IO ou la monade Parser ..., ou tout autre chose. Pour la monade triviale return ≡ id, et le constructeur-lifteur est inexistant. Deuxième ingrédient indispensable pour la définition d’une monade est l’opérateur bind : >>= qui sait comment appliquer une fonction à un objet monadique. Le résultat de l’pplication doit également être un objet monadique. Pour la monade triviale x >>= f ≡ f x. En général le type de cet opérateur est : (>>=) :: Monad m => m a -> (a -> m b) -> m b Troisième ingrédient qui n’a pas de nom propre unique, et qui parfois n’est pas utilisé, est la fonction d’« aplâtissement » qui transforme un objet de type m(m a) en m a, une sorte de concat généralisé. 201 On ajoute à cette panoplie un opérateur de séquencement (>>) qui peut combiner deux objets monadiques en un seul, et finalement une fonction fail :: String -> m a qui n’appartient aux monades mathématiques, mais qui peut être très utile sur le plan pratique. Très souvent, quand un type de constructeur est monadique, il est également l’instance de la classe Functor qui définit une application généralisée, la fonctionnelle fmap, similaire conceptuellement au map sur les listes. On peut passer aux exemples non-triviaux. L’exemple classique d’une monade « échec » et le type Maybe. Rappelons le, et ses propriétés monadiques : data Maybe a = Nothing | Just a deriving (Eq, Ord, Read, Show) instance Functor Maybe where fmap f Nothing = Nothing fmap f (Just x) = Just (f x) 202 instance Monad Maybe where Just x >>= k = k x Nothing >>= k = Nothing return = Just fail s = Nothing En général dans la classe Monad (comme dans toute autre) on peut définir des méthodes par défaut qui sont valables sauf si on les redéfinit dans une ou plusieurs instances. Ainsi, nous pouvons préciser que m1 >> m2 = m1 >>= \_ -> m2 La « suite » c’est un bind qui ignore le résultat passé. Mais parfois on peut optimiser cette définition. 203 Dans Maybe l’aplâtissement (la concaténation) est simplement Just (Just x) → Just x. Le bind du Nothing avec une fonction quelconque donne toujours Nothing, les fonctions refusent de s’appliquer à « l’échec ». Alors si dans un programme fonctionnel typique on remplace toutes les applications f x par (lift x) >>= (lift f), ou « lift » est un convertisseur (générique, symbolique, il doit être concrétisé selon la sémantique voulue !) des objets « normaux » en monadiques, on obtient un programme enrichi. La monade Maybe permet de neutraliser les erreurs, en propageant l’échec. Avant de passer aux autres monades, un rappel important : Les monades ne vous donnent directement aucun outil pour résoudre vos problèmes. Elles permettent de les formuler de manière universelle et souvent intuitive. Ce qui est réellement important ce sont les fonctions concrètes qui opèrent sur les objets monadiques. Chaque monade a sa spécificité, et personne ne remplacera l’utilisateur, le seul qui sait ce qu’il veut. 204 La monade nondéterministe Cerci est une généralisation de la monade Maybe, et son constructeur n’est rien d’autre que le constructeur des listes []. Sa sémantique est la suivante : au lieu de rendre un résultat, la fonction peut être nondéterministe et en rendre plusieurs, éventuellement un seul, ou aucun. Dans ce dernier cas nous aurons l’échec. La monade est partiellement basée sur Maybe au sens que bind propage l’échec. Comme auparavant, la paresse du langage sera ici très importante. La liste qui représente l’objet monadique non-déterministe contient toutes les solutions d’un problème. En parcourant cette liste nous pouvons traiter une par une ces solutions. La liste peut même être infinie (potentialement), si telle est la sémantique du problème. Nous savons déjà que les listes constituent un instance du Functor, où fmap ≡ map. Voici l’instance de la classe Monad : 205 instance Monad [ ] where (x:xs) >>= f = f x ++ (xs >>= f) [] >>= f = [] return x = [x] fail s = [] Notez bien que bind peut être exprimé de manière plus générique. D’abord on applique f à tous les éléments de la liste-source, mais ensuite, à l’instar de map, au lieu de cons’er les résultats, on les concatène avec (++). Donc : l >>= f = concat (map f l) à titre d’exemple, transformons un problème nondéterministe classique, la génération de toutes les permutations d’une liste, en un programme monadique, de Prolog vers Haskell. Le prédicat original en Prolog est présenté ci-dessous. 206 Commençons par un prédicat nondéterministe ndinsert qui peut insérer un objet X dans une liste n’importe où. ndinsert(X,L,[X|L]). ndinsert(X,[Y|Q],[Y|R]):-ndinsert(X,Q,R) permut([ ],[ ]). permut([X|Q],R):-permut(Q,R1),ndinsert(X,R1,R). (Si la liste est vide, ndinsert ne peut que mettre X à la tête ; la seconde clause du ndinsert est inactive). Les permutations sont générées de manière intuitive. On produit une permutation de la queue, et on insère la tête dans une position quelconque. 207 La solution Haskell doit être soigneuse pour être optimale, mais nous proposons une translation presque directe, moins efficace, mais plus lisible sur le plan monadique. La présence de deux ou plusieurs clauses compatibles en Prolog ne se traduit pas en plusieurs clauses en Haskell ; celles-là sont des réelles alternatives contradictoires. Par contre, on concatène les résultats partiels en un résultat nondéterministe complet. Le résultat est très court. ndinsert x [] = return [x] ndinsert x l@(y:yq) = let l1 = return (x:l) l2 = ndinsert x yq >>= \p -> return (y:p) in (l1 ++ l2) permut [] = return [] permut (x:xs) = permut xs >>= ndinsert x 208 La monade des continuations (CPS) Le principe nous est connu. Au lieu d’avoir une donnée, nous traiterons un objet fonctionnel dont le paramètre est le « futur » du calcul. Toute fonction monadique appliquée par bind reconstruit une expression continuée. La construction explicite et complètement générique n’est pas possible, car le « vrai » résultat final est différé jusqu’à la sortie de la chaîne monadique. On peut lifter une simple donnée par lift0 x = \c -> c x mais on n’a aucune chance de savoir quel est le type retourné par lift0. La construction peut être donc la suivante (ce n’est pas la seule possibilité). D’abord nous allons spécifier le type du résultat, p. ex type Ans = Double. Définissons un type synonymique restreint qui représente un objet continué : 209 type Ans = Double newtype Cnt a = Cnt ((a->Ans) -> Ans) Ensuite introduirons les fonctions du lifting, p. ex. lift0 qui monadise les valeurs, et lift1 qui monadise les fonctions à un paramètre. lift0 x = Cnt (\c -> c x) lift1 f x = Cnt (\c -> c (f x)) (Bien sûr, lift0 = lift1 id). On peut définir quelques fonctions et valeurs liftées cq = lift0 5.0 csqrt = lift1 sqrt ccos = lift1 cos 210 L’instance monadique de notre type se réduit à app f a = g where Cnt g = f a instance Monad Cnt where return = lift0 Cnt m >>= f = Cnt (\c -> m (\a -> app f a c)) La balise Cnt et la fonction app sont strictement accessoires et redondantes, présentes uniquement pour satisfaire le typage. À présent la composition fonctionnelle cos(sqrt(5.0)) et le résultat numérique final seront exprimés par xpr = cq >>= csqrt >>= ccos res = r id where Cnt r = xpr 211 Pour les opérateurs binaires et les fonctions (opérateurs) binaires, les constructions sont un peu plus compliquées, mais seulement techniquement. Si on change le type du résultat final Ans, il faudra re-coder la totalité. Haskell ne permettra pas de définir les types ci-dessus en remplaçant Ans par un type générique b. La monade des états Cette monade est assez complexe. À chaque valeur dans le programme nous associons un « état interne », une entité invisible, mais qui peut changer, et qui normalement change lors de chaque opération. Une donnée est liftée au domaine des actions, un peu comme dans la monade IO, mais spécifiées différemment. Une valeur x de type a sera liftéé vers un type fonctionnel : st -> (a,st). Ceci signifie que l’objet monadique agira sur un état, en produisant une valeur concrète, et un nouvel état. 212 Une fonction monadifiée agissant sur un objet m (à travers bind) construit un nouvel objet monadique : une fonction d’état initial. L’objet m agit sur cet état initial, et produit une valeur concrète appariée avec un état intermédiaire. La fonction récupère la valeur et produit un objet monadique censé de s’appliquer à l’état intermédiaire et produire l’état final. Voici la définition essentielle de la monade de transformateurs d’états : newtype Stc st a = Stc (st -> (a,st)) instance Monad (Stc st) where return x = Stc (\s -> (x,s)) Stc m >>= f = Stc (\ini -> let (x,mid) = m ini Stc k = f x in k mid) 213 Souvent, pour la documentation et l’enseignement, on laisse tomber la balise artificielle qui caractérise le newtype. Alors le type monadique est tout simplement une fonction \s -> (x,s). Le bind est très simplifié : (m >>= f) ini = case m ini of (x,mid) -> f x mid Exemple? Mais on la connaît, cette monade, un parseur en est un exemple. Les parseurs combinent le non-déterminisme et l’état. Si on déclare que tout parseur rend un et un seul résultat, les définitions monadiques subissent les simplifications suivantes : newtype Parser state a = Pa (state -> (a,state)) Pa pfun -*> inpt = pfun inpt -- pas de failure ! succeed x = Pa (\s -> (x,s)) 214 instance Monad (Parser c) where return = succeed (Pa prs) >>= fun = Pa (\inp -> fun v -*> inp1 where (v,inp1) <- prs inp) où nous avons à peine éliminé quelques crochets et concat. La monade du tracing Le modèle est un peu différent du tracing ajouté à notre machine virtuelle (le modèle précédent permet aussi une formulation monadique, mais c’est un bon sujet pour votre travail individuel). Ici la paresse du langage Haskell peut jouer un rôle important. Le lifting monadique d’une valeur x sera une paire (x,s), où s est une chaîne qui est crée quand x est formé. Cette chaîne contient un message informatif. 215 Le return peut être très simple, p. ex. return x = (x,""), ou, si on le veut : return x = (x,show x). Rappelons encore une fois : les monades ne nous disent rien concernant l’implantation concrète des instances concernées. Elles permettent de structurer le programme. Cependant, c’est à l’utilisateur de définir return, bind, etc., cas par cas, selon la sémantique voulue. Donc, nous pouvons définir un bind générique : (x,s) >>= f = let (y,s’) = f x in (y,s++s’) qui ajoute un nouveau texte au précédant quand la fonction f est appliquée. Cependant, quel est ce texte (par exemple, il peut contenir la signature de la fonction, ainsi que la forme textuelle de l’argument et du résultat) dépend de la fonction f. 216 Analyse sémantique générale Attributs Les techniques descendantes, tels que l’approche monadique, parfois causent des problèmes, comme la nécessité d’introduire la normalisation de Greibach, et des non-terminaux accessoires, mais leurs avantages sont également visibles. En particulier, il est facile de passer l’information en deux sens, de la racine vers les feuilles, à travers des paramètres des parseurs, et des feuilles vers la racine, en construisant le résultat. Nous avons déjà vu les deux catégories d’attributs. En général, le résultat constitue l’attribut sémantique principal d’un symbole syntaxique. La génération du code est la composition des attributs sémantiques. Le résultat est un attribut synthétisé, l’information qui passe de droite vers la gauche, si le parseur correspondant à une production genre N → A1 A2 ... An est appliquée. 217 Pour les parseurs descendants cette information est assemblée lors du retour de la procédure du parsing. Les parseurs ascendants (discutés brièvement un peu plus tard) combinent les informations de manière itérative, et non pas récursive, mais le résultat est le même. Voici quelques mots sur l’analyse syntaxique augmentée par l’analyse sémantique : par le jeu des attributs. Rappelons encore une fois une grammaire qui construit des entiers. Il nous faudra ajouter des indices identifiant les non-terminaux sémantiquement différents, appartenant à la même catégorie syntaxique. N0 → Da N0 → DbN1 Notez que notre grammaire est récursive à droite, pour varier. . . L’attribut qui nous intéresse est la valeur de N0, disons N0.v . (On peut penser que sémantiquement chaque non-terminal correspond à un record avec plusieurs champs, qui stockent les attributs). 218 Il est évident que la valeur finale dépend des valeurs à droite des productions, mais ces valeurs sont liées entre elles ; par exemple la « valeur concrète » de Da dépend de sa position dans la chaîne (unités, dizaines, centaines . . . ). Introduisons donc l’attribut p comme position, ainsi que l’attribut longueur l, puisque la position d’un chiffre dépend de la longueur de son voisin à droite. Ajoutons un attribut fondamental de chaque chiffre : son code, sa valeur absolue D.c (d’habitude son code ASCII - 48). Bien sûr, la longueur d’un chiffre est 1. Il est facile de constater que les équations suivantes ont lieu : 1. Pour la première alternative : N0.l = 1 N0.v = Da.c 219 2. Pour la seconde : Db.p Db.v N0.l N0.v = = = = N1.l Db.c · 10Db.p 1 + N1.l Db.v + N1.v et en principe il est facile de construire le parseur correspondant, qui retourne une valeur composite (n,l) pour un entier. Pour un chiffre il est facile de retourner le code, mais attention : le parseur chiffre défini ci-dessous ne pourra en aucun cas retourner la valeur positionnelle (relative) d’un chifre, car ceci est une information contextuelle. Supposons que le résultat est un nombre entre 0 et 9. Voici le parseur déduit de notre raisonnement : entier = chiffre >>= \d -> (entier >>= \(n1,l1) -> return(n1+d*10^l1,l1+1)) # return(d,1) 220 Et pour la grammaire récursive à gauche? Analysons les attributs avant de normaliser la forme N0 → Da N0 → N1Db Il est clair que la seconde règle produit : N0.v = 10 · N1.v + Db.c. La longueur de la chaîne n’est pas nécessaire. Le seul problème est l’impossibilité d’implémenter cette grammaire telle quelle par la stratégie descendante. La grammaire normalisée selon Greibach est N0 → Da S Se → ∅ S0 → Db S1 et, attention à présent ! Nous n’allons pas combiner la valeur de Da avec la valeur de S pour obtenir N0.v , mais nous écrivons brutalement N0.v = S.v . Qu’estce passe-t-il avec le chiffre? Il n’est pas oublié, mais il fournit un attribut hérité, contextuel, à S . Appelons-le le contexte gauche, S.g . La règle donnant N0 précise donc que S.g = Da.c. 221 Passons aux productions définissant S . La première résulte en Se.v = Se.g . Ceci peut être construit automatiquement par un générateur capable d’effectuer la normalisation. La seconde production donne S1.g = 10 · S0.g + Db.c. L’occurrence de S à droite récupère l’information de gauche. C’est ça, le mystère des attributs hérités. La production récursive ne retourne jamais rien, elle boucle, en construisant des nouvelles instances des attributs hérités, jusqu’à l’échec de la clause récursive (plus de chiffres). Le parseur correspondant a déjà été construit. Quels sons d’autres attributs intéressants dans notre modèle du compilateur? – Être une constante (explicite ou symbolique). Un nombre explicit, p. ex. 3.14159 peut être retourné par le parseur comme un record qui contient sa valeur, mais également son statut, disons, CONST. En générant le code résultant de l’application d’un opérateur à deux arguments, le parseur/générateur peut vérifier ces attributs. Si les deux arguments sont constants, au lieu de générer le code le compilateur exécute l’opération (si applicable), et stocke le résultat. Ceci est une des techniques fondamentales d’optimisation. 222 – « Adresse » d’une variable. Quand le parseur trouve un identificateur, il doit immédiatemment vérifier dans la table des symboles (l’environnement) si cette variable possède déjà quelques attributs, notamment l’indice qui permettra localiser sa valeur. Sinon, il faut l’insérer et retourner un nouvel environnement, enrichi par cette variable. – Type d’une variable/constante. Les types peuvent se propager. L’expression x+1 (dans un langage monomorphe) déclenche une procédure d’unification, ou de solution de contraintes, permettant de constater que « + » possède un argument entier, et donc c’est une opération entière. Donc x doit être également entier ! Ceci est, bien sûr, loin de la vérité pratique. 1. Dans des langages comme C les expressions mixtes sont parfaitement légitimes. Mais x doit être déclaré, le compilateur connaît son type. S’il n’est pas entier, il doit appartenir à un sur-ensemble des entiers (flottant, complexe, etc.), ce qui permet la conversion appropriée de nombre 1. D’autres cas sont aussi possibles, p. ex. x + 1.0 force la conversion du x si celui-ci est entier. Tout est basé sur l’information passée sous forme d’attributs entre les éléments du parseur. 223 2. En Haskell la situation est un peu différente, car il n’y a pas de déclarations. . . . Comment compiler : f x = x+1? La réponse est délicate. La fonction f est polymorphe, équivalente à f x = x + fromInteger 1. L’opérateur (+) ne peut être compilé directement, puisque le compilateur ne sait pas quelle variante sera effective lors de l’appel de la fonction f (tout dépend du type de l’argument actuel). Il existe plusieurs modèles de compilation polymorphe, le plus simple (pas forcément le plus efficace) exploite des arguments « cachés » : des dictionnaires de « fonctions virtuelles ». Si on applique f y où y est connu, p. ex. complexe, alors la fonction f reçoit un argument caché – un dictionnaire attachée à la classe Num ; les classes servent justement à déclarer des dictionnaires, et les instances remplissent ces dictionnaires avec les associations concrètes entre les « messages », p. ex l’opérateur (+), et les « méthodes » – p. ex. la procédure d’addition. 224 – Finalement le dernier exemple : la génération du code séquentiel. Supposons que le programme parsé est une séquence d’instructions P → I1 I2 . . . In. Toute instruction I possède son attribut principal, sa « valeur », le code généré. Mais également un autre attribut très important pour l’organisation globale : le successeur. Dans les manuels de compilation « classiques » on parlait d’étiquettes, adresses, etc. Mais nous pouvons garder une vision plus abstraite. Le successeur c’est le code qui sera deployé après P . Est-ce un attribut synthétisé, ou hérité? Hérité, bien sûr. On commence par le parseur identifié avec le « programme principal » (supposons que c’est notre P ) qui n’a pas de successeur. On le sait a priori. Pas besoin de lancer le parseur pour savoir que P.s =STOP, ou quelque chose comme ça. Mais à présent nous pouvons effectivement compiler le reste, sachant que I1.s = I2; . . . In.s = P.s. (Exemples concrets avec while etc. ont été discutés lors de la description des machines virtuelles). 225 226 227