Cómo crear tu propio lenguaje de programación: teoría, herramientas y consejos de un profesional. Cómo crear tu propio lenguaje de programación: teoría, herramientas y consejos de un practicante Especialidad "Programación de sistemas"

¡Hola queridos lectores! Hoy profundizaremos un poco en la teoría. Seguramente todos alguna vez quisiste enviar tu programa súper tonto a un amigo. ¿Pero cómo hacer esto? ¡No lo fuerces a instalar PascalABC.NET! Hoy hablaremos sobre cómo hacer esto.

Todos los lenguajes de programación se dividen en dos tipos: interpretable Y compilado.

Intérpretes

Cuando programamos en un lenguaje interpretado, escribimos un programa que no se ejecutará en el procesador, sino que lo ejecutará un programa intérprete. También se le llama máquina virtual.

Como regla general, el programa se convierte en algún código intermedio, es decir, un conjunto de instrucciones que la máquina virtual puede entender.

Cuando se interpreta, el código se ejecuta secuencialmente línea por línea (de instrucción en instrucción). El sistema operativo se comunica con el intérprete, no con el código fuente.

Ejemplos de lenguajes interpretados: PHP, JavaScript, C#, Python.

Los programas compilados se ejecutan más rápido, pero se dedica mucho tiempo a compilar el código fuente.

Los programas diseñados para intérpretes se pueden ejecutar en cualquier sistema donde esté presente dicho intérprete. Un ejemplo típico es el código JavaScript. Cualquier navegador moderno actúa como intérprete. Puede escribir código JavaScript una vez, incluirlo en un archivo html, y se ejecutará igual en cualquier entorno donde haya un navegador. No importa si es Safari en Mac OS o Internet Explorer en Windows.

Compiladores

Un compilador es un programa que convierte el código fuente escrito en un lenguaje de programación en instrucciones de máquina.

A medida que el texto del programa se convierte en código de máquina, el compilador puede detectar errores (en la sintaxis del lenguaje, por ejemplo). Por lo tanto, todos los problemas de punto y coma olvidados, paréntesis olvidados, errores en los nombres de funciones y variables en este caso se resuelven en la etapa de compilación.

Cuando se compila, todo código fuente(el que escribe el programador) se traduce inmediatamente al lenguaje de máquina. Un llamado separado archivo ejecutable, que no tiene nada que ver con el código fuente. La ejecución del archivo ejecutable está garantizada por el sistema operativo. Es decir, por ejemplo, se forma un archivo .EXE.

Ejemplos de lenguajes compilados: C, C++, Pascal, Delphi.

Progreso del compilador.

Preprocesamiento

Esta operación se lleva a cabo preprocesador de texto.

El texto original se procesa parcialmente; se produce lo siguiente:

  • Reemplazar comentarios con líneas vacías
  • Conexión de módulos, etc., etc.

Compilación

El resultado de la compilación es código objeto.

Código objeto es un programa en lenguaje de código máquina con preservación parcial de la información simbólica necesaria durante el proceso de ensamblaje.

Disposición

El diseño también puede tener los siguientes nombres: atadura, asamblea o enlace.

Este es el último paso en el proceso de obtención de un archivo ejecutable, que consta de vincular todos los archivos de objetos del proyecto.

Archivo EXE.

Después de vincular, tendrá un archivo .EXE de su programa. Puedes enviárselo a un amigo y se abrirá directamente en la línea de comando, como en el viejo DOS. Intentemos crear un archivo .EXE. Todas las acciones se darán en PascalABC.NET.

Vaya a Herramientas -> Configuración -> Opciones de compilación. Comprobamos si la casilla de verificación junto a 2 puntos está marcada. Si es así, lo eliminamos.

Ahora abre tu programa y ejecútalo.

Abre el directorio donde tienes el código fuente del programa.

Aquí está el archivo .EXE.

Haga clic en la aplicación. Como puede ver, después de ingresar los datos, la ventana se cierra inmediatamente. Para evitar que la ventana se cierre inmediatamente, debe agregar dos líneas de código, a saber: usa crt (antes de la sección de descripción de la variable) y readkey (al final del código, antes del operador final).


Conectamos la biblioteca crt externa y usamos la función readkey incorporada.

Ahora la ventana se cerrará cuando presiones cualquier tecla.

Nota: PascalABC.NET es un entorno de desarrollo integrado.

El entorno de desarrollo incluye:

  • editor de texto;
  • compilador;
  • herramientas de automatización de montaje;
  • depurador

