Estructuras de control en Clojure I

Imagen generada por IA inspirada en las Puertas Lógicas

Todo lenguaje de programación de alto nivel, como Clojure, depende en último término de tecnologías de compilación o de interpretación y de otros lenguajes de bajo nivel (e.g. Assembly), para traducir el código en instrucciones que nuestro computador puede ejecutar.

Quizá una de las ideas en ciencias de la computación más cercana al metal, es decir, a la manera en que funcionan efectivamente los computadores, es aquella de las estructuras de control, en especial, los condicionales.

El CPU, el cerebro de nuestro computador

Un CPU o procesador está compuesto, entre otros elementos, de uno o más núcleos. Cada núcleo, como mínimo, está integrado por una unidad de control (cuya tarea es controlar los dispositivos de entrada y salida), una unidad arimético-lógica (ALU) (la parte que efectivamente ejecuta los cálculos) y un conjunto de registros (compartimientos de memoria para almacenar datos temporales).

Un procesador está hecho de miles de millones de transistores (por ejemplo, el procesador Ryzen 9 8950X3D, el más moderno de AMD para el momento en que escribo estas líneas, tendría un total de 216 mil millones de transistores). Un transistor es como una especie de interruptor electrónico que bloquea o deja pasar la corriente eléctrica. Y todos estos transistores están dispuestos en forma de circuitos para cumplir cada una de las funciones que describimos arriba (a saber, control, cálculo, almacenamiento). Dentro de todos estos componentes, el ALU es el encargado de ejecutar las instrucciones escritas por la unidad de control en los registros. El ALU lee estas instrucciones del registro y es capaz, dependiendo de la complejidad de la tarea, de ejecutar miles de millones de instrucciones por segundo.

Procesadores y lógica booleana

La lógica booleana es un sistema de razonamiento que utiliza dos valores: verdadero y falso, que se pueden representar con 1 y 0 respectivamente. Estos valores se pueden combinar mediante operaciones lógicas como AND, OR y NOT, que tienen reglas definidas para obtener un resultado. Por ejemplo, la operación AND solo resulta verdadera si ambos valores son verdaderos; la operación OR arroja verdadero si al menos uno de los valores es verdadero; y la operación NOT invierte el valor.

Puertas lógicas

Dado que los circuitos de un procesador son conjuntos de componentes electrónicos que pueden transmitir o bloquear una corriente eléctrica (transistores), se utiliza la lógica y el álgebra booleana para diseñar circuitos y entender su comportamiento con muchísima precisión. Por ejemplo, un circuito lógico AND se puede formar con dos transistores conectados en serie; un circuito lógico OR se puede formar con dos transistores conectados en paralelo; y un circuito lógico NOT se puede formar con un transistor conectado al revés. Los circuitos lógicos se pueden combinar para formar circuitos más complejos que realizan otras funciones, como sumar, restar, multiplicar o comparar números binarios. Estos números binarios son secuencias de 1 y 0 que representan la información que el procesador necesita procesar. Así, los circuitos de un procesador utilizan la lógica booleana para manipular la información binaria y realizar las instrucciones que le envía la unidad de control.

El dominio de los condicionales requiere una buena comprensión del álgebra booleana y una familiaridad bastante práctica con las llamadas tablas de verdad (sobre este tema ver los vídeos correspondientes en nuestra aula virtual). Las operaciones lógicas son operaciones aritméticas en el sistema binario. Para sumar dígitos binarios debemos tener en cuenta que se trata de una operación clausurada en el conjunto de los números binarios, lo que quiere decir que una operación aritmética entre números binarios sólo puede dar lugar a otro número binario. En este orden de ideas, 1+1=1 y 1+0=1. La suma representa la operación lógica del OR. Y la multiplicación (1*1=1 y 1*0=0) representa la operación lógica AND.

Álgebra booleana y lenguajes de programación

Dado que el ALU procesa las instrucciones en forma secuencial, tiene sentido que el código se organice también de este modo. No obstante, incluso la realización de tareas simples no se puede llevar a cabo sin cierta repetición, es decir, existen tareas que, al diagramarlas de un modo u otro, exhiben una estructura circular. Por otra parte, también resulta necesario que el procesador ejecute ciertas porciones del código cuando se cumpla determinado conjunto de condiciones, ya que de otra manera nuestra capacidad de ejecutar tareas complejas se vería seriamente limitada.

De esta manera nacen los primeros constructos de los lenguajes de programación, como lo son los bucles y las estructuras condicionales.

