Jerarquías ad-hoc en Clojure

A la hora de aplicar polimorfismo, en los lenguajes orientados a objetos tradicionales como Java o C#, una de las herramientas más utilizadas es la herencia entre clases o interfaces, que nos permite establecer una jerarquía entre los diferentes tipos que forman parte de una aplicación. Esto nos permite enviar mensajes (lo que suele traducirse en invocar métodos) de forma uniforme sobre distintos tipos de datos, y dejar que cada tipo interprete el mensaje de una forma específica. Este tipo de jerarquías presenta una limitación importante: se definen de forma estática al declarar las clases y no podemos modificarlas a posteriori.

En Clojure existen otros enfoques para aplicar polimorfismo, como son los protocolos y los multimétodos, de los que ya hemos hablado por aquí, pero además podemos utilizar un estilo más «clásico» basado en jerarquías. Una ventaja de las jerarquías en Clojure es que las podemos definir dinámicamente, de forma independiente a la declaración de tipos, y eso nos ofrece una potencia adicional sobre el enfoque tradicional.

Qué es qué

En Clojure existen varias funciones que nos permiten obtener información sobre una jerarquía de tipos. La primera de ellas, isa?, nos indica si entre dos tipos existe una relación es un, similar a la herencia típica de un lenguaje orientado a objetos.

Si tuviésemos las siguientes clases en Java:

class Person {}
class Employee extends Person {}

Desde Clojure podríamos analizar su relación:

(isa? Employee Person)
;; => true

De igual modo, utilizando las funciones ancestors, parents y descendants podemos obtener un set con los tipos base y tipos derivados de uno dado.

Es importante destacar que isa? opera sobre tipos, no sobre instancias. Es decir:

(isa? Employee Person)
;; => true
(isa? (Employee. "Lucas") Employee)
;; => false

Esto puede resultar extraño si lo comparamos con los operadores is o isinstanceof de C# o Java, que sí que operan sobre objetos. Aun así, podemos obtener un comportamiento equivalente si antes de aplicar la función isa? utilizamos la función class para obtener el tipo de la instancia:

(isa? (class (Employee. "Lucas")) Employee)
;; => true
(isa? (class (Employee. "Lucas")) Person)
;; => true

Otro factor a tener en cuenta (y cuya utilidad veremos dentro de un momento) es que la función isa? la primera comprobación que hace es de igualdad. Es decir, (isa? x x) siempre es cierto:

(isa? 21 21)
;; => true
(isa? [1 2 3] [1 2 3])
;; => true

Jerarquías personalizadas

Hasta aquí, no hay nada especialmente interesante. La cosa se vuelve más entretenida cuando pensamos que podemos definir jerarquías en cualquier momento utilizando la función derive, sin necesidad de modificar la declaración de tipos, y que incluso podemos definir jerarquías entre keywords y símbolos aunque no sean tipos como tal:

(derive ::mammal ::animal)
(derive ::dog ::mammal)
(derive ::cat ::mammal)

(isa? ::dog ::animal)
;; => true

En estas jerarquías podemos mezclar keywords con tipos, incluyendo como tales tanto clases java, como tipos definidos con deftype como tipos definidos con defrecord:

(defrecord Person [name age])
(defrecord Dog [name breed])

(derive Person ::mammal)
(derive Dog ::mammal)

(def lucas (Person. "Lucas" 30))
(def toby (Dog. "Toby" "Labrador"))

(isa? (class lucas) ::mammal)
;; => true
(isa? (class toby) ::mammal)
;; => true

Una limitación de esta técnica es que no podemos establecer relaciones de «herencia» directamente entre tipos. El segundo argumento de derive debe ser un símbolo o una keyword. Esto puede parece una limitación grande, pero en realidad no lo suele ser; en caso de ser necesario incluir tipos en las jerarquías, se establecen primero unas reglas de «conversión» entre tipo y keyword, y a partir de ese momento se trabaja con los keywords.

Todos los ejemplos que hemos visto hasta ahora añaden relaciones en la «jerarquía global», por lo que estas relaciones quedan definidas para toda la aplicación. Podemos crear nuevas jerarquías aisladas utilizando la función make-hierarchy y utilizando las sobrecargas de las funciones derive, isa? y compañía que reciben como primer parámmetro la jerarquía a utilizar.

Jerarquías y multimétodos

Aplicando los conceptos que acabamos de ver a lo que ya sabíamos sobre multimétodos, es fácil crear métodos que hagan un dispatch sobre el tipo, al estilo de lo que ocurriría con una relación de herencia en lenguajes como C# o Java:

(defrecord Person [name age])
(defrecord Dog [name breed])

(defmulti say-hello class)
(defmethod say-hello Person [p]
  (println "Hola " (:name p) ", tienes " (:age p) " años"))
(defmethod say-hello Dog [d]
  (println "Hola " (:name d) ", eres un " (:breed d)))