¡Eso es todo por hoy! Haga cualquier pregunta en los comentarios de este artículo. No olvide hacer clic en los botones y compartir enlaces a nuestro sitio con sus amigos. Y para no perderte la publicación del próximo artículo, te recomiendo que te suscribas a la newsletter de nuestra web. Uno de ellos está en la parte superior derecha y el otro en el pie de página del sitio.

M. Cherkashin Revista "Monitor" N 4 1992 Un compilador se escribe así... Tienes que escribir un compilador con más frecuencia de lo que sueles pensar. Casi todos los sistemas grandes incluyen un lenguaje de entrada, desde primitivo hasta muy complejo. Al menos recordemos dBASE: no es un lenguaje de programación, sino un sistema de base de datos. Incluso se escriben programas para ello. Y si necesita un lenguaje de entrada, también necesita un compilador. Y a menudo se necesita rápidamente. Por supuesto, todo el mundo preferirá un compilador compacto, rápido y bien optimizado, pero no a todo el mundo le gustará escribir un compilador de este tipo. ..compila. Más precisamente, monitorea el progreso de la compilación. Una vez que queda claro con qué constructo estamos tratando, el control se transfiere a un procedimiento especializado que trata sólo con ese constructo, llamado rutina semántica. Escáner Entre todas las estructuras percibidas por el compilador, hay algunas mínimas y que ya no tiene sentido dividir en partes. Por ejemplo: identificador, número, cadena, palabra clave. Se llaman lexemas. El compilador no necesita comprender su estructura. Por cierto, en la descripción del viejo Algol-60 se utilizó el término jeroglíficos, es decir, algo que se puede indicar mediante un icono. Pero el ordenador sólo tiene 256 caracteres y hay que escribir, por ejemplo, "procedimiento". Por lo tanto, la idea es la siguiente: un procedimiento separado lee el programa de un archivo, extrae tokens de allí y los pasa a la parte principal del compilador. Y a ella ya no le importa el texto del programa. Esta subrutina se llama escáner. "El procedimiento GetCh producirá el mismo símbolo la próxima vez. Tabla de nombres Cuando una persona escribe su propio programa o entiende el de otra persona, necesita mantener cierta información en su cabeza. Pero la máquina es de hierro, no tiene cabeza. Por lo tanto, una Se necesita una estructura de datos que es sobre cada variable para almacenar cierta información. Esta estructura se llama tabla de nombres. Ningún compilador puede prescindir de ella. Una tabla de nombres es realmente como una tabla. Consta de "filas" - celdas de la tabla de nombres. puede contener información sobre una variable simple, por ejemplo, una variable de tipo entero (normalmente estas celdas se denominan entradas de tabla de nombres, pero aquí se utiliza deliberadamente un término diferente para evitar confusión con el lenguaje SIMPLE). estructura de nuestra tabla de nombres. Lo más simple es cuando la variable es de tipo entero. Para ello, no es necesario almacenar nada en absoluto: un nombre y un tipo (entero). En compiladores grandes y reales, hay algo más. su dirección, por ejemplo. ¡Pero, gracias a Dios, compilamos en Pascal! Las primeras entradas de MaxKey en la tabla de nombres no son en realidad nombres, sino palabras clave. Los nombres y las palabras clave son generalmente muy fáciles de confundir. Por lo tanto, si se encuentra un nombre en la tabla de nombres, pero en una entrada con un número no mayor que MaxKey, entonces en realidad no es un nombre, sino una palabra clave. Por supuesto, puede crear una lista separada para palabras clave, pero la forma en que se hace aquí es más sencilla. Pero si lo tradujéramos a código de máquina, necesitaríamos saber en qué dirección comienza esta declaración. Y para ello necesitas una estructura de datos llamada pila. ¿A dónde ir ahora? Deja LexType y LexVal en variables. Pnombre; Ref:= LexVal;

Compilación / Compilador

La compilación es el proceso de convertir un programa fuente en uno ejecutable. El proceso de compilación consta de dos etapas. En la primera etapa, se verifica el texto del programa en busca de errores, en la segunda, se genera un programa ejecutable (archivo exe).

Después de ingresar el texto, se puede acceder desde el menú a las funciones para procesar el evento y guardar el proyecto. Proyecto seleccione un equipo Compilar y compilar. El proceso de compilación y el resultado se reflejan en el cuadro de diálogo. Compilando(FIGURA B38). El compilador muestra errores, advertencias y sugerencias en esta ventana. Los mensajes de error, advertencias y sugerencias se muestran en la parte inferior de la ventana del editor de código (Figura B39).