En un principio, existía una palabra clave que codificaba una instrucción que hacía las veces de condicional. Me refiero al go to. Con esta instrucción se le indicaba al intérprete que debía dirigirse a determinada línea dentro del código fuente y ejecutarla. Sin embargo, el go to dio lugar a un antipatrón denominado código spaguetti (tarea: averiguar qué es código spaguetti) y es en este contexto donde E. Dijsktra y otros colegas dieron el puntapie inicial al movimiento de la programación estructurada (véase Dijkstra, Hoare, & Dahl (1972). Structured Programming. Disponible en nuestra biblioteca virtual)

Dijsktra argumentaba que el uso excesivo de go to's era signo de una pobre estructuración del código, lo que se traducía en programas difíciles de mantener, de entender y propensos a error. Su propuesta implica el uso de abstracciones como el if-then-else y el bucle while.

Así pues, en resumidas, una estructura de control es una abstracción que nos permite determinar cuándo y bajo qué condiciones se ejecutan determinados trozos de código. La ventaja de las estructuras de control, en comparación con lo que existía antes, es que brindan la posibilidad de escribir programas con mayor rigurosidad lógica, más comprensibles y más confiables.

Condicionales

La estructura de un condicional es simple: comenzamos con la función if seguida de tres argumentos. El primero marca la condición, el segundo el valor a devolver en caso que la condición sea verdadera y, el último, el valor a devolver en caso que la condición sea falsa. Veamos.

(if (> 5 4)
  "Es mayor"
  "Es menor")
0.1s

Repasemos detenidamente lo que pasa acá. Tenemos en posición de función al if, con esto el compilador espera que le pasemos tres argumentos: alguna expresión simbólica, un símbolo (como el que representa a una variable), algún tipo de dato básico (como, por ejemplo, un string como hicimos arriba) o una combinación de todos estos.

En la segunda posición, como hicimos en el ejemplo de arriba, lo más común es colocar un predicado, esto es, una función que devuelve true o false. Ese el caso de > ó mayor qué (recordemos que utilizamos la notación prefija o polaca, por lo que siempre va de primero el operador -aunque, en sentido estricto, para Clojure no existen operadores sino funciones).

Pero, en caso que coloquemos una expresión o tipo de dato, ¿cómo saber a qué va a resolver la evaluación?

Pues bien, en Clojure toda expresión o tipo de dato evalúa a verdadero, a excepción de false ó nil. Por ejemplo, todas estas expresiones serán verdaderas:

(if '()
  "Es verdad"
  "Es falso")
0.1s
(if 1
  1
  0)
0.1s
(if ""
  :string
  :no-string)
0.1s
(if "Hola"
  "Es un saludo"
  "No es un saludo")
0.1s

Y todas estas expresiones evalúan a falso:

(if false
  true
  false)
0.1s
(if nil
  "Es verdadero"
  "Es falso")
0.0s

Pongamos en práctica lo aprendido hasta acá.

Vamos a crear una función muy sencilla que recibe un número que va a representar la edad de una persona y nos va a decir si esa persona es mayor de edad o no.

(defn es-mayor-de-edad?
  [edad]
  (if (> edad 18)
    "Es mayor de edad"
    "Es menor de edad"))
0.1s

Probemos:

(es-mayor-de-edad? 21)
0.1s
(es-mayor-de-edad? 3)
0.0s

Ahora les voy a pedir que modifiquen esta función (cámbienle el nombre para que no se pise con la anterior) para que admita como segundo argumento el límite de edad. De modo que para llamarla hagamos lo siguiente (es-mayor-de-edad2? 12 18).

;; Escriba su código aquí

Clojure dispone de varias funciones que representan las operaciones lógicas comunes, a saber, and, or, not y not=.

En la función and ambas expresiones deben ser verdaderas para que devuelva true; en la función or basta que una sea verdadera; la función not invierte el valor de una expresión, si es verdadera la hace falsa, si es falsa la hace verdadera; y la función not= es verdadera si los valores que compara son diferentes.

Veamos:

(def a 10)
(def b 20)
(def c 15)
(if (and (> c a) (< c b))
  c
  a)
0.1s
(if (not (number? a))
  :no-es-numero
  :es-numero)
0.1s
(if (not= a b)
  c
  100)
0.1s
(if (or (= b 20) (> a 15))
  :valido
  :invalido)
0.1s

Los condicionales se pueden anidar tan profundamente como deseemos. Esto quiere decir que uno de los argumentos que admite el if puede ser otro if. Veamos este ejemplo:

(def x 10)
(def y 100)
(def z 40)
0.1s
(if (> x 10)
  (if (= y 100) y z)
  (if (not= y z) z x))

Este es un caso relativamente sencillo, pero existen ocasiones en los que entender la lógica de una estructura condicional muy anidada puede ser una verdadera pesadilla. Por esta razón en muchos lenguajes suele considerarse que esto es un code smell o un antipatrón, es decir, algo que no deberíamos hacer.

En todo caso, dado que Clojure posee el poder de los macros es posible simplificar estructuras condicionales muy anidadas haciéndolas más legibles e inteligibles. De hecho, en clojure.core tenemos varios macros condicionales que son muy útiles, a saber, cond, condp, case, cond->, cond->>, when, entre otras.

Empecemos por el caso más simple, el when. Para los casos en los que sólo nos interesa especificar qué ejecutar sólo cuando la condición sea verdadera, tenemos el when:

(when (> a 5) :parametro-valido)
0.1s
(when (> b 20) "Yess!")
0.0s

Si la condición no se cumple, como arriba, el when retorna nil.

Luego tenemos el cond, el cual nos permite crear una estructura condicional arbitrariamente grande compuesta de pares prueba ó predicado / expresión. Lo verán más claro en el ejemplo (asegúrense de evaluar la celda donde está declarada la variable y):

(cond
  (> y 100) "mayor a cien"
  (< y 500) "menor a quinientos"
  (> y 1000) "mayor a mil"
  (odd? y) "es impar"
  :else (str "y es igual a " y))
0.0s

Acá vemos que no cumplió con ninguna condición, por lo que evaluó el caso de por defecto que colocamos luego del :else

Es importante tener en cuenta que el cond se va a detener en el primer momento en que encuentra una cláusula verdadera. Por ejemplo, acá va a devolver la primera condición y no evaluará lo demás:

(cond
  (>= y 100) "mayor o igual a cien"
  (< y 500) "menor a quinientos"
  (> y 1000) "mayor a mil"
  (odd? y) "es impar"
  :else (str "y es igual a " y))
0.1s

Luego tenemos a la función case. El caso más práctico para usar esta función es cuando las condiciones que deseemos verificar sean constantes, esto es, que no sean funciones o expresiones complejas que el computador deba calcular para conocer su valor. Por ejemplo, la siguiente expresión va a devolver :desconocido porque debe primero pasar la variable a a las funciones odd? e even? respectivamente:

(let [a 100]
  (case a
     odd? :impar
     even? :par
    :desconocido))
0.0s

Recuerden que un let es una forma especial que nos permite declarar variables (asociaciones o bindings, para ser más precisos) dentro del contexto léxico determinado por la misma expresión. Dicho de otra forma: el símbolo dentro del vector, a en este caso, sólo tendrá el valor asociado (a saber, 100) dentro de los límites del let.

La función case la deberíamos usar más bien en casos como este:

(let [v :alfa]
  (case v
    :beta 1
    :zeta 2
    :theta 3
    :omicron 4
    :alfa 5
    0))
0.1s

Noten que el último caso, el 0, es el caso por defecto; case, a diferencia de cond, no admite el :else. Si lo colocas te arrojará un error; sólo se escribe la expresión que se devolverá por defecto, 0, en este caso.

También podemos utilizar una lista para chequear que el valor la variable a probar pertenezca a ella. Por ejemplo:

(let [letra-griega :alfa]
  (case letra-griega
    (:alfa :beta :zeta :theta) true
    false))
0.1s

Las siguientes funciones, a saber, condp, cond-> y cond->> son un poco más avanzadas, por lo que no es obligatorio que las dominen. Sin embargo, les mostraremos cómo funcionan en caso que se animen a probarlas.

condp recibe: a) un predicado (como lo sugiere la letra p), b) la expresión a probar y c) una serie de cláusulas que pueden tener dos formas, la primera, como expresión/resultado y, la segunda, como expresión :>> función-resultado; y d) se puede agregar una expresión que será devuelta como default en caso que no exista ninguna coincidencia.

Veamos. Digamos que queremos devolver un precio en función del producto:

(let [producto :pizza]
  (condp = producto
    :bebida 600
    :empanadas 350
    :alfajor 340
    :café 750
    :pizza 1500
    :no-existe))
0.2s

Noten que inmediatamente después del condp llamamos a una función del tipo predicado como lo es = y a continuación la variable que queremos probar, a saber, producto. Seguidamente pasamos varios pares del tipo expresión/resultado. Y como es de esperar, el resultado es 1500.

