¿Cuál es el procedimiento que se sigue al escribir un lexer basado en una gramática?

13

Mientras leía una respuesta a la pregunta Clarificación sobre Gramáticas, Lexers y Parsers , la respuesta decía que:

[...] una gramática BNF contiene todas las reglas que necesita para el análisis y análisis léxico.

Esto me pareció extraño porque hasta ahora, siempre había pensado que un lexer no se basaba en una gramática, mientras que un analizador se basaba en gran medida en uno. Llegué a esta conclusión después de leer numerosas publicaciones de blog sobre la escritura de lexers, y ninguna de ellas usó 1 EBNF / BNF como base para el diseño.

Si los lexers, así como los analizadores, se basan en una gramática EBNF / BNF, entonces, ¿cómo se podría crear un lexer usando ese método? Es decir, ¿cómo construiría un lexer usando una gramática EBNF / BNF dada?

He visto muchos, muchos puesto que tienen que ver con la escritura de un programa de análisis utilizando EBNF / BNF como una guía o un plano, pero me he encontrado con ninguno hasta ahora que muestran el equivalente con el diseño de léxico.

Por ejemplo, tome la siguiente gramática:

input = digit| string ;
digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" ;
string = '"', { all characters - '"' }, '"' ;
all characters = ? all visible characters ? ;

¿Cómo crearía un lexer basado en la gramática? Me imaginaba cómo se podía escribir un analizador sintáctico a partir de una gramática así, pero no entiendo el concepto de hacer lo mismo con un lexer.

¿Se utilizan ciertas reglas o lógica para realizar una tarea como esta, como escribir un analizador? Francamente, empiezo a preguntarme si los diseños léxers utilizan una gramática EBNF / BNF, y mucho menos se basan en una.


1 BNF extendido y Backus-Naur forma

Christian Dean
fuente

Respuestas:

18

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 digity stringse 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).

amon
fuente
Muy buena respuesta @amon, gracias. Voy a tener que tomarme un tiempo para digerirlo completamente. Sin embargo, me preguntaba algunas cosas sobre tu respuesta. Alrededor del octavo párrafo, muestra cómo podría modificar mi gramática EBNF de ejemplo en reglas para un analizador sintáctico. ¿La gramática que mostraste también sería utilizada por el analizador? ¿O todavía hay una gramática separada para el analizador?
Christian Dean
@ Ingeniero He hecho un par de ediciones. Su EBNF puede ser utilizado directamente por un analizador sintáctico. Sin embargo, mi ejemplo muestra qué partes de la gramática pueden ser manejadas por un lexer separado. Cualquier otra regla aún sería manejada por el analizador principal, pero en su ejemplo eso es justo input = digit | string.
amon
44
La gran ventaja de los analizadores sin escáner es que son mucho más fáciles de componer; El ejemplo extremo de eso son las bibliotecas de combinador de analizadores, donde no hace nada más que componer analizadores. La composición de analizadores es interesante para casos como ECMAScript-embedded-in-HTML-embedded-in-PHP-sprinkled-with-a-template-language-on-top o Ruby-examples-embedded-in-Markdown- incrustado en Ruby-documentación-comentarios o algo así.
Jörg W Mittag
El último punto es muy importante, pero creo que la forma en que lo ha escrito es engañoso. Es cierto que los lexers no se pueden usar fácilmente con una sintaxis basada en sangría, pero el análisis sin escáner es aún más difícil en ese caso. Por lo tanto, en realidad desea usar un lexer si tiene ese tipo de lenguaje, ampliándolo con el estado relevante.
user541686
@Mehrdad Los tokens de sangría / sangría controlados por lexer estilo Python solo son posibles para lenguajes sensibles a la sangría muy simples, y generalmente no son aplicables. Una alternativa más general son las gramáticas de atributos, pero su soporte carece de herramientas estándar. La idea es anotar cada fragmento AST con su sangría y agregar restricciones a todas las reglas. Los atributos son fáciles de agregar con el análisis de combinador, lo que también facilita el análisis sin escáner.
amon