Nota

Si durante la compilación de la ventana Compilando no está en la pantalla, luego seleccione en el menú Herramientas equipo Opciones de entorno y en la pestaña Preferencias ponga el interruptor en encendido Mostrar programa compiladoress.

Compilador

Compilador

Un compilador es un programa que convierte texto escrito en un lenguaje algorítmico en un programa que consta de instrucciones de máquina. El compilador crea una versión completa del programa en lenguaje de máquina.

¿Cómo funciona el compilador?

La creación de un compilador es una práctica común para los estudiantes de TI en los países occidentales: esta habilidad ayuda al estudiante a comprender mejor la tecnología de creación de software del sistema, mirando más allá de la interfaz de usuario del sistema operativo. Desafortunadamente, este enfoque no se practica en la educación rusa, lo que lleva al hecho de que hay muy poco software informático ruso.

Un compilador es un programa que convierte el código fuente creado en un lenguaje de programación en código de máquina que puede ser ejecutado por un procesador. Más precisamente, un cargador para el sistema operativo para el que está diseñado el compilador. Los propios lenguajes de programación pueden ser estándar o propietarios. La creación de un lenguaje propietario y un compilador para él es muy loable, pero hay que tener en cuenta que la difusión de este paquete será difícil debido al apoyo inicial de la comunidad de programación.

Como regla general, un compilador consta de varias partes estándar: un analizador sintáctico, un analizador léxico, un generador de código y un optimizador. El analizador monitorea la exactitud de la estructura del programa, el analizador léxico selecciona lexemas del flujo del analizador, los define y compila tablas internas de variables, llamadas a procedimientos, etc. Si el compilador es de un solo paso, entonces el generador de código puede funcionar durante el análisis de lexemas, generando código de máquina de salida. Dado que las computadoras ahora son muy rápidas, tiene sentido desarrollar compiladores de múltiples pasadas: en este caso, todo el programa se traduce a código intermedio, por ejemplo, código pi. El código Pi es interesante porque le permite optimizar el programa en la etapa inicial, y el generador de código puede convertirlo no solo en los códigos de una computadora determinada, sino también en el código de salida de otra plataforma. De esta manera puede obtener un compilador multiplataforma que funcione, digamos, en DOS/DOS32/Windows y Linux.

Antes de crear un compilador, es aconsejable documentar el lenguaje de programación para que pueda describirse en el lenguaje gráfico formal BNF. El lenguaje BNF no está vinculado a ningún lenguaje específico y solo describe las reglas para componer bloques y condiciones en el programa. Ahora existen generadores especiales que, utilizando únicamente BNF, pueden crear un marco para un nuevo compilador, incluida la mayoría de los módulos para analizar el texto fuente.

Curiosamente, es posible que el compilador no se implemente como un programa separado, como antes: se puede crear como una biblioteca dll y, por ejemplo, usarse para admitir scripts o macros en programas más complejos.

Les ofrezco una traducción del diario de Rui Ueyama, un programador de Google, que escribió mientras trabajaba en la implementación de un compilador de C hace unos tres años y medio.
Este diario no tiene ningún uso práctico y no es un tutorial, pero me interesó mucho leerlo, espero que a ti también te guste esta historia :)

Escribí un compilador de C en 40 días, al que llamé 8cc. Este es un diario que escribí en ese momento. El código y su historial se pueden ver en GitHub.

Día 8

Estoy escribiendo un compilador. Comenzó a funcionar después de escribir alrededor de 1000 líneas de código. A continuación se muestran algunos ejemplos que ya funcionan:

Ent a = 1; un + 2; // => 3 int a = 61; int *b = *b; // => 61

Las matrices se convierten correctamente en punteros, por lo que el siguiente código también funciona. También se admiten llamadas a funciones.

Carbón *c = "ab" + 1; printf("%c", *c); // => b

No fue difícil implementar esto, porque... Esta es la segunda vez que hago esto. Aprendí a mejorar en el trabajo con matrices y punteros.

Día 15

Estoy bastante avanzado en la implementación del compilador y funciona sorprendentemente bien. Se compilan y ejecutan programas no triviales, como por ejemplo éste, que resuelve el problema de las ocho reinas.

Por supuesto, carece de muchas funciones. Estos programas de muestra están diseñados para ser inutilizables.
La implementación es bastante sencilla; Ni siquiera hay asignación de registros.
Compila programas en la pila, utilizando la pila de la máquina como pila. Cada operación requiere un acceso a la memoria. Pero por ahora estoy contento con él.

