Estructuras de control en Clojure II

{:deps {org.clojure/clojure {:mvn/version "1.10.3"}
        ;; complient is used for autocompletion
        ;; add your libs here (and restart the runtime to pick up changes)
        compliment/compliment {:mvn/version "0.3.9"}}}
Extensible Data Notation

Imagen generada por Bing AI inspirada en las ideas de iteración, recursión y bucles.

Bucles

Además de los condicionales, otra de las técnicas que se emplean en programación para controlar el flujo de ejecución de un programa son los bucles.

Un bucle implica la ejecución repetida de un conjunto de instrucciones hasta tanto se cumpla una condición que hemos fijado con anterioridad. En consecuencia, consiste básicamente de dos elementos, una condición de salida y un bloque de código a ejecutarse mientras la condición de salida sea falsa.

En Clojure existen dos funciones que cumplen con estas características, a saber, loop y while. Sin embargo, antes de continuar, es importante destacar que a diferencia de otros lenguajes donde los bucles se encuentran dentro de la categoría de statements (esto es, no son funciones ni expresiones, sino algo sui generis) y son de uso frecuente, en Clojure el uso de estas funciones queda reducido a una serie de casos límite.

En lo siguiente expondremos el uso de estas funciones, así como las alternativas que utilizadas para los casos más frecuentes. La razón de esta particularidad subyace en el enfoque funcional con el que está diseñado el lenguaje que estamos aprendiendo.

La función loop y sus alternativas

Loop, que literalmente significa bucle, es una función que pide como argumentos un vector con bindings o asociaciones y un conjunto de expresiones. Estas expresiones, por lo general, se resumen en una expresión condicional que indica la condición de salida y cómo proceder en la iteración.

Esta función trabaja como la cadena de una bicicleta. En el vector de asociaciones colocamos las variables sobre las que vamos a iterar, cada una de ellas es como el diente de un disco que engancha la cadena. En el otro extremo, como si se tratase del otro disco dentado, tenemos, por lo general, una función llamada recur en la cual vamos a indicar cómo mutar aquellas variables declaradas en el vector. Por cada variable debe haber una función o expresión cuyo resultado va a pasar a ser el nuevo valor que va adquirir la variable en el nuevo ciclo de iteración.

A diferencia de una bicicleta real, donde un disco puede ser más grande que otro, en nuestra función loop, el número de elementos del vector debe ser siempre igual al número de expresiones en el llamado de la función recur. De lo contrario, obtendremos una excepción. De igual modo, el orden de las expresiones se correlaciona con el orden en que fueron declaradas las variables en el vector.

Veamos un ejemplo. Creemos un bucle que parta del cero y se detenga al llegar a diez.

(loop [n 0]
  (if (== 10 n)
    n
    (recur (inc n))))
0.0s

En nuestro ejemplo vemos que en el vector de asociaciones tenemos a la variable n, a la cual le asignamos el valor 10. Luego tenemos un condicional simple que establece claramente nuestra condición de salida, esto es, si n es igual a 10, devuelve n. Atentos que cuando comparamos números en Clojure utilizamos ==, en vez de =. En caso que no se cumpla nuestra condición de salida, llamamos a la función recur y le pasamos como argumento una sola expresión (porque sólo tenemos una variable declarada en nuestro vector). Esta expresión es el llamado a la función inc con n como argumento. La función inc recibe un número y le suma uno.

Digamos que ahora quiero sumar todos los números del 1 al 10. En este caso voy a necesitar dos variables: una que sirva de contador y otra de acumulador. La condición de salida debemos tenerla siempre muy clara antes de empezar, de lo contrario caeremos en un bucle infinito colgando el proceso en cuestión. En esta ocasión, la condición de salida es muy similar; la diferencia radica en que nuestro contador debe llegar a 11, pues si se detiene al llegar a diez no realizará la última suma, a saber, 45 + 10.

(loop [contador 0
       acumulador 0]
  (if (== contador 11)
    acumulador
    (recur (inc contador)
      (+ acumulador contador))))
