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.
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 --versionPermite 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.
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
numberconvierte strings numéricos y booleans (true→1,false→0).stringconvierte cualquier valor a su representación textual.boolsigue las reglas de truthiness de Lox:falseynilson 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.
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.
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.
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.
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.
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 delswitchautomáticamente. No hace faltabreakpara evitar fallthrough. - Se puede usar
breakdentro de uncasey funciona igual: corta la ejecución del case y sale delswitch. - Si el
switchestá dentro de un loop, elbreakdentro de uncaseno corta el loop, solo elswitch. Elbreakdel loop sigue funcionando normalmente fuera delswitch. - El
defaultes opcional y se ejecuta si ningúncasecoincide.
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.
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
inites 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 ejemplothis.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
initcon 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,
thishace referencia a la instancia receptora. Siempre que se quiera acceder a un campo o llamar a otro método del mismo objeto, se usathisexplí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.supersolo funciona para métodos, no para atributos. - Si una subclase no define
init, hereda el del padre automáticamente. Si sí define su propioinit, el del padre queda tapado por el override y no se llama a menos que se invoque explícitamente consuper.init(...). superresuelve 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
superfuera 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.
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.
Ejecutar REPL:
go run main.goEjecutar archivo .lox:
go run main.go examples/hello.loxDistintos 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.loxGenerar binario:
go build -o glox main.go
./glox examples/hello.loxLa carpeta de demostración está en examples/demo y tiene 2 casos (un fibonacci y loops), cada uno en su subdirectorio:
- examples/demo/loops/loops.lox
- examples/demo/loops/main.go
- examples/demo/fibonacci/fibonacci.lox
- examples/demo/fibonacci/main.go
Ejecutar el demo de loops con:
go run main.go examples/demo/loops/loops.loxEjecutar el demo de fibonacci con:
go run main.go examples/demo/fibonacci/fibonacci.loxSe 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.goNota: 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.
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).
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
go test ./...Por paquete:
go test ./scanner
go test ./parser
go test ./evaluatorLa 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.goLuego ejecutar:
python3 real-tests/script.pyscanner/: análisis léxicoparser/: análisis sintácticoresolver/: resolución de variables por scopeevaluator/: ejecución del ASTexamples/: programas de ejemplo y casos para prueba/benchmarkexamples/demo/: casos separados de demostración y benchmark en Lox/Go
- 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.