Al principio, el compilador cabía en unas 20 líneas y lo único que era capaz de hacer era leer un valor entero de la entrada estándar y ejecutar un programa que inmediatamente completaba la devolución de este número entero.

Ahora contiene alrededor de 2000 líneas. Si miras a git, parece haberse desarrollado de esta manera:

  • añadido "+" y "-"
  • las fases de análisis y generación de código están separadas
  • añadido "*" y "/"
  • variables agregadas (lo que implica tipo int)
  • llamada de función agregada
  • líneas agregadas
  • tokenizador separado y análisis de sintaxis
  • soporte para la declaración de tipos base
  • punteros y matrices añadidos
  • soporte para expresiones de inicialización de matrices
  • agregó "si"
  • declaración de función compatible
  • añadido "para" y "retorno"
  • asignación de puntero soportada
  • añadido "=="
  • Se agregó indexación de matrices y aritmética de punteros.
  • Se agregaron "++", "--" y "!"

Día 17

He implementado con éxito las estructuras. Una estructura es un objeto que puede ocupar más de una palabra de máquina. Son más difíciles de implementar que los tipos primitivos, pero fue más fácil de lo que esperaba.

Esto parece funcionar como debería; Puedo definir una estructura que contiene una estructura. Puedo definir un puntero a una estructura y eliminarle la referencia. También funcionan las estructuras que contienen matrices y matrices de estructuras. Aunque ya sabía que el código debería funcionar en teoría, me alegré cuando realmente funcionó, incluso en un caso tan complejo.

Sin embargo, no creo entender completamente por qué este código funciona correctamente. Parece un poco mágico debido a su naturaleza recursiva.

No puedo pasar estructuras a funciones. En la convención de llamada x86, la estructura se copia en la pila y se pasa un puntero a la función. Pero en x86-64 hay que dividir la estructura en varios datos y pasarlos a través de registros. Es complicado, así que lo dejaré de lado por ahora. Pasar estructuras por valor es necesario con menos frecuencia que pasarles punteros.

Día 18

Fue más fácil implementar las fusiones porque es simplemente una variante de la estructura en la que todos los campos tienen el mismo desplazamiento. También se implementa el operador "->". No podría ser más sencillo.

Fue difícil organizar el apoyo a los números de coma flotante. Parece que la conversión de tipos implícita entre int y float funciona, pero los flotantes no se pueden pasar a funciones. En mi compilador, todos los parámetros de la función primero se insertan en la pila y luego se escriben en los registros en el orden especificado en la convención de llamada x86-64. Pero parece haber un error en este proceso. Devuelve un error de acceso a la memoria (SIGSEGV). Es difícil depurar mirando la salida del ensamblador porque mi compilador no optimiza el ensamblador para la lectura. Pensé que podría terminar esto en un día, pero me equivoqué.

Día 19

Perdí el tiempo porque olvidé que, según la convención de llamadas x86-64, el marco de la pila debe estar alineado con 16 bytes. Descubrí que printf() falla con SEGV si lo alimentas con varios flotadores. Intenté encontrar condiciones bajo las cuales esto pueda reproducirse. Resultó que la posición del marco de la pila es importante, lo que me hizo recordar los requisitos de ABI x86-64.

No me ocupé de esto en absoluto, por lo que el marco de la pila estaba alineado solo con 8 bytes, pero print() no se quejó siempre que solo aceptara números enteros. Este problema se puede corregir fácilmente ajustando el marco de la pila antes de llamar a la instrucción CALL. Pero estos problemas no se pueden evitar si no lee atentamente la especificación antes de escribir el código.

Día 20

Cambié la sangría en el código del compilador de 2 a 4. Estoy más acostumbrado a usar sangría de 2 espacios porque eso es lo que uso en mi trabajo en Google. Pero por alguna razón creo que la sangría de 4 espacios es más apropiada para el “hermoso programa de código abierto”.

Hay un cambio más, más significativo. Reescribí las pruebas desde los scripts de shell en C. Antes de este cambio, cada función de prueba compilada por mi compilador estaba asociada con main() que fue compilada por GCC y luego ejecutada por el script de shell. Fue lento porque... generó muchos procesos para cada prueba. No tuve otra opción cuando comencé el proyecto, porque... Mi compilador no tenía muchas funciones. Por ejemplo, no pudo comparar el resultado con el valor esperado debido a la falta de operadores de comparación. Ahora es lo suficientemente potente como para compilar código de prueba. Así que los reescribí para hacerlos más rápidos.