0.0s

Acá, como vemos, necesitamos devolver el valor del acumulador, ya que contiene el resultado que deseamos. Y en recur tenemos dos expresiones. Una es el llamado de función inc, que ya vimos arriba. Y la otra es una simple suma entre el acumulador y el contador.

Toda iteración en la que se parta de n cantidad de elementos para llegar a uno se denomina reducción. Por lo general, las reducciones tienen justamente esta estructura, a saber, un contador y un acumulador. En Clojure tenemos una función que realiza esta clase de tareas de forma mucho más simple que declarando el bucle explícitamente. Se trata de la función reduce.

Con esta función, nuestro ejemplo de arriba quedaría así:

(reduce + (range 11))
0.1s

Si, en cambio, lo que necesito es aplicar una función a cada elemento de una colección lo que necesito entonces es la función map. Por ejemplo, digamos que tengo un vector de strings y quiero convertir cada string en un keyword.

(map keyword ["alfa" "beta" "zeta" "theta"])
0.0s

Map, al igual que reduce, es una función que recibe como argumentos una función y una colección. La diferencia está en que map siempre te devuelve otra colección del mismo tamaño, mientras que reduce toma una colección y aplicando la función recursivamente a cada par de elementos, te devuelve un sólo elemento al final de la operación.

Supongamos ahora que queremos aplicar recursivamente una función (esto es, reintroduciremos sucesivamente el resultado como nuevo input de la función), por ejemplo:

formula not implemented

Aplicaremos esa iteración unas cinco veces y queremos cada valor que obtengamos como resultado en un vector

(loop [contador 0
       x 0
       resultado []]
  (if (== contador 5)
    resultado
    (recur (inc contador)
      (+ (* x x) (* 3 x) 2)
      (conj resultado x))))
0.0s

En este caso hemos usado tres variables en nuestro vector. La primera es nuestro contador; es nuestra guía para saber cuántas veces vamos a repetir la operación. Ya que empezamos desde 0 queremos que se detenga al llegar a 5, pues entonces ya contaría la quinta vez. Luego tenemos el valor inicial de la variable x, la cual fijamos en 0. Y por último tenemos un vector vacío donde vamos a añadir el resultado de cada iteración sobre la fórmula de arriba.

En el recur tenemos el incremento para el contador, la expresión de la función en notación prefija (noten que todo es una suma y dentro de ésta colocamos las expresiones que representan las otras operaciones, a saber, el cuadrado, la multiplicación y el 2 que se va a sumar al resultado de todo esto) y, por último, la función conj para agregar un elemento a nuestro vector que hemos vinculado al nombre resultado.

Sin embargo, para este caso tenemos también una función que nos hace menos laborioso el trabajo. Se trata de la función iterate. Esta función pide como argumentos una función y un valor inicial. Por sí sola se va a ejecutar ad infinitum, así que, por lo general, si la vamos a emplear repetitivamente la almacenamos en una variable y luego tomamos lo que necesitamos. En caso contrario, al momento de llamarla tomamos lo que necesitamos. Y para tomar, ¿a que no adivinan qué función vamos a utilizar?

Sí, take:

(take 5
  (iterate (fn [x]
             (+ (* x x) (* 3 x) 2))
    0))
0.1s

Como vemos, es una opción mucho menos laboriosa y menos propensa a error.

La función take sencillamente toma 5 elementos de la secuencia infinita que genera iterate (en realidad Clojure no genera una secuencia infinita, sino que te proporciona exactamente lo que necesitas). La función iterate recibe una función, en este caso confeccionamos una función anónima (que también pudimos hacer redactado usando la notación abreviada, a saber,

#(+ (* % %) (* 3 %) 2)

pero creo que así es más claro) y el valor inicial que es 0.

A menudo no es buena idea utilizar un loop para implementar funciones recursivas. Lo recomendable es usar iterate u otra función pertinente.

Otro caso es aquel en el que queremos recorrer una colección para filtrar algunos de sus elementos. Pongamos el caso sencillo en el que quiero obtener los número pares de una secuencia. Con loop haríamos lo siguiente:

(loop [coll (range 101)
       resultado []]
  (if (empty? coll)
    resultado
    (recur (rest coll)
      (let [p (first coll)]
        (if (even? p)
          (conj resultado p)
          resultado)))))
0.0s

Acá en nuestro vector de asociaciones tomamos una secuencia del 1 al 100 al que llamamos coll y creamos un vector vacío llamado resultado, en el cual vamos a agregar los números pares que encontremos. Fijamos nuestra condición de salida, a saber, cuando la colección se encuentre vacía devuelve el vector llamado <resultado>. En la función recur, en la posición de la colección que recorremos, llamamos a la función rest la cual nos devolverá esa misma colección menos su primer elemento. De esta forma vamos disminuyendo el tamaño de la colección elemento por elemento. En la segunda expresión empleamos una nueva asociación con el let, para no tener que escribir varias veces (first coll) sino colocar p en su lugar. Y entonces introducimos un condicional: si <p> es par, agrégalo al vector, de lo contrario, devuelve el vector tal cual está.

No obstante, existe la función filter que es mucho más versátil para estos casos. Sólo tendríamos que hacer lo siguiente para obtener el mismo resultado:

(filter even? (range 101))
0.0s

Pues bien, ahora pongamos que dada una colección quiero obtener los elementos únicos, descartando los repetidos. Puedo hacer esto con la función loop del siguiente modo (hasta ahora habíamos prescindido de las funciones para enfocarnos en la estructura de la función bajo examen, pero en este ejemplo vamos a crear una función ya que es el contexto donde usualmente emplearemos este bucle):

