Skip to content

Giuse-04/glox

Repository files navigation

glox

Trabajo práctico para la materia Lenguajes y Compiladores I (TB027) - Cátedra Del Mazo.

Integrantes:

  • Mancinelli, Giuseppe - Legajo 112066
  • Aroa, Alexia - Legajo 110014

Durante la cursada desarrollamos en clase un intérprete del lenguaje Lox en Python, siguiendo el diseño del libro Crafting Interpreters. Este repositorio es una reimplementación de ese intérprete en Go, al que se le agregaron funcionalidades adicionales por encima del Lox base: nuevos operadores, estructuras de control, tipos de variables y soporte para programación orientada a objetos.

Entorno de ejecución y prerrequisitos

Este proyecto fue pensado y probado para ejecutarse en Linux.

Prerrequisitos:

  • Go 1.26 (posiblemente funcione en versiones menos recientes)
  • Python 3
  • hyperfine (para benchmarking)

Verificar dependencias:

go version
python3 --version
hyperfine --version

Diferencias con plox visto en clase

Operador ternario

Permite escribir una expresión condicional en una sola línea:

var x = 10;
var resultado = x > 5 ? "grande" : "chico";
print resultado; // grande

La forma es condición ? valor_si_true : valor_si_false. El operador es una expresión, por lo que puede usarse en cualquier lugar donde se espera un valor.

Implementación: el parser crea TernaryExpr desde parser.go y el evaluador lo resuelve en evaluator.go.


Casts explícitos

Se pueden convertir valores entre tipos con bool(...), number(...) y string(...):

print number("42");   // 42
print string(3.14);   // 3.14
print bool(nil);        // false
print bool("hola");   // true
  • number convierte strings numéricos y booleans (true1, false0).
  • string convierte cualquier valor a su representación textual.
  • bool sigue las reglas de truthiness de Lox: false y nil son falsos, todo lo demás es verdadero.

Implementación: los tokens BOOL_CAST, NUMBER_CAST y STRING_CAST están en token.go. El parser crea CastExpr desde parser.go y la lógica de conversión está en evaluator.go.


Incremento y decremento

Se soportan los operadores ++ y -- tanto en forma prefija como postfija:

var i = 0;
i++;      // i = 1
++i;      // i = 2
i--;      // i = 1
--i;      // i = 0
print i;  // 0
  • La forma prefija (++x) incrementa y devuelve el nuevo valor.
  • La forma postfija (x++) devuelve el valor actual y luego incrementa.

Implementación: el prefijo se traduce a asignación en parser.go. El postfijo crea PostfixExpr en parser.go. La evaluación vive en evaluator.go.


Builtin type()

Devuelve el tipo de un valor como string:

print type(42);        // number
print type("hola");    // string
print type(true);      // bool
print type(nil);       // nil
print type(type);      // builtin

Útil para debugging e inspección en tiempo de ejecución.

Implementación: la función está en builtin.go y se registra en evaluator.go.


Sentencia break

Permite salir de un loop antes de que la condición se vuelva falsa:

var i = 0;
while (i < 10) {
  if (i == 3) { break; }
  print i;
  i++;
}
// Imprime: 0, 1, 2

Funciona dentro de while y for. Usar break fuera de un loop es un error detectado por el resolver.

Implementación: el statement está en stmt.go, el parser lo incorpora en parser.go y el manejo de control de flujo está en evaluator.go.


Variables constantes

Se declaran con la palabra clave const en lugar de var. Una vez asignadas, no pueden reasignarse:

const PI = 3.14159;
print PI; // 3.14159

const MENSAJE = "Hola, " + "mundo";
print MENSAJE; // Hola, mundo
  • Las constantes deben inicializarse en la declaración. const X; es un error de parser.
  • Intentar reasignar una constante es un error en tiempo de ejecución: PI = 3; falla.
  • Los operadores ++ y -- también están prohibidos sobre constantes.
  • Una constante puede hacer shadowing de una variable externa (y viceversa), sin afectar a la otra:
var X = 0;
{
  const X = 50;
  print X; // 50
}
X = X + 1;
print X; // 1
  • No se puede redeclarar una constante en el mismo scope: const y = 1; var y = 2; es un error.

Implementación: el token CONST está en token.go; VarDecl tiene el campo IsConstant en stmt.go; el parser lo maneja en parser.go; el resolver valida redeclaraciones y asignaciones en resolver.go; el evaluador define la variable en evaluator.go; y el entorno almacena el flag junto al valor en env.go.