También implementé tipos grandes como long y double. Escribir el código fue divertido porque me volví muy bueno implementando estas funciones muy rápidamente.

Día 21

Casi termino de implementar un preprocesador C en un día. En realidad, este es un puerto de mi intento anterior de escribir un compilador.

Implementar un preprocesador C no es una tarea fácil.

Es parte del estándar C, por lo que está definido en la especificación. Pero la especificación dice muy poco para que esto sea útil para una implementación de bricolaje. La especificación incluye varias macros en su forma ampliada, pero dice muy poco sobre el algoritmo en sí. No creo que ella siquiera explique los detalles de su comportamiento esperado. En general, está poco especificado.

Día 31

Funciones implementadas para varargs, a saber, va_start, va_arg y va_end. No se usan con frecuencia, pero los necesitaba para compilar funciones como printf.

La especificación vararg para C no está muy bien pensada. Si pasa todos los argumentos a una función a través de la pila, va_start se puede implementar con bastante facilidad, pero en los procesadores modernos y en las convenciones de llamadas modernas, los argumentos se pasan a través de registros para reducir la sobrecarga de las funciones de llamada. Por tanto, la especificación no se corresponde con la realidad.

En términos generales, la ABI x86-64 estandarizada por AMD requiere funciones variadas para copiar todos los registros en la pila en preparación para una llamada posterior a va_start. Entiendo que no tenían otra opción, pero todavía parece incómodo.

Me interesé en cómo otros compiladores manejan funciones con un número variable de argumentos. Miré los encabezados TCC y no parecen ser compatibles con la ABI x86-64. Si la estructura de datos para varargs es diferente, entonces las funciones que pasan va_list (como vprintf) se vuelven incompatibles. ¿O me equivoco? [Y en realidad me equivoco: son compatibles.] También miré a Clang, pero parece confuso. No lo leí. Si leo demasiado código de otros compiladores, podría arruinar la diversión de mi propia implementación.

Día 32

Después de solucionar algunos problemas menores y agregar secuencias de escape para cadenas literales (aún no hay "" y cosas similares), pude compilar otro archivo. Siento un progreso seguro.

Intenté implementar soporte para funciones que tienen más de seis parámetros, pero no pude terminarlo en un día. En x86-64, los primeros 6 parámetros enteros se pasan a través de registros y los restantes a través de la pila. Actualmente solo se admite la transferencia a través de registros. El paso de pila no es difícil de implementar, pero requiere demasiado tiempo para depurarlo. No creo que mi compilador tenga funciones que tengan más de seis parámetros, así que pospondré implementarlas por ahora.

Día 33

Tres archivos más compilados hoy. Total 6 de 11. Pero si cuentas las líneas de código, esto es aproximadamente el 10% del total. Los archivos restantes son mucho más grandes, ya que contienen el código central del compilador.

Peor aún, estoy usando características C relativamente nuevas en los archivos del kernel, como literales compuestos e inicializadores designados. Hacen que la autocompilación sea muy difícil. No debería haberlos usado, pero reescribir el código en C simple no sería productivo, así que quiero admitirlos en mi compilador. Aunque esto llevará tiempo.

Día 34

Algunas notas sobre las herramientas de depuración. Dado que el compilador es una pieza de código compleja que consta de muchos pasos, es necesario que haya una manera de examinarlo de alguna manera para su depuración. Mi compilador no es una excepción; Implementé varias funciones que encontré útiles.

En primer lugar, el analizador léxico recuerda su posición de lectura y cuando es interrumpido por motivos inesperados, devuelve esa posición. Esto facilita encontrar un error cuando el compilador no acepta los datos de entrada correctos.

Hay una opción de línea de comando para imprimir el árbol de sintaxis abstracta interna. Si hay un error en el analizador, quiero mirar el árbol de sintaxis.

El generador de código permite un uso extensivo de la recursividad porque genera fragmentos de código ensamblador a medida que atraviesa un árbol de sintaxis abstracta. De esta manera pude imprimir un seguimiento de mini pila para cada línea de salida del ensamblaje. Si noto que algo anda mal, puedo rastrear el generador de código mirando su salida.

La mayoría de las estructuras de datos internas tienen funciones para convertir a una cadena. Esto es útil cuando se usa printf para depurar.

Siempre escribo pruebas unitarias cuando escribo una nueva característica. Incluso después de implementarlo, trato de mantener el código compilado para poder ejecutar pruebas. Las pruebas están escritas para ejecutarse en un corto período de tiempo, por lo que puede ejecutarlas con la frecuencia que desee.

Día 36