(say-hello (Person. "Lucas" 30))
;; => "Hola Lucas, tienes 30 años"

(say-hello (Dog. "Toby" "Labrador"))
;; => "Hola Toby, eres un labrador"

Al utilizar class como función de dispatch, podremos definir implementaciones concretas para cada tipo con el que queremos que trabaje el multimétodo. Si nos parásemos aquí, esto no tendría mucho sentido y seguramente fuese mejor utilizar un protocolo para implementar este tipo de polimorfismo, pero si lo juntamos con las jerarquías personalizadas, podemos hacer cosas más interesantes:

(defrecord Person [name age])
(defrecord Dog [name breed])
(defrecord Parrot [name color])

(derive Person ::mammal)
(derive Dog ::mammal)
(derive Parrot ::bird)

(defmulti say-hello class)
(defmethod ::mammal [m]
  (println "Hola mamífero"))
(defmethod ::bird [b]
  (println "Hola pájaro"))

(say-hello (Person. "Lucas" 20))
;; => "Hola mamífero"
(say-hello (Dog. "Toby" "Labrador"))
;; => "Hola mamífero"
(say-hello (Parrot. "Lorito" "Verde"))
;; => "Hola pájaro"

En este caso, aunque nuestro multimétodo utiliza la función class para hacer el dispatch, estamos aprovechando la jerarquía que hemos construido entre los tipos Person, Dog y Parrot, con respecto a las keywords ::mammal y ::bird para tratar por separado los casos de mamíferos y aves.

Lo interesante es que durante la definición de los tipos en ningún momento tuvimos necesidad de prever la jerarquías de las que iban a formar parte, y podemos construir (y eliminar) estas jerarquías dinámicamente en función de nuestras necesidades posteriores. Conseguir algo similar en un lenguaje como C# posiblemente implicaría crearse unas cuantas clases implementando adapters para adaptar nuestros tipos originales a la jerarquía que queremos usar.

Todo esto funciona porque el multimétodo, cuando ejecuta la función de dispatch y comprueba cuál de las implementaciones debe utilizar, emplea la función isa? entre el valor devuelto por la función de dispatch y el valor de «guarda» de cada implementación. Eso hace que además de funcionar con valores constantes, funcione navegando por jerarquías.

Conclusiones

Cuando pasas gran parte del tiempo trabajando con lenguajes orientados a objetos de tipado estático como Java o C#, tu forma de pensar acaba estando muy influenciada por las capacidades de esos lenguajes. Si a eso le unimos que muchos de nosotros el mayor contacto que tenemos con lenguajes dinámicos es a través de javascript, que no permite tampoco demasiadas florituras, ver técnicas como ésta resulta interesante.

Poder definir al vuelo jerarquías entre los distintos valores de nuestra aplicación abre un abanico de posibilidades que permite realizar diseños muy limpios aprovechando el polimorfismo de una forma bastante natural y flexible, sin necesidad de escribir mucho código de relleno para andar convirtiendo entre unas jerarquías de tipos y otras, como ocurre cuando empiezas a necesitar adapters u otros patrones de diseño más complejos.

5 comentarios en “Jerarquías ad-hoc en Clojure

  1. No veo claro los de mezclar el sistema de dispatch por tipo del lenguaje anfitrion (records, types y protocols) con los multimetodos y las jerarquias basadas en keywords.
    Una de las razones de que en clojure se añadieran los primeros, aun que ya existian los multimetodos, fue que el rendimiento del dispatch por tipo era mucho mas rapido usando el del lenguaje anfitrion (java,js o c#)
    Por otro lado el usar protocolos tambien te permite incluir el dispatch sobre tipos previos sin modificarlos ni hacer adapters (incluso sobre las clases del core del lenguaje anfitrion, como String o Null)
    Segun entendi en su dia las motivaciones de añadir los protocolos, estos venian a sustituir los multimetodos en cuanto su funcion de dispatch era el tipo ya que era mucho mas eficiente hacerlo asi. Los multimetodos se quedarian para hacer dispatch sobre condiciones mas ricas, sobre valores concretos de los registros o mapas por ejemplo.


  2. def no(s) {s[0]}
    def entiendo="Un par menos de separadores, ligeramente movidos de sitio"

    no([entiendo])
    // Pues eso, esto en groovy, si lo hacemos en java ya te mueres

    En clojure el codigo de arriba seria mas como:

    (defn no [s] (s 0))
    (def entiendo "blabla")
    (no [entiendo])

    ¿En serio se entiendo uno del todo y otro nada de nada? :-P

  3. jneira, estoy de acuerdo con lo que comentas.

    Normalmente (al menos en el código que he visto), si vas a hacer un dispatch basado en tipos usas protocolos (de hecho tengo un post sobre eso).

    Una posible ventaja de mezclar tipos y keywords es que te ahorras implementar dos veces el protocolo. En el ejemplo del post, si usaras protocolos tendrías que duplicar la misma implementación para Dog y Person.

Comentarios cerrados.