Introducción

A su nivel más básico, un programa es una serie de instrucciones para manipular datos de entrada, y producir datos de salida. Clojure favorece un estilo de programación en donde los datos a manipular toman central importancia, esto en contraste con otros lenguajes de programación (por ejemplo Java, C/C++, PHP, entre otros) en donde se enfatiza la secuencia de pasos a realizar, y no tanto la forma de los datos a manipular.

Para ilustrar a lo que me refiero con que los datos a manipular toman central importancia, veamos un caso concreto. Supongamos que queremos representar la siguiente estructura:

  • Heavy Metal
    • Shock Rock
      • Alice Cooper
      • Gwar
      • Marilyn Manson
    • Early Metal (UK)
      • New Wave of British Heavy Metal (NWOBHM)
        • Motorhead
        • Def Leppard
        • Iron Maiden
    • Doom
      • Witchfinder General
      • Trouble
      • Candlemass

Y supongamos que necesitamos imprimir la lista de bandas que pertenecen al New Wave of British Heavy Metal. Veamos como podemos hacer esto en un lenguaje como Java:

import java.util.*;
public class HeavyMetalDemo {

   public static void main(String args[]) {

      // Creacion de los datos de entrada
      HashMap hm = new HashMap();
      hm.put("Shock Rock",
          new String[]{"Alice Cooper", "Gwar", "Marilyn Manson"});
      hm.put("Doom",
          new String[]{"Witchfinder General", "Trouble", "Candlemass"});

      HashMap earlyMetalUK = new HashMap();
      earlyMetalUK.put("NWOBHM",
          new String[]{"Motorhead", "Def Leppard", "Iron Maiden"});
      hm.put("Early Metal (UK)", earlyMetalUK);

      String[] nwobhm = (String[])(((Map)hm.get("Early Metal (UK)"))
                           .get("NWOBHM"));

      for (String band : nwobhm) {
          System.out.println(band);
      }
   }
}

En este caso la operación se define como una serie de pasos:

  1. Obtener la raíz de la estructura: "Heavy Metal".
  2. Obtener la sub-lista correspondiente a "Early Metal (UK)".
  3. Obtener la sub-lista correspondiente a "New Wave of British Heavy
    Metal"
  4. Imprimir la sub-lista.

Ahora veamos cómo pudiéramos hacer lo mismo en Clojure:

(def heavy-metal-map
  {:heavy-metal
    {:shock-rock '("Alice Cooper" "Gwar" "Marilyn Manson")
     :early-metal-uk
       {:nwobhm '("Motorhead" "Def Leppard" "Iron Maiden")}
     :doom '("Witchfinder General" "Trouble" "Candlemass")}})

(let [{{{nwobhm :nwobhm} :early-metal-uk} :heavy-metal}
      heavy-metal-map]
  (clojure.pprint/pprint nwobhm))

;; output
("Motorhead" "Def Leppard" "Iron Maiden")

En el caso de Clojure, más que una serie de pasos, la operación completa se define como:

  1. Describir la estructura de los datos que estamos buscando.
  2. Imprimir.

El lenguaje ofrece mecanismos directos para describir los datos de entrada, y obtener la parte que nos interesa. Esto es incluso más obvio en donde se describen los datos de entrada.

Sin embargo este enfoque que enfatiza los datos más que una serie de operaciones, requiere que nosotros como programadores estemos familiarizados con cómo generar, agrupar y manipular estos datos. Para esto necesitamos saber sobre /Clojure Collections/.

Colecciones

Clojure es un lenguaje dogmático. Una de las características que "compras" cuando lo usas es esta idea de estructuras de datos inmutables y persistentes (entre otras cosas).

Las colecciones en Clojure son un grupo de estructuras de datos inmutables y persistentes que sirven - como su nombre lo indica - para colecciones o grupos de datos. Estas colecciones nos van a servir para estructurar (o modelar) los datos de entrada que necesitemos manipular, y los datos de salida que vayamos a generar como respuesta a nuestro problema.

En el ejemplo anterior vimos algunas de estas colecciones:

  • Mapas. La variable heavy-metal-map contiene un mapa.
  • Listas. Las distintas bandas que pertenecen a un género se agrupan en una lista. Por ejemplo '("Witchfinder General" "Trouble" "Candlemass").
  • Vectores. El bloque let usa un vector para definir sus /bindings/ o definiciones (¿uniones?). Otro ejemplo más sencillo sería (let [foo "bar"] (println foo)).

Pero hay más. Y aunque todas comparten cierta funcionalidad, el comportamiento de cada colección es distinto dependiendo de las características de la colección que se esté utilizando.

Para ilustrar esto veamos un pequeño ejemplo. Una de las características de todas las colecciones es que soportan la operación conj (de /conjoin/ o unir) para "agregar" elementos a la colección. Su funcionamiento es muy sencillo: recibe una colección y uno o más elementos a agregar, y regresa una nueva colección (recordemos que todas las colecciones son inmutables) con los datos originales de la colección de entrada mas los elementos agregados.

(conj [] :foo)
;; outputs [:foo]

(conj [] :foo :bar)
;; outputs [:foo :bar]

Primero agregamos :foo a una colección vacía. Un vector en este caso para ser exactos. Y efectivamente el resultado es el esperado: [:foo]. Después realizamos la misma operación, pero agregando dos elementos. Sin sorpresas, el resultado es el vector [:foo :bar].

Ahora veamos que pasa si realizamos la misma secuencia de operaciones pero con una lista en vez de un vector:

(conj '() :foo)
;; output (:foo)

(conj '() :foo :bar)
;; output (:bar :foo)

La primera operación es igual. Sin embargo el resultado de la segunda operación es distinto: el orden de los elementos es contrario al orden en el que agregaron a la colección: (:bar :foo).

Esto se debe a cómo está implementada una lista. Las listas son una secuencia de elementos en donde cada elemento apunta al elemento siguiente excepto el último elemento de la lista (que como es el último, no apunta a ningún otro), y a su vez cada elemento es apuntado por el elemento anterior excepto el primer elemento de la lista (que como es el primero, nadie lo apunta). Una explicación completa de cómo se implementa esta estructura de datos queda fuera del alcance de este artículo.

Pensemos en cómo podríamos agregar un elemento a una lista:

  1. Podríamos modificar el último elemento para que apunte al nuevo. ¿Pero cómo llegamos al último elemento? necesitamos una referencia al primer elemento de la lista, que apunta al segundo, que apunta al tercero, que apunta... y así sucesivamente hasta llegar al último y finalmente modificarlo para que apunte al nuevo.
  2. O podríamos modificar el nuevo elemento para que apunte al primero de la lista.

Es mucho más eficiente la segunda opción, y por lo tanto conj hace exactamente eso. Por esta razón se dice que las listas "crecen" del inicio de la colección mientras que en nuestro caso los vectores crecen al final de la colección.

Son estas similitudes y diferencias lo que hace muy importante conocer a fondo las colecciones en Clojure, ya que seleccionar la colección correcta puede facilitarnos nuestro trabajo y/o hacer nuestro código mucho mas eficiente. Mientras que seleccionar la colección incorrecta puede causarnos muchos dolores de cabeza y/o hacer nuestro código ineficiente.

Por ejemplo, supongamos que tenemos una lista de nombres y apellidos, y necesitamos imprimirla en pares de "Nombre Apellido". Utilizando un vector:

(def frontmans [["Ozzy" "Osbourne"] ["Ronnie" "James" "Dio"]
                ["Rob" "Halford"] ["Bruce" "Dickinson"]])

(doseq [frontman frontmans]
  (println (clojure.string/join " " frontman)))

;; output
Ozzy Osbourne
Ronnie James Dio
Rob Halford
Bruce Dickinson

Usamos un vector que a su vez tiene vectores dentro. Cada vector representa un nombre completo. Imprimir los nombres es solo cuestión de recorrer el vector exterior y, por cada vector interior, imprimir todos los elementos que contiene que forman el nombre completo (agregando un espacio entre cada elemento).

Supongamos ahora que no nos gustan los vectores y queremos usar mapas:

(def frontmans {"Ozzy" "Osbourne" "Ronnie James" "Dio"
                "Rob" "Halford" "Bruce" "Dickinson"})

(doseq [frontman (map #(str %1 " " %2)
                      (keys frontmans) (vals frontmans))]
  (println frontman))

;; output
Ozzy Osbourne
Ronnie James Dio
Rob Halford
Bruce Dickinson

Aunque no es terriblemente complicado, si sirve para ilustrar el concepto de usar la colección correcta. En este caso los nombres están representados en un mapa. Un mapa es una colección en donde cada elemento es representado por una llave y un valor.

En nuestro caso usamos la clave para representar el nombre, y el valor para representar el apellido. Con la operación keys obtenemos una colección con todas las llaves de un mapa, y con la operación vals obtenemos una colección con todos los valores de un mapa.

Después usamos la función map para "mezclar" estas dos colecciones. map aplica una función a todos los elementos de una o más colecciones. Cuando son más de una colección de entrada, la función aplicada recibe como argumentos el primer elemento de cada colección, después el segundo elemento de cada colección, y así sucesivamente.

La función aplicada recibe como primer argumento un nombre, y como segundo argumento un apellido, y forma el nombre completo agregando un espacio entre ambos.map regresa una lazy-list (que veremos su funcionamiento en otra ocasión).

Finalmente iteramos la lista regresada por map e imprimimos cada elemento.

El ejemplo con vector no solamente es más sencillo de entender, sino que es más versátil ya que permite agregar cada nombre en una cadena distinta (como ["Ronnie" "James" "Dio"]). Además no hay necesidad de usar map para procesar los elementos, creando una colección intermedia por lo que es más eficiente con el uso de la memoria. También la implementación con vector te permite controlar el orden en el que se van a imprimir los nombres, algo que no es posible con mapas (aunque así lo parezca con un número limitado de elementos. Más de esto en la próxima sección).

Hay otras operaciones en común para las colecciones como count para contar el número de elementos en una colección, y seq para obtener una secuencia que puede recorrer todos los elementos de la colección.

Características de las colecciones

Como vimos anteriormente, las colecciones tienen características y operaciones en común, pero a la vez tienen características que las hacen únicas. Es importante conocer estas características que las hacen únicas para determinar cuando usar una colección en vez de otra.

Orden de los elementos

Ya comentábamos en la sección anterior, que al usar un mapa no podemos garantizar el orden en el que se accesan (e imprimen en nuestro caso) los elementos. Hay colecciones que mantienen el orden de sus elementos, y otras que no.

Las siguientes colecciones mantienen el orden de los elementos como fueron agregados:

  • Lista (List)
  • Cola (Queue)
  • Vector

Las siguientes colecciones mantienen el orden de los elementos de acuerdo a una llave:

  • Mapa ordenado (SortedMap)
  • Conjunto ordenado (SortedSet)

Finalmente las siguientes colecciones no mantienen ningún orden en particular:

  • Mapa (HashMap)
  • Conjunto (Set)

Es importante mencionar que estas últimas colecciones aparentemente mantienen el orden para un número pequeño de elementos. Pero a medida que el número de elementos se incrementa, el orden de los mismos puede cambiar.

Para ejemplificar lo anterior, supongamos que tenemos una función md5 que recibe una cadena de entrada y nos regresa el hash md5 correspondiente. Cómo calcular el hash no es relevante, lo importante es saber que la misma cadena de entrada siempre va a generar el mismo hash md5. De tal manera que si sabemos que el hash para la cadena "¡Hola Mundo!" es "37a80ea86cd89e68c4d1f1103e0e2775" entonces:

(= "37a80ea86cd89e68c4d1f1103e0e2775" (md5 "¡Hola Mundo!"))
;; output
true

Saber el hash md5 es una manera de asegurarnos que dos cadenas son iguales. Entonces podemos comprobar si efectivamente las colecciones anteriores mantienen o no el orden de los elementos a como fueron agregados, de la siguiente manera:

;; crea una coleccion de 1000 cadenas aleatorias
;; y las introduce en un vector
(def entrada
     (reduce (fn [coll _] (conj coll (rand-str 10)))
             [] (take 1000 (range))))

(md5 (apply str entrada))
;; output
"c53cc37069d0c81c10d64a3598f9ef84"

;; introducimos los elementos de entrada a una lista
;; y revertimos el orden (recordemos que al agregar a
;; una lista los elementos van a estar en el orden
;; contrario
(def entrada-lista (reverse (into '() entrada)))
(md5 (apply str entrada-lista))
;; output
"c53cc37069d0c81c10d64a3598f9ef84"

;; finalmente introducimos los elementos a un conjunto
;; y volvemos a calcular el md5
(def entrada-conjunto (into #{} entrada))
(md5 (apply str entrada-conjunto))
;; output
"294210b512c096432213f15b5d903261"

Como podemos observar, el hash md5 es el mismo para el caso del vector y de la lista (en orden reverso) lo cual nos indica que el orden de sus elementos se mantiene. Sin embargo, al introducir los elementos a un conjunto, el orden no se mantiene y por lo tanto el hash md5 resultante es distinto.

Elementos duplicados

Otra característica de las colecciones es si "recuerdan" o no elementos duplicados. Es decir, si la colección permite guardar elementos repetidos.

De primera instancia pareciera muy sencillo, pero ¿qué significa que un elemento sea igual a otro?. Hay casos triviales, como por ejemplo números enteros. Es obvio que 1 = 1, 2 = 2, etc. Para otros tipos de datos también es igual de intuitivo: (= :foo :foo) y (= "foo" "foo") ambas expresiones regresan true.

Pero incluso entre estos casos aparentemente "obvios" hay excepciones. Por ejemplo (= 1 1.0) regresa false. Esto pudiera parecer contra-intuitivo a alguien que viene de JavaScript (por mencionar un lenguaje), sin embargo tiene su lógica. Antes de explicar por qué es esto, es importante mencionar que Clojure define el operador = cuando es aplicado a colecciones como una comparación de valor y no igualdad.

Volviendo al "sorprendente" caso de (= 1 1.0), la razón por la cual es false es porque el operador = cuando se aplica a números, toma en cuenta el tipo. Veamos:

(= 1 1.0)
;; output false

(type 1.0)
;; output java.lang.Double

(type 1)
;; output java.lang.Long

(type (long 1.0))
;; output java.lang.Long

(= 1 (long 1.0))
;; output true

Mientras que (= 1 1.0) es false, (= 1 (long 1.0)) es true ya que primero convertimos 1.0 que Clojure lo lee como tipo Double a Long, para que sea del mismo tipo que 1 y por lo tanto la comparación regresa true al final.

Para estos casos existe otra función ==:

(== 1 1.0)
;; output true

(== 1 1.000000)
;; output true

(== 1 1M)
;; output true

== compara números si todos tienen el valor equivalente sin importar el tipo.

Volviendo al tema de las colecciones, existen las que permiten duplicados: Vector, List, Queue y las que no permiten duplicados: Set, SortedSet, HashMap y SortedMap. La duplicidad es determinada por el uso del operador = (y de ahí la importancia de conocer cómo funciona).

¿Qué sucede si tratamos de introducir duplicados a una colección que no lo permite? Veamos:

#{1 1 2 3}
;;      java.lang.IllegalArgumentException: Duplicate key: 1 clojure.lang.LispReader$ReaderException: java.lang.IllegalArgumentException: Duplicate key: 1

{:foo "bar" :foo "baz"}
;;     java.lang.IllegalArgumentException: Duplicate key: :foo clojure.lang.LispReader$ReaderException: java.lang.IllegalArgumentException: Duplicate key: :foo

#{1 1.0 1M}
;; output #{1.0 1 1M}

Tipo de acceso

Otra característica de las colecciones de Clojure es el tipo de acceso (lookup) que ofrecen. Por acceso me refiero a la manera en la que se hace referencia a uno de los elementos que contienen. Aquí podemos dividir el acceso en dos grupos:

  1. Acceso aleatorio, ya sea:
  • Por llave: HashMap y SortedMap
  • Por posición: Vector
  • Por valor: Set y SortedSet
  1. Acceso secuencial:
  • Por posición: List y Queue

Aquí igual aplican diferencias entre cada una de las colecciones. Por ejemplo un vector solo puede tener posiciones representadas por números enteros positivos, mientras que un mapa usa llaves para hacer referencia a valores dentro de la colección.

Para las colecciones de acceso aleatorio se puede usar la función ~get~ para obtener un valor:

(get #{:foo 2 3} 2)
2

(get #{:foo 2 3} 3)
3

(get #{:foo 2 3} 1)
nil

(get {:foo "bar"} "bar")
nil

(get {:foo "bar"} :foo)
"bar"

(get [:foo :bar] 2)
nil

(get [:foo :bar] 1)
:bar

(get [:foo :bar] 0)
:foo

(get [:foo :bar] :bar)
nil

Es importante notar cómo el valor que se usa para hacer la búsqueda dentro de la colección cambia, de acuerdo a la colección que se tiene. En el caso de un vector, solo un número entero positivo puede regresar un valor.

Operadores para colecciones

Finalmente enumeramos una serie de operadores que todas las colecciones soportan. De nuevo, su funcionamiento y rendimiento dependen de la colección que se utilice. No vamos a ver todos, y ciertamente dejaremos los que requieren de mayor explicación (como map, reduce, iterate, assoc-in, update-in, y aquellos que dependan de lazy sequences) para un futuro artículo.

  • = verifica la equivalencia de los valores contenidos en dos colecciones.
  • count regresa el número de elementos en una colección. Es mucho más eficiente en vectores que en listas, ya que en estas últimas tiene que recorrer la lista completa para determinar el tamaño:
(def en-lista (take 10000000 (range)))
#'user/en-lista

(time (count en-lista))
"Elapsed time: 10156.832915 msecs"
10000000

(time (count en-lista))
"Elapsed time: 463.671511 msecs"
10000000

(time (count en-lista))
"Elapsed time: 458.747604 msecs"
10000000

(def en-vector (into [] (take 10000000 (range))))
#'user/en-vector

(time (count en-vector))
"Elapsed time: 0.023567 msecs"
10000000

(time (count en-vector))
"Elapsed time: 0.020834 msecs"
10000000

(time (count en-vector))
"Elapsed time: 0.018776 msecs"
10000000

La primera vez que se cuenta tarda más tiempo ya que hasta ese momento se "realiza" o se genera la lista completa. Esto tiene que ver con otro de los dogmas de Clojure: evaluación retardada (lazy execution) que veremos en otra ocasión.

Es claro que count es más lento en listas, que en vectores por un factor de 4 órdenes de magnitud nada mas y nada menos.

Otras operaciones comunes:

  • conj agrega elementos a la colección de la manera más eficiente posible. Ya vimos como para listas agrega al inicio, y para vectores al final. También es posible usar conj para agregar elementos a un mapa y un /Set/.
  • empty regresa una colección vacía del mismo tipo del argumento.
  • empty? regresa true si la colección está vacía.
  • not-empty regresa nil si la colección está vacía. Si no es el caso, regresa la misma colección que se le pasó como argumento.
  • seq genera una secuencia de elementos. Representa una vista secuencial de la colección. seq soporta todas las colecciones, y a su vez permite el uso de las siguientes funciones:
  • first regresa el primer elemento de una secuencia.
  • rest regresa el resto de los elementos de una secuencia.
  • next también regresa el resto de los elementos de una secuencia. La diferencia está en qué sucede cuando la colección tiene solo un elemento; para el caso de rest esta regresa una lista vacía (), y para el caso de next esta regresa nil.
  • get regresa el valor especificado por la llave, posición o valor según sea el caso.
  • assoc recibe una llave, un valor y una colección, y regresa una colección del mismo tipo con la llave y el valor asociados.
  • dissoc recibe una colección y una serie de llaves, y regresa una colección con las llaves y sus correspondientes valores eliminados.
  • contains? regresa true si la llave está presente en la colección. Siempre regresa false para listas. En vectores toma en cuenta el índice y no el valor (ya que de otra manera tendría que recorrer el arreglo completo).
  • some aplica un predicado (una función que regresa true o false) a cada valor de la colección hasta que un valor distinto de false/nil es regresado como resultado.
  • every? regresa true si el predicado es true para todos los elementos de la colección.