web-dev-qa-db-fra.com

Construction d'une arborescence de syntaxe abstraite avec une liste de jetons

Je veux construire un AST à partir d'une liste de jetons. Je fais un langage de script et j'ai déjà fait la partie d'analyse lexicale, mais je ne sais pas comment créer un AST. La question est donc de savoir comment prendre quelque chose comme ceci:

Word, int
Word, x
SYMBOL, =
NUMBER, 5
SYMBOL, ;

et le convertir en un arbre de syntaxe abstraite? De préférence, je voudrais le faire sans une bibliothèque comme ANTLR ou autre, je préfère essayer de le faire moi-même. Cependant, si c'est une tâche vraiment complexe, cela ne me dérange pas d'utiliser une bibliothèque :) Merci

35
metro-man

L'astuce fondamentale consiste à reconnaître que l'analyse, même accomplie, se produit par étapes incrémentielles, y compris la lecture des jetons un par un.

À chaque étape incrémentielle, il est possible de créer une partie des fragments AST en combinant AST fragments construits par d'autres étapes incrémentielles. Il s'agit d'une idée récursive, et il se termine dans la construction AST nœuds foliaires pour les jetons lors de leur analyse. Cette idée de base se produit dans presque tous les analyseurs de construction AST.

Si l'on construit un analyseur de descente récursif, on construit en fait un système coopératif de procédures récursives, dont chacune reconnaît un non-terminal dans la grammaire qui est implémentée. Pour une analyse pure, chaque procédure renvoie simplement un booléen pour "non terminal (non) reconnu".

Pour construire un AST avec un analyseur de descente récursif, on conçoit ces procédures pour retourner deux valeurs: le booléen "reconnu", et, s'il est reconnu, un AST construit (en quelque sorte) pour le non terminal. (Un hack commun est de retourner un pointeur, qui est nul pour "non reconnu", ou pointe vers le construit AST si "reconnu"). La façon dont le résultant AST pour une seule procédure est construit, c'est en combinant les AST des sous-procédures qu'il invoque. C'est assez trivial à faire pour les procédures feuilles, qui lisent un jeton d'entrée et peuvent immédiatement construire un arbre.

L'inconvénient de tout cela est qu'il faut coder manuellement la descente récursive et l'augmenter avec les étapes de construction de l'arbre. Dans le grand schéma des choses, c'est en fait assez facile à coder pour les petites grammaires.

Pour l'exemple d'OP, supposons que nous avons cette grammaire:

GOAL = ASSIGNMENT 
ASSIGNMENT = LHS '=' RHS ';' 
LHS = IDENTIFIER 
RHS = IDENTIFIER | NUMBER

OK, notre analyseur de descente récursif:

boolean parse_Goal()
{  if parse_Assignement()
   then return true
   else return false
}

boolean parse_Assignment()
{  if not Parse_LHS()
   then return false
   if not Parse_equalsign()
   then throw SyntaxError // because there are no viable alternatives from here
   if not Parse_RHS()
   then throw SyntaxError
   if not Parse_semicolon()
   the throw SyntaxError
   return true
}

boolean parse_LHS()
{  if parse_IDENTIFIER()
   then return true
   else return false
}

boolean parse_RHS()
{  if parse_IDENTIFIER()
   then return true
   if parse_NUMBER()
   then return true
   else return false
}

boolean parse_equalsign()
{  if TestInputAndAdvance("=")  // this can check for token instead
   then return true
   else return false
}

boolean parse_semicolon()
{  if TestInputAndAdvance(";")
   then return true
   else return false
}

boolean parse_IDENTIFIER()
{  if TestInputForIdentifier()
   then return true
   else return false
}

boolean parse_NUMBER()
{  if TestInputForNumber()
   then return true
   else return false
}

Maintenant, révisons-le pour construire un arbre de syntaxe abstrait:

AST* parse_Goal() // note: we choose to return a null pointer for "false"
{  node = parse_Assignment()
   if node != NULL
   then return node
   else return NULL
}

AST* parse_Assignment()
{  LHSnode = Parse_LHS()
   if LHSnode == NULL
   then return NULL
   EqualNode = Parse_equalsign()
   if EqualNode == NULL
   then throw SyntaxError // because there are no viable alternatives from here
   RHSnode = Parse_RHS()
   if RHSnode == NULL
   then throw SyntaxError
   SemicolonNode = Parse_semicolon()
   if SemicolonNode == NULL
   the throw SyntaxError
   return makeASTNode(ASSIGNMENT,LHSNode,RHSNode)
}

AST* parse_LHS()
{  IdentifierNode = parse_IDENTIFIER()
   if node != NULL
   then return IdentifierNode
   else return NULL
}

AST* parse_RHS()
{  RHSnode = parse_IDENTIFIER()
   if RHSnode != null
   then return RHSnode
   RHSnode = parse_NUMBER()
   if RHSnode != null
   then return RHSnode
   else return NULL
}

AST* parse_equalsign()
{  if TestInputAndAdvance("=")  // this can check for token instead
   then return makeASTNode("=")
   else return NULL
}

AST* parse_semicolon()
{  if TestInputAndAdvance(";")
   then return makeASTNode(";")
   else return NULL
}

AST* parse_IDENTIFIER()
{  text = TestInputForIdentifier()
   if text != NULL
   then return makeASTNode("IDENTIFIER",text)
   else return NULL
}

AST* parse_NUMBER()
{  text = TestInputForNumber()
   if text != NULL
   then return makeASTNode("NUMBER",text)
   else return NULL
}

J'ai évidemment passé sous silence certains détails, mais je suppose que le lecteur n'aura aucun mal à les remplir.

Les outils générateurs d'analyseurs tels que JavaCC et ANTLR génèrent essentiellement des analyseurs de descente récursifs et disposent d'installations pour construire des arbres qui fonctionnent très bien comme ceci.

Les outils du générateur d'analyseurs qui construisent des analyseurs ascendants (YACC, Bison, GLR, ...) construisent également des nœuds AST dans le même style. Cependant, il n'y a pas d'ensemble de fonctions récursives; à la place, une pile de jetons vus et non-terminaux est gérée par ces outils. Les nœuds AST sont construits sur une pile parallèle; lorsqu'une réduction se produit, le AST = les nœuds de la partie de la pile couverte par la réduction sont combinés pour produire un nœud non terminal AST pour les remplacer. Cela se produit avec des segments de pile "de taille nulle" pour les règles de grammaire qui sont également vides) faisant apparaître AST nœuds (généralement pour 'liste vide' ou 'option manquante') apparemment de nulle part.

Avec des langages minuscules, il est assez pratique d'écrire des analyseurs à descente récursive qui construisent des arbres.

Un problème avec les vraies langues (qu'elles soient anciennes et rauques comme COBOL ou chaudes et brillantes comme Scala) est que le nombre de règles de grammaire est assez important, compliqué par la sophistication de la langue et l'insistance sur le comité de langue qui en est chargé. ajouter perpétuellement de nouveaux goodies offerts par d'autres langages ("envie de langage", voir la course évolutive entre Java, C # et C++). Maintenant, écrire un analyseur de descente récursive devient très incontrôlable et on a tendance à utiliser des générateurs d'analyseurs. Mais même avec un générateur d'analyseur, écrire tout le code personnalisé pour construire AST nœuds est également une grande bataille (et nous n'avons pas discuté de ce qu'il faut pour concevoir une bonne syntaxe "abstraite"). la première chose qui vient à l'esprit.) Le maintien des règles de grammaire et AST la construction de goo devient progressivement plus difficile avec l'échelle et l'évolution continue. (Si votre langue réussit, dans un an vous voudrez changer Donc, même écrire les règles de construction AST devient gênant.

Idéalement, on voudrait juste écrire une grammaire, et obtenir un analyseur et un arbre. Vous pouvez le faire avec certains générateurs d'analyseurs récents: Notre boîte à outils de réingénierie logicielle DMS accepte les grammaires entièrement libres de contexte et construit automatiquement un AST , aucun travail de la part de l'ingénieur de grammaire; c'est ce qu'il fait depuis 1995. Les gars d'ANTLR ont finalement compris cela en 2014, et ANTLR4 propose maintenant une option comme celle-ci.

Dernier point: avoir un analyseur (même avec un AST) n'est guère une solution au problème réel que vous vous êtes proposé de résoudre, quel qu'il soit. C'est juste une pièce de base, et à la grande surprise de la plupart des novices en analyse, c'est la partie la plus petite d'un outil qui manipule le code. Google mon essai sur la vie après l'analyse (ou consultez ma biographie) pour plus de détails.

94
Ira Baxter

Ce n'est pas difficile du tout; en fait, c'est l'une des choses les plus faciles que j'ai faites. L'idée générale est que chaque structure (alias règles d'analyse) n'est qu'une liste d'autres structures, et lorsqu'une fonction parse () est appelée, elles parcourent simplement leurs enfants et leur disent d'analyser. Ce n'est pas une boucle infinie; les jetons sont des structures, et lorsque leur parse () est appelé, ils analysent la sortie de lexer. Ils devraient également avoir un nom pour l'identification, mais ce n'est pas obligatoire. parse () retournerait normalement un arbre d'analyse. Les arbres d'analyse sont comme des structures - des listes d'enfants. Il est également bon d'avoir un champ "texte" et sa structure parent pour l'identification. Voici un exemple (vous voudriez mieux l'organiser et gérer le null pour les projets réels):

public void Push(ParseTree tree) { // ParseTree
    children.add(tree);
    text += tree.text;
}

public ParseTree parse() { // Structure
    ParseTree tree = new ParseTree(this);
    for(Structure st: children) {
        tree.Push(st.parse());
    }
    return tree;
}

public ParseTree parse() { // Token
    if(!lexer.nextToken() || !matches(lexer.token))
        return null;
    ParseTree tree = new ParseTree(this);
    tree.text = lexer.token;
    return tree;
}

Là. Appelez parse () de la structure principale et vous obtenez un AST. Bien sûr, ceci est un exemple très simple et ne fonctionnera pas hors de la boîte. Il est également utile d'avoir des "modificateurs"; par exemple. faire correspondre l'enfant 3 une ou plusieurs fois, l'enfant 2 est facultatif. C'est aussi facile à faire; stockez-les dans un tableau de la même taille que votre nombre d'enfants, et lors de l'analyse, vérifiez-le:

public void setModifier(int id, int mod) {
    mods[id] = mod;
}

public ParseTree parse() {
    ...
    ParseTree t;
    switch(mods[i]) {
        case 1: // Optional
            if((t = st.parse()) != null) tree.Push(t);
        case 2: // Zero or more times
            while((t = st.parse()) != null) tree.Push(t);
        ...
        default:
            tree.Push(st.parse());
    }
    ...
}
1
jv110