Se implementaron literales compuestos y se reescribió el inicializador para estructuras y matrices. No me gustó la implementación anterior. El inicializador ahora es mejor. Debería haber escrito un código hermoso desde el principio, pero como solo me di cuenta de esto después de escribir el código funcional, era inevitable reescribirlo.

Creo que la única característica que falta en la autocompilación es la asignación de estructuras. Espero que todo funcione según lo previsto sin mucha depuración una vez que se implemente.

Día 37

Supongo que no tengo más remedio que usar printf para depurar porque la segunda generación se compila a través de mi compilador que no admite información de depuración. Agregué printf en lugares sospechosos. Se mostraron mensajes de depuración de Printf al compilar la segunda generación, lo que me sorprendió un poco. Quería que los mensajes de depuración se mostraran solo cuando yo uso segunda generación, por lo que no esperaba que la salida funcionara cuando la segunda generación es solo se crea.

Me recuerda a la película Inception. Necesitamos profundizar más para reproducir este error. Esta es la parte divertida de depurar un compilador autocompilador.

Día 38

Solucioné un problema que ocurría en la segunda generación si el analizador léxico era autocompilado. Causó un error por el cual -1 > 0 a veces devolvía verdadero (me olvidé de la expansión firmada). Hay otro error en la ubicación de estructuras (diseño de estructuras). Sólo quedan tres archivos.

Día 39

El generador de código ahora también puede compilarse. Quedan dos archivos. El trabajo está casi terminado, aunque probablemente no debería ser demasiado optimista. Es posible que todavía haya obstáculos inesperados.

Solucioné muchos problemas causados ​​por la mala calidad del código que escribí al principio de este proyecto. Me cansó.

Creí que tenía todas las capacidades para compilarme, pero esto no es cierto. Ni siquiera existen operadores de incremento/decremento de prefijo. Para algunas características de C99 reescribí parte del compilador para hacerlo más fácil de compilar. Como no esperaba llegar a la función de autocompilación tan rápido, utilicé tantas funciones nuevas de C como quise.

Día 40

¡Hurra! ¡Mi compilador ahora puede compilarse solo por completo!

Tardaron unos 40 días. Es un período de tiempo muy corto para escribir un compilador de C autocompilador, ¿no lo crees? Creo que mi enfoque de crear primero un pequeño compilador para un subconjunto muy limitado de C y luego convertirlo en un compilador de C real ha funcionado muy bien. De todos modos, hoy estoy muy feliz.

Mirando mi código, incluso sabiendo que lo escribí yo mismo, se siente un poco mágico porque puede tomarse a sí mismo como entrada y convertirlo en ensamblador.

Quedan muchos errores y funciones no realizadas. Probablemente terminaré con ellos y luego comenzaré a trabajar para mejorar el código de salida.

El programador William W. Vold cuenta la historia

Durante los últimos seis meses he estado trabajando en la creación de un lenguaje de programación (PL) llamado Pinecone. No me atrevería a llamarlo completo, pero ya puedes usarlo: contiene suficientes elementos para ello, como variables, funciones y estructuras de datos personalizadas. Si desea comprobarlo antes de leer, le sugiero que visite la página oficial y el repositorio en GitHub.

Introducción

No soy un experto. Cuando comencé a trabajar en este proyecto, no tenía idea de lo que estaba haciendo y todavía no la tengo. Nunca estudié intencionalmente los principios de la creación de un idioma; solo leí algunos materiales en Internet e incluso en ellos no encontré casi nada útil para mí.

Sin embargo, escribí un lenguaje completamente nuevo. Y funciona. Debo estar haciendo algo bien.

En este artículo intentaré mostrar cómo Pinecone (y otros lenguajes de programación) convierten el código fuente en lo que muchos consideran mágico. También me centraré en situaciones en las que tuve que hacer concesiones y explicaré por qué tomé las decisiones que tomé.

El texto ciertamente no pretende ser una guía completa para crear un lenguaje de programación, pero para los curiosos será un buen punto de partida.

Pinitos

“¿Por dónde empiezo?” Es una pregunta que otros desarrolladores suelen hacer cuando se enteran de que escribo en mi propio idioma. En esta parte intentaré responderla en detalle.

¿Compilado o interpretado?

El compilador analiza todo el programa, lo convierte en código de máquina y lo almacena para su posterior ejecución. El intérprete analiza y ejecuta el programa línea por línea en tiempo real.