Más ejemplos en examples/const.lox.


Sentencia switch

Permite ramificar la ejecución según el valor de una expresión, con soporte para default:

var x = 2;
switch (x) {
  case 1:
    print "uno";
  case 2:
    print "dos";
  default:
    print "otro";
}
// dos
  • El comportamiento es similar al de Go: al entrar a un case, se ejecuta su contenido y se sale del switch automáticamente. No hace falta break para evitar fallthrough.
  • Se puede usar break dentro de un case y funciona igual: corta la ejecución del case y sale del switch.
  • Si el switch está dentro de un loop, el break dentro de un case no corta el loop, solo el switch. El break del loop sigue funcionando normalmente fuera del switch.
  • El default es opcional y se ejecuta si ningún case coincide.

Implementación: el token SWITCH está en token.go; el statement en stmt.go; el parser maneja el switchDepth para distinguir break de loop vs. break de switch en parser.go. La evaluación con el recover del BreakSignal está en evaluator.go.


Programación Orientada a Objetos

Glox soporta programación orientada a objetos con clases, instancias, herencia simple y acceso a métodos de la superclase vía super.

Clases e instancias

class Circle {
  init(radius) {
    this.radius = radius;
  }

  area() {
    print this.radius * this.radius * 3.14159;
  }
}

Circle(5).area(); // 78.53975
  • Se definen clases con la palabra clave class.
  • El método init es el constructor: se llama automáticamente al instanciar la clase. No es obligatorio utilizarlo. Si no se define, la clase simplemente no recibe argumentos y la instancia empieza sin campos.
  • Los campos no se declaran por adelantado. Se crean al momento de asignar sobre this (por ejemplo this.x = 5). Si se intenta leer un campo que nunca fue asignado, el intérprete lanza un error en tiempo de ejecución.
  • Si la clase tiene init con parámetros y se la instancia con el número incorrecto de argumentos, se obtiene un error: Expected N arguments but got M.
  • Dentro de un método, this hace referencia a la instancia receptora. Siempre que se quiera acceder a un campo o llamar a otro método del mismo objeto, se usa this explícitamente (Lox no tiene acceso implícito).
  • Las clases son valores de primera clase: se pueden asignar a variables, pasar como argumento a funciones, retornar desde funciones, y definir dentro de un bloque con scope local.

Herencia

class Animal {
  init(name) {
    this.name = name;
  }

  speak() {
    print this.name + " hace un sonido";
  }
}

class Dog < Animal {
  init(name) {
    super.init(name);
  }

  speak() {
    print this.name + " ladra: Woof!";
  }
}

class Duck < Animal {
  init(name) {
    super.init(name);
  }

  say() {
    print this.name + " dice: Cuack!";
  }
}

Dog("Snoopy").speak();  // Snoopy ladra: Woof!
Duck("Donald").speak(); // Donald hace un sonido  <- heredado de Animal
Duck("Donald").say();   // Donald dice: Cuack!
  • La herencia se indica con <: class Subclase < Superclase. Solo soporta herencia simple (una única superclase).
  • Una subclase hereda todos los métodos de su superclase. Si el método no se encuentra en la subclase, se busca automáticamente en la cadena de superclases.
  • Se puede hacer override de métodos redefiníéndolos en la subclase.
  • Con super.metodo() se puede invocar la versión del método en la superclase. super solo funciona para métodos, no para atributos.
  • Si una subclase no define init, hereda el del padre automáticamente. Si sí define su propio init, el del padre queda tapado por el override y no se llama a menos que se invoque explícitamente con super.init(...).
  • super resuelve siempre al método en la clase donde está escrito, no en la instancia concreta (ver el ejemplo A/B/C en examples/inheritance.lox).
  • Errores detectados por el resolver: usar super fuera de una clase, dentro de una clase sin superclase, o herencia circular (class A < A).
  • Error en tiempo de ejecución: si la superclase no es una clase, el intérprete lanza un error.

Implementación: los tokens class, this y super están en token.go; las expresiones GetExpr, SetExpr, ThisExpr y SuperExpr en expr.go; ClassDecl en stmt.go; el parsing de clases, propiedades y this/super en parser.go; la resolución de scopes y validaciones en resolver.go; y los tipos de runtime LoxClass y LoxInstance en loxclass.go y loxinstance.go.

