Los Lexers son simples analizadores que se utilizan como optimización del rendimiento para el analizador principal. Si tenemos un lexer, el lexer y el analizador trabajan juntos para describir el lenguaje completo. Los analizadores que no tienen una etapa lexing separada a veces se denominan "sin escáner".
Sin lexers, el analizador tendría que operar carácter por carácter. Dado que el analizador tiene que almacenar metadatos sobre cada elemento de entrada, y puede que tenga que calcular previamente las tablas para cada estado de elemento de entrada, esto daría como resultado un consumo de memoria inaceptable para grandes tamaños de entrada. En particular, no necesitamos un nodo separado por carácter en el árbol de sintaxis abstracta.
Dado que el texto carácter por carácter es bastante ambiguo, esto también generaría mucha más ambigüedad que es molesto de manejar. Imagina una regla R → identifier | "for " identifier
. donde el identificador se compone de letras ASCII. Si quiero evitar la ambigüedad, ahora necesito una anticipación de 4 caracteres para determinar qué alternativa se debe elegir. Con un lexer, el analizador solo tiene que verificar si tiene un token IDENTIFICADOR o FOR: una búsqueda anticipada de 1 token.
Gramáticas de dos niveles.
Los Lexers trabajan traduciendo el alfabeto de entrada a un alfabeto más conveniente.
Un analizador sin escáner describe una gramática (N, Σ, P, S) donde los no terminales N son los lados izquierdos de las reglas en la gramática, el alfabeto Σ es, por ejemplo, caracteres ASCII, las producciones P son las reglas en la gramática , y el símbolo de inicio S es la regla de nivel superior del analizador.
El lexer ahora define un alfabeto de tokens a, b, c, ... Esto permite que el analizador principal use estos tokens como alfabeto: Σ = {a, b, c, ...}. Para el lexer, estos tokens son no terminales, y la regla de inicio S L es S L → ε | una S | b S | c S | ..., es decir: cualquier secuencia de tokens. Las reglas en la gramática lexer son todas las reglas necesarias para producir estos tokens.
La ventaja de rendimiento proviene de expresar las reglas del lexer como un lenguaje normal . Estos pueden analizarse de manera mucho más eficiente que los lenguajes sin contexto. En particular, los idiomas regulares se pueden reconocer en el espacio O (n) y el tiempo O (n). En la práctica, un generador de código puede convertir dicho lexer en tablas de salto altamente eficientes.
Extraer fichas de tu gramática.
Para tocar su ejemplo: las reglas digit
y string
se expresan a nivel de personaje por personaje. Podríamos usarlos como tokens. El resto de la gramática permanece intacta. Aquí está la gramática lexer, escrita como una gramática lineal derecha para dejar en claro que es regular:
digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" ;
string = '"' , string-rest ;
string-rest = '"' | STRING-CHAR, string-rest ;
STRING-CHAR = ? all visible characters ? - '"' ;
Pero como es regular, usualmente usamos expresiones regulares para expresar la sintaxis del token. Aquí están las definiciones de tokens anteriores como expresiones regulares, escritas usando la sintaxis de exclusión de clase de caracteres .NET y las clases de caracteres POSIX:
digit ~ [0-9]
string ~ "[[:print:]-["]]*"
La gramática del analizador principal contiene las reglas restantes que el lexer no maneja. En su caso, eso es solo:
input = digit | string ;
Cuando los lexers no se pueden usar fácilmente.
Cuando diseñamos un idioma, generalmente nos encargamos de que la gramática se pueda separar limpiamente en un nivel lexer y un nivel de analizador, y que el nivel lexer describa un lenguaje regular. Esto no siempre es posible.
Al incrustar idiomas. Algunos lenguajes permiten interpolar código en cadenas: "name={expression}"
. La sintaxis de la expresión es parte de la gramática libre de contexto y, por lo tanto, no puede ser tokenizada por una expresión regular. Para resolver esto, recombinamos el analizador sintáctico con el lexer o introducimos tokens adicionales como STRING-CONTENT, INTERPOLATE-START, INTERPOLATE-END
. La regla gramatical para una cadena podría tener el siguiente aspecto: String → STRING-START STRING-CONTENTS { INTERPOLATE-START Expression INTERPOLATE-END STRING-CONTENTS } STRING-END
. Por supuesto, la Expresión puede contener otras cadenas, lo que nos lleva al siguiente problema.
Cuando los tokens pueden contenerse En lenguajes tipo C, las palabras clave no se pueden distinguir de los identificadores. Esto se resuelve en el lexer priorizando las palabras clave sobre los identificadores. Tal estrategia no siempre es posible. Imagine un archivo de configuración donde Line → IDENTIFIER " = " REST
, donde el resto es cualquier carácter hasta el final de la línea, incluso si el resto parece un identificador. Una línea de ejemplo sería a = b c
. El lexer es realmente tonto y no sabe en qué orden pueden aparecer los tokens. Entonces, si priorizamos IDENTIFICADOR sobre RESTO, el lexer nos daría IDENT(a), " = ", IDENT(b), REST( c)
. Si priorizamos REST sobre IDENTIFICADOR, el lexer simplemente nos daría REST(a = b c)
.
Para resolver esto, tenemos que recombinar el lexer con el analizador sintáctico. La separación se puede mantener algo haciendo que el lexer sea perezoso: cada vez que el analizador necesita el siguiente token, se lo solicita al lexer y le dice al lexer el conjunto de tokens aceptables. Efectivamente, estamos creando una nueva regla de nivel superior para la gramática lexer para cada posición. Aquí, esto daría lugar a las llamadas nextToken(IDENT), nextToken(" = "), nextToken(REST)
, y todo funciona bien. Esto requiere un analizador que conozca el conjunto completo de tokens aceptables en cada ubicación, lo que implica un analizador de abajo hacia arriba como LR.
Cuando el lexer tiene que mantener el estado. Por ejemplo, el lenguaje Python delimita los bloques de código no mediante llaves, sino mediante sangría. Hay formas de manejar la sintaxis sensible al diseño dentro de una gramática, pero esas técnicas son excesivas para Python. En cambio, el lexer verifica la sangría de cada línea y emite tokens INDENT si se encuentra un nuevo bloque sangrado, y tokens DEDENT si el bloque ha terminado. Esto simplifica la gramática principal porque ahora puede pretender que esos tokens son como llaves. Sin embargo, el lexer ahora necesita mantener el estado: la sangría actual. Esto significa que el lexer técnicamente ya no describe un lenguaje regular, sino un lenguaje sensible al contexto. Afortunadamente, esta diferencia no es relevante en la práctica, y el lexer de Python todavía puede funcionar en el tiempo O (n).
input = digit | string
.