La llave :no-existe es nuestro caso por defecto, esto es, lo que devuelve la expresión en caso que no encuentre ninguna coincidencia.

Ahora probemos el caso expresión :>> función-resultado. Con esto se quiere decir que necesitamos un predicado que admita dos argumentos. El primero de ellos será uno de los diferentes casos a contemplar y el segundo será la variable que vayamos a probar. Luego el resultado se le pasa a una función que aridad 1 (es decir, una función que admita un sólo argumento), la cual devolverá el resultado.

Sigamos con el ejemplo de nuestros productos. Digamos que queremos sumarle un porcentaje al precio dependiendo del producto.

Lo primero que debo hacer es crear la función o predicado que voy a necesitar. En este caso, como voy a utilizar un mapa anidado, voy a crear una función que reciba una llave y un mapa en el cual buscar esa llave, y necesito que me devuelva el mapa interior. Es decir, si tengo el mapa {:pizza {:precio 3500}}, quiero obtener como resultado el mapa {:precio 3500}.

Hagámoslo:

(defn obtener-precio
  [keyA m]
  {:pre [(keyword? keyA) ;; Hago una aserción para que verifique que el primer argumento sea una llave
         (map? m)]}      ;; y el segundo un mapa
  (-> m keyA))
0.1s

Ahora estamos listos para usar condp. Definamos dentro de un let el mapa que vamos a usar, luego llamemos a la función que va nos va a devolver el mapa actualizado con el precio del producto y, como argumento de ésta función, la estructura condicional:

(let [producto {:pizza {:precio 3500}}]
  (update-in producto [:pizza :precio] + 
    (condp obtener-precio producto
      :cerveza :>> #(/ (* (:precio %) 20) 100)
      :empanada :>> #(/ (* (:precio %) 25) 100)
      :pizza :>> #(/ (* (:precio %) 50) 100)
      :alfajor :>> #(/ (* (:precio %) 10) 100)
      0)))
0.1s

Como pueden ver, es un caso más complejo el uso de condp. Pero veamos primeramente la estructura del condicional, que es lo que nos interesa. condp llama a la función que creamos, esto es, obtener-precio, y le pasa como primer argumento una de las llaves de abajo y como segundo argumento el resultado de la expresión producto. El primer caso es :cerveza. Entonces, condp para el primer caso haría el siguiente llamado: (obtener-precio :cerveza {:pizza {:precio 3500}}). Como esto devuelve nil, porque no existe la llave :cerveza, va con el siguiente, es decir, :empanada. Y así hasta llegar a :pizza. Cuando evalúa (obtener-precio :pizza {:pizza {:precio 3500}}) obtiene como resultado {:precio 3500}. Esta expresión pasa por :>>, lo que significa que se le va a pasar a nuestra función anónima #(/ (* (:precio %) 50) 100). Esta función sencillamente calcula el 50% del precio. El símbolo % se va a sustituir por el mapa que obtuvimos como resultado, esto es, (/ (* (:precio {:precio 3500}) 50) 100) ó en notación infija (3500 * 50)/ 100. Y este resultado es lo que va a devolver nuestro condp.

Luego hemos envuelto este condp en la función update-in para que nos devuelva el mapa actualizado con el nuevo precio de la pizza. update-in pide el mapa, un vector de llaves que indican qué tan profundo va a atravesar la estructura, una función que va a utilizar para actualizar el valor de la llave encontrada y uno de los argumentos que utilizará esa función. Entonces, la función + sumará 3500 con el 50% de esa cantidad que le retorna la expresión condp y devolverá el total, a saber, 5520.

Conviene detenerse en este ejercicio lo que sea necesario para entender cada paso. En todo caso, como advertimos arriba, es una función avanzada que no se les va a exigir que dominen en esta instancia. Pero está bueno que sepan que existe pues, en determinada ocasión, puede que sea justamente lo que necesitan para resolver el problema que quieren resolver. Y si se traban siempre pueden pedir ayuda.

Continuemos.

Recordemos que cond se detiene en el mismo instante en que consigue una correspondencia, esto es, cuando una prueba devuelve verdadero. Pues bien, ¿qué sucede si quiero introducir una nueva condición sólo en el caso que la prueba sea verdadera y así sucesivamente n veces? Normalmente tendríamos que hacer algo como lo siguiente:

(when "verdadero"
  (when "también verdadero")
  (when "más verdadero aún")
  (when "todavía más verdadero") "Meta!!")
0.1s

Sin usar el macro when se ve aún mejor cuán anidado es el condicional:

(if "verdadero"
  (if "también verdadero"
    (if "más verdadero aún"
      (if "todavía más verdadero"
        "Meta!!"
        nil)
      nil)
    nil)
  nil)
0.1s

Para estos casos tenemos cond-> y cond->>. La diferencia entre ambos es la misma que al usar el macro de hilado -> ó ->> respectivamente: el primero inserta el argumento en la primera posición, mientras que el segundo lo inserta al final. El segundo lo usamos idealmente cuando trabajamos con colecciones, ya que muchas (no todas, ¡ojo!) de las funciones que trabajan con colecciones reciben el argumento de la colección al final, mientras que el primero podemos usarlo cuando trabajamos con tipos de datos sencillos.

Sin embargo, a diferencia del acaso de arriba la expresión resultado, en caso que la condición sea verdadera, debe ser una función que tome el argumento que se está probando.

Para continuar el ejemplo de arriba, deberíamos usar cond-> y podría verse así:

(cond-> "verdadero "
  true? (str "también verdadero ")
  true? (str "más verdadero aún ")
  true? (str "todavía más verdadero ")
  true? (str "Meta!!"))
0.1s

O para ir en consonancia con el resultado provisto en los ejemplos anteriores, podríamos hacer algo como esto:

(require '[clojure.string :as s])
(cond-> "verdadero "
  true? (s/replace #"verdadero" "también verdadero")
  true? (s/replace #"también verdadero" "más verdadero aún")
  true? (s/replace #"más verdadero aún" "todavía más verdadero")
  true? (s/replace #"todavía más verdadero" "Meta!!"))
0.1s

En el primer caso, elegimos una función sencilla que concatena strings, a saber, str. Sin embargo, en este último caso elegimos la función replace de clojure.string, la cual toma tres argumentos: el string a reemplazar, una expresión regular que se va a utilizar para cotejar patrones y buscar en el string esos patrones (acá sencillamente le pedimos que busque por la coincidencia exacta, por eso escribimos el string tal cual dentro de la expresión que le indica a Clojure que estamos usando una expresión regular, a saber, #"") y, por último, el string de reemplazo.

Dado que cond-> inserta el argumento en la primera posición, nos viene muy bien, ya que justamente replace exige que en primera posición esté el string que se va a sustituir (o el string donde se van a buscar patrones para luego sustituirlos por el string de reemplazo).

Entonces cond-> verifica que "verdadero" es true y lo pone como primer argumento a la función replace. Luego obtiene "también verdadero", y como toda expresión que no sea nil o false es verdadera, devuelve también true e inserta "también verdadero" como primer argumento en la función replace y obtiene "más verdadero aún". Y así sucesivamente.

Ahora pongamos un ejemplo con cond->>. En este caso usaremos una colección. Creemos una secuencia del 0 al 2000, tomemos los números impares y multipliquemos cada uno por dos. Luego esa colección de números multiplicados por dos, la vamos a tomar y si todos son pares (que lo son) los vamos a multiplicar por 3 y a sumarle 1.

(cond->> (range 2000)
  (map odd?) (map #(* 2 %))
  (map even?) (map #(+ (* % 3) 1)))
0.1s

Noten lo que está pasando acá: tanto la función de prueba como la función resultado, map en ambos casos, pide dos argumentos, el primero una función y el último una colección. Como estamos utilizando el hilado ->>, y éste introduce el argumento que toma en el último lugar, la función de prueba va a recibir la colección en la última posición y a devolver una nueva colección con aquellos números que sean impares. Luego aplica la función resultado (map #(* 2 %)), le pasa la colección como argumento, y obtiene otra colección secuencial con cada valor multiplicado por dos. Por último, ese resultado se le pasa a la función de prueba (map even?) y el resultado, que es otra colección, se le pasa a la función resultado (map #(+ (* % 3) 1). El valor que devuelve finalmente la expresión es una colección cuyos números han sido multiplicados por tres y sumados uno.

Sin duda alguna, todos estos últimos son casos más difíciles que el uso de condicionales simples como if, when, case y cond. Sin embargo, los ejemplos que repasamos nos sirven para ir poco a poco enriqueciendo nuestro vocabulario computacional, al tiempo que nos vamos formando una intuición sobre cómo resolver problemas al programar.

Como es usual, dense la oportunidad de hacer Remix de esta notebook y crear algunas celdas para poner en práctica lo aprendido.

La práctica es fundamental para asimilar los conceptos que hemos aprendido.

Runtimes (1)