Volver al inicio

Compilador desde cero: lexer parser type inference

El artículo describe la creación de un compilador para mi propio lenguaje sin experiencia en construcción de compiladores. Desglose detallado de etapas: lexer, parser con lookahead para generics, inferencia de tipos en múltiples etapas, preparación de AST para lambdas y punteros, generación de LLVM IR. Discute problemas de enlace y limitaciones de generics.

Cómo un novato construyó un compilador: generics y LLVM
Advertisement 728x90

Construyendo un compilador desde cero: La experiencia de un principiante sin conocimientos profundos

Un compilador se construye por etapas: lexer, parser, inferencia de tipos, preparación y generación de código. El autor, sin experiencia previa en construcción de compiladores, comenzó estudiando un proyecto de código abierto en C# con semántica similar a Rust. En lugar de modificar código existente, implementó todo desde cero mientras aprendía los conceptos básicos. El proceso no fue estrictamente descendente o ascendente: las etapas se rehicieron múltiples veces debido a interdependencias.

El flujo de datos sigue este esquema: código fuente → tokens → AST → AST tipado → AST preparado → LLVM IR.

Lexer y Parser: Manejo de ambigüedades

El lexer y parser se adaptaron de un proyecto externo con un parser LR(1). Surgieron problemas con genéricos: expresiones como var a = Test < b > c; requieren mirar más de un token hacia adelante para distinguir parámetros de plantilla de comparaciones. El LR(1) estándar no puede manejar esto sin contexto.

Google AdInline article slot

Un caso más complejo es var a = Test < b > (c);. Tales ambigüedades se posponen a la etapa de inferencia de tipos. El parser genera nodos AST con manejo de errores si los tokens no coinciden con el formato esperado.

Inferencia de tipos: Recorrido múltiple del AST

La inferencia de tipos requiere múltiples pasadas sobre el AST debido a dependencias entre declaraciones en diferentes archivos. La secuencia:

  • Procesar declaraciones de tipos de nivel superior (escritura en ámbito de espacio de nombres).
  • Analizar parámetros genéricos y sus restricciones.
  • Manejar herencia.
  • Delegados, tipos anidados, funciones.
  • Campos, propiedades, inicializadores.
  • Atributos.
  • Solo entonces: cuerpos de funciones.

Los genéricos causaron las principales dificultades. Ejemplo de generación recursiva:

Google AdInline article slot
public class Test<T> 
{ 
    public static Test<E> GetTest<E>(); 
}

El compilador entró en bucle, creando infinitos Test<E>. Se resolvió con restricciones: campos/propiedades estáticas con parámetros genéricos de clase están prohibidos (public static T CoolField; no está permitido). Esto evita conflictos durante el enlazado.

Las operaciones para diferentes tipos y métodos virtuales/abstractos requirieron refinamientos.

Preparación de tipos para generación de código

Una etapa adicional transforma el AST para facilitar la generación de IR:

Google AdInline article slot
  • Lambdas y Closures: Se generan clases sintéticas para capturar contexto. Ejemplo:
public class Test
{
    public Action ReturnFunction(int a, int b)
    {
        var action = () => 
        { 
            Console.WriteLine($"Result: {a + b}"); 
        };
        return action;
    }
}

Transformado en:

public class __SyntheticClass0
{
    public int a;
    public int b;
    public void Lambda0()
    {
        Console.WriteLine($"Result: {a + b}"); 
    }
}

public class Test
{
    public Action ReturnFunction(int a, int b)
    {
        __SyntheticClass0 tmpVar = new __SyntheticClass0();
        tmpVar.a = a;
        tmpVar.b = b;
        return tmpVar.Lambda0;
    }
}
  • Generación de get/set para propiedades.
  • Reemplazo de accesos a propiedades con llamadas.
  • Eliminación de código muerto (excepto para bibliotecas).
  • Constructores estáticos.
  • Conversión de clases a punteros.
  • Adición de tablas virtuales (vtables).

Generación de código a LLVM IR

La generación de LLVM IR se simplifica por la preparación: detalles específicos de plataforma (setjmp, va_list) se resuelven temprano. Los principales problemas son enlazado y genéricos.

Prohibir campos genéricos estáticos se justifica por dependencias de bibliotecas. En el esquema A → B(int), A → C(int) → App, cada biblioteca genera su propio SomeType<int>. Cambios en B no se sincronizan con C, y el enlazador no sabe cuál elegir.

Otros matices: guiones bajos en nombres globales para Windows x86 (resuelto por el enlazador LLVM), vtables. La sintaxis de LLVM IR se domina rápidamente después de la preparación del AST.

Conclusiones clave

  • Los genéricos requieren diseño cuidadoso: mirada adelante en el parser, inferencia de tipos multipaso, restricciones en elementos estáticos.
  • La etapa de preparación antes del IR es crítica para punteros, lambdas y código muerto.
  • LLVM simplifica el desarrollo multiplataforma pero no elimina conflictos de enlazado.
  • El desarrollo paralelo de etapas lleva a reescrituras: es mejor comenzar con un subconjunto simple del lenguaje.
  • Las clases sintéticas para closures preservan semántica sin conocimiento profundo de closures.

— Editorial Team

Advertisement 728x90

Leer después