J'ai plongé profondément dans le monde des analyseurs récemment, voulant créer mon propre langage de programmation.
Cependant, j'ai découvert qu'il existe deux approches quelque peu différentes de l'écriture des analyseurs: les générateurs d'analyseurs et les combinateurs d'analyseurs.
Fait intéressant, je n'ai pu trouver aucune ressource expliquant dans quels cas quelle approche est la meilleure; Au contraire, de nombreuses ressources (et personnes) que j'ai interrogées sur le sujet ne connaissaient pas l'autre approche, expliquant seulement leur l'approche comme la approche et ne mentionnant pas du tout l'autre:
Un générateur d'analyseur prend un fichier écrit dans un DSL qui est un dialecte de forme Backus-Naur étendue , et le transforme en source code qui peut ensuite (une fois compilé) devenir un analyseur pour la langue d'entrée décrite dans cette DSL.
Cela signifie que le processus de compilation se fait en deux étapes distinctes. Fait intéressant, les générateurs d'analyseurs eux-mêmes sont également des compilateurs (et beaucoup d'entre eux sont en effet auto-hébergement ).
Un analyseur syntaxique décrit des fonctions simples appelées - analyseurs qui acceptent toutes une entrée comme paramètre et tentent d'arracher le ou les premiers caractères de cette entrée s'ils correspondent. Ils retournent un Tuple (result, rest_of_input)
, où result
peut être vide (par exemple nil
ou Nothing
) si l'analyseur n'a pas pu analyser quoi que ce soit à partir de cette entrée. Un exemple serait un analyseur digit
. Les autres analyseurs peuvent bien sûr prendre les analyseurs comme premiers arguments (le dernier argument restant toujours la chaîne d'entrée) pour combiner eux: par ex. many1
tente de faire correspondre un autre analyseur autant de fois que possible (mais au moins une fois, sinon il échoue).
Vous pouvez maintenant bien sûr combiner (composer) digit
et many1
, pour créer un nouvel analyseur, dites integer
.
En outre, un analyseur choice
de niveau supérieur peut être écrit qui prend une liste d'analyseurs, en essayant chacun d'eux à tour de rôle.
De cette façon, des lexers/parseurs très complexes peuvent être construits. Dans les langues prenant en charge la surcharge des opérateurs, cela ressemble beaucoup à EBNF, même s'il est toujours écrit directement dans la langue cible (et vous pouvez utiliser toutes les fonctionnalités de la langue cible que vous désirez).
Langue:
Lexing/Parsing:
Cependant, même étant donné ces différences (et cette liste de différences est probablement loin d'être complète!), Je ne peux pas faire un choix éclairé sur quand pour utiliser lequel. Je ne vois pas quelles sont les implications/conséquences de ces différences.
Quelles propriétés de problème indiqueraient qu'un problème serait mieux résolu à l'aide d'un générateur d'analyseur? Quelles propriétés de problème indiqueraient qu'un problème serait mieux résolu à l'aide d'un combinateur d'analyseur?
J'ai fait beaucoup de recherches ces derniers jours pour mieux comprendre pourquoi ces technologies distinctes existent et quelles sont leurs forces et leurs faiblesses.
Certaines des réponses déjà existantes laissaient entrevoir certaines de leurs différences, mais elles ne donnaient pas le tableau complet et semblaient quelque peu exprimées, c'est pourquoi cette réponse a été écrite.
Cette exposition est longue mais importante. supportez-moi (Ou si vous êtes impatient, faites défiler jusqu'à la fin pour voir un organigramme).
Pour comprendre les différences entre les combinateurs et les générateurs d'analyseurs, il faut d'abord comprendre la différence entre les différents types d'analyse qui existent.
L'analyse est le processus d'analyse d'une chaîne de symboles selon une grammaire formelle. (En informatique,) l'analyse est utilisée pour permettre à un ordinateur de comprendre le texte écrit dans une langue, créant généralement un arbre d'analyse qui représente le texte écrit, stockant la signification des différents écrits parties dans chaque nœud de l'arbre. Cet arbre d'analyse peut ensuite être utilisé pour une variété de fins différentes, comme la traduire dans une autre langue (utilisée dans de nombreux compilateurs), interpréter les instructions écrites directement d'une certaine manière (SQL, HTML), permettant des outils comme Linters pour faire leur travail, etc. Parfois, un arbre d'analyse n'est pas explicitement généré, mais plutôt l'action qui doit être effectuée sur chaque type de nœud dans l'arbre est exécuté directement. Cela augmente l'efficacité, mais sous l'eau, il existe toujours un arbre d'analyse implicite.
L'analyse est un problème difficile à calculer. Il y a eu plus de cinquante ans de recherches sur ce sujet, mais il reste encore beaucoup à apprendre.
En gros, il existe quatre algorithmes généraux pour permettre à un ordinateur d'analyser une entrée:
Notez que ces types d'analyse sont des descriptions théoriques très générales. Il existe plusieurs façons d'implémenter chacun de ces algorithmes sur des machines physiques, avec des compromis différents.
LL et LR ne peuvent regarder que les grammaires sans contexte (c'est-à-dire que le contexte autour des jetons qui sont écrits n'est pas important pour comprendre comment ils sont utilisés).
L'analyse PEG/Packrat et l'analyse Earley sont beaucoup moins utilisées: l'analyse Earley est agréable car elle peut gérer beaucoup plus de grammaires (y compris celles qui ne sont pas nécessairement sans contexte) ) mais il est moins efficace (comme le prétend le livre du dragon (section 4.1.1); je ne sais pas si ces affirmations sont toujours exactes). Analyse syntaxe expression + Packrat-analyse est une méthode qui est relativement efficace et peut également gérer plus de grammaires que LL et LR, mais cache des ambiguïtés, comme nous le verrons rapidement ci-dessous.
C'est probablement la façon la plus naturelle de penser à l'analyse. L'idée est de regarder le jeton suivant dans la chaîne d'entrée, puis de décider lequel des appels récursifs possibles peut-être être pris pour générer une structure arborescente.
Cet arbre est construit de haut en bas, ce qui signifie que nous commençons à la racine de l'arbre et parcourons les règles de grammaire de la même manière que nous parcourons la chaîne d'entrée. Il peut également être considéré comme la construction d'un équivalent "postfix" pour le flux de jetons "infix" en cours de lecture.
Les analyseurs effectuant une analyse de style LL peuvent être écrits pour ressembler beaucoup à la grammaire d'origine qui a été spécifiée. Cela les rend relativement faciles à comprendre, à déboguer et à améliorer. Les combinateurs d'analyseurs classiques ne sont rien de plus que des `` pièces lego '' qui peuvent être assemblées pour construire un analyseur de style LL.
L'analyse LR se déroule dans l'autre sens, de bas en haut: à chaque étape, les éléments supérieurs de la pile sont comparés à la liste de grammaire, pour voir s'ils pourraient être réduits à un règle de niveau supérieur dans la grammaire. Sinon, le prochain jeton du flux d'entrée est shift ed et placé au-dessus de la pile.
Un programme est correct si à la fin nous nous retrouvons avec un seul nœud sur la pile qui représente la règle de départ de notre grammaire.
Dans l'un ou l'autre de ces deux systèmes, il est parfois nécessaire de jeter un œil à plus de jetons de l'entrée avant de pouvoir décider du choix à faire. Il s'agit de la syntaxe (0)
, (1)
, (k)
Ou (*)
Que vous voyez après les noms de ces deux algorithmes généraux, tels que LR(1)
ou LL(k)
. k
signifie généralement "autant que vos besoins grammaticaux", tandis que *
signifie généralement "cet analyseur effectue un retour arrière", qui est plus puissant/facile à implémenter, mais a une mémoire beaucoup plus élevée et l'utilisation du temps qu'un analyseur qui peut simplement continuer à analyser de façon linéaire.
Notez que les analyseurs de style LR ont déjà de nombreux jetons sur la pile lorsqu'ils peuvent décider de `` regarder vers l'avenir '', ils ont donc déjà plus d'informations à distribuer. Cela signifie qu'ils ont souvent besoin de moins d'anticipation qu'un analyseur de style LL pour la même grammaire.
En lisant les deux descriptions ci-dessus, on peut se demander pourquoi l'analyse de style LR existe, car l'analyse de style LL semble beaucoup plus naturelle.
Cependant, l'analyse de style LL a un problème: Récursion gauche .
Il est très naturel d'écrire une grammaire comme:
expr ::= expr '+' expr | term
term ::= integer | float
Mais, un analyseur de style LL restera coincé dans une boucle récursive infinie lors de l'analyse de cette grammaire: lorsque vous essayez la possibilité la plus à gauche de la règle expr
, il revient à cette règle à nouveau sans consommer aucune entrée.
Il existe des moyens de résoudre ce problème. Le plus simple est de réécrire votre grammaire pour que ce type de récursivité ne se reproduise plus:
expr ::= term expr_rest
expr_rest ::= '+' expr | ϵ
term ::= integer | float
(Ici, ϵ représente la 'chaîne vide')
Cette grammaire est maintenant récursive. Notez qu'il est immédiatement beaucoup plus difficile à lire.
En pratique, la récursion à gauche peut se produire indirectement avec de nombreuses autres étapes intermédiaires. Cela en fait un problème difficile à rechercher. Mais essayer de le résoudre rend votre grammaire plus difficile à lire.
Comme l'indique la section 2.5 du Dragon Book:
Nous semblons avoir un conflit: d'une part, nous avons besoin d'une grammaire qui facilite la traduction, d'autre part, nous avons besoin d'une grammaire sensiblement différente qui facilite l'analyse. La solution consiste à commencer par la grammaire pour une traduction facile et à la transformer soigneusement pour faciliter l'analyse. En éliminant la récursivité gauche, nous pouvons obtenir une grammaire appropriée pour une utilisation dans un traducteur prédictif de descente récursive.
Les analyseurs de style LR n'ont pas le problème de cette récursion à gauche, car ils construisent l'arborescence de bas en haut. Cependant, la traduction mentale d'une grammaire comme ci-dessus à un analyseur de style LR (qui est souvent implémenté comme un automate à états finis )
est très difficile (et sujet aux erreurs), car il y a souvent des centaines ou des milliers d'états + transitions d'états à considérer. C'est pourquoi les analyseurs de style LR sont généralement générés par un générateur d'analyseur, également connu sous le nom de "compilateur compilateur".
Nous avons vu deux méthodes pour résoudre les ambiguïtés de récursion à gauche ci-dessus: 1) réécrire la syntaxe 2) utiliser un analyseur LR.
Mais il existe d'autres types d'ambiguïtés qui sont plus difficiles à résoudre: que se passe-t-il si deux règles différentes sont également applicables en même temps?
Voici quelques exemples courants:
Les analyseurs de style LL et LR ont tous deux des problèmes avec ces derniers. Les problèmes d'analyse des expressions arithmétiques peuvent être résolus en introduisant la priorité des opérateurs. De la même manière, d'autres problèmes comme le Dangling Else peuvent être résolus, en choisissant un comportement de priorité et en le respectant. (En C/C++, par exemple, la balançoire else appartient toujours au "si" le plus proche).
Une autre `` solution '' à cela est d'utiliser la grammaire d'expression syntaxique (PEG): elle est similaire à la grammaire BNF utilisée ci-dessus, mais en cas d'ambiguïté, choisissez toujours la première. Bien sûr, cela ne "résout" pas vraiment le problème, mais cache plutôt qu'une ambiguïté existe réellement: les utilisateurs finaux peuvent ne pas savoir quel choix fait l'analyseur, ce qui peut conduire à des résultats inattendus.
Plus d'informations qui sont beaucoup plus approfondies que ce post, y compris pourquoi il est impossible en général de savoir si votre grammaire n'a pas d'ambiguïtés et les implications de cela est le merveilleux article de blog LL et LR en contexte: pourquoi les outils d'analyse sont difficiles . Je peux fortement le recommander; cela m'a beaucoup aidé à comprendre toutes les choses dont je parle en ce moment.
Mais la vie continue. Il s'est avéré que les analyseurs de style LR "normaux" implémentés comme des automates à états finis avaient souvent besoin de milliers d'états + transitions, ce qui était un problème de taille de programme. Ainsi, des variantes telles que Simple LR (SLR) et [~ # ~] lalr [~ # ~] (Look-ahead LR) ont été écrites et combinent d'autres techniques pour rendre l'automate plus petit, réduisant ainsi l'encombrement du disque et de la mémoire des programmes d'analyse.
En outre, une autre façon de résoudre les ambiguïtés répertoriées ci-dessus consiste à utiliser les techniques généralisées dans lesquelles, en cas d'ambiguïté, les deux possibilités sont conservées et analysées: l'une ou l'autre peut échouer dans l'analyse de la ligne. (dans ce cas, l'autre possibilité est la "bonne"), ainsi que de renvoyer les deux (et de cette façon montrer qu'il existe une ambiguïté) dans le cas où ils sont tous les deux corrects.
Fait intéressant, après que l'algorithme LR généralisé a été décrit, il s'est avéré qu'une approche similaire pouvait être utilisée pour implémenter Analyseurs LL généralisés , qui est tout aussi rapide ($ O (n ^ 3) $ complexité temporelle pour les grammaires ambiguës, $ O(n) $ pour les grammaires sans ambiguïté, bien qu'avec plus de comptabilité qu'un simple analyseur LR (LA), ce qui signifie un facteur constant plus élevé), mais encore une fois, permettre à un analyseur d'être écrit dans un style de descente récursive (de haut en bas) qui est beaucoup plus naturel à écrire et à déboguer.
Ainsi, avec cette longue exposition, nous arrivons maintenant au cœur de la question:
Quelle est la différence entre les combinateurs et les générateurs d'analyseurs, et quand faut-il les utiliser l'un par rapport à l'autre?
Ce sont vraiment différents types de bêtes:
Les combinateurs d'analyseurs ont été créés parce que les gens écrivaient des analyseurs descendants et ont réalisé que beaucoup d'entre eux avaient beaucoup en commun .
Les générateurs d'analyseurs ont été créés parce que les gens cherchaient à créer des analyseurs qui n'avaient pas les problèmes rencontrés par les analyseurs de style LL (c.-à-d. Analyseurs de style LR), qui s'est avéré très difficile à faire à la main. Les plus courants incluent Yacc/Bison, qui implémentent (LA) LR).
Fait intéressant, de nos jours, le paysage est quelque peu confus:
Il est possible d'écrire des analyseurs syntaxiques qui fonctionnent avec l'algorithme [~ # ~] gll [~ # ~] , résolvant les problèmes d'ambiguïté que les analyseurs classiques de style LL avaient, tout en étant tout aussi lisible/compréhensible que toutes sortes d'analyses descendantes.
Les générateurs d'analyseurs peuvent également être écrits pour les analyseurs de style LL. [~ # ~] antlr [~ # ~] fait exactement cela et utilise d'autres heuristiques (Adaptive LL (*)) pour résoudre les ambiguïtés des analyseurs classiques de style LL.
En général, la création d'un générateur d'analyseur LR et le débogage de la sortie d'un générateur d'analyseur de style LR (LA) fonctionnant sur votre grammaire sont difficiles, en raison de la traduction de votre grammaire originale au format LR `` à l'envers ''. D'un autre côté, des outils comme Yacc/Bison ont eu de nombreuses années d'optimisations, et ont vu beaucoup d'utilisation dans la nature, ce qui signifie que beaucoup de gens le considèrent maintenant comme le façon de faire l'analyse et sont sceptiques envers les nouvelles approches.
Lequel vous devez utiliser dépend de la force de votre grammaire et de la vitesse à laquelle l'analyseur doit être. Selon la grammaire, l'une de ces techniques (/ implémentations des différentes techniques) peut être plus rapide, avoir une empreinte mémoire plus petite, avoir une empreinte disque plus petite, ou être plus extensible ou plus facile à déboguer que les autres. Votre kilométrage peut varier .
L'analyse lexicale peut être utilisée à la fois pour les combinateurs d'analyseurs et les générateurs d'analyseurs. L'idée est d'avoir un analyseur 'stupide' qui est très facile à implémenter (et donc rapide) qui effectue un premier passage sur votre code source, supprimant par exemple les espaces blancs répétitifs, les commentaires, etc., et éventuellement la 'tokenisation' dans un très de manière grossière les différents éléments qui composent votre langue.
Le principal avantage est que cette première étape rend l'analyseur réel beaucoup plus simple (et à cause de cela peut-être plus rapide). Le principal inconvénient est que vous avez une étape de traduction distincte, et par exemple le rapport d'erreur avec les numéros de ligne et de colonne devient plus difficile en raison de la suppression de l'espace blanc.
Un lexer à la fin n'est "qu'un" autre analyseur et peut être implémenté en utilisant l'une des techniques ci-dessus. En raison de sa simplicité, souvent d'autres techniques sont utilisées que pour l'analyseur principal, et par exemple des "générateurs de lexer" supplémentaires existent.
Tl; Dr:
Pour les entrées qui sont garanties exemptes d'erreurs de syntaxe, ou lorsqu'une réussite syntaxique globale est correcte, les combinateurs d'analyseurs sont beaucoup plus simples à utiliser, en particulier dans les langages de programmation fonctionnels. Ce sont des situations comme la programmation d'énigmes, la lecture de fichiers de données, etc.
La fonctionnalité qui vous donne envie d'ajouter la complexité des générateurs d'analyseurs est les messages d'erreur. Vous voulez des messages d'erreur qui pointent l'utilisateur vers une ligne et une colonne et, espérons-le, sont également compréhensibles par un humain. Il faut beaucoup de code pour le faire correctement, et les meilleurs générateurs d'analyseurs comme antlr peuvent vous aider.
Cependant, la génération automatique ne peut que vous mener jusqu'à présent, et la plupart des compilateurs open source commerciaux et à longue durée de vie finissent par écrire manuellement leurs analyseurs. Je suppose que si vous vous sentiez à l'aise de le faire, vous n'auriez pas posé cette question, je vous recommande donc d'utiliser le générateur d'analyseur.
Sam Harwell, l'un des mainteneurs du générateur d'analyseur ANTLR, écrit récemment :
J'ai trouvé que les [combinateurs] ne répondaient pas à mes besoins:
- ANTLR me fournit des outils pour gérer des choses comme les ambiguïtés. Pendant le développement, il existe des outils qui peuvent me montrer des résultats d'analyse ambigus afin que je puisse éliminer ces ambiguïtés dans la grammaire. Au moment de l'exécution, je peux tirer parti de l'ambiguïté résultant d'une entrée incomplète dans le IDE pour produire des résultats plus précis dans des fonctionnalités telles que la complétion de code.
- Dans la pratique, j'ai trouvé que les combinateurs d'analyseurs n'étaient pas adaptés pour atteindre mes objectifs de performance. Une partie de cela remonte
- Lorsque les résultats d'analyse sont utilisés pour des fonctionnalités telles que le contour, la complétion de code et le retrait intelligent, il est facile pour des modifications subtiles de la grammaire d'avoir un impact sur la précision de ces résultats. ANTLR fournit des outils qui peuvent transformer ces disparités en erreurs de compilation, même dans les cas où les types seraient autrement compilés. Je peux en toute confiance prototyper une nouvelle fonctionnalité de langage qui affecte la grammaire sachant que tout le code supplémentaire qui forme le IDE fournira une expérience complète pour la nouvelle fonctionnalité dès le début. Ma fourchette d'ANTLR 4 (sur lequel la cible C # est basée) est le seul outil que je connaisse qui tente même de fournir cette fonctionnalité.
Essentiellement, les combinateurs d'analyseurs sont un jouet cool avec lequel jouer, mais ils ne sont tout simplement pas faits pour faire un travail sérieux.
Comme Karl le mentionne, les générateurs d'analyseurs ont généralement un meilleur rapport d'erreurs. En plus:
D'un autre côté, les combinateurs ont leurs propres avantages: