Pourquoi implémenter un lexer comme un tableau 2D et un commutateur géant?

24

Je travaille lentement pour terminer mon diplôme, et ce semestre est Compilers 101. Nous utilisons le Dragon Book . Peu de temps dans le cours et nous parlons de l'analyse lexicale et de la façon dont elle peut être implémentée via des automates finis déterministes (ci-après, DFA). Configurez vos différents états de lexer, définissez les transitions entre eux, etc.

Mais le professeur et le livre proposent de les implémenter via des tables de transition qui équivalent à un tableau 2D géant (les différents états non terminaux comme une dimension, et les symboles d'entrée possibles comme l'autre) et une instruction switch pour gérer tous les terminaux ainsi que la répartition vers les tables de transition dans un état non terminal.

La théorie est très bien, mais en tant que personne qui a écrit du code pendant des décennies, l'implémentation est vile. Ce n'est pas testable, ce n'est pas maintenable, ce n'est pas lisible, et c'est une douleur et demie à déboguer. Pire encore, je ne vois pas comment ce serait à distance pratique si le langage était compatible UTF. Avoir un million d'entrées de table de transition par état non terminal devient vite imprudent.

Alors, quel est le problème? Pourquoi le livre définitif sur le sujet dit-il de procéder de cette façon?

Le surcoût des appels de fonction est-il vraiment si important? Est-ce quelque chose qui fonctionne bien ou est nécessaire lorsque la grammaire n'est pas connue à l'avance (expressions régulières?)? Ou peut-être quelque chose qui gère tous les cas, même si des solutions plus spécifiques fonctionneront mieux pour des grammaires plus spécifiques?

( Remarque: la duplication possible " Pourquoi utiliser une approche OO au lieu d'une déclaration de commutateur géant? " est proche, mais je ne me soucie pas de OO. Une approche fonctionnelle ou même une approche impérative plus saine avec des fonctions autonomes serait bien.)

Et à titre d'exemple, considérons un langage qui n'a que des identifiants, et ces identifiants le sont [a-zA-Z]+. Dans l'implémentation DFA, vous obtiendrez quelque chose comme:

private enum State
{
    Error = -1,
    Start = 0,
    IdentifierInProgress = 1,
    IdentifierDone = 2
}

private static State[][] transition = new State[][]{
    ///* Start */                  new State[]{ State.Error, State.Error (repeat until 'A'), State.IdentifierInProgress, ...
    ///* IdentifierInProgress */   new State[]{ State.IdentifierDone, State.IdentifierDone (repeat until 'A'), State.IdentifierInProgress, ...
    ///* etc. */
};

public static string NextToken(string input, int startIndex)
{
    State currentState = State.Start;
    int currentIndex = startIndex;
    while (currentIndex < input.Length)
    {
        switch (currentState)
        {
            case State.Error:
                // Whatever, example
                throw new NotImplementedException();
            case State.IdentifierDone:
                return input.Substring(startIndex, currentIndex - startIndex);
            default:
                currentState = transition[(int)currentState][input[currentIndex]];
                currentIndex++;
                break;
        }
    }

    return String.Empty;
}

(bien que quelque chose qui gérerait correctement la fin du fichier)

Par rapport à ce que j'attendrais:

public static string NextToken(string input, int startIndex)
{
    int currentIndex = startIndex;
    while (currentIndex < startIndex && IsLetter(input[currentIndex]))
    {
        currentIndex++;
    }

    return input.Substring(startIndex, currentIndex - startIndex);
}

public static bool IsLetter(char c)
{
    return ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'));
}

Le code étant NextTokenrefactorisé dans sa propre fonction une fois que vous disposez de plusieurs destinations depuis le début de DFA.

Telastyn
la source
5
un héritage des principes anciens (1977) de la conception de compilateurs ? Il y a 40 ans, le style de codage était très différent
gnat
7
Comment mettriez-vous en œuvre les transitions des États du DFA? Et en ce qui concerne les terminaux et les non-terminaux, les "non-terminaux" se réfèrent généralement aux règles de production dans la grammaire, qui viendraient après l'analyse lexicale.
10
Ces tableaux ne sont pas destinés à être lisibles par les humains, ils sont destinés à être utilisables par le compilateur et à fonctionner très rapidement. Il est facile de sauter autour d'une table lorsque l'on regarde en avant dans l'entrée (par exemple pour attraper la récursion gauche, bien qu'en pratique la plupart des langages soient construits pour éviter cela).
5
Si une partie de votre irritation vient de savoir comment faire un meilleur travail et de ne pas avoir la moindre rétroaction ou appréciation pour une approche que vous préférez - comme des décennies dans l'industrie nous entraînent à attendre des commentaires et parfois une appréciation - peut-être vous devez écrire votre meilleure implémentation et la publier sur CodeReview.SE pour en obtenir une partie pour votre tranquillité d'esprit.
Jimmy Hoffa
7
La réponse est simple parce que le lexer est généralement implémenté comme une machine à états finis et généré automatiquement à partir de la grammaire - et, comme on pouvait s'y attendre, une table d'états est représentée le plus facilement et de manière compacte sous forme de table. Comme avec le code objet, le fait qu'il n'est pas facile pour les humains de travailler avec est sans importance parce que les humains ne travaillent pas avec lui; ils changent la source et génèrent une nouvelle instance.
keshlam

Réponses:

16

En pratique, ces tableaux sont générés à partir d'expressions régulières qui définissent les jetons du langage:

number := [digit][digit|underscore]+
reserved_word := 'if' | 'then' | 'else' | 'for' | 'while' | ...
identifier := [letter][letter|digit|underscore]*
assignment_operator := '=' | '+=' | '-=' | '*=' | '/=' 
addition_operator := '+' | '-' 
multiplication_operator := '*' | '/' | '%'
...

Nous avons des utilitaires pour générer des analyseurs lexicaux depuis 1975 lorsque lex a été écrit.

Vous proposez essentiellement de remplacer les expressions régulières par du code procédural. Cela développe quelques caractères dans une expression régulière en plusieurs lignes de code. Le code procédural manuscrit pour l'analyse lexicale de tout langage moyennement intéressant a tendance à être à la fois inefficace et difficile à maintenir.

Kevin Cline
la source
4
Je ne suis pas sûr de proposer cela en gros. Les expressions régulières traiteront des langues arbitraires (régulières). N'y a-t-il pas de meilleures approches lorsque vous travaillez avec des langues spécifiques? Le livre aborde les approches prédictives mais les ignore ensuite dans les exemples. De plus, après avoir fait un analyseur naïf pour C # il y a des années, je n'ai pas eu beaucoup de mal à le maintenir. Inefficace? certes, mais pas terriblement, étant donné mes compétences à l'époque.
Telastyn
1
@Telastyn: il est presque impossible d'aller plus vite qu'un DFA piloté par une table: obtenir le caractère suivant, rechercher l'état suivant dans la table de transition, changer d'état. Si le nouvel état est terminal, émettez un jeton. En C # ou Java, toute approche impliquant la création de chaînes temporaires sera plus lente.
kevin cline
@kevincline - bien sûr, mais dans mon exemple, il n'y a pas de chaînes temporaires. Même en C, ce ne serait qu'un index ou un pointeur parcourant la chaîne.
Telastyn
6
@JimmyHoffa: oui, les performances sont définitivement pertinentes dans les compilateurs. Les compilateurs sont rapides car ils ont été optimisés en enfer et en arrière. Pas de micro-optimisations, elles ne font tout simplement pas de travail inutile comme la création et la suppression d'objets temporaires inutiles. D'après mon expérience, la plupart des codes de traitement de texte commerciaux font un dixième du travail d'un compilateur moderne et prennent dix fois plus de temps pour le faire. Les performances sont énormes lorsque vous traitez un gigaoctet de texte.
Kevin Cline du
1
@Telastyn, quelle "meilleure approche" aviez-vous en tête et de quelle manière vous attendriez-vous à ce qu'elle soit "meilleure"? Étant donné que nous avons déjà des outils de lexing qui sont bien testés et qu'ils produisent des analyseurs très rapides (comme d'autres l'ont dit, les DFA pilotés par table sont très rapides), il est logique de les utiliser. Pourquoi voudrions-nous inventer une nouvelle approche spéciale pour une langue spécifique, alors que nous pourrions simplement écrire une grammaire lex? La grammaire de lex est plus facile à maintenir, et l'analyseur résultant est plus susceptible d'être correct (compte tenu de la qualité des tests de lex et des outils similaires).
DW
7

La motivation de l'algorithme particulier est en grande partie qu'il s'agit d'un exercice d'apprentissage, il essaie donc de rester proche de l'idée d'un DFA et de garder les états et les transitions très explicites dans le code. En règle générale, personne n'écrirait de toute façon manuellement ce code de toute façon - vous utiliseriez un outil pour générer du code à partir d'une grammaire. Et cet outil ne se soucierait pas de la lisibilité du code car ce n'est pas du code source, c'est une sortie basée sur la définition d'une grammaire.

Votre code est plus propre pour quelqu'un qui gère un DFA manuscrit, mais un peu plus éloigné des concepts enseignés.

psr
la source
7

La boucle intérieure de:

                currentState = transition[(int)currentState][input[currentIndex]];
                currentIndex++;
                break;

présente de nombreux avantages en termes de performances. Il n'y a pas de branche du tout, car vous faites exactement la même chose pour chaque caractère saisi. Les performances du compilateur peuvent être gated par le lexer (qui doit fonctionner sur une échelle de chaque caractère d'entrée). C'était encore plus vrai lorsque le Dragon Book a été écrit.

En pratique, à part les étudiants CS qui étudient les lexers, personne n'a à implémenter (ou déboguer) cette boucle interne car elle fait partie du passe-partout fourni avec l'outil qui construit la transitiontable.

Ben Jackson
la source
5

De mémoire, - cela fait longtemps que je n'ai pas lu le livre, et je suis presque sûr que je n'ai pas lu la dernière édition, je ne me souviens certainement pas de quelque chose ressemblant à Java - cette partie a été écrite avec le code étant destiné à être un modèle, la table étant remplie d'un générateur de lexer comme lexer. Toujours de mémoire, il y avait une section sur la compression de table (encore une fois de mémoire, elle a été écrite de telle manière qu'elle était également applicable aux analyseurs par table, donc peut-être plus loin dans le livre que ce que vous avez vu jusqu'à présent). De même, le livre dont je me souviens supposait un jeu de caractères 8 bits, je m'attendrais à une section sur la gestion d'un jeu de caractères plus important dans les éditions ultérieures, probablement dans le cadre de la compression du tableau. J'ai donné une autre façon de gérer cela comme une réponse à une question SO.

Il y a un avantage certain en termes de performances à avoir une boucle serrée pilotée dans une architecture moderne: elle est assez conviviale pour le cache (si vous avez compressé les tables), et la prédiction de saut est aussi parfaite que possible (une erreur à la fin du lexème, peut-être une manque pour le basculement de la répartition vers le code qui dépend du symbole; cela suppose que la décompression de votre table peut être effectuée avec des sauts prévisibles). Déplacer cette machine d'état vers du code pur diminuerait les performances de prédiction de saut et augmenterait peut-être la pression du cache.

AProgrammer
la source
2

Après avoir travaillé sur le Dragon Book auparavant, la principale raison d'avoir des leviers et des analyseurs pilotés par table est que vous pouvez utiliser des expressions régulières pour générer le lexer et BNF pour générer l'analyseur. Le livre couvre également le fonctionnement d'outils comme lex et yacc, et afin que vous sachiez comment ces outils fonctionnent. De plus, il est important pour vous de travailler à travers quelques exemples pratiques.

Malgré de nombreux commentaires, cela n'a rien à voir avec le style de code qui a été écrit dans les années 40, 50, 60 ..., cela a à voir avec l'acquisition d'une compréhension pratique de ce que les outils font pour vous et de ce que vous avez à faire pour les faire fonctionner. Cela a tout à voir avec la compréhension fondamentale du fonctionnement des compilateurs d'un point de vue théorique et pratique.

Si tout va bien, votre instructeur vous permettra également d'utiliser lex et yacc (à moins qu'il s'agisse d'un cours de niveau supérieur et que vous ne puissiez écrire lex et yacc).

Robert Baron
la source
0

Tard dans la soirée :-) Les jetons sont comparés aux expressions régulières. Comme il y en a beaucoup, vous avez le moteur multi regex, qui est à son tour un DFA géant.

"Pire encore, je ne vois pas comment ce serait à distance pratique si la langue était compatible UTF."

Elle n'est pas pertinente (ou transparente). Outre UTF a une belle propriété, ses entités ne se chevauchent même pas partiellement. Par exemple, l'octet représentant le caractère "A" (du tableau ASCII-7) n'est plus utilisé pour aucun autre caractère UTF.

Ainsi, vous avez un DFA unique (qui est multi-expression régulière) pour le lexeur entier. Comment mieux l'écrire que le tableau 2D?

greenoldman
la source