Intérprete de lenguaje Lisp-like
Índice
Introducción
Un lenguaje de la familia Lisp se basa en dos funciones principales: eval y apply.
eval toma como argumento una lista de la forma (f x y z) donde f es una función
matemáticamente pura, es decir, sin efectos no explícitos, x y z son los argumentos que se le van
a pasar a f y los paréntesis delimitan la lista. Por otro lado, apply toma una función y una lista y
evalúa la función con todos los elementos de la lista como argumentos. Es decir, (apply f '(1 2 3)) se evalúa a
(f 1 2 3). Estas dos funciones permiten modelar la computación de una forma tan elegante que le han ganado a "Lisp"
(la nube confusa de lenguajes que comparten unas cuantas características como: las expresiones S, eval y apply como
corazón del lenguaje, código como información, etcétera.) una vida de 68 años (contando desde la publicación de
«Funciones recursivas de expresiones simbólicas y su cómputo a máquina, Parte I» por John McCarthy en 1958), que no
tiene apariencia de acabar pronto. Este pequeño proyecto mío implementa lo más básico para un lenguaje de esta familia:
eval, apply y un REPL. El propósito de este artículo es explicar cada una de sus partes.
El proyecto entero está escrito en Guile scheme y licenciado bajo la GPL 3.0. El código fuente se puede encontrar aquí: repo en codeberg.
El corazón del programa: eval
Mi implementación de eval toma dos argumentos: expr y env, la expresión a evaluar y el ambiente en el que se evalúa.
(define (call expr env) (let ((head (eval (car expr) env)))
Aquí env es una association list, una lista de asociaciones, tiene la forma ((x . 10) (y . 20) (mensaje . "hola")),
en esta estructura ((name . val) (name1 . val1)) codifica los nombres de todas las variables definidas junto con su valor.
En el ejemplo anterior, diríamos que x = 10, y = 20 y mensaje = "hola".
La función continúa:
(if (not (pair? expr)) (if (symbol? expr) (let ((sym (assoc expr env))) (if sym (cdr sym) (throw 'error "symbol no found"))) expr) () ;; Lo mostraré más adelante, esto es la rama en la que entran las funciones/formas especiales
Preguntamos: ¿Es expr un no par?, que es lo mismo que decir ¿Es expr atómico?, en el caso de que
el ordenador nos responda: «Sí.», nosotros le respondemos: «¿Es expr un símbolo?», Si la computadora
nos responde «No.», nosotros le mandamos: «Al que te pidió evaluar expr, sé recíproco para con él, y regrésale expr como te lo dio.», pues
con una expresión de estas propiedades no se puede hacer mucho más.
Si se da que expr es un símbolo, ordenamos al ordenador «Busca en env este símbolo», y como la función assoc
regresa #f si el primer argumento no existe dentro del segundo, guardamos el valor para el siguiente if.
Aquí entra en juego una parte peculiar de Guile: solamente #f se considera falso, puede parecer obvio, claro,
pero esto significa que cualquier resultado excepto un #f literal es truthy, es decir que con este como if-expression
if ejecutará la then-expression, en nuestro caso, (cdr sym), y como assoc regresó un par de forma (nombre . valor)
esto se evaluará a valor. En el caso que assoc regrese #f, es decir, expr no existe en env,
causamos una excepción con el mensaje "symbol not found", que se traduce como: símbolo no encontrado.
Funciones/formas especiales
(let ((head (car expr))) (cond ((eq? head 'if) (let* ((args (cdr expr)) (condition (car args)) (then-expr (car (cdr args))) (else-expr (car (cdr (cdr args)))) (truth-val (eval condition env))) (if truth-val (eval then-expr env) (eval else-expr env)))) ((or (eq? head 'lambda) (eq? head 'λ)) (list 'closure (cadr expr) (caddr expr))) ((eq? head 'quote) (cadr expr)) (else (call expr env))))
En la else-expr, primero que todo, asignamos el nombre head a la expresión (car expr), el primer elemento
de la lista expr, que vamos a usar para decidir qué hacer.
En la primera rama del bloque cond que acabamos de abrir, bajo la condición de que head sea igual a 'if, extraemos condition (if-expression), then-expr, else-expr y
truth-val, condition evaluada en el env actual. Si truth-val es #t evaluamos then-expr en el env actual, de lo contrario, hacemos
lo mismo con else-expr. No hay mucho que explicar.
En la siguiente rama, preguntamos: «¿Es head igual a lambda (la palabra o el símbolo)?», si el ordenador dice: «Sí.», creamos un closure, una lista
que contiene los parámetros que toma una función y el cuerpo de dicha función, que podemos almacenar cómodamente gracias a la
homoiconicidad de Guile y la familia Lisp en general.
Entramos a la regla más sencilla de todas: si head es 'quote, regresa el cuerpo
de la expresión íntegro. Por ultimo, Si todo lo demás falló,
vamos a la rama else, donde llamamos a call, mi implementación de apply con expr y env
(que deberías saber qué es, llegado este punto) como argumentos, y así se termina eval.
Y supongo que seguiremos al código (o el código nos seguirá a nosotros), porque call es la siguiente función que
voy a explicar.
call: tenemos apply en casa
(define (call expr env))
Como eval, call toma expr y env.
(if (procedure? head) ;; built-in/primitive (apply head (map (lambda (x) (eval x env)) (cdr expr)))
Primero, evaluamos (car expr) en el env que recibimos como argumento y le asignamos el
nombre head. Si este head evaluado es un procedimiento (una función), simplemente usamos el
apply de nuestro lenguaje host, Guile. Como dice el comentario esto solo debería usarse para
built-ins del intérprete (*, /, +, -).
(if (and (pair? head) (eq? (car head) 'closure)) (let* ((parameters (cadr head)) (body (caddr head)) (arguments (cdr expr))) (if (= (length parameters) (length arguments)) (let* ((eval-args (map (lambda (x) (eval x env)) arguments)) (inside-env (append (map cons parameters eval-args) env))) (eval body inside-env)) (throw 'error "num of args and params do not match"))) (throw 'error "not valid"))
Llegamos a la else-expr, primero que todo nos aseguramos de que head sea un par, para después
poder llamar (car head) y saber si tratamos con un closure. Si head es un closure, entramos
al bloque let* y extraemos parameters (los parámetros que toma la función), body (el cuerpo
de la función) y arguments (los argumentos que le pasamos a la función).
Si parameters y arguments tienen el mismo tamaño entramos en el bloque let*, si no levantamos
una excepción "num of args and params do not match", el número de argumentos y parámetros
no son iguales. En el bloque let*, creamos eval-args, que es, como dice el nombre, todos los
argumentos que tomamos evaluados. Construimos inside-env anexando el mapeo de cons entre
parameters y eval-args, que regresa una association list, lista de asociaciones, con cada parámetro
emparejado a un argumento.
Después de todo, simplemente evaluamos body dentro de inside-env.
REPL
Para hacer algo, tenemos que comunicarnos con el usuario.
Exec
(define (exec statement env) (let* ((head (car statement)) (args (cdr statement))) (cond ((eq? head 'define) (let* ((name (car args)) (body (cadr args)) (value (eval body env))) (bind name value env))) (else (error "not valid statement" env)))))
Exec toma un statement y un env, si statement es igual a 'define, empareja a name y value
y retorna un nuevo env con ese binding. De otra forma solo tira un error.
Planeaba expandirla, pero me dio pereza.
helpers: REPL, aún no
Estas son dos funciones muy sencillas: safe-read y classify.
La primera que veremos, safe-read que lee una línea con la función estándar de Guile read, busca en env
a PS1, si no existe, muestra λ, si ocurre un error, lo atrapa y se vuelve a llamar a sí misma, y así hasta que read
retorne algo útil. Esto previene que todo el programa se detenga por una expresión malformada.
(define (safe-read env) (catch #t (lambda () (let ((p (assoc 'PS1 env))) (if p (display (cdr p))) (display "λ ")) (read)) (lambda (key . args) (display "invalid expression\n") (safe-read))))
classify
classify toma expr y lo clasifica. No hay mucho que decir.
(define (classify expr) (if (pair? expr) ;; listas (funciones) (if (eq? (car expr) 'define) 'exec 'eval) ;; Atomos (if (eq? expr 'exit) 'exit 'eval)))
REPL, ahora sí
(define (repl env) (let* ((expr (safe-read)) (class (classify expr))) (catch #t (lambda () (cond ((eq? class 'exec) (repl (exec expr env))) ((eq? class 'eval) (begin (display (eval expr env)) (newline) (repl env))) ((eq? class 'exit) (begin (display "exiting") (newline) #t)) (else (begin (display "exiting") (newline) #t)))) (lambda (key . args) (begin (display error) (newline) (display args) (newline) (repl env))))))
Esta es una implementación bastante básica del loop Read -> Eval -> Print -> Loop (REPL).
Tomamos env como argumento y lo primero que hacemos es llamar a safe-read y a su resultado
le asignamos el nombre expr, inmediatamente después llamamos a classify, asignamos su resultado al
nombre class y entramos en un bloque catch, #t para atrapar a todos los errores y el thunk en
nuestro loop. En el bloque cond, si class es 'exec, llamamos a exec, igual con eval y llamamos a repl con el
nuevo env, en el caso de 'exec, que modifica env. Si class es igual a 'exit simplemente salimos
del programa. En el handler, simplemente mostramos el error y volvemos a llamar a repl con el mismo
env. Todo este bloque catch tiene que manejar las excepciones que tiramos en eval y call.