Unidad 5: Recursión y mutación
|
Borrador generado con IA. El texto de esta unidad fue generado automáticamente a partir de las transcripciones de las clases y está pendiente de revisión y edición por parte del equipo docente. Puede contener errores, omisiones o imprecisiones; ante cualquier discrepancia, prevalecen las presentaciones y el material oficial del curso. |
En las unidades anteriores construimos, de forma incremental, un intérprete para un lenguaje con funciones de primera clase (fun/app), alcance estático (implementado con clausuras que capturan el ambiente de definición), sustitución diferida (ambientes en lugar de sustitución textual) y evaluación temprana. Llamamos a este punto de partida el lenguaje FAE (First-class-function Arithmetic Expressions).
En esta unidad partimos siempre desde ese lenguaje y avanzamos en dos direcciones que, aunque distintas, comparten una misma herramienta técnica —la mutación del lenguaje de implementación:
-
Recursión. ¿Por qué una función anónima no puede referirse a sí misma bajo alcance estático, y cómo lo resolvemos con un ambiente cíclico? Luego estudiamos el costo en pila de la recursión y sus optimizaciones (tail calls).
-
Mutación. ¿Cómo dotamos a nuestro lenguaje-objeto de estructuras de datos mutables (cajas) y de variables asignables, sin romper el alcance estático? La respuesta es el store y el estilo store-passing.
|
Un hilo conductor de la unidad: mantener simultáneamente sustitución diferida y alcance estático es lo que nos ha obligado, una y otra vez, a desarrollar conceptos nuevos (clausuras, clausuras de expresión). La recursión y la mutación no son la excepción: cada una tensiona el alcance estático y exige una construcción adicional. |
Recursión y autorreferencia
La recursión es el principio de autorreferencia: un elemento que se refiere a sí mismo. Es el viejo chiste del diccionario: «Recursión: véase Recursión». En los lenguajes de programación aparece de dos formas:
-
Recursión en el control: funciones recursivas y constructos iterativos (el comportamiento del programa se refiere a sí mismo).
-
Recursión (inducción) en los datos: árboles binarios, listas y, en general, cualquier tipo de dato inductivo.
Datos recursivos vs. datos cíclicos
Conviene distinguir dos nociones que se confunden con facilidad:
- Datos recursivos (inductivos)
-
Un dato se refiere a algo del mismo tipo, pero que es una instancia distinta. Un árbol binario tiene como hijos otros árboles binarios, pero esos subárboles no son el árbol completo. Estos datos siempre definen estructuras finitas y acotadas.
- Datos cíclicos
-
Un dato se refiere, directa o indirectamente, a sí mismo: la misma instancia. El ejemplo por excelencia es un grafo donde el nodo
aapunta abybapunta de vuelta aa. Con datos cíclicos podemos representar estructuras infinitas (por ejemplo, la lista infinita de unos que vimos en Haskell con evaluación perezosa) en un lenguaje con evaluación temprana.
Las estructuras cíclicas serán nuestra herramienta para implementar recursión, así que las estudiamos primero.
Estructuras de datos cíclicas
En un lenguaje con evaluación temprana, la única manera de introducir un ciclo es mediante mutación. Hasta ahora habíamos evitado deliberadamente la mutación (enfoque puramente funcional: mismas entradas producen siempre las mismas salidas). Racket ofrece dos familias de estructuras mutables:
-
Cajas (
box): una celda mutable con una referencia de memoria y un valor almacenado.(box 5) ;; crea una caja con contenido 5 (unbox (box 5)) ;; => 5 (let ([b (box 5)]) (set-box! b 6) b) ;; muta el contenido: la caja pasa a contener 6 -
Pares mutables (
mcons): comocons, pero conset-mcar!yset-mcdr!.(mcons 1 2) (mcdr (mcons 1 2)) ;; => 2 (let ([b (mcons 1 2)]) (set-mcdr! b 3) b) ;; muta el segundo componente
Lo esencial es que set-box! (o set-mcdr!) cambia el contenido sin cambiar la identidad de la caja: si otro fragmento del programa capturó esa misma caja (por ejemplo, dentro de una clausura), verá el nuevo valor.
La receta «nombrar y conquistar»
Para construir una estructura cíclica no podemos escribir el ciclo de un solo golpe, porque en el momento de mencionarse a sí misma la estructura aún no existe. La estrategia es:
-
Dar un nombre a la estructura y colocar un valor provisorio (un placeholder, un
'dummy) en el lugar donde debe ir la autorreferencia. -
Mutar ese placeholder para que apunte al identificador recién definido —que ya nombra a la estructura completa.
Veamos la lista cíclica de unos y una caja que se contiene a sí misma. Este es el código real:
#lang play
(define ones
(let ([b (mcons 1 'dummy)])
(begin
(set-mcdr! b b) ;; <-- mutación para introducir el ciclo
b)))
(define (mtake mpair n)
(if (> n 0)
(cons (mcar mpair) (mtake mpair (- n 1)))
'()))
(define self-box
(let ([b (box 'dummy)])
(begin
(set-box! b b)
b)))
ones es un par mutable cuyo mcdr es él mismo: en la dirección 0 hay un par cuyo primer valor es 1 y cuyo segundo componente vuelve a la dirección 0. Podemos consumir tantos elementos como necesitemos sin caer en la impresión infinita:
(mtake ones 5) ;; => '(1 1 1 1 1)
(unbox self-box) ;; => la propia caja self-box (referencia cíclica)
|
El truco de las estructuras cíclicas
La autorreferencia requiere un nombre y requiere mutación. Sin un identificador que designe la estructura completa, no hay a qué apuntar; sin mutación, no podemos «cerrar» el ciclo después de crear la estructura. La expresión |
La autorreferencia no tiene por qué ir en el «último» componente. Podemos combinar un prefijo finito con una sección cíclica —por ejemplo, la representación decimal del número periódico 1,4545… como (mcons 1 (mcons 4 (mcons 5 ⊡))), donde ⊡ es la autorreferencia al par que contiene el 4.
Nuestro objetivo, sin embargo, no es representar grafos: es construir un ambiente cíclico que nos permita implementar recursión. Antes veamos exactamente qué se rompe.
El problema de la recursión con funciones de primera clase
Supongamos que extendemos FAE con multiplicación y escribimos el factorial como una función anónima ligada con with:
(define fact5 '{with {fact {fun {n}
{if0 n
1
{* n {fact {- n 1}}}}}}
{fact 5}})
Al ejecutarlo obtenemos un resultado sorprendente:
-
{fact 0}evalúa correctamente a1. -
{fact 5}falla con el errorfree identifier: fact.
Por qué fallan las definiciones recursivas
El parser desazucara el with en la aplicación de una función anónima:
;; {with {fact E} {fact 5}} se traduce en:
{{fun {fact} {fact 5}} E}
;; donde E = {fun {n} {if0 n 1 {* n {fact {- n 1}}}}}
Al interpretar la aplicación, E se evalúa en el ambiente vigente en ese punto —que aún no contiene fact. La clausura resultante captura ese ambiente:
fact -> (closureV n {if0 n 1 {* n {fact {- n 1}}}} [])
El ambiente capturado por la clausura es vacío: no tiene ninguna asociación para fact. Por eso:
-
{fact 0}funciona: entra en la rama base y nunca necesitafact. -
{fact 5}evalúa la rama recursiva, llega a{fact {- n 1}}, buscafacten el ambiente clausurado (vacío) y no lo encuentra.
El problema no es un error nuestro: nace de las reglas de alcance de with/let. La asociación [fact → …] solo está disponible en el cuerpo del with, no dentro de la expresión nombrada. Racket sufre exactamente lo mismo:
;; falla: fact es un identificador libre en la expresión nombrada
(let ([fact (lambda (n) (if (zero? n) 1 (* n (fact (- n 1)))))])
(fact 5))
Codificar la recursión con mutación
La solución es la misma receta de las estructuras cíclicas: introducimos un nivel de indirección con una caja y cerramos el ciclo con mutación.
;; La caja fact SÍ es capturada por la clausura, porque está en un let anterior.
(let ([fact (box 'dummy)])
(let ([fact-fun
(lambda (n) (if (zero? n)
1
(* n ((unbox fact) (- n 1)))))]) ;; llamada recursiva vía la caja
(set-box! fact fact-fun) ;; cerramos el ciclo
((unbox fact) 6))) ;; => 720
La caja fact sí es capturada por la clausura (está en un let externo), y como la mutación cambia el contenido sin alterar la identidad, la función encuentra su propia definición al invocarse. Racket, por supuesto, provee letrec, que hace exactamente esto de forma transparente:
(letrec ([fact (lambda (n) (if (zero? n) 1 (* n (fact (- n 1)))))])
(fact 6)) ;; => 720
Nuestra meta es dar a nuestro lenguaje-objeto su propio letrec.
La expresión rec
Introducimos un nuevo constructor, rec, cuya única diferencia con with es que el identificador ligado está disponible tanto en el cuerpo como en la expresión nombrada:
{rec {fact {fun {n} {if0 n 1 {* n {fact {- n 1}}}}}}
{fact 5}} ;; => 120
Sintaxis: un nodo nuevo, no azúcar sintáctico
A diferencia de with, rec no puede desazucararse a una aplicación de función (eso reintroduciría el problema del alcance). Necesitamos un nodo explícito en la sintaxis abstracta:
(deftype Expr
(num n)
(add l r)
(sub l r)
(mul l r)
(if0 c t f)
(id x)
(fun arg body)
(app f arg)
(rec id named-expr body)) ;; <-- nuevo nodo
El parser construye ese nodo directamente (su sintaxis concreta es idéntica a la de with, pero la acción es distinta):
[(list 'rec (list (? symbol? x) e) b)
(rec x (parse e) (parse b))]
Así, {with {fact E} B} y {rec {fact E} B} producen árboles distintos: el primero es una aplicación (por el azúcar), el segundo es un nodo rec.
La intuición: una pirámide infinita de clausuras
¿Cuál debe ser el ambiente capturado por la clausura de fact? Si extendemos el ambiente una sola vez con [fact → clausura], funciona para {fact 0} y {fact 1}. Si lo hacemos dos veces, funciona hasta {fact 2}. Para cualquier número fijo de niveles siempre existe una invocación más profunda que falla. Necesitamos que la clausura capture un ambiente que se refiera a sí mismo: un ambiente donde fact esté asociado a una clausura que captura ese mismo ambiente. Un ambiente cíclico.
Ambientes con cajas
Para poder cerrar el ciclo con mutación, extendemos el ADT de ambientes con una variante cuyo valor vive dentro de una caja:
(deftype Env
(mtEnv)
(aEnv id val env)
(aBoxEnv id bval env)) ;; <-- el valor está en una caja mutable
(define extend-env aEnv)
(define box-extend-env aBoxEnv)
(define (env-lookup x env)
(match env
[(mtEnv) (error "free identifier" x)]
[(aEnv y v e)
(if (symbol=? x y) v (env-lookup x e))]
[(aBoxEnv y bv e)
(if (symbol=? x y) (unbox bv) (env-lookup x e))])) ;; abre la caja al buscar
cyclic-env: construir el ambiente cíclico
Aplicamos la receta «nombrar y conquistar» al ambiente. Este es el código real:
;; cyclic-env :: Sym Expr Env -> Env
;; Supuesto: fun-expr es una definición de función.
(define (cyclic-env id fun-expr env)
;; 1. Caja con un valor provisorio (placeholder)
(def fun-val-holder (box 'dummy))
;; 2. Ambiente cíclico "en construcción": id -> (box 'dummy)
(def new-env (box-extend-env id fun-val-holder env))
;; 3. Interpretamos la definición EN new-env: la clausura captura el ambiente cíclico
(def fun-val (interp fun-expr new-env))
;; 4. Cerramos el ciclo con mutación
(begin
(set-box! fun-val-holder fun-val)
new-env))
El paso 3 es crucial: la función se interpreta dentro del nuevo ambiente, de modo que la clausura captura un ambiente donde id ya aparece (todavía apuntando al 'dummy). El paso 4 reemplaza el 'dummy por la clausura misma. Resultado:
new-env = [ fact -> (box #0=(closureV n <body> [fact -> (box #0)] ...)) ; ...env ]
|
El truco del ambiente cíclico
El orden de los pasos es lo que hace que todo funcione:
El identificador de la caja (su ubicación de memoria) no cambia al mutar su contenido; por eso la clausura, que capturó una referencia a esa caja, «ve» la nueva definición. Cada vez que se ejecuta la clausura, la llamada recursiva busca |
Interpretar rec
La semántica de rec es simplemente interpretar el cuerpo bajo el ambiente cíclico:
[(rec id e b)
(interp b (cyclic-env id e env))]
Con esto, la definición recursiva funciona para cualquier número natural:
(print-only-errors #t)
(define recfact5 '{rec {fact {fun {n}
{if0 n
1
{* n {fact {- n 1}}}}}}
{fact 5}})
(test (run recfact5) 120)
Cuando el intérprete evalúa {fact 5}, lo hace bajo el ambiente cíclico. La llamada recursiva {fact 4} se ejecuta bajo el ambiente clausurado por fact, que es el mismo ambiente cíclico; y así toda llamada recursiva sucesiva.
Peligros de rec y divergencia
La expresión nombrada podría ser arbitraria, incluso el propio identificador que estamos ligando:
{rec {x x} x} ;; definición circular sin sentido: diverge / no se puede evaluar
Para evitar estas inconsistencias, restringimos la expresión nombrada de rec a que sea una definición de función (por eso cyclic-env asume que fun-expr es una función). Racket impone una restricción análoga: reporta «cannot use identifier before its definition».
Ahora bien, la posibilidad de divergencia no es exclusiva de rec. Ya en FAE puro podíamos escribir el combinador Ω, que se reduce a sí mismo indefinidamente:
Ω = {{fun {x} {app x x}} {fun {x} {app x x}}}
Evaluar Ω nunca termina, porque un paso de reducción produce exactamente la misma expresión. La no-terminación es un fenómeno independiente de tener o no un constructor recursivo.
Otra vía: recursión sin mutación (autoaplicación)
Existe una forma puramente funcional de implementar recursión, sin mutación: la autoaplicación. El problema es que la función «no sabe» a quién llamar en el paso recursivo; la solución es pasarle esa función como argumento.
(define fact
(lambda (self)
(lambda (n)
(if (zero? n) 1 (* n ((self self) (- n 1)))))))
((fact fact) 5) ;; => 120
Cada paso recursivo vuelve a aplicar self a self, de modo que la función siempre dispone de sí misma. Este patrón puede automatizarse con el combinador Y, que abstrae la autoaplicación y permite escribir definiciones recursivas con sintaxis normal. En consecuencia, podríamos tratar rec como azúcar sintáctico apoyado en el combinador Y.
|
El combinador Y es material de anexo: es interesante teóricamente (muestra que la recursión no requiere mutación y es posible en lenguajes funcionales puros), pero no se evalúa en controles ni exámenes. Lo que sí debe quedar claro es que existen dos enfoques para implementar recursión: ambientes cíclicos (con mutación) y autoaplicación (Y, sin mutación). En la práctica, las implementaciones eficientes y legibles usan mutación. |
Contraste: recursión «gratis» con funciones de primer orden
En el lenguaje con funciones de primer orden la recursión venía «gratis». Allí las aplicaciones siempre eran contra funciones con nombre, y los nombres se buscaban en una lista global de definiciones (lookup en FunDef), disponible durante toda la ejecución. Por eso una función podía referirse a sí misma —e incluso a otras (recursión mutua, como even/odd)— sin ninguna maquinaria adicional. En FAE, en cambio, los identificadores se buscan en el ambiente, y por eso necesitamos el ambiente cíclico para que la búsqueda siempre tenga éxito.
Optimizaciones para recursión: llamadas por la cola
La recursión tiene un costo de implementación: cada llamada pendiente ocupa espacio en la pila de ejecución. El siguiente programa duplica un número de forma recursiva:
(define (double_rec accum value)
(if (zero? value)
accum
(double_rec (+ accum 2) (- value 1))))
En C, un programa equivalente desborda la pila (segmentation fault) al superar cierta cantidad de llamadas (por ejemplo, un millón). En Racket, el mismo cómputo corre con millones o miles de millones de iteraciones sin caerse. ¿Por qué esta diferencia?
La pila de llamadas
Cada invocación de función agrega un stack frame que contiene el estado del llamador (para restaurarlo al retornar), la dirección de retorno y otros metadatos. Habrá un frame por cada llamada no terminada. Al ejecutar una función recursiva, la pila crece en proporción a la profundidad de la recursión, hasta desbordarse.
Dos perfiles de ejecución
Comparemos dos funciones. El factorial no optimizable:
(fact 4) (* 4 (fact 3)) (* 4 (* 3 (fact 2))) (* 4 (* 3 (* 2 (fact 1)))) (* 4 (* 3 (* 2 (* 1 (fact 0))))) <- la pila crece: hay multiplicaciones pendientes
Y el máximo común divisor, cuya llamada recursiva es el resultado final:
(gcd 14 21) (gcd 21 14) (gcd 14 7) (gcd 7 0) <- no hay contexto pendiente alrededor de la llamada 7
En ambos casos la pila crece con la profundidad; pero en el primero hay un contexto de control que crece alrededor de cada llamada (las multiplicaciones que quedan «recordadas» para hacerse al volver). En el segundo no hay contexto pendiente: el resultado de (gcd 14 21) es, sin más, el valor de (gcd 21 14). Este segundo perfil admite una optimización.
Posición de cola
Una llamada a función está en posición de cola si su resultado se retorna inmediatamente como el valor de la función que la contiene, sin computación pendiente posterior. Es una propiedad estática: se observa en el código fuente, sin ejecutarlo, y aplica a cualquier llamada, no solo a las recursivas.
function bar(data) {
if (a(data)) { return b(data); } // b(data): llamada en posición de cola
return c(data); // c(data): llamada en posición de cola
}
// a(data) NO está en posición de cola: el if espera su resultado para decidir la rama
a(data) no está en posición de cola porque el if debe esperar su valor para elegir la rama; hay un contexto pendiente (la evaluación del condicional). b(data) y c(data) sí lo están: son el resultado final. Nótese que para que una función entera sea de cola, todas sus ramas deben terminar en llamadas en posición de cola.
TCO y TRO
- Optimización de llamadas por la cola (Tail Call Optimisation, TCO)
-
Una llamada en posición de cola no necesita crear un nuevo frame: puede reutilizar (sobrescribir) el frame del llamador, porque este ya no hará ninguna computación adicional. La llamada retorna directamente al punto donde se invocó al llamador original.
- Optimización de recursión por la cola (Tail Recursion Optimisation, TRO)
-
Es el caso particular de TCO cuando la función es recursiva. Una función es recursiva por la cola si todas sus llamadas recursivas están en posición de cola. TRO es más simple de implementar que TCO general, porque es una optimización local (basta mirar el cuerpo de la función).
Con TRO, el tamaño de la pila permanece constante sin importar si hay 10, 1000 o un millón de llamadas: el frame se reutiliza y solo cambian los argumentos.
Acumuladores: transformar a recursión por la cola
La versión original de fact no es recursiva por la cola porque retornamos antes de multiplicar. La técnica general es agregar un acumulador donde realizamos el cálculo antes de la llamada recursiva, de modo que no quede contexto pendiente:
;; NO recursiva por la cola: la multiplicación queda pendiente
(define (fact n)
(if (= n 0) 1 (* n (fact (- n 1)))))
;; SÍ recursiva por la cola: el cálculo se hace antes de recurrir
(define (tail-fact acc n)
(if (= n 0) acc (tail-fact (* n acc) (- n 1))))
(tail-fact 1 4) ;; => 24
El desarrollo muestra que la pila ya no crece:
(tail-fact 1 4) (tail-fact 4 3) (tail-fact 12 2) (tail-fact 24 1) (tail-fact 24 0) 24
El valor inicial del acumulador depende de lo que calcula la función (para el factorial es 1); suele coincidir con el valor del caso base. La misma idea, con dos acumuladores, transforma Fibonacci:
;; original (no tail): dos llamadas recursivas, ninguna en posición de cola
(define (fib n)
(if (< n 2) n (+ (fib (- n 1)) (fib (- n 2)))))
;; versión tail-recursive con dos acumuladores
(define (tail-fib a b n)
(if (= n 0) a (tail-fib b (+ a b) (- n 1))))
(tail-fib 0 1 6) ;; => 8
(tail-fib 0 1 6) (tail-fib 1 1 5) (tail-fib 1 2 4) (tail-fib 2 3 3) (tail-fib 3 5 2) (tail-fib 5 8 1) (tail-fib 8 13 0) 8
Toda función recursiva puede traducirse a una versión recursiva por la cola. La idea general es pasar el contexto de control como un argumento adicional: qué hacer con el resultado de las llamadas recursivas (una continuación, en términos generales).
TRO es propiedad de la implementación, no del lenguaje
Que una llamada por la cola se optimice o no depende de la implementación (compilador/intérprete), no del código fuente. El mismo programa en C, recompilado con -O2, deja de desbordar la pila.
-
Scheme y Racket son una excepción: su especificación obliga a implementar TRO. Un lenguaje tipo Scheme sin TRO no es un dialecto de Scheme.
-
La mayoría de los lenguajes orientados a objetos (Java, Python, Ruby, JavaScript) no aplican TRO por defecto; algunos lo permiten vía librería (Python), flag del compilador (C++, Ruby) o de fábrica (Scala, con anotación
@tailrec).
Una desventaja de la optimización: se pierde información de depuración, porque el historial de llamadas se colapsa al reutilizar los frames.
Trampolines
¿Qué hacer si la implementación soporta TRO pero no TCO general, o si la optimización no cubre funciones mutuamente recursivas (el clásico even/odd)? Usamos un trampolín: codificamos la computación en una estructura de datos y la conducimos con un bucle (o con una función recursiva por la cola). La computación es una estructura TailCall que es, o bien el valor final (done), o bien un thunk (una lambda sin argumentos) que encapsula el siguiente paso (call):
(deftype TailCall
(done value) ;; la computación terminó con este valor
(call thunk)) ;; hay algo pendiente: un thunk que hará el siguiente paso
;; even-tr y odd-tr NO se llaman entre sí directamente: retornan un TailCall
(define (even-tr n)
(cond [(zero? n) (done #t)]
[(zero? (- n 1)) (done #f)]
[else (call (λ () (odd-tr (- n 1))))]))
(define (odd-tr n)
(cond [(zero? n) (done #f)]
[(zero? (- n 1)) (done #t)]
[else (call (λ () (even-tr (- n 1))))]))
;; result internaliza el control: si terminó, retorna el valor; si no, ejecuta el
;; siguiente thunk. Es recursiva POR LA COLA, así que corre en pila constante.
(define (result tc)
(match tc
[(done v) v]
[(call th) (result (th))]))
(result (even-tr 100)) ;; => #t
result es análoga a la función strict que vimos en evaluación perezosa: aplica un paso tras otro hasta obtener un valor. Se llama «trampolín» porque el control sube y baja entre result y los thunks: result invoca el thunk, el thunk devuelve otro TailCall a result, y así sucesivamente, siempre en un tamaño de pila fijo. Si el lenguaje no tuviera TRO, result se escribiría con un while en lugar de recursión. En síntesis: el trampolín consigue TCO a partir de TRO, de forma explícita.
Estructuras de datos mutables: el store
Pasamos a la segunda gran extensión: dotar a nuestro lenguaje-objeto de mutación. Definimos mutación como el cambio de los valores asociados con nombres. Ya asociamos identificadores a valores (con with, en el ambiente); lo que no teníamos era la capacidad de cambiar ese valor. La mutación dota a los programas de estado e introduce una noción de orden temporal (un antes y un después).
- La mutación no es necesaria (cualquier lenguaje puramente funcional, como Haskell, es Turing-completo sin ella), pero es conveniente para modelar entidades del mundo real que evolucionan en el tiempo (la carga de combustible de un auto, un interruptor de luz). Su costo: hace más difícil razonar sobre los programas. Con `f
-
number → number` ya definida, en un lenguaje puro
(= (f 1) (f 1))es siempre verdadero; con mutación,(= (f 1) (f 1))puede ser falso, porque cada invocación puede modificar el estado subyacente. Aparece además la dependencia del orden de evaluación.
Sintaxis de las cajas
Extendemos el lenguaje con cuatro expresiones nuevas:
(deftype Expr
(num n) (add l r) (sub l r) (mul l r) (if0 c t f)
(id x) (fun arg body) (app f arg)
(seqn e1 e2) ;; evalúa e1 y luego e2; retorna el valor de e2
(newbox e) ;; crea una caja nueva con el valor de e
(setbox b v) ;; muta la caja b para que contenga el valor de v
(openbox b)) ;; retorna el contenido de la caja b
El programa objetivo, que debe reducir a 1:
{with {b {newbox 0}}
{seqn {setbox b 1}
{openbox b}}}
¿Por qué el ambiente no basta?
En la secuencia seqn hay una propagación implícita: cuando termina la primera expresión, la caja tiene un valor nuevo, y ese valor debe verse en la segunda expresión. ¿Qué es lo que «fluye»? El primer candidato es el ambiente —pero no puede ser, porque rompería el alcance estático:
{seqn {with {b 3} b}
b} ;; si el ambiente fluyera, esto daría 3; pero b es libre aquí
Si la mutación se propagara por el ambiente, b en la segunda expresión estaría ligado y el programa daría 3; pero por alcance estático b es un identificador libre allí, y debe fallar. Considérese también:
{with {a {newbox 1}}
{with {f {fun {x} {+ x {openbox a}}}}
{seqn {setbox a 2}
{f 5}}}} ;; debe reducir a 7
Aquí conviven dos reglas en tensión: la caja a es capturada por la clausura de f mediante alcance estático (regla estática), pero la mutación {setbox a 2}, que ocurre fuera de la clausura, debe afectar lo que sucede dentro (regla dinámica). El ambiente por sí solo no puede expresar ambas a la vez.
La solución: un proceso de dos fases
Convertimos la asociación de valores a identificadores en un proceso de dos fases, introduciendo las ubicaciones de memoria (o celdas):
| Estructura | Rol |
|---|---|
Ambiente ( |
Mapea identificadores a ubicaciones de memoria. Asegura el alcance estático. |
Store / almacén ( |
Mapea ubicaciones a valores. Registra los cambios dinámicos del contenido de las cajas. |
Para obtener el valor de un identificador, ahora componemos dos búsquedas: primero la ubicación (en el ambiente), luego el valor (en el store).
Implementación de las estructuras
El store se implementa igual que el ambiente (una lista enlazada); la única diferencia es que las ubicaciones son números naturales en lugar de símbolos. El ambiente cambia: ahora asocia identificadores a ubicaciones, no a valores.
;; Store: loc -> value
(deftype Store
(mtSto)
(aSto loc val sto))
(define empty-sto (mtSto))
(define extend-sto aSto)
(define (lookup-sto l sto)
(match sto
[(mtSto) (error 'lookup-sto "No value at location: ~a" l)]
[(aSto loc val rest)
(if (equal? loc l) val (lookup-sto l rest))]))
;; Ambiente: id -> loc (antes era id -> value)
(deftype Env
(mtEnv)
(aEnv id loc env))
(define empty-env (mtEnv))
(define extend-env aEnv)
(define (lookup-env x env)
(match env
[(mtEnv) (error "free identifier" x)]
[(aEnv y loc e)
(if (equal? x y) loc (lookup-env x e))]))
Necesitamos también reclamar ubicaciones frescas, para que dos identificadores distintos no colisionen en la misma dirección. next-location cuenta los elementos del store y devuelve el siguiente índice libre:
;; next-location :: Store -> Loc
(define (next-location sto)
(match sto
[(mtSto) 0]
[(aSto _ _ rest) (+ 1 (next-location rest))]))
Extendemos los valores con las cajas (boxV), que no almacenan un valor sino la ubicación donde vive su contenido; y definimos el tipo de retorno del intérprete, Value*Store, que empareja un valor con el store resultante:
;; <value> ::= (numV n) | (closureV arg body env) | (boxV loc)
(deftype Value
(numV n)
(closureV arg body env)
(boxV loc))
;; El intérprete ya no retorna solo un valor, sino un valor Y un store.
(deftype Value*Store
(v*s val sto))
El intérprete en estilo store-passing
- La firma del intérprete cambia a `interp
-
Expr Env Store → Value*Store`. La idea central: el store se hila (threads) a través de todas las evaluaciones en el orden en que ocurren. Cada subexpresión recibe el store del paso anterior y produce un store posiblemente modificado, que se propaga al paso siguiente.
Los casos que no mutan simplemente reenvían el store tal cual:
[(num n) (v*s (numV n) sto)] ;; reenvía el store
[(id x) (v*s (lookup-sto (lookup-env x env) sto) sto)] ;; búsqueda en dos fases
[(fun id body) (v*s (closureV id body env) sto)] ;; la clausura NO captura el store
En el condicional, las mutaciones de la condición deben propagarse a la rama elegida:
[(if0 c t f)
(def (v*s c-val c-sto) (interp c env sto)) ;; c-sto tiene las mutaciones de evaluar c
(if (num-zero? c-val)
(interp t env c-sto) ;; la rama se evalúa en el store MODIFICADO
(interp f env c-sto))]
En las operaciones binarias, hilamos el store de un operando al otro (decisión de diseño: de izquierda a derecha) y retornamos el último store:
[(add l r)
(def (v*s l-val l-sto) (interp l env sto)) ;; store inicial -> l-sto
(def (v*s r-val r-sto) (interp r env l-sto)) ;; l-sto -> r-sto
(v*s (num+ l-val r-val) r-sto)] ;; retorna el ÚLTIMO store
[(sub l r)
(def (v*s l-val l-sto) (interp l env sto))
(def (v*s r-val r-sto) (interp r env l-sto))
(v*s (num- l-val r-val) r-sto)]
[(mul l r)
(def (v*s l-val l-sto) (interp l env sto))
(def (v*s r-val r-sto) (interp r env l-sto))
(v*s (num* l-val r-val) r-sto)]
La aplicación es el caso más elaborado: evaluamos la función (obteniendo fun-sto), luego el argumento en fun-sto (obteniendo arg-sto), reclamamos una ubicación nueva sobre arg-sto, y evaluamos el cuerpo extendiendo el ambiente (arg-name → new-loc) y el store (new-loc → arg-val):
[(app f arg-expr)
(def (v*s (closureV arg-name body closed-env) fun-sto) (interp f env sto))
(def (v*s arg-val arg-sto) (interp arg-expr env fun-sto)) ;; usa fun-sto, no sto
(def new-loc (next-location arg-sto)) ;; ubicación fresca sobre arg-sto
(interp body
(extend-env arg-name new-loc closed-env) ;; env clausurado + nueva ubicación
(extend-sto new-loc arg-val arg-sto))] ;; store + (new-loc -> valor del argumento)
|
El encadenamiento del store: el error más común
En cada punto donde se evalúa una subexpresión hay que pasar el store más reciente, no el store original. El error clásico —y una pregunta típica de control— es pasar
Cualquier subexpresión puede ser una secuencia con un |
Las primitivas de mutación
Con la infraestructura lista, las primitivas son directas. Este es el código real:
[(seqn e1 e2)
(def (v*s _ sto1) (interp e1 env sto)) ;; se descarta el valor de e1; solo importa su efecto
(interp e2 env sto1)] ;; e2 se evalúa en el store modificado por e1
[(newbox val-expr)
(def (v*s val-val val-sto) (interp val-expr env sto))
(def new-loc (next-location val-sto))
(v*s (boxV new-loc) ;; el valor de la caja es su ubicación
(extend-sto new-loc val-val val-sto))] ;; store extendido: new-loc -> contenido
[(openbox box-expr)
(def (v*s (boxV loc) box-sto) (interp box-expr env sto)) ;; obtiene la ubicación de la caja
(v*s (lookup-sto loc box-sto) box-sto)] ;; retorna su contenido
[(setbox box-expr val-expr)
(def (v*s (boxV loc) box-sto) (interp box-expr env sto)) ;; ubicación de la caja
(def (v*s val-val val-sto) (interp val-expr env box-sto)) ;; nuevo valor (en box-sto)
(v*s val-val (extend-sto loc val-val val-sto))] ;; sobrescribe loc con el valor
setbox retorna el valor asignado (toda expresión debe retornar algún valor), pero su efecto importante es extender el store asociando la ubicación de la caja al nuevo valor. Como lookup-sto busca desde el frente, la asociación más reciente enmascara a la anterior.
|
En lugar de |
Ejemplo trabajado: el interruptor de luz
Un objeto con estado. switch es una caja (0 = apagado, 1 = encendido); toggle invierte el estado. La clausura de toggle captura la caja switch (alcance estático) y la muta en cada invocación:
(test (run '{with {switch {newbox 0}}
{with {toggle {fun {dummy}
{seqn {setbox switch {- 1 {openbox switch}}}
{openbox switch}}}}
{+ {toggle 1729} {toggle 1729}}}})
1)
La función toggle recibe un argumento (dummy) que no usa —en nuestro lenguaje toda función toma exactamente un argumento. La primera invocación enciende el interruptor y retorna 1; la segunda lo apaga y retorna 0; luego 1 + 0 = 1. El resultado es 1 independientemente del orden de evaluación (porque 1 + 0 = 0 + 1). La evolución del estado, tras la primera invocación:
env = [ toggle -> 2, switch -> 1 ]
store = [ 2 -> (closureV dummy {seqn ...} [switch -> 1]),
1 -> (boxV 0),
0 -> (numV 1) ] <- la caja (ubicación 0) pasó de 0 a 1
El ambiente permanece constante entre invocaciones; solo cambia el contenido de la ubicación 0 en el store.
El orden de evaluación importa
Cuando una operación binaria tiene un operando con efectos, el resultado depende del orden:
(test (run '{with {b {newbox 1}}
{+ {seqn {setbox b 5} {openbox b}} ;; muta b a 5 y retorna 5
{openbox b}}}) ;; lee b
10)
Con evaluación de izquierda a derecha (nuestra decisión de diseño), el operando izquierdo muta b a 5 y retorna 5; el derecho lee b y obtiene 5; total 10. Si evaluáramos de derecha a izquierda, el derecho leería 1 primero y el total sería 6. Ninguno es «más correcto»; es una decisión del diseñador del lenguaje.
Observaciones finales
-
Las clausuras capturan el ambiente, pero nunca el store. Si capturaran el store, las mutaciones externas no serían visibles dentro de la función, y el ejemplo que debía dar
7no funcionaría. -
Ambiente y store tienen patrones de propagación distintos: el ambiente es «horizontal» (mismo
enva las subexpresiones), el store es «hilvanado» (threaded) por todos los nodos en un orden preciso. -
Cuidado con dónde aparece una mutación. En
{fun {x} {seqn {setbox …} …}}elsetboxes el cuerpo de la función: se evalúa en cada invocación. Si esa misma expresión fuese la expresión nombrada de unwith, en un lenguaje eager se evaluaría una sola vez.
Variables: call-by-value vs. call-by-reference
Hasta aquí, la mutación operaba sobre contenedores (cajas): setbox cambia el valor dentro de una caja, y la expresión de la caja puede ser arbitraria (cualquier expresión que reduzca a un contenedor). Existe una segunda forma de mutación: la de variables, que cambia el valor ligado a un identificador. Aquí el operando izquierdo debe ser un identificador, no una expresión que reduzca a uno.
| Estructuras mutables (contenedores) | Variables |
|---|---|
|
|
|
|
Agregamos la expresión set para mutar variables. Su semántica: reducir la expresión de valor, y actualizar el store para que la ubicación del identificador contenga el nuevo valor (retornando el valor asignado):
[(set id val-expr)
(def (v*s val-val val-sto) (interp val-expr env sto))
(def loc (lookup-env id env)) ;; ubicación del identificador
(v*s val-val (extend-sto loc val-val val-sto))]
Interacción con funciones
¿A qué valor se reduce un programa que muta el parámetro formal de una función y luego consulta el parámetro real?
{with {a 0}
{seqn {{fun {x} {set x 5}} a}
a}}
La respuesta depende de la semántica de paso de parámetros:
- Call-by-value (nuestra
fun) -
Al aplicar la función, el parámetro formal
xse liga a una ubicación fresca (new-loc) que contiene una copia del valor del parámetro real. La mutación{set x 5}afecta solo esa copia; la ubicación deaqueda intacta. El programa se reduce a0. - Call-by-reference
-
Al aplicar la función, se pasa una referencia al parámetro real: el formal
xse liga a la misma ubicación quea. Cualquier mutación dentro de la función es visible en el llamador. El programa se reduce a5.
Soportar call-by-reference: refun
Extendemos el lenguaje con un segundo tipo de función, refun (call-by-reference), que se debe invocar con un identificador como parámetro real. Añadimos el valor refclosureV para distinguir ambas clausuras:
(deftype Expr
;; ... (num) (add) ... (fun arg body)
(refun arg body) ;; función call-by-reference
;; ... (app) (seqn) (newbox) (setbox) (openbox)
(set x e))
(deftype Value
(numV n)
(closureV arg body env) ;; call-by-value
(refclosureV arg body env) ;; call-by-reference
(boxV loc))
[(refun id body) (v*s (refclosureV id body env) sto)]
La aplicación ahora depende del tipo de la función. Para closureV se procede como antes (ubicación fresca con una copia del valor); para refclosureV se recupera la ubicación del parámetro real (que debe ser un identificador) y se liga el formal a esa misma ubicación:
[(app f arg-expr)
(def (v*s fun-val fun-sto) (interp f env sto))
(match fun-val
[(closureV arg-name body closed-env) ;; CALL-BY-VALUE
(def (v*s arg-val arg-sto) (interp arg-expr env fun-sto))
(def new-loc (next-location arg-sto)) ;; ubicación NUEVA (copia)
(interp body
(extend-env arg-name new-loc closed-env)
(extend-sto new-loc arg-val arg-sto))]
[(refclosureV arg-name body closed-env) ;; CALL-BY-REFERENCE
(def loc (lookup-env (id-x arg-expr) env)) ;; ubicación del parámetro real
(interp body
(extend-env arg-name loc closed-env) ;; MISMA ubicación (alias)
fun-sto)])]
En la rama call-by-reference, (id-x arg-expr) extrae el símbolo del nodo (id …) del parámetro real (por eso refun exige que se invoque con un identificador), y lookup-env obtiene su ubicación. El formal arg-name queda como alias de esa ubicación: no se crea memoria nueva ni se copia el valor.
El mismo programa, dos resultados
;; call-by-value: la mutación afecta una copia local; a permanece en 0
(test (run '{with {a 0}
{seqn {{fun {x} {set x 5}} a}
a}})
0)
;; call-by-reference: x es un alias de a; la mutación afecta a a
(test (run '{with {a 0}
{seqn {{refun {x} {set x 5}} a}
a}})
5)
La única diferencia entre ambos programas es fun vs. refun. En el primero, x recibe una ubicación fresca con una copia de 0; {set x 5} muta esa copia y a sigue valiendo 0. En el segundo, x comparte la ubicación de a; {set x 5} muta directamente la variable del llamador, y consultar a da 5. Toda la diferencia semántica se reduce a qué ubicación liga el ambiente al parámetro formal.
Ejercicios propuestos
-
Estructuras cíclicas. Usando
mcons/set-mcdr!, construya la lista cíclica que representa el número decimal periódico 1,4545… (prefijo1, ciclo4 5 4 5 …). Escriba una funciónmtakeque, a diferencia de la del texto, avance por elmcdren cada paso, y verifíquela extrayendo los primeros 6 elementos. -
Ambiente cíclico, paso a paso. Para el programa
{rec {fact {fun {n} {if0 n 1 {* n {fact {- n 1}}}}}} {fact 3}}, dibuje el ambiente cíclico que construyecyclic-envy trace las tres llamadas recursivas, indicando bajo qué ambiente se evalúa cada una. Explique por qué la clausura «ve» siempre afact. -
Por qué falla
with. Explique, mostrando la traducción por azúcar sintáctico y el ambiente capturado por la clausura, por qué{with {fact E} {fact 5}}producefree identifier: factmientras que{with {fact E} {fact 0}}evalúa a1. -
Identificar llamadas por la cola. Para tres funciones a su elección (con condicionales y/o operaciones aritméticas alrededor de las llamadas), marque cada llamada como en posición de cola o no, y justifique. Concluya para cada función si es recursiva por la cola.
-
Transformación a tail-recursive. Transforme a una versión recursiva por la cola: (a) la suma de una lista de números; (b)
reversede una lista; (c) la función que cuenta los nodos de un árbol binario. Indique en cada caso el valor inicial del acumulador y por qué. -
Trampolín. Usando el tipo
TailCally la funciónresultdel texto, implementesum-tr :: number → TailCallque sume0 + 1 + … + nsin desbordar la pila aun sin TRO. Explique qué papel cumple el thunk(λ () …). -
Encadenamiento del store. Se le entrega un intérprete con store-passing en el que el caso de
addpropaga el store mal ((interp r env sto)en vez del-sto). Escriba un programa que distinga la versión correcta de la incorrecta: debe evaluar a un valor bajo la implementación correcta y a otro valor bajo la incorrecta. Muestre ambos resultados. -
Call-by-value vs. call-by-reference. Escriba un programa con
swap(intercambio de dos variables) e indique a qué se reduce bajofun(call-by-value) y bajorefun(call-by-reference). Luego explique por quérefundebe recibir un identificador como parámetro real y qué ocurriría al invocarlo con una expresión aritmética.