Un caso práctico con Clojure (y II)

En el post anterior empezamos a escribir un pequeño programa en clojure que nos ayudase a resolver un caso real de proceso de ficheros. Tienes todos los detalles en la primera parte de este caso práctico con clojure, pero por recordarlo un poco, el problema era algo así:

Tenemos dos aplicaciones con gran cantidad de textos en común. Una de ellas está traducida a varios idiomas, mientras que la otra sólo está en español. El objetivo es procesar los ficheros de recursos en español de ambas aplicaciones, comprobar cuales se han mantenido iguales, y generar los ficheros de traducción de la nueva aplicación copiando los recursos de cada idioma correspondientes a los recursos en español que no han variado.

Al final del post anterior habíamos conseguido parsear los ficheros de entrada y almacenar el resultado en variables:

; Textos de la aplicación 1 en español
(def es-orig (parse-file "app1.es.txt"))
; Textos de la aplicación 2 en español
(def es-new (parse-file "app2.es.txt"))
; Textos de la aplicación 1 en francés
(def fr-orig (parse-file "app1.fr.txt"))

Cada una de estas variables contiene una lista de recursos, y cada recurso no es más que un vector en el que el primer elemento es el identificador del recurso, y el segundo elemento es el texto del recurso:

(["1" "Abrir"] ["2" "Cerrar"] ["3" "Cancelar"])

Generando el fichero de salida

Para generar el fichero de salida, lo primero que necesitamos es encontrar los recursos que no han variado en español entre una aplicación y otra. Para ello definimos la variable matches:

(def matches (set/intersection (set es-orig) (set es-new)))

La variable matches almacena el resultado de realizar la intersección entre los conjuntos de recursos en español de las dos aplicaciones. Puesto que para ello utiliza la función intersection que opera sobre conjuntos y nosotros tenemos los recursos almacenados en vectores, necesitamos convertir los vectores a conjuntos, cosa que podemos hacer con la función set.

Una vez que tenemos los matches, necesitamos obtener aquellos recursos en francés cuyo identificador se corresponde con alguno de los matches, ya que estos serán los recursos que podemos reutilizar en la nueva aplicación:

(def to-keep 
  (let [matched-ids (map first matches)]
    (filter #(some #{(first %)} matched-ids) fr-orig)))

Definimos una nueva variable, to-keep, que construimos aplicando a partir de una expresión let en la que primero calculamos los identificadores de los recursos que queremos reutilizar usando las funciones map y first, que nos devuelve el primer elemento de una colección (recordar que los recursos los teníamos almacenados como un vector en el que cada elemento era otro vector con el identificador del recurso y el texto del recurso).

A continuación, usamos la función filter, equivalente al Where de Linq, para filtrar la lista de recursos en francés de la aplicación original y quedarnos sólo con aquellos que tiene un identificador que está contenido en matched-ids. Para eso volvemos a definir una función anónima como en el post anterior, usando la sintaxis #() y la pasamos como predicado de la función filter.

En este caso la función anónima es un poco más complicada que antes:

#(some #{(first %)} matched-ids)

Esta función usa some para averiguar si hay algún elemento en la colección matched-ids que cumpla una determinada condición (algo parecido, aunque no idéntico, al Any de Linq). El primero argumento de some es otra función con la condición que estamos comprobando, sin embargo, en este caso, lo que estamos pasando es un conjunto que contiene únicamente un elemento, el identificador del elemento que estamos procesando (que es el argumento que recibe esta función anónima).

¿Por qué podemos usar un conjunto como si fuera una función? Porque clojure permite evaluar un conjunto como una función que recibe un argumento y que devuelve nil (equivalente a null en C#/Java) si el argumento no se encuentra en el conjunto, y el propio argumento en el caso de que sí pertenezca al conjunto. Aprovechando eso y que en clojure, al igual que en javascript, tenemos valores truthies, podemos usarlo para escribir nuestro predicado de una forma muy sucinta.

(let [lines (map #(str (first %) "=" (second %) "\r\n") to-keep)]
  (spit "app2.fr.txt" (apply str lines)))

Lo único que nos queda es escribir el fichero de salida, y eso lo hacemos directamente evaluando la expresión anterior. No hace falta definir una función para ello porque no lo vamos a reutilizar en ninguna parte, así que lo ejecutamos directamente.

Nuevamente usamos un let para definir una variable que contendrá las líneas del fichero, que creamos mapeando una función anónima que usa str para construir cada línea de texto concatenando varios strings.

La parte de escritura como tal se realiza en el cuerpo del let usando la función spit. Para obtener el contenido del fichero podemos usar otra vez la función str que concatena sus argumentos, pero como en este caso tenemos los argumentos en forma de colección (los acabamos de generar con el map una línea antes), no podemos invocar directamente la función y tenemos que recurrir a apply, que al igual que el apply de javascript nos permite invocar una función pasando todos sus argumentos como una única colección.

El código completo del programa es el siguiente:

(ns com.koalite
  (:require [clojure.string :as str])
  (:require [clojure.set :as set]))

(defn parse-file [filename]
  (let [content (slurp filename)
        lines (str/split content #"\r\n")]
    (map #(str/split % #"=") lines)))

(def es-orig (parse-file "app1.es.txt"))
(def es-new (parse-file "app2.es.txt"))
(def fr-orig (parse-file "app1.fr.txt"))

(def matches (set/intersection (set es-orig) (set es-new)))

(def to-keep 
  (let [matched-ids (map first matches)]
    (filter #(some #{(first %)} matched-ids) fr-orig)))
 
(let [lines (map #(str (first %) "=" (second %) "\r\n") to-keep)]
  (spit "app2.fr.txt" (apply str lines)))

Cómo ejecutarlo

De momento nos vamos a conformar con ejecutar nuestro pequeño programa a través del REPL de leiningen:

(load-file "merge.clj")
; = > nil

Al cargarlo con la función load-file el programa se ejecutará y se generará el fichero de salida.

Resumen

Para aprender un lenguaje nuevo, lo que mejor me funciona es intentar ponerlo en práctica con un objetivo concreto, aunque sea simple. Requiere algo de disciplina, sobre todo porque si es un problema real a veces te dan ganas de recurrir a tu lenguaje preferido y resolverlo en 30 segundos, pero es la mejor forma de ver cómo se comporta el lenguaje en escenarios reales.

Pese a ser un ejemplo muy sencillo, se ve bastante bien el estilo funcional del lenguaje y permite hacerse una idea del aspecto de un programa en clojure.

Esto no ha hecho más que empezar. Todavía no hemos intentado ejecutar algo fuera del REPL, ni hemos escrito tests unitarios, ni… Vamos, que queda mucha diversión por delante.