(defn distinto
  [coll]
  (loop [coll coll
         resultado []]
    (if (empty? coll)
      resultado
      (recur (rest coll)
        (let [p (first coll)]
          (if-not (some #{p} resultado)
            (conj resultado p)
            resultado))))))
0.1s

Arriba creamos la función distinto, la cual toma un solo argumento que denominamos coll para sugerir que debe ser una colección, en inglés, collection. Luego tenemos nuestro bucle loop; en el vector de asociaciones o bindings tenemos dos variables, coll (podemos usar el mismo nombre; en este caso no resulta confuso pues coll representará en cada iteración los elementos que quedan por recorrer en la colección) y resultado (el vector donde vamos a colectar los elementos únicos tomados de la colección pasada como input).

Nuestra condición de salida está marcada por el vacío de la colección de input, es decir, una vez que hayamos recorrido uno por uno todos sus elementos damos fin al bucle devolviendo la colección resultado. En el recur tenemos dos expresiones: en primer lugar, un llamado a la función rest que nos devuelve el cuerpo de una colección a excepción de su primer elemento. Es decir, rest devuelve todos menos el primero. La segunda expresión comienza con un let binding; aquí asociamos al nombre p con el valor del llamado a la función first, que nos devuelve el primer elemento de la colección que estamos recorriendo. Luego tenemos uno de los tantos macros condicionales que tiene Clojure, if-not. La prueba o predicado que establece este condicional está dada por la función some, la cual básicamente se pregunta si existe algún elemento del conjunto #{p} en la colección resultado.

Acá estamos realizando la tarea esencial de nuestra función, pues estamos verificando que el primer elemento de la colección no se encuentre ya en la colección que estamos acumulando. Si se trata de un elemento único, lo agregamos a nuestro vector llamado resultado. De lo contrario, devolvemos el vector resultado tal como está al momento de la iteración.

En esta función estamos aplicando una técnica muy común en la programación funcional para trabajar con colecciones. Se trata de dividir la colección en dos partes, cabeza (first coll) y cola (rest coll). Tomamos la cabeza y realizamos la operación que deseemos y en la próxima iteración tomaremos de nuevo la cabeza del resto de la colección. Y así sucesivamente hasta recorrerla por completo.

Veamos cómo trabaja nuestra función:

(distinto [134 2 43 423 34 321 53 25934 34809 323 1 343 3411 1 13 424 311 1 134 134 2342 2 323])
0.0s

Otra prueba:

(distinto [1 1 1 1 1 1 1 1 1 1 1 1 1 1])
0.0s

No obstante, también en esta ocasión Clojure dispone de una función que nos hace el trabajo más fácil, ¿cómo se llama?

Se llama distinct:

(distinct [1 1 1 1 1 1 1 1 1 1 1 1 1 1])
0.1s
(distinct [134 2 43 423 34 321 53 25934 34809 323 1 343 3411 1 13 424 311 1 134 134 2342 2 323])
0.0s

Pongamos un último caso. Digamos que deseamos tomar una secuencia y obtener un rango de la misma. Tengo una secuencia de números arbitarios y quiero obtener los que son mayores de 60 y menos de 100. Primero creemos esta colección.

(def coleccion (take 250 (repeatedly #(rand-int 369))))
coleccion
0.0s

¡Bien! Acá creamos una colección de 250 elementos. A la función rand-int le pusimos un techo 369, es decir, que no generará números aleatorios mayores de 369. repeatedly genera una secuencia infinita de número aleatorios llamando a la función rand-int y take toma la cantidad solicitada.

Ahora bien, con loop podemos hacer lo siguiente:

(loop [col coleccion
       resultado []]
  (if (empty? col)
    resultado
    (recur (rest col)
      (let [p (first col)]
        (if (and (> p 60) (< p 100))
          (conj resultado p)
          resultado)))))
0.1s

Y si sospechan que debe haber una función en la biblioteca clojure.core que haga esto más fácil, ¿adivinen qué? ¡Están en lo cierto!

Esto lo podemos hacer con la función subseq. El detalle con esta función es que no admite cualquier colección, sino que tiene que ser un sorted-set o un sorted-map o cualquier colección que implemente la interfaz de Java clojure.lang.Sorted. Esto no es mayor problema ya que podemos convertir nuestra colección a un sorted-set.

(def coleccion-a-set (apply sorted-set coleccion))
coleccion-a-set
0.0s

Podrán notar que no llamamos directamente a la función sorted-set y le pasamos a la colección, ya que de haberlo hecho tendríamos un conjunto de un solo elemento, a saber, la colección misma. Y esto no es lo que queremos. Por eso usamos la función apply para que tome cada elemento de la colección y lo inserte en el sorted-set o conjunto ordenado.

Después del argumento de una colección ordenada, la función subseq recibe alguna función de prueba (básicamente alguna de las siguientes: >, <, >=, <=) y una llave o valor a contrastar. Opcionalmente, puede recibir otro par de argumentos prueba/llave. En nuestro caso, vamos a llamar a esta función con sus 5 argumentos:

(subseq coleccion-a-set > 60 < 100)
0.0s

De seguro se estarán preguntado, ¿cuándo entonces es una buena idea usar directamente la función loop?

Casi siempre existe una buena alternativa en la biblioteca clojure.core, pero en aquellos casos en que no encuentres alguna función que se ajuste a tu caso puedes usar la función loop. Sin embargo, debes tener en cuenta que si vas a recorrer colecciones muy grandes y/o realizar cálculos recursivos, usar un loop es una pésima idea.

Loop es el equivalente al for en otros lenguajes como Java o Javascript. Pero dado que Clojure es un lenguaje funcional, se suelen emplear funciones alternativas al clásico for. Por cierto, atentos con esto, porque el for en Clojure se corresponde más bien con lo que se denomina una list comprehension o comprensión de listas (que no es otra cosa que una operación que nos permite crear una lista como resultado de la transformación de una lista ya existente).

Hagamos un breve repaso sobre qué funciones nos conviene en qué casos.

  1. Si queremos aplicar una transformación sobre una colección de modo que de todos sus elementos quede un solo valor, entonces estamos aplicando una reducción, por lo que necesitamos la función reduce.

  2. Si queremos aplicar una transformación sobre cada elemento de una colección y como resultado esperamos obtener una colección del mismo tamaño, por lo general vamos a querer usar la función map.

  3. Si de una colección queremos obtener sólo aquellos elementos que cumplan con determinada condición, por lo que obtendremos como resultado una colección más pequeña, en ese caso vamos a necesitar los servicios de la función filter.

  4. Si deseamos crear una secuencia siguiendo alguna fórmula matemática o alguna regla de transformación específica determinada por alguna función x, entonces nuestra mejor opción es la función iterate. Recordemos que iterate y take suelen ir de la mano.

  5. Si queremos obtener los elementos que no se repitan de una colección, usaremos distinct.

  6. Si queremos obtener una nueva secuencia a partir de un rango de una colección numérica, entonces usaremos subseq.

Existen algunas otras funciones que les ayudarán a recorrer secuencias y colecciones en Clojure. Los animo a que las descubran.

La función for

Como decíamos arriba, en Clojure la función for es una compresión de listas. Pero, ¿qué quiere decir comprensión de listas?

Se trata de una expresión muy común en teoría de conjuntos del tipo:

formula not implemented

Esto puede leerse de la siguiente forma: S es un conjunto cuyos elementos x se forman multiplicando cada elemento por 2.

Esta definición no dice nada sobre el tamaño de S, aunque, en realidad, tal como está definido, S sería el conjunto de todos los números multiplicados por 2. Así que limitemos a S al conjunto de los números del 1 al 100.

(for [x (range 101)] (* x 2))
0.1s

Como resultado, como podemos ver, obtuvimos una nueva lista cuyos miembros están conformados por cada elemento de la primera multiplicados por 2.

La función for recibe como argumentos un vector donde vamos a colocar un símbolo que va a ser asociado a cada elemento de la colección a medida que se la recorre, la colección en cuestión y una serie de opcionales como la llave :let y la llave :when.

Podemos introducir bindings o asociaciones dentro de un for y justamente en este caso empleamos la llave :let y un vector con las asociaciones que vengan al caso:

(for [x (range 101) :let [y (- x 1)]] (* x y))
0.0s

En este caso creamos una lista cuyos elementos se forman de multiplicar x por y, donde y es igual a x - 1.

Si queremos colocar alguna condición usamos la llave :when junto con una expresión que marque alguna condición:

(for [x (range 101) :when (odd? x)] x)
0.0s

Acá filtramos los elementos impares de los primeros 100 números naturales.

Y, por supuesto, también podemos combinar ambas formas opcionales:

(for [x (range 101)
      :let [y (if-not (zero? x)
                (/ x 2.)
                1)]
      :when (double? y)] (+ x y))
0.1s

En este caso creamos una lista cuyos elementos son resultado de dividir x entre 2 (siempre y cuando x no se cero, en cuyo caso devolvemos 1) y si el resultado de esa operación, al que llamamos y, es un número real, entonces sumamos x + y.

Ahora digamos que queremos recrear un tablero de 12 x 12 donde cada cuadrante ha de estar representado por sus coordenadas dentro de esta matriz. Veamos cómo lo podemos hacer:

(for [x (range 1 13) y (range 1 13)] [x y])
0.0s

Lo que hemos calculado se denomina un producto cartesiano y es el resultado de la combinatoria de dos conjuntos. Siempre que necesitemos algún tipo de operación combinatoria, la función for representa una gran opción.

La función while

Esta función recibe dos argumentos, a saber, una prueba o test (que sabemos que se trata de algún tipo de condición) y un cuerpo que puede estar compuesto de más de una expresión.

A diferencia de otros lenguajes, en Clojure es muy raro el uso de esta función ya que implica recurrir a algún tipo de mutación (y es que en Clojure todas nuestras estructuras de datos y definiciones son inmutables). En consecuencia, se suele emplear en contextos donde se trabaja con efectos secundarios.

Pero ¿qué es esto de los efectos secundarios?

Cuandoquiera que escribimos o leemos un registro de nuestra base de datos, leemos o escribimos un archivo, nos comunicamos con otra computadora a través de algún tipo de protocolo (como HTTP, por ejemplo, cuando visitamos una página web en nuestro navegador) o imprimimos un mensaje al stdout (standard output) de nuestra consola, estamos trabajando con efectos secundarios.

Se les llama así porque estamos trabajando en un contexto impuro donde el estado de los procesos que lleva a cabo nuestro programa no depende enteramente del mismo; y donde las respuestas que obtenemos de esos procesos dependen del estado de otros procesos.

Pongamos un ejemplo para ilustrar este punto. Una función que calcule el cuadrado de un número siempre va a devolver el mismo output, siempre y cuando reciba el mismo input. Es decir, el cuadrado de 2 siempre va a ser 4. No importa si llamo a la función una vez o si la llamo mil veces. A esta propiedad se le llama idempotencia. Sin embargo, si yo escribo un archivo, ya el estado del archivo no va a ser el mismo de antes. Si escribo el archivo una vez su estado ya cambió y si lo escribo mil veces, su estado cambia mil veces. Ocurre lo mismo con una base de datos: supongamos que escribo un registro y luego lo borro. Aunque en términos pragmáticos vuelvo al estado anterior en que no existía el registro, para nuestra base de datos se produjeron dos eventos que cambiaron su estado, a saber, la inserción de un registro y la remoción del mismo.

Si esto de los efectos secundarios no te parece muy convincente, piensa en el hecho que en muchos procesos que cotidianamente ejecutas desde tu computador y que involucran la comunicación con una red pública o privada, no eres sólo tú el que, por ejemplo, realiza una petición por HTTP a un servidor o el que inserta, borra o actualiza registros en alguna base de datos (con tu conocimiento o sin él), sino miles de usuarios al mismo tiempo. Es en este contexto donde cobra relevancia la inmutabilidad de nuestras variables y estructuras de datos, la pureza de nuestras funciones y el mantener dentro un contexto bien delimitado la producción de efectos secundarios. Y es que sólo con estas precauciones podemos entender exactamente cómo se comporta nuestro programa y por qué.

Ya tendremos ocasión de volver sobre este tema.

Continuemos ahora con el bucle while y veamos un ejemplo de cómo funciona.

En Clojure existe un tipo de referencia que empleamos regularmente cuando necesitamos una estructura de datos mutable. Se le denomina átomo.

Se trata de una función que recibe como argumento lo que sea que le quieras pasar, un tipo de dato o una estructura de datos. En este caso, creemos un átomo con el número 0.

(def variable (atom 0))
0.0s

Los átomos tienen una semántica especial. Para consultar su valor debemos de-referenciarlos. Ejemplo:

@variable
0.0s

O bien así:

(deref variable)
0.0s

Y para cambiar su valor debemos utilizar alguna de las siguientes funciones, swap! o reset! (es una convención en Clojure nombrar a las funciones que tienen efectos secundarios con un signo de exclamación al final).

(swap! variable inc)
0.0s

swap! pide una función como argumento para modificar el valor del átomo, mientras que a reset! sencillamente puedes pasarle directamente el nuevo valor.

Así:

(reset! variable 0)
0.0s

Hemos creado esta variable mutable porque la vamos a necesitar en nuestro bucle while. En este caso el efecto secundario que vamos a generar será algo simple, a saber, escribir por pantalla. Y lo que queremos lograr será imprimir el valor de variable hasta tanto sea menor o igual a 10.

Hagámoslo:

(while (<= @variable 10)
  (println @variable)
  (swap! variable inc))
0.4s

Siempre que imprimos por pantalla Clojure nos devuelve nil, ya que la función println no devuelve nada. Noten que en el test o prueba de-referenciamos variable. Luego empleamos dos expresiones, una para lograr el efecto esperado (imprimir por pantalla) y otra para actualizar el valor de la variable.

Y bien, hemos llegado al fin de esta lección. Recuerden que esta es una notebook interactiva, así que los invito a jugar con el código, agregar nuevas celdas y practicar, practicar, practicar.

Runtimes (1)