Más ejemplos en examples/classes.lox y examples/inheritance.lox.


Modo --resolving

Imprime las profundidades de scope resueltas para cada variable al ser usada antes de ejecutar el programa:

go run main.go --resolving examples/var_used_check.lox

Útil para inspeccionar cómo el resolver asigna distancias a las variables en distintos scopes.

Implementación: la resolución está en resolver.go y el reporte en runner.go.


Cómo correr

Ejecutar REPL:

go run main.go

Ejecutar archivo .lox:

go run main.go examples/hello.lox

Distintos modos de debugging:

go run main.go --scanning examples/hello.lox
go run main.go --parsing examples/hello.lox
go run main.go --resolving examples/hello.lox

Generar binario:

go build -o glox main.go
./glox examples/hello.lox

Programa de demostración

La carpeta de demostración está en examples/demo y tiene 2 casos (un fibonacci y loops), cada uno en su subdirectorio:

Ejecutar el demo de loops con:

go run main.go examples/demo/loops/loops.lox

Ejecutar el demo de fibonacci con:

go run main.go examples/demo/fibonacci/fibonacci.lox

Benchmark de performance

Se realizaron mediciones con hyperfine (3 warmups, 10 runs) comparando glox contra una versión equivalente en Go nativo. El objetivo es dimensionar el costo de la interpretación frente a un lenguaje productivo compilado.

Primero, compilar glox:

go build -o glox main.go

Nota: El binario de glox se ejecuta sin recompilación, mientras que Go nativo se compila cada vez. Esto es justo porque refleja la realidad: el intérprete ejecuta código interpretado, mientras que el programa nativo paga el costo de compilación en cada ejecución.

Benchmark 1: loops

El programa ejecuta 1.000.000 iteraciones totales con loops anidados (10.000 * 100).

hyperfine --warmup 3 --runs 10 \
  './glox examples/demo/loops/loops.lox' \
  'go run examples/demo/loops/main.go'
Tiempo promedio
glox 545.3 ms
Go nativo 79.9 ms

Go nativo es ~7x más rápido que glox.

Esto es esperable: glox es un intérprete de árbol (tree-walking), lo que significa que cada vez que ejecuta un programa recorre y evalúa el AST nodo por nodo. Go compila a código de máquina, que ejecuta directamente instrucciones mucho más eficientes.

Con binarios precompilados (sin overhead de compilación):

  • glox: 582.5 ms | Go nativo: 3.3 ms → ~175x más rápido

La diferencia muestra el impacto del overhead de compilación de Go (go run).

Benchmark 2: fibonacci recursivo

El programa calcula fibonacci de forma recursiva, lo que implica una gran cantidad de llamadas a funciones anidadas.

hyperfine --warmup 3 --runs 10 \
  './glox examples/demo/fibonacci/fibonacci.lox' \
  'go run examples/demo/fibonacci/main.go'
Tiempo promedio
glox 5.090 s
Go nativo 74.6 ms

Go nativo es ~68x más rápido que glox.

Este resultado es aún más dramático que en el benchmark anterior porque los programas recursivos intensivos son particularmente sensibles a la velocidad de ejecución: cada llamada a función pasa por la evaluación interpretada del AST, mientras que Go ejecuta código de máquina compilado directamente.

Con binarios precompilados (sin overhead de compilación):

  • glox: 5.347 s | Go nativo: 10.0 ms → ~532x más rápido

Tests

go test ./...

Por paquete:

go test ./scanner
go test ./parser
go test ./evaluator

Tests oficiales de la cátedra

La carpeta real-tests contiene los casos oficiales de cátedra y el script de ejecución. Para correrlo hace falta Python3:

Antes de correrlos, compilar el binario con el nombre esperado por el script:

go build -o glox main.go

Luego ejecutar:

python3 real-tests/script.py

Estructura del proyecto

  • scanner/: análisis léxico
  • parser/: análisis sintáctico
  • resolver/: resolución de variables por scope
  • evaluator/: ejecución del AST
  • examples/: programas de ejemplo y casos para prueba/benchmark
  • examples/demo/: casos separados de demostración y benchmark en Lox/Go

Referencias

  • Nystrom, R. — Crafting Interpreters (2021). Diseño e implementación de intérpretes de árbol y bytecode para el lenguaje Lox. Disponible en craftinginterpreters.com.

About

Intérprete de Lox hecho en Go

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors