Unidad 6: Metaprogramación y orientación a objetos
|
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. |
Esta última unidad reúne dos temas que a primera vista parecen independientes pero que en este curso están íntimamente ligados: la metaprogramación con macros y la orientación a objetos.
El vínculo es el siguiente: no vamos a implementar objetos extendiendo el intérprete como hicimos en las unidades anteriores (nuevos nodos del AST, nuevas reglas de parse e interp). En cambio, vamos a dar una codificación funcional de objetos directamente en Racket, usando clausuras. Esa codificación es correcta pero muy incómoda de escribir a mano. Las macros son precisamente el mecanismo que nos permitirá esconder esa codificación detrás de una sintaxis limpia (OBJECT, CLASS, →). Por eso estudiamos primero las macros: son la herramienta con la que construiremos el sistema de objetos.
Todo el código de esta unidad usa #lang play, que exporta defmac para definir macros.
Metaprogramación con macros
Lenguajes de propósito general y de dominio específico
Los lenguajes que usamos habitualmente para desarrollar (C, Java, Haskell, Python, Racket) son lenguajes de propósito general (GPL): aplicables a cualquier dominio, pero sin características especializadas para ninguno. En contraste, un lenguaje de dominio específico (DSL) está especializado en un dominio particular: SQL para bases de datos relacionales, Mathematica/Wolfram para cálculo simbólico, los lenguajes de shell para automatizar el sistema operativo, Emacs Lisp para configurar un editor.
Un DSL puede ofrecer una notación más conveniente (más cercana a la del dominio) y mejor desempeño (explotando características del dominio). ¿Por qué, entonces, programamos mayoritariamente en GPLs? Porque desplegar un DSL nuevo obliga a construir desde cero todo su ecosistema: compilador o intérprete, editor, depurador, detector de errores de sintaxis. Eso cuesta tiempo y dinero, y vuelve más riesgosa la propuesta.
Una alternativa para bajar ese costo es no reinventar la rueda: incrustar (o compilar) el DSL dentro de un lenguaje anfitrión existente, que ya trae todas esas herramientas. Una forma de lograrlo es mediante macros.
Macros como compiladores
Muchos lenguajes ofrecen un preprocesador sintáctico que traduce términos antes de entregárselos al intérprete o compilador: macros en C o Scheme, templates en C++, genéricos en Java. Comparten una idea: permiten extender el lenguaje con nuevos constructos sin modificar internamente su implementación.
Recordemos cómo agregábamos un constructo en las unidades anteriores: extender la sintaxis concreta, extender el AST, extender parse y extender interp. Cada capa había que tocarla. Las macros ofrecen otro camino: el preprocesador "compila" el lenguaje extendido hacia el lenguaje original.
Ya vimos un caso puntual de esto: la expresión with no tenía un nodo propio en el AST; el parser la traducía a una aplicación de función anónima. Las macros generalizan esa idea de definir una nueva sintaxis y traducirla al núcleo del lenguaje.
Un ejemplo motivador: medir el tiempo de evaluación
Queremos un constructo my-time que mida cuántos milisegundos toma evaluar una expresión, apoyándonos en la primitiva current-milliseconds.
Primer intento: una función
(define (my-time e)
(let ([begin-time (current-milliseconds)])
(begin
e
(- (current-milliseconds) begin-time))))
Este intento siempre devuelve 0. La razón es que Racket es un lenguaje con evaluación temprana (eager): en la invocación (my-time (expt 2 10000)) el argumento se reduce a un valor antes de comenzar a ejecutar el cuerpo de my-time. Cuando el cuerpo empieza, e ya está calculado y evaluarlo no cuesta tiempo. El código no es incorrecto para medir tiempo; el problema es que no podemos expresarlo como una función por la evaluación temprana.
Segundo intento: thunks
Podemos posponer la evaluación encerrando la expresión en una función de cero argumentos (un thunk):
(define (my-time2 t)
(let ([begin-time (current-milliseconds)])
(begin
(t)
(- (current-milliseconds) begin-time))))
Ahora (my-time2 (lambda () (expt 2 10000))) sí mide el tiempo. Funciona, pero le impone al programador un patrón artificial: debe recordar envolver la expresión en un lambda, o no funcionará. La abstracción que usamos para codificar quedó visible para el usuario final.
Tercer intento: una macro
Con defmac obtenemos lo mejor de ambos mundos: el mismo código, pero como traducción sintáctica en lugar de función.
(defmac (my-time3 e)
(let ([begin-time (current-milliseconds)])
(begin
e
(- (current-milliseconds) begin-time))))
> (my-time3 (expt 2 10000))
507 ;; un número positivo de milisegundos
Para el usuario, my-time3 se usa igual que una función y funciona correctamente, sin saber ni preocuparse de que por detrás haya una traducción. Aquí aparece la regla práctica:
|
En Racket recurrimos a macros cuando las funciones resultan demasiado restringidas, y eso ocurre típicamente cuando necesitamos alterar el orden o la ocurrencia de la evaluación de los argumentos. Si quiero cambiar cuándo (o si) se evalúan los argumentos, probablemente necesito una macro, no una función. |
Cómo funcionan las macros: la expansión
Una definición con defmac se parece mucho a una definición de función: hay un nombre, un patrón con metavariables y un cuerpo que es la expansión. La diferencia es lo que ocurre en tiempo de compilación:
-
Las macros se expanden: al detectar el uso del patrón, se realiza un reemplazo (como un copy-paste automático) por la expansión, sustituyendo las metavariables por el código que calzó con ellas.
-
El programa ya expandido, sin rastro de macros, se le entrega al evaluador.
El evaluador no sabe nada de las macros: solo conoce el lenguaje núcleo. Tenemos entonces dos etapas separadas: primero un tiempo de expansión que traduce el lenguaje-con-macros al lenguaje núcleo, y luego un tiempo de ejecución donde el evaluador corre el programa expandido. En DrRacket, el Macro Stepper permite observar paso a paso estas transformaciones.
En Racket, muchísimos constructos que damos por primitivos están en realidad implementados como macros, apiladas unas sobre otras. El sistema de macros no es un adorno: es la herramienta central con la que el lenguaje se refina a sí mismo.
Definiciones locales y la elipsis …
Definamos let con un único par de asociación como macro:
(defmac (my-let-1 ([id e]) body)
((lambda (id) body) e))
> (my-let-1 ([x 5]) (* 3 x))
15
La expansión es la aplicación de una función anónima, tal como hacía el parser con with. Pero el let real admite varios pares. Para eso necesitamos la elipsis …, que en el patrón significa "cero o más ocurrencias del patrón anterior" y en la expansión repite el fragmento correspondiente. Observemos cómo id y e aparecen juntos en el patrón pero se expanden por separado:
(defmac (my-let ([id e] ...) body)
((lambda (id ...) body) e ...))
Así, (my-let ([x 1] [y 2]) (+ x y)) se expande a lambda (x y) (+ x y 1 2). Una sola definición, muy parecida a la de una función, soporta una cantidad arbitraria de argumentos. La elipsis es el mecanismo que hace a las macros genuinamente más expresivas que las funciones a nivel sintáctico.
Palabras clave: #:keywords
Las macros permiten introducir nuevas formas de escribir condicionales, porque pueden alterar el flujo de control normal. Queremos una sintaxis (check c then e1 else e2). Un primer intento sin restricciones:
(defmac (check1 c then e1 else e2)
(if c e1 e2))
El problema es que then y else son tratados como metavariables cualesquiera: (check1 (< 10 2) (/ 1 0) "hola" else "chao") se acepta sin chistar (y ni siquiera evalúa (/ 1 0), porque nunca aparece en la expansión). Queremos obligar a que ciertas palabras aparezcan literalmente. Para eso está #:keywords:
(defmac (check c then e1 else e2)
#:keywords then else
(if c e1 e2))
> (check (< 10 2) then "hola" else "chao")
"chao"
Ahora then y else no son metavariables: deben calzar literalmente. Usar la macro con otra palabra en su lugar produce el famoso error bad syntax, que significa "detecto que intentas usar una macro, pero la estás usando mal".
Cortocircuito y evaluación duplicada
Como podemos modificar el flujo de control, podemos definir una disyunción binaria con lógica de cortocircuito: evaluar el segundo argumento solo si el primero es falso. Esto tampoco puede ser una función, porque la evaluación temprana forzaría ambos argumentos.
(defmac (my-or e1 e2)
(let ([result e1])
(if result
result
e2)))
> (my-or (begin (display "a") #t) (begin (display "b") #f))
a#t ;; solo se imprime "a": el segundo argumento no se evalúa
¿Por qué el let? Consideremos una variante ingenua:
(defmac (my-or2 e1 e2)
(if e1 e1 e2))
> (my-or2 (begin (display "a") #t) (begin (display "b") #f))
ab#t ;; se imprime "ab": e1 se evaluó DOS veces
Como la expansión es un copy-paste literal, e1 aparece dos veces en (if e1 e1 e2) y por lo tanto se evalúa dos veces. El let de my-or evita esto: evalúa e1 una sola vez, la liga a un identificador y reutiliza ese valor ya calculado.
|
La evaluación duplicada es un error frecuente al escribir macros. Cada vez que una metavariable aparece repetida en la expansión, la expresión que calzó con ella se ejecutará repetidas veces (con sus efectos secundarios incluidos). La solución idiomática es ligar la expresión a un identificador local con |
Higiene y captura inadvertida de identificadores
Volvamos a my-or, cuya expansión usa internamente el nombre result. ¿Qué pasa si el contexto que invoca la macro también usa un identificador llamado result?
> (let ([result #t])
(my-or #f result))
#t
Intuitivamente el resultado correcto es #t (es una disyunción entre #f y #t). Pero si la expansión fuese una sustitución textual ingenua, obtendríamos:
(let ([result #t])
(let ([result #f]) ;; result interno de la macro
(if result result result))) ;; => #f, ¡incorrecto!
El result interno de la macro opacaría al result externo del programador. Esto se llama captura inadvertida de identificadores: sin darse cuenta, el usuario "le achuntó" a un nombre usado dentro de la expansión y su programa cambió de significado. El contexto que invoca no tiene manera de saber ni de adivinar qué identificadores usa la macro por dentro.
|
Higiene de macros
El sistema de macros de Scheme/Racket es higiénico: al expandir, los identificadores introducidos por la macro se renombran automáticamente a nombres frescos, garantizados de no colisionar con ninguno preexistente. Por eso En contraste, el preprocesador de C no es higiénico: los programadores deben elegir nombres oscuros con la esperanza de que nadie los use, lo que no da ninguna garantía y vuelve las macros más difíciles de mantener. La higiene, como el alcance dinámico, no es buena como comportamiento por defecto; pero más adelante veremos que romperla selectiva y explícitamente (con Explicar qué es la higiene y mostrar un ejemplo donde sin higiene se obtiene un resultado y con higiene otro es una pregunta típica de evaluación. |
Aplicaciones de las macros: DSLs
La filosofía de Scheme: núcleo mínimo más macros
Ningún lenguaje puede proveer de antemano todos los constructos que podrían ser útiles: ni siquiera especificaciones enormes, como las casi 700 páginas del lenguaje Java, logran anticiparlo todo. La respuesta del diseño de Scheme, citada en su Revised6 Report, es:
Programming languages should be designed not by piling feature on top of feature, but by removing the weaknesses and restrictions that make additional features appear necessary.
Es decir: un núcleo mínimo pero muy poderoso, pocas restricciones sobre qué puede aparecer dónde (valores verdaderamente de primera clase) y un sistema de macros potente para extender el lenguaje con nuevas primitivas de alto nivel. Las macros son fáciles de distribuir (son parte del lenguaje, no del compilador), por lo que las extensiones no fragmentan el ecosistema. Ilustremos este poder con dos DSLs: bucles guardados y autómatas finitos.
While guardado
Queremos una primitiva my-while que reciba una condición y un cuerpo y los itere. No puede ser una función (la evaluación temprana evaluaría la condición una sola vez). La idea de implementación es desenvolver el cuerpo del bucle un paso a la vez:
while cond body ≡ if cond then (body; while cond body)
Primer intento: expansión infinita
(defmac (my-while1 cond body)
(if cond
(begin body (my-while1 cond body))
(void)))
La definición es válida, pero al usarla el programa no termina y consume toda la memoria. ¿Por qué? Aquí hay una confusión crucial entre las dos etapas. El if que escribimos se evalúa en tiempo de ejecución, pero la macro my-while1 se expande en tiempo de expansión. La expansión no evalúa código: simplemente traduce. Al expandir my-while1, su cuerpo contiene otra invocación de my-while1, que hay que expandir, que contiene otra invocación… La expansión nunca termina, porque cada paso genera una nueva necesidad de expandir. El if no puede detenerla, porque el if recién actúa mucho después, en ejecución.
|
Una macro no puede usarse recursivamente a sí misma en su propia expansión (al menos no con |
Solución con letrec
En vez de una macro recursiva, expandimos hacia una función recursiva usando letrec. La recursión ocurre entonces en ejecución y termina:
(defmac (my-while cond body)
(letrec ([iter (lambda ()
(if cond
(begin body (iter))
(void)))])
(iter)))
La expansión es un solo paso: el while se traduce a la definición de iter y su invocación. Al ejecutarse, iter evalúa la condición, corre el cuerpo una vez y se vuelve a llamar. Además, como las llamadas a iter están en posición de cola, este bucle hereda la optimización de llamadas de cola (TCO) del lenguaje base y no desborda la pila.
|
Toda extensión hecha con macros hereda la semántica del lenguaje base, porque su expansión es un programa en ese lenguaje base. Aunque usemos macros para alterar el flujo de evaluación, el resultado se rige por la semántica original: aquí, la optimización de llamadas de cola. |
Autómatas finitos
Como segundo DSL, agreguemos una primitiva para decidir el lenguaje aceptado por un autómata finito determinista (con un único estado de aceptación). El siguiente autómata reconoce los accesores de listas de Lisp (car, cdr, cadr, caddr…): toda cadena que empieza con c, termina con r, y entre medio tiene una o más a o d.
Codificación manual
La idea es representar cada estado como una función del mismo nombre, que recibe la cadena por procesar y decide si es aceptada por el autómata iniciado en ese estado. Las funciones son mutuamente recursivas, así que se definen juntas con letrec, y la representación final del autómata es la función del estado inicial:
(define m1
(letrec ([init
(lambda (stream)
(and (cons? stream)
(case (first stream)
[(c) (more (rest stream))]
[else false])))]
[more
(lambda (stream)
(and (cons? stream)
(case (first stream)
[(a) (more (rest stream))]
[(d) (more (rest stream))]
[(r) (end (rest stream))]
[else false])))]
[end
(lambda (stream)
(or (empty? stream)
(case (first stream)
[else false])))])
init))
Un estado no final exige que queden símbolos ((and (cons? stream) …)) y consume el primero según sus transiciones; si el símbolo no corresponde a ninguna, cae en else y rechaza. El estado final acepta si ya no quedan símbolos ((or (empty? stream) …)).
La macro automaton
Ahora generalizamos esa codificación a una sintaxis arbitraria. El usuario escribirá el estado inicial, el estado final y las transiciones de cada estado con la notación estado : (símbolo → destino) …:
(defmac (automaton init-state
final-state
(state : (action -> target) ...) ...)
#:keywords : ->
(letrec ([state
(if (eq? (quote state) (quote final-state))
; si es el estado final
(lambda (stream)
(or (empty? stream)
(case (first stream)
[(action) (target (rest stream))]
...
[else false])))
; si NO es el estado final
(lambda (stream)
(and (cons? stream)
(case (first stream)
[(action) (target (rest stream))]
...
[else false]))))] ...)
init-state))
Notemos varios elementos aprendidos: los : y → son #:keywords; hay elipsis anidadas (… para las transiciones de un estado, y otro … para los estados); y quote permite inspeccionar el nombre de cada estado en tiempo de expansión, para decidir con un if (que corre en ejecución) si ese estado es el final. La representación del autómata es de nuevo init-state.
(define m (automaton init end
[init : (c -> more)]
[more : (a -> more)
(d -> more)
(r -> end)]
[end : ]))
> (m '(c a d r))
#t
El usuario final nunca ve esta codificación interna: solo la sintaxis automaton. Podríamos cambiar la implementación (funciones, vectores, listas…) sin afectar a quienes la usan, siempre que mantengamos la interfaz. Esta misma idea —una macro que esconde una codificación funcional— es exactamente la que aplicaremos a los objetos.
Objetos: representación procedural
¿Qué es un objeto?
Un objeto es la encapsulación de estado y comportamiento:
-
Estado: los campos (fields). Puede ser mutable o no.
-
Comportamiento: los métodos.
Y un principio fundamental: la invocación de métodos se concibe como paso de mensajes. Le enviamos un mensaje a un objeto; si lo entiende, ejecuta el método asociado, cuyo resultado puede depender de su estado interno.
Notemos qué no aparece en esta definición: clases, herencia, tipos estáticos, ni ningún lenguaje en particular. Todo eso se construye encima de estas ideas. Vamos a codificar objetos en Racket usando clausuras, y el propósito es entender la semántica de la orientación a objetos desde cero.
Clausuras como objetos
Una clausura ya es, en germen, un objeto: encapsula estado (sus variables libres) y tiene un método (la aplicación). Con estado inmutable:
(define add
(λ (n)
(λ (m)
(+ m n))))
(define add2 (add 2)) ;; objeto con estado inmutable n = 2
> (add2 5)
7
Con estado mutable, capturado por alcance estático:
(define counter
(let ([count 0])
(λ ()
(begin
(set! count (+ 1 count))
count))))
> (counter)
1
> (counter)
2
Estos objetos son primitivos: tienen un solo método (la aplicación). El salto conceptual llega cuando queremos más de un método.
Despacho de mensajes con case
Lo único que podemos variar al invocar una función son sus argumentos. Aprovechemos entonces el primer argumento como un mensaje que selecciona el método. Un contador bidireccional:
(define bicounter
(let ([count 0])
(λ (cmd)
(case cmd
[(dec) (begin (set! count (- count 1)) count)]
[(inc) (begin (set! count (+ count 1)) count)]))))
Este es el patrón de dispatch: el objeto recibe un mensaje, hace un análisis de casos y ejecuta el código asociado. Para métodos con distinta cantidad de argumentos usamos la notación de punto (λ (cmd . args) …), donde args es la lista de argumentos adicionales:
(define stack
(let ([vals '()])
(let ([pop (λ ()
(if (empty? vals)
(error "cannot pop from an empty stack")
(let ([val (car vals)])
(set! vals (cdr vals))
val)))]
[push (λ (val)
(set! vals (cons val vals)))])
(λ (cmd . args)
(case cmd
[(pop) (pop)]
[(push) (push (car args))]
[else (error "invalid command")])))))
Observemos la estructura: primero el estado (vals) definido afuera, para que sobreviva entre invocaciones y sea capturado por alcance estático; luego los métodos, anidados después de los campos para poder acceder a ellos; finalmente el dispatcher. La encapsulación es consecuencia directa de usar clausuras: una vez creado el objeto, vals no es accesible desde ninguna parte salvo a través de los métodos.
El patrón de código para objetos
Generalicemos. Todo objeto sigue esta forma: un let con los campos, una lista de asociación methods que empareja cada nombre de método con su clausura, y un dispatcher que busca el método con assoc y lo aplica con apply:
(define object-name
(let ([s1 0]
[s2 0])
(let ([methods (list (cons 'm1 (λ () body1))
(cons 'm2 (λ (x y) body2)))])
(λ (msg . args)
(apply (cdr (assoc msg methods)) args)))))
|
Dos utilidades de Racket son clave aquí. |
La macro OBJECT y el envío de mensajes
Pedirle al programador que escriba ese patrón para cada objeto garantizaría 0% de adopción. Lo mecanizamos con una macro OBJECT que expande al patrón, y una macro → para enviar mensajes:
(defmac (OBJECT ([field fname init] ...)
([method mname args body] ...))
#:keywords field method
(let ([fname init] ...)
(let ([methods (list (cons 'mname (λ args body)) ...)])
(λ (msg . vals)
(apply (cdr (assoc msg methods)) vals)))))
(defmac (-> o m arg ...)
(o 'm arg ...))
field y method son palabras clave; las elipsis expanden todos los campos y todos los métodos. La macro → es azúcar: (→ o m arg …) se traduce a (o 'm arg …), ocultando que el objeto es una función y que el mensaje es un símbolo. Ahora los objetos se escriben de forma limpia:
(define counter2
(OBJECT ([field count 0])
([method inc () (begin (set! count (+ count 1)) count)]
[method dec () (begin (set! count (- count 1)) count)])))
> (-> counter2 inc)
1
> (-> counter2 dec)
0
Constructores de objetos y dispatch dinámico
Como los objetos son funciones (valores de primera clase), podemos usar funciones de orden superior como constructores (fábricas) de objetos, almacenarlos en estructuras, pasarlos como argumentos, etc. El ejemplo clásico es un árbol binario donde nodos y hojas responden ambos al mensaje sum con implementaciones distintas:
(define (make-node l r)
(OBJECT
([field left l]
[field right r])
([method sum () (+ (-> left sum) (-> right sum))])))
(define (make-leaf v)
(OBJECT
([field value v])
([method sum () value])))
> (let ([tree (make-node
(make-node (make-leaf 3)
(make-node (make-leaf 10)
(make-leaf 4)))
(make-leaf 1))])
(-> tree sum))
18
Que (→ … sum) invoque la suma recursiva de un nodo o el valor de una hoja según el objeto concreto en tiempo de ejecución es dispatch dinámico: la selección de la implementación de una operación polimórfica se hace en ejecución. Es una característica central para la flexibilidad de la orientación a objetos.
Manejo de errores
Con la macro anterior, enviar un mensaje desconocido produce un error de bajo nivel (cdr: contract violation …) que expone la implementación: el objeto point no tiene ninguna lista, pero el error habla de una. Basta mejorar el dispatcher para dar un error al nivel de abstracción correcto (el resto de la macro no cambia):
(λ (msg . vals)
(let ([found (assoc msg methods)]) ;; #f si no se encuentra
(if found
(apply (cdr found) vals)
(error "message not understood:" msg))))
Self, forwarding y delegación
La necesidad de self
Consideremos un método above sobre puntos, que recibe otro punto y retorna el que está más arriba en el eje Y:
[method above (other-point)
(if (> (-> other-point y?) y)
other-point
self)]
Cuando el otro punto no es el más alto, queremos retornar el objeto que recibió el mensaje: self. Nuestros objetos todavía no pueden referirse a sí mismos. Necesitamos dotarlos de identidad.
Self estático con letrec
El objeto es una función; queremos referirnos a esa función desde dentro de sí misma. Es exactamente el problema de una definición recursiva, que resolvemos con letrec:
(define point
(letrec ([self
(let ([x 0])
(let ([methods (list (cons 'x? (λ () x))
(cons 'x! (λ (nx)
(begin (set! x nx) self))))])
(λ (msg . args)
(apply (cdr (assoc msg methods)) args))))])
self))
> ((point 'x! 10) 'x?)
10
El setter x! retorna self, lo que permite encadenar invocaciones. Esta es una definición estática de self: por alcance estático, self queda ligado de una vez y para siempre al objeto.
Self con macros: #:captures
Al llevar esto a la macro, envolvemos el patrón en un letrec que liga self:
(defmac (OBJECTv3 ([field fname init] ...)
([method mname args body] ...))
#:keywords field method
(letrec ([self
(let ([fname init] ...)
(let ([methods (list (cons 'mname (λ args body)) ...)])
(λ (msg . vals)
(apply (cdr (assoc msg methods)) vals))))])
self))
Pero al usarla, self resulta ser un identificador no ligado. La razón es la higiene: el self que escribe el programador en el cuerpo de un método y el self que introduce la macro son, tras la expansión, identificadores distintos. La higiene, que antes nos protegía, ahora nos estorba: aquí queremos que se capture self. La solución es romper la higiene selectiva y explícitamente con #:captures:
(defmac (OBJECT ([field fname init] ...)
([method mname args body] ...))
#:keywords field method
#:captures self
(letrec ([self
(let ([fname init] ...)
(let ([methods (list (cons 'mname (λ args body)) ...)])
(λ (msg . vals)
(apply (cdr (assoc msg methods)) vals))))])
self))
#:captures self le indica al sistema que no sea higiénico con self, de modo que el self escrito por el programador en los métodos calce con el self del letrec. Es el mismo patrón que con el alcance dinámico: mal comportamiento por defecto, pero valioso cuando se activa de forma deliberada.
Usos de self
Con self podemos retornarnos a nosotros mismos (como en x! arriba) y enviarnos mensajes a nosotros mismos. Un objeto con dos métodos mutuamente recursivos:
(define odd-even
(OBJECT ()
([method even (n)
(case n
[(0) #t]
[(1) #f]
[else (-> self odd (- n 1))])]
[method odd (n)
(case n
[(0) #f]
[(1) #t]
[else (-> self even (- n 1))])])))
> (-> odd-even odd 15)
#t
> (-> odd-even even 17)
#f
Sin self, un método no podría invocar a otro método del mismo objeto respetando el paso de mensajes. (Como las invocaciones a self están en posición de cola, además se benefician de la optimización de llamadas de cola.)
Forwarding (reenvío)
¿Qué hacer cuando un objeto no entiende un mensaje, además de fallar? Una opción es reenviarlo a otro objeto. Motivación: un vendedor (seller) que ofrece productos, y un intermediario (broker) que no conoce los precios y simplemente le reenvía las consultas.
Reenviar explícitamente cada mensaje es tedioso: hay que anticipar todos los mensajes que el broker podría recibir y, para cada uno, escribir el reenvío. Lo automatizamos con una macro OBJECT-FWD cuyo dispatcher, si no encuentra el método localmente, reenvía el mensaje a un objeto target:
(defmac (OBJECT-FWD target
([field fname init] ...)
([method mname args body] ...))
#:keywords field method
#:captures self
(letrec ([self
(let ([fname init] ...)
(let ([methods (list (cons 'mname (λ args body)) ...)])
(λ (msg . vals)
(let ([found (assoc msg methods)])
(if found
(apply (cdr found) vals)
(apply target msg vals))))))]) ;; reenvío
self))
(define seller
(OBJECT ()
([method price (prod)
(* (case prod
[(1) (-> self price1)]
[(2) (-> self price2)])
(-> self unit))]
[method price1 () 100]
[method price2 () 200]
[method unit () 1])))
(define broker (OBJECT-FWD seller () ()))
> (-> broker price 2)
200
> (-> broker unit)
1
Se pueden encadenar reenvíos tan largos como se quiera y terminar en un objeto raíz que no entienda nada (análogo a la clase Object de Java).
Delegación y self dinámico
El forwarding falla cuando queremos que el intermediario refine el comportamiento del vendedor. Supongamos un broker que quiere duplicar los precios redefiniendo unit a 2:
(define broker2
(OBJECT-FWD seller ()
([method unit () 2])))
> (-> broker2 price 1)
100 ;; ¡esperábamos 200!
El refinamiento no surte efecto. El broker recibe price, lo reenvía al seller, y cuando el seller ejecuta price y hace (→ self unit), ese self es el seller (self estático: letrec respeta el alcance léxico), no el broker. La invocación a unit llega a seller-unit, que retorna 1.
La solución es la delegación: self debe ligarse dinámicamente al objeto que originalmente recibió el mensaje (el receptor, o rcvr). En ausencia de alcance dinámico, lo logramos pasando self como parámetro de los métodos, y parametrizando el objeto entero por su receptor:
(defmac (OBJECT-DEL parent
([field fname init] ...)
([method mname args body] ...))
#:keywords field method
#:captures self
(let ([fname init] ...)
(let ([methods (list (cons 'mname (λ (self) (λ args body))) ...)])
(λ (rcvr)
(λ (msg . vals)
(let ([found (assoc msg methods)])
(if found
(apply ((cdr found) rcvr) vals)
(apply (parent rcvr) msg vals))))))))
Cada método es ahora (λ (self) (λ args body)): primero recibe el self, luego sus argumentos reales. Al aplicarlo, le pasamos rcvr como self. Y al delegar en parent, le pasamos el mismo rcvr, de modo que el padre también trate al receptor original como self. La parametrización por self hace innecesario el letrec. La macro de envío de mensajes también debe cambiar, porque ahora el objeto espera primero a su receptor, que es el mismo objeto:
(defmac (->> o m arg ...)
(let ([obj o])
((obj obj) 'm arg ...)))
El let evita evaluar o dos veces. (obj obj) significa "aplica el objeto pasándose a sí mismo como receptor de las llamadas a self". Con un objeto raíz que no entiende nada:
(define root
(λ (rcvr)
(λ (msg . args)
(error "not understood" msg))))
(define seller2
(OBJECT-DEL root ()
([method price (prod)
(* (case prod
[(1) (->> self price1)]
[(2) (->> self price2)])
(->> self unit))]
[method price1 () 100]
[method price2 () 200]
[method unit () 1])))
(define broker4
(OBJECT-DEL seller2 ()
([method unit () 2])))
> (->> seller2 price 1)
100
> (->> broker4 price 1)
200 ;; ¡ahora sí, el refinamiento funciona!
|
Self dinámico
La diferencia esencial entre forwarding y delegación es la ligadura de
El self dinámico es esencial: un método heredado o delegado debe operar sobre el objeto que invocó el mensaje, no sobre el objeto donde el método fue definido. Esta misma idea reaparecerá, ya como herencia, en las próximas secciones. |
Clases como macros
Motivación: factorizar el comportamiento
Supongamos que necesitamos crear 100 puntos unidimensionales con (make-point init-x). Cada objeto obtiene su propia copia de los métodos, aunque su código fuente es idéntico. Con 100 puntos tenemos 100 getters y 100 setters en memoria: uso de memoria lineal en la cantidad de instancias.
¿Son "los mismos" métodos? No exactamente: cada clausura cierra sobre su propio x y su propio self. El código es igual, pero computacionalmente difieren solo por las variables que capturan. Aquí aparece un patrón: hay una parte común (nombre, argumentos y cuerpo de los métodos) y una parte variable (el contenido de los campos y el self). Factorizar significa sacar la parte común afuera de los objetos: eso es una clase.
Separar lo común de lo variable
Para factorizar los métodos, hay que parametrizarlos por lo que varía: los campos y el self. La estrategia: los métodos se parametrizan por self, y self pasa a encargarse del estado mediante dos mensajes predefinidos, read y write, sobre una tabla hash de campos. Así los métodos se definen una sola vez (capturados en la función constructora) y cada instancia mantiene solo su estado. Queda todavía una redundancia: cada objeto tiene su propia copia del dispatcher.
Al mover también el dispatcher a la función constructora, make-point deja de ser una simple fábrica y se convierte en una clase. Una clase es responsable de: crear instancias, manejar el acceso a campos (read/write) e invocar métodos. Una instancia queda reducida al mínimo: su clase y sus valores.
La estructura de una clase
Representamos una instancia con una estructura de dos campos. define-struct es análogo a deftype: genera el constructor make-obj y los accesores obj-class y obj-values.
(define-struct obj
(class values))
La clase es una función recursiva (necesita referirse a sí misma al crear instancias) que responde a los mensajes create, read, write e invoke. Los métodos, parametrizados por self, se definen una sola vez:
(define Point
(let ([methods
(list
(cons 'x? (λ (self) (λ () ((obj-class self) 'read self 'x))))
(cons 'x! (λ (self) (λ (nx)
(begin ((obj-class self) 'write self 'x nx)
self)))))])
(letrec
([class
(λ (msg . args)
(case msg
[(create) (make-obj class (make-hash (list (cons 'x 0))))]
[(read) (dict-ref (obj-values (first args)) (second args))]
[(write) (dict-set! (obj-values (first args)) (second args) (third args))]
[(invoke)
(let ([found (assoc (second args) methods)])
(if found
(apply ((cdr found) (first args)) (cddr args))
(error "message not understood")))]))])
class)))
Un método se invoca en dos pasos: cdr found) (first args aplica la lambda del método al self (la instancia, (first args)), y apply le pasa los argumentos restantes ((cddr args)). Que cada instancia sea (make-obj class …) con referencia a class es lo que reduce el objeto a "su clase más su estado".
La macro CLASS y las macros ? ! →
Generalizamos con la macro CLASS, y factorizamos hacia macros auxiliares las partes no esenciales: el acceso a campos (? y !, que solo manipulan la tabla hash) y la aplicación del método (que se mueve a →), dejando en la clase solo la responsabilidad esencial: el lookup del método.
(defmac (CLASS
([field f init] ...)
([method m params body] ...))
#:keywords field method
#:captures self
(let ([methods (list (cons 'm (λ (self) (λ params body))) ...)])
(letrec
([class
(λ (msg . args)
(case msg
[(create) (make-obj class (make-hash (list (cons 'f init) ...)))]
[(lookup)
(let ([found (assoc (first args) methods)])
(if found
(cdr found)
(error "message not understood")))]))])
class)))
(defmac (-> obj m arg ...)
(let ([o obj])
((((obj-class o) 'lookup 'm) o) arg ...)))
(defmac (? fd) #:captures self
(dict-ref (obj-values self) 'fd))
(defmac (! fd v) #:captures self
(dict-set! (obj-values self) 'fd v))
(define (new class) (class 'create))
→ pide a la clase el método ('lookup 'm), lo aplica al self (la instancia o) y luego a los argumentos. Las macros ? y ! capturan self (existe porque los métodos están parametrizados por él). Ahora se programa como es habitual:
(define Point
(CLASS
([field x 0])
([method x? () (? x)]
[method x! (new-x) (! x new-x)]
[method move (n) (-> self x! (+ (-> self x?) n))])))
(define p2 (new Point))
(define p3 (new Point))
> (-> p2 move 10)
> (-> p2 x?)
10
> (-> p3 x?)
0
Encapsulación fuerte e inicialización
Al mover read/write a las macros ? y !, y definir estas localmente dentro de los cuerpos de método (con local), obtenemos encapsulación fuerte: es imposible acceder a los campos de un objeto desde afuera; solo se puede interactuar por paso de mensajes (si el objeto ofrece getters/setters). Usar ? o ! fuera de un método es un error de sintaxis, porque necesitan capturar self.
Podemos también soportar inicialización al estilo constructor, exigiendo un método initialize que reciba tantos argumentos como campos. La función new los reenvía a create:
(define (new class . init-vals)
(apply class 'create init-vals))
Y el manejador de create invoca initialize cuando se le pasan argumentos:
[(create)
(let ([o (make-obj class (make-hash (list (cons 'f init) ...)))])
(when (not (empty? args))
(let ([found (assoc 'initialize methods)])
(if found
(apply ((cdr found) o) args)
(error "initialize not implemented in:" class))))
o)]
Herencia
La herencia organiza las clases en una jerarquía: una clase puede extender otra (su superclase), en un esquema de herencia simple (una sola superclase por clase). Su propósito es reutilizar y refinar selectivamente clases existentes, de forma muy análoga a la delegación. Impacta sobre todo la búsqueda de métodos: al enviar un mensaje, se busca en la clase del objeto; si no está, se sube a la superclase, y así hasta encontrarlo o alcanzar una clase raíz que no entiende ningún mensaje.
La jerarquía de clases y la clase Root
Definimos la raíz de la jerarquía. No usa la macro CLASS: es solo un dispatcher que no entiende mensajes (y anticipa el mensaje all-fields que necesitaremos):
(define Root
(lambda (msg . args)
(case msg
[(all-fields) '()]
[(lookup) (error "message not understood:" (first args))])))
extends y el lookup que sube
Modificamos CLASS para recibir la palabra clave extends y la expresión de la superclase. Ahora, cuando lookup no encuentra el método localmente, lo delega a la superclase en vez de fallar:
[(lookup)
(let ([found (assoc (first args) methods)])
(if found
(cdr found)
(scls 'lookup (first args))))] ;; sube a la superclase
Con esto, una subclase que sobrescribe un método funciona correctamente:
(define A
(CLASS extends Root ()
([method foo () "foo"]
[method bar () "bar"])))
(define B
(CLASS extends A ()
([method bar () "B bar"])))
> (-> (new A) bar)
"bar"
> (-> (new B) foo) ;; heredado de A
"foo"
> (-> (new B) bar) ;; refinado en B
"B bar"
El self dinámico se mantiene: el lookup puede encontrar el método en cualquier clase de la jerarquía, pero el método siempre queda parametrizado por self, y el self que se le pasa es siempre la instancia receptora original.
Herencia de campos: all-fields
Enfocarse solo en el lookup deja un problema con la creación. Una subclase que no declara campos crearía instancias sin los campos heredados:
(define Counter
(CLASS extends Root
([field count 0] [field step 1])
([method inc () (begin (! count (+ (? count) (? step))) (? count))]
...)))
(define ReactiveCounter (CLASS extends Counter () ()))
(define rc (new ReactiveCounter)) ;; ¡instancia sin los campos count/step!
La solución no es copiar los campos a mano (la jerarquía puede ser profunda). Hacemos que cada clase sepa responder all-fields con todos sus campos, en orden jerárquico: primero los de la superclase, luego los propios. Con let* para poder encadenar las definiciones:
[fields (append (scls 'all-fields) (list (cons 'f init) ...))]
Como Root responde all-fields con la lista vacía y cada clase antepone los campos de su superclase, fields queda ordenada de ancestros a descendientes. La creación usa entonces todos los campos: (make-obj class (make-hash fields)).
Field shadowing: vectores y find-last
¿Qué pasa si una subclase declara un campo con el mismo nombre que un ancestro (field shadowing)? Con una tabla hash no hay solución: las claves duplicadas colapsan en una sola. Un método alojado en la superclase terminaría leyendo el valor de la subclase (late binding de campos, indeseable):
(define F (CLASS extends Root ([field x "F"]) ([method fx () (? x)])))
(define G (CLASS extends F ([field x "G"]) ([method gx () (? x)])))
> (-> (new G) fx)
"G" ;; ¡incorrecto! fx está alojado en F, debería leer "F"
La semántica correcta: un objeto puede tener valores distintos para campos del mismo nombre, y el método debe usar el campo de su clase anfitriona (la que aloja el método). Representamos esto en dos lugares: la clase guarda la lista de nombres de campos (con repetidos), y la instancia guarda un vector de valores alineado por posición. Necesitamos una función que encuentre la última ocurrencia de un nombre (la más derivada por defecto, o la de la clase anfitriona vía su lista de campos estática):
;; find-last :: sym list[sym] -> number
;; Retorna el último índice del símbolo en la lista, o #f.
(define (find-last fd fields)
(letrec ([loop
(lambda (lst i last)
(cond
[(null? lst) last]
[(eq? (car lst) fd) (loop (cdr lst) (add1 i) i)]
[else (loop (cdr lst) (add1 i) last)]))])
(loop fields 0 #f)))
(test (find-last 'x '(x)) 0)
(test (find-last 'x '(x x)) 1)
(test (find-last 'x '(x y z a b x w x)) 7)
(test (find-last 'y '(x)) #f)
La creación construye el vector de valores, y las macros ?/! acceden por posición usando find-last sobre la lista de nombres de su clase anfitriona, que está capturada estáticamente. Esta es la versión definitiva de CLASS (Clase 29), que integra herencia, shadowing y —como veremos— super-sends:
(defmac (CLASS extends scls-expr
([field f init] ...)
([method m params body] ...))
#:keywords field method extends
#:captures self ? ! sup
(let* ([scls scls-expr]
[fields (append (scls 'all-fields) (list (cons 'f init) ...))]
[methods
(local [(defmac (? fd)
#:captures self
(vector-ref (obj-values self) (find-last 'fd (map car fields))))
(defmac (! fd v)
#:captures self
(vector-set! (obj-values self) (find-last 'fd (map car fields)) v))
(defmac (sup md . args) ;; scls: superclase de la clase anfitriona (estática)
#:captures self
(((scls 'lookup 'md) self) . args))]
(list (cons 'm (λ (self) (λ params body))) ...))])
(letrec
([class
(λ (msg . args)
(case msg
[(all-fields) fields]
[(create) (make-obj class (list->vector (map cdr fields)))]
[(lookup)
(let ([found (assoc (first args) methods)])
(if found
(cdr found)
(scls 'lookup (first args))))]))])
class)))
Ahora el shadowing es correcto. Nótese la separación clave: el índice se busca en la lista fields de la clase anfitriona (estática, capturada en la definición), pero el vector siempre es el de self (la instancia receptora original, dinámica):
(define F2 (CLASS extends Root ([field x "F2"]) ([method fx () (? x)])))
(define G2 (CLASS extends F2 ([field x "G2"]) ([method gx () (? x)])))
> (-> (new G2) fx) ;; fx alojado en F2 -> índice 0 -> "F2"
"F2"
> (-> (new G2) gx) ;; gx alojado en G2 -> índice 1 -> "G2"
"G2"
Super-sends: sup
A veces, al refinar un método en una subclase, queremos invocar la versión de la superclase (por ejemplo, para hacer algo antes o después). Eso es un super-send. Motivación: un contador reactivo que, al incrementarse, ejecute automáticamente una reacción. Refinamos inc para llamar al inc original vía sup y luego reaccionar:
(define ReactiveCounterV3
(CLASS extends Counter
([field predicate (λ (n) #f)]
[field action (λ (n) #f)])
([method register (p a) (begin (! predicate p) (! action a))]
[method react ()
(when ((? predicate) (? count))
((? action) (? count)))]
[method inc ()
(let ([val (sup inc)]) ;; invoca inc de la superclase
(-> self react)
val)])))
La macro sup es (((scls 'lookup 'md) self) . args): busca el método en scls (la superclase), lo aplica a self y a los argumentos.
|
Super-sends
Un super-send no hace que la búsqueda empiece en la superclase del receptor. Eso produciría un bucle infinito: si Un super-send hace que la búsqueda empiece en la superclase de la clase anfitriona del método donde ocurre el super-send. En el ejemplo, |
Con el refinamiento por super-send, el contador reactivo reacciona de forma automática:
(define rc3 (new ReactiveCounterV3))
(-> rc3 register even? (λ (v) (printf "reacting to ~a~n" v)))
> (-> rc3 inc) ;; count = 1: predicado even? falso, no reacciona
> (-> rc3 inc) ;; count = 2: even? verdadero -> imprime "reacting to 2"
reacting to 2
Síntesis
Recorrimos toda la orientación a objetos con un mismo hilo conductor —las macros—:
-
Un objeto es estado más comportamiento, invocado por paso de mensajes; lo codificamos como una clausura con un dispatcher (macro
OBJECT, envío→). -
El self da identidad al objeto; puede ser estático (con
letrec) o dinámico (parametrizando por el receptor). El forwarding usa self estático; la delegación, self dinámico, que habilita el refinamiento. -
Factorizar lo común de lo variable produce las clases, responsables de crear instancias y de la búsqueda de métodos. La instancia se reduce a su clase más su estado.
-
La herencia refina el
lookup(sube por la jerarquía), la creación (all-fields, con vectores yfind-lastpara el field shadowing) y agrega los super-sends (sup).
Todo esto se ofrece al programador como una interfaz uniforme y limpia que oculta la codificación interna, y como las clases e instancias son valores de primera clase de Racket, heredamos gratis fábricas de objetos, almacenamiento en estructuras y paso como argumentos.
Ejercicios propuestos
-
Macro
my-and. Defina una macromy-andpara la conjunción binaria con lógica de cortocircuito:(my-and e1 e2)evalúae2solo sie1es verdadero. Demuestre con un ejemplo de efectos secundarios que su versión no evalúae2cuandoe1es falso, y explique por qué no puede implementarse como función. -
Higiene. Escriba una macro
swap!que intercambie los valores de dos variables usando una variable temporal internatmp. Muestre un uso donde el programador tenga a su vez una variabletmpy explique, apoyándose en la higiene, por qué el intercambio funciona correctamente pese a la coincidencia de nombres. -
DSL de autómatas. Usando la macro
automaton, construya un procedimiento de decisión para el lenguaje(ab)*(cero o más repeticiones deab) sobre el autómata con estado inicialinit(también de aceptación) que transitaa → midy desdemidtransitab → init. Pruébelo con'(a b a b)y con'(a b a). -
Objeto
counter. Implemente, con la macroOBJECT(con manejo de errores y self), un objetocountercon métodosinc,dec,resetyget, dondeincydecretornenselfpara permitir encadenamiento. Verifique que(→ (→ (→ counter inc) inc) get)devuelve 2. -
Forwarding vs. delegación. Defina un
loggerque reenvíe (forwarding) todos sus mensajes a un objetotarget. Luego reimpleméntelo con delegación (OBJECT-DEL) y agregue una subclase por delegación que refine un método del target. Explique con un ejemplo concreto por qué el refinamiento solo funciona con self dinámico. -
Jerarquía de figuras. Con la macro
CLASS, defina una claseShape(conextends Root) y subclasesCircleyRectangle, cada una con métodosareayperimeter. Cree instancias connew(agregue un métodoinitialize) y verifique el dispatch dinámico invocandoareasobre una lista heterogénea de figuras. -
find-lasty shadowing. Implementefind-lastsin usar recursión por la cola (por ejemplo, con orden superior sobre índices) y verifique que pasa los mismos tests. Luego construya una jerarquía de tres clases donde el mismo nombre de campo aparezca en las tres, y muestre con vectores yfind-lastque cada método accede al campo de su clase anfitriona. -
Super-sends. Defina una clase
Basecon un métododescribeque retorne un string, y una subclaseDetailedcuyodescribeinvoque(sup describe)y concatene información adicional. Explique por qué usar(sup describe)no cae en un bucle infinito, en términos de la clase anfitriona y del receptor.