Técnicamente, cualquier lenguaje puede compilarse e interpretarse. Pero para cada lenguaje, un método es más adecuado que el otro, y la elección del paradigma en una etapa temprana determina el diseño posterior. En sentido general, la interpretación es flexible y la compilación proporciona un alto rendimiento, pero esto es sólo la punta de un tema muy complejo.

Quería crear un lenguaje simple pero eficaz, lo cual es poco común, así que decidí compilar Pinecone desde el principio. Sin embargo, Pinecone también tiene un intérprete: al principio, el lanzamiento solo era posible con su ayuda, más adelante explicaré por qué.

Nota traducción Por cierto, tenemos una descripción general rápida: este es un gran ejercicio para quienes aprenden Python.

Selección de idioma

Una especie de metapaso: el lenguaje de programación en sí es un programa que debe escribirse en algún lenguaje. Elegí C++ por su rendimiento, su rico conjunto de funcionalidades y simplemente porque me gusta.

Pero en general se pueden dar los siguientes consejos:

  • lenguaje interpretable muy recomendadoescribir en un lenguaje compilado (C, C++, Swift). De lo contrario, la sobrecarga de rendimiento aumentará como una bola de nieve mientras el metaintérprete interpreta a su intérprete;
  • lenguaje compilado puedes escribir en lenguaje interpretado (Python, JS). El tiempo de compilación aumentará, pero no el tiempo de ejecución del programa.

diseño de arquitectura

La estructura de un lenguaje de programación tiene varias etapas, desde el código fuente hasta el archivo ejecutable, en cada una de las cuales los datos se formatean de una manera determinada, así como funciones para la transición entre estas etapas. Hablemos de esto con más detalle.

Analizador/Lexer léxico

La línea del código fuente pasa a través del lexer y se convierte en una lista de tokens.

El primer paso en la mayoría de los idiomas es el análisis léxico. En pocas palabras, representa la división del texto en tokens, es decir, unidades del lenguaje: variables, nombres de funciones (identificadores), operadores, números. Por lo tanto, al alimentar al lexer con una cadena con el código fuente como entrada, recibiremos como salida una lista de todos los tokens que contiene.

Ya no se accederá al código fuente en etapas posteriores, por lo que el lexer deberá proporcionar toda la información necesaria para las mismas.

Doblar

Al crear el lenguaje, lo primero que hice fue escribir un lexer. Más tarde, exploré herramientas que podrían facilitar el análisis léxico y reducir la cantidad de errores que surgen.

Una de las principales herramientas de este tipo es Flex, un generador de analizadores léxicos. Toma como entrada un archivo que describe la gramática del lenguaje y luego crea un programa en C, que a su vez analiza la cadena y produce el resultado deseado.

mi decisión

Decidí conservar el analizador que escribí. Al final, no vi ninguna ventaja particular para Flex, y su uso sólo crearía dependencias adicionales que complicarían el proceso de construcción. Además, mi elección permite una mayor flexibilidad; por ejemplo, puede agregar una declaración al idioma sin tener que editar varios archivos.

Analizador/analizador

La lista de tokens pasa por el analizador y se convierte en un árbol.

La siguiente etapa es el analizador. Transforma el texto fuente, es decir, una lista de tokens (teniendo en cuenta los paréntesis y el orden de las operaciones), en un árbol de sintaxis abstracta, que permite representar estructuralmente las reglas del lenguaje creado. El proceso en sí puede considerarse simple, pero a medida que aumenta el número de construcciones del lenguaje se vuelve mucho más complicado.

Bisonte

En este paso, también estaba pensando en usar una biblioteca de terceros y buscar en Bison para generar el analizador. Es muy parecido a Flex: un archivo personalizado con reglas de sintaxis se estructura utilizando un programa en C. Pero nuevamente, decidí no automatizar.

Ventajas de los programas personalizados

Con Lexer, mi decisión de escribir y usar mi propio código (de unas 200 líneas) fue bastante obvia: me encantan los acertijos, y este también es relativamente trivial. El analizador es una historia diferente: ahora la longitud del código es de 750 líneas, y este ya es el tercer intento (los dos primeros fueron simplemente terribles).

Sin embargo, decidí crear el analizador yo mismo. Estas son las principales razones:

  • minimizar el cambio de contexto;
  • simplificación del montaje;
  • Deseo de afrontar la tarea de forma independiente.

Me convenció de la viabilidad de la solución la afirmación de Walter Bright (el creador del lenguaje D) en uno de sus artículos:

No recomendaría el uso de generadores léxicos y analizadores, así como otros llamados "compiladores". Escribir un lexer y un analizador no llevará mucho tiempo, y el uso del generador lo vinculará firmemente a él en trabajos futuros (lo cual es importante al migrar el compilador a una nueva plataforma). Además, los generadores se caracterizan por emitir mensajes de error irrelevantes.

Gráfico semántico abstracto

Transición de un árbol sintáctico a un gráfico semántico

En esta parte, implementé una estructura que es esencialmente la más cercana a la "representación intermedia" en LLVM. Existe una pequeña pero importante diferencia entre un árbol de sintaxis abstracta (AST) y un gráfico semántico abstracto (ASG).

ASG frente a TEA

En términos generales, un gráfico semántico es un árbol sintáctico con contexto. Es decir, contiene información como qué tipo devuelve la función o en qué lugares se utiliza la misma variable. Debido a que el gráfico necesita reconocer y recordar todo este contexto, el código que lo genera necesita soporte en forma de muchas tablas explicativas diferentes.

Lanzamiento

Una vez elaborado el gráfico, ejecutar el programa es una tarea bastante sencilla. Cada nodo contiene una implementación de una función que recibe alguna entrada, hace lo que está programado para hacer (incluidas posibles llamadas a funciones auxiliares) y devuelve un resultado. Este es el intérprete en acción.

Opciones de compilación

Quizás se pregunte de dónde vino el intérprete, ya que originalmente definí Pinecone como un lenguaje compilado. El punto es que la compilación es mucho más difícil que la interpretación; mencioné anteriormente que encontré algunos problemas con este paso.

Escribe tu propio compilador

Al principio me gustó la idea: me gusta hacer las cosas yo mismo y hace tiempo que quiero aprender el lenguaje ensamblador. Pero crear un compilador multiplataforma desde cero es más difícil que escribir código de máquina para cada elemento del lenguaje. Encontré esta idea completamente impráctica y no valía la pena los recursos gastados.

LVM

LLVM es una colección de herramientas de compilación utilizadas por los desarrolladores de Swift, Rust y Clang, por ejemplo. Decidí optar por esta opción, pero nuevamente no calculé la complejidad de la tarea que me había propuesto. Para mí, el problema no era dominar el ensamblador, sino trabajar con una enorme biblioteca multicomponente.

transpilando

Todavía necesitaba una solución, así que escribí algo que definitivamente funcionaría: un transpilador de Pinecone a C++: realiza una compilación de fuente a fuente y también agregué la capacidad de compilar automáticamente la salida de GCC. Este método no es escalable ni multiplataforma, pero al menos por el momento funciona para casi todos los programas en Pinecone, lo cual es bueno.

Planes futuros

En este momento me falta la práctica necesaria, pero en el futuro implementaré el compilador Pinecone de principio a fin usando LLVM; me gusta la herramienta y los tutoriales son buenos. Hasta ahora, el intérprete es suficiente para programas primitivos y el transpilador para programas más complejos.

Conclusión

Espero que este artículo sea útil para alguien. Recomiendo encarecidamente al menos intentar escribir su propio idioma, a pesar de que tendrá que comprender muchos detalles de implementación; este es un experimento de aprendizaje, desarrollo y simplemente interesante.

Aquí hay algunos consejos generales míos (bastante subjetivos, por supuesto):

  • Si no tienes preferencia y dudas si escribir en un lenguaje compilado o interpretado, elige este último. Los lenguajes interpretados son generalmente más fáciles de diseñar, ensamblar y aprender;
  • Haz lo que quieras con lexers y analizadores. El uso de herramientas de automatización depende de tu deseo, experiencia y situación específica;
  • Si no está preparado/quiere dedicar tiempo y esfuerzo (mucho tiempo y esfuerzo) a idear su propia estrategia de programación, siga la cadena de acciones descritas en este artículo. Le puse mucho esfuerzo y funciona;
  • Nuevamente, si no tienes suficiente tiempo/motivación/experiencia/deseo o cualquier otra cosa para escribir en lenguaje clásico, intenta escribir uno esotérico, como Brainfuck. (Le recomendamos que recuerde que si un idioma se escribe por entretenimiento, esto no significa que escribirlo también sea puro entretenimiento. - aprox. traducción.)

Cometí bastantes errores en el camino, pero ya había reescrito la mayor parte del código que podría haberse visto afectado por ellos. El lenguaje ahora funciona bien y seguirá desarrollándose (en el momento de escribir este artículo, podría compilarse en Linux y, con distintos grados de éxito, en macOS, pero no en Windows).

No me arrepiento en absoluto de haberme involucrado en la historia de la creación de Pinecone: es un gran experimento y apenas comienza.




Arriba