Tengo rato queriendo poner ejemplos de código en Scala que sean relativamente fáciles de seguir: He visto código que he hecho a lo largo de estos años, pero no sé si sea adecuado para mostrar características del lenguaje.
Encontré el “mini clon” de Twitter que me hicieron crear en la H. compañía descrita en el post de Luz, pero definitivamente no es nada básico. De hecho, ahora que vi el código de nuevo y ya con cierta experiencia en el lenguaje, me doy cuenta de que es la PEOR manera de que alguien entre en el mundo de Scala. Con razón nadie de los de ahí realmente entendía qué estaba pasando.
Voy a intentarlo con un problema simple que tuve hace tiempo, a ver qué tal queda.
Situación
Tengo un archivo de 112528 líneas, que contiene información en pseudo XML (porque no es válido. Un parser no lo procesa por lo mismo). Dentro de esa información están entidades Unicode (por ejemplo algo como &xyz:) que necesito listar. Hay que crear un programa que las extraiga, elimine duplicados, y las imprima en pantalla (en realidad hay que guardarlas en un archivo, pero para este post con ponerlas en pantalla está bien).
Ejemplo de una línea en el archivo:
<xyz type=”なにか”> &jajaja;漢字漢字漢字&jejeje;漢字だらけ&jajajaja;>
De esta línea el resultado sería:
- &jajaja;
- &jejeje;
Scala
Abrir un archivo de texto en Scala y obtener el contenido es realmente muy sencillo. En el paquete scala.io existe el objeto Source que facilita la tarea de crear una representación iterable (que podemos analizar con un iterador) de un archivo. El método que buscamos es fromFile, el cual recibe como parámetro el path completo del archivo que deseamos abrir. fromFile regresa un BufferedSource, que podemos convertir a Iterator[String] usando el método getLines. Este Iterator[String] contiene todas las líneas del archivo, las cuales podemos obtener de una por una al recorrerlo. Vamos además poniendo esto dentro de una función para tener todo modularizado:
def readFile(filename: String) = Source.fromFile(filename).getLines
Primer intento: Usando listas
Como los elementos de un Iterator se consumen en el momento en el que algo se le hace (como leer un valor), y sabiendo que voy a necesitar juntar todas las entidades que saque de todas las líneas, lo primero que se me ocurrió fue usar un lista para guardar todas las entidades extraídas y después quitarle los duplicados. Veamos qué tal funciona esta estrategia:
- Cada línea puede contener n entidades, donde n >= 0
- Una vez extraídas, las meto a una lista que las va acumulando.
- Quito entidades duplicadas.
- Imprimo en pantalla cada entidad.
1) Una expresión regular para extraer una entidad; la aplico en cada línea para encontrar todas las que haya en ella.
Scala permite crear una expresión regular llamando a la función r de la clase String.
val regex = """&[^;\W]*;""".r
Uso la función findAllIn de la clase RegEx en la línea de texto para obtener un MatchIterator que incluye todas las entidades extraídas, y lo convierto en lista para poder agregarlo a la gran lista que incluye todo.
regex.findAllIn(lineadetext).toList
Pongo todo en una función a la que llamaré getEntitiesFromLine:
def getEntitiesFromLine(line: String) = """&[^;\W]*;""".r.findAllIn(line).toList
Una cadena entre tres comillas en Scala indica que es una cadena multilínea, y que además se pueden usar signos que normalmente están reservados en el lenguaje y que hay que escribir de cierta forma (como una doble \\ para indicar una sola \).
2) Meter las entidades a una lista. Esto significa que tengo que aplicar getEntitiesFromLine a cada línea del archivo, y el resultado lo tengo que agregar a la lista que contendrá todo.
En lenguajes como Java, esto significa crear un ciclo con for o while, obtener la línea, aplicar la función a esa línea y guardar el resultado. Sin embargo, en Scala (en C# y otros lenguajes también , pero con otro nombre) existe la función foldLeft, que básicamente recorre una colección de izquierda a derecha aplicando una función a cada elemento de ella y guarda el resultado en un acumulador. El resultado de foldLeft es el último valor que el acumulador tenga.
Estrictamente hablando, la función foldLeft se describe a continuación:
c,foldLeft(v)(f)
Donde:
- c = colección en donde se aplicará foldLeft
- v = valor inicial del acumulador. Si la colección está vacía, éste es el valor que foldLeft regresará.
- f = función de aridad 2, cuyos parámetros son:
1.- El último valor del acumulador.
2.- El siguiente elemento a analizer en la colección.
Lo que regrese esta función será el nuevo valor del acumulador, por lo que los tipos de datos deben coincidir.
Suena complicado, pero una vez que se entiende qué se necesita y cómo funciona, uno termina adorando los folds (existe un foldRight también).
Ejemplo: Sumar los elementos de una lista de enteros:
val l = List(1,2,3,4,5) /* v = 0. Valor inicial del acumulador f = (acum,sig) => acum + sig acum es el valor del acumulador sig es el siguiente elemento a analizar El resultado de acum + sig se asigna al acumulador. Primera llamada: (0,1) => 0 + 1 Segunda llamada: (1,2) => 1 + 2 Tercera llamada: (3,3) => 3 + 3 Cuarta llamada: (6,4) => 6 + 4 Quinta llamada: (10, 5) => 10 + 15 Resultado: 15 */ l.foldLeft(0)((acum,sig) => acum + sig)
En vez de declarar explícitamente la función que suma los 2 parámetros, uso una expresión lambda que hace función de la función ahí mismo.
Como nota: sé que sumar una lista de enteros puede ser mucho más compacto. Dejé todo explícito para que sea más fácil de entender.
Regresando al problema original, una vez que tengo todas las líneas del archivo, la idea es usar un foldLeft aplicando la función getEntities y guardando cada resultado en el acumulador. Por tanto:
/* entities es una lista de Strings que contiene todas las entidades extraídas. Todavía hay duplicados aquí. */ def printEntities(fileWithEntities: String) = { val entities = readFile(fileWithEntities).foldLeft(List[String]()){ (acc, line) => acc ::: getEntities(line) } }
La función ::: anexa los elementos de una lista a otra.
3) Para eliminar duplicados de una lista tenemos 2 opciones: convertirla a un Set, haciendo que los duplicados desaparezcan solos, o usar el método distinct de las listas, que crea una lista sin elementos duplicados. Cualquier opción es aceptable. Usemos toSet
/* entities es ahora un set de Strings. Los Set no contienen duplicados. */ def printEntities(fileWithEntities: String) = { val entities = readFile(fileWithEntities).foldLeft(List[String]()){ (acc, line) => acc ::: getEntities(line) }.toSet }
4) Imprimir en pantalla. Como queremos imprimir cada elemento del Set, usamos la función forEach, que nos permite iterar una colección, y aplicamos println a cada elemento.
def printEntities(fileWithEntities: String) = { val entities = readFile(fileWithEntities).foldLeft(List[String]()){ (acc, line) => acc ::: getEntities(line) }.toSet entities.foreach(println) }
Notarán que al llamar a println no estamos usando ningún parámetro. Ésta es una característica de Scala: si dentro de un fold, ciclo o similar en una colección, la función a llamar toma un sólo parámetro y no hay que modificarlo para nada, es posible omitirlo. Scala se encarga del resto.
Programa completo:
object EntityPrinter { def readFile(filename: String) = Source.fromFile(filename).getLines def getEntities(line: String) = """&[^;\W]*;""".r.findAllIn(line).toList def printEntities(fileWithEntities: String) = { val entities = readFile(fileWithEntities).foldLeft(List[String]()){ (acc, line) => acc ::: getEntities(line) }.toSet entities.foreach(println) } def main(a: Array[String]) { printEntities("miarchivo.txt") } }
Aplicado al archivo mencionado, obtiene 206 entidades en 181 segundos aproximadamente. Hmm… funciona, pero algo se debe poder hacer.
Segundo intento – HashSet
Hasta que puse el toSet me di cuenta de que, si desde el principio usara un Set en vez de una Lista, no tendría que preocuparme por eliminar duplicados. Además, la función ::: es muy lenta (O(n), porque hay que ir hasta el final de la lista), La opción es HashSet.
El programa debe cambiar un poco: ahora hay que agregar cada elemento que getEntities regrese al Set. Necesitamos hacer otro foldLeft, y aquí la explicación de por qué:
En programación funcional pura, no se permite el cambio de estado. Es decir: no se permite el uso de variables. Si se necesita modificar algún valor, hay que crear otro que contenga la modificación requerida. En el código de arriba, al momento de aplicar ::: al acumulador para agregar los elementos de la lista, lo que sucede es que se crea una nueva lista que contiene los elementos que ya tenía el acumulador más los elementos que getEntities regresa. De la misma manera, si ahora vamos a agregar los elementos a un HashSet, lo que sucede es que se va a crear un nuevo Set cada vez que “agreguemos” un elemento a él.
Modifiquemos printEntities:
def printEntities(fileWithEntities: String) = { readFile(fileWithEntities).foldLeft(HashSet[String]()){ (acc, line) => getEntities(line).foldLeft(acc)((ac2, ent) => ac2 + ent) }.foreach(println) }
Qué cambió:
- El valor inicial del acumulador en el foldLeft de afuera es ahora un HashSet vacío de String.
- Aplicamos un foldLeft a la lista que getEntities regresa, poniendo como valor inicial de su acumulador lo que tiene el acumulador del foldLeft externo. Si getEntities no regresa ninguna entidad para alguna línea, el resultado será lo que se lleve acumulado hasta ese momento.
- Se fue la constante entities. Sólo servía como paso intermedio.
La mejora se nota al momento de que ejecuté el programa: 206 entidades en 6 segundos. Suena mucho más bonito.
¿Y si el archivo no existe?
¡Ajá! Errores, excepciones y demás. Sí. En Java tenemos que usar un try/catch por si acaso el archivo que especifiquemos no existe. Scala también maneja los try/catch, pero desde la versión 2.10 (si mal no recuerdo), añadió una instancia llamada Try, que hace las veces de lo que hace un try/catch, pero de forma funcional:
Try recibe como parámetro la parte de código que puede generar una excepción/error, y su valor de retorno depende de si la operación tuvo éxito o no:
- Success: No hubo errores. Todos felices.
- Failure: Error. Envuelve un Throwable que contiene la información de lo que ocurrió.
Sin entrar en detalles, gracias a su comportamiento es posible ejecutar una serie de operaciones en el resultado de una operación que regresa un Try sólo en el caso de que haya sido exitosa. Si no, todo lo que le siga no se ejecuta.
Modifiquemos readFile para que regrese un Try:
def readFile(filename: String) = Try[Iterator[String]](Source.fromFile(filename).getLines)
Como se puede ver, es en sí lo mismo, sólo envuelto en un Try[Iterator[String]], porque es el tipo de datos que regresa la operación entre paréntesis.
Ahora, printEntities
def printAllEntities(filename: String) = println(readFile(filename).map( _.foldLeft(HashSet[String]()){ (acc, line) => getEntities(line).foldLeft(acc)((ac2,ent) => ac2 + ent) }).map(_.mkString("\n")) .getOrElse("Error working with file " + filename))
Varias instrucciones nuevas:
- Como el resultado de readFile es ahora un Try y no un Iterator[String], necesitamos saber si tuvo éxito o no, y en caso de que haya sido exitoso, trabajar con el Iterator[String] que contiene.
- Para tal efecto, usamos la función map, que aplica una funcion a cada elemento en una colección. Podemos considerar a Try como una colección con un elemento en el caso de que haya tenido éxito, y cero elementos si es un Failure.
- Cuando el Try es un Success, dentro del map el parámetro que pasa es el Iterator[String], que es con el que se trabajará.Algo así:
readFile(filename).map(iterator => iterator.foldLeft...)
En estos casos, cuando inmediatamente vamos a usar el parámetro que pasa la función, podemos poner en su lugar un subguión. El código que sigue es equivalente al anterior:
readFile(filename).map(_.foldLeft...)
- map regresa una colección del mismo tipo en la que fue aplicada. Por tanto, si la usamos en un Try, el resultado será un Try (un Success será un Success y un Failure será un Failure). Por eso el segundo map después de obtener el Set con todas las entidades.
- mkString convierte una colección en un String usando el parámetro proporcionado como separador entre cada uno de sus elementos.
- Una vez que todas las operaciones han terminado, tendremos o un Failure o un Success[String]. Para obtener el valor dentro del Success usamos getOrElse(B), función que obtiene el valor o, si no puede (como en el caso de un Failure) el parámetro B. En este caso, si todo salió bien, se imprimirán en pantalla todas las entidades, o si hubo algún problema, se imprime el mensaje “Error working with file”.
Ahora bien. esa última versión de printEntities definitivamente no es muy amigable para los recién iniciados en el lenguaje (la copié tal cual como está en mi código). Lo que se puede hacer para darle más legibilidad es asignar cada operación a una constante para que sea más claro qué está pasando:
/* Las funciones que se llaman dentro de los foldLeft están definidas como constantes al principio de printEntities: addToSet getAllEntities */ def printEntities(filename: String) = { val addToSet = (h: HashSet[String], s: String) => h + s val getAllEntities = (h: HashSet[String], l: String) => getEntities(l).foldLeft(h)(addToSet) val lines = readFile(filename) val entities = lines.map(_.foldLeft(HashSet[String]())(getAllEntities)) val entitiesWithNewLine = entities.map(_.mkString("\n")) println(entitiesWithNewLine.getOrElse("Error working with file + filename")) }
Hay que recordar que en Scala las funciones son ciudadanos de primera clase, por lo que pueden ser definidas como variables, constantes, pasadas como parámetros, como valor de regreso de otra función, etc.
Programa completo, con HashSet y Try:
object EntityPrinter { def readFile(filename: String) = Try[Iterator[String]](Source.fromFile(filename).getLines) def getEntities(line: String) = """&[^;\W]*;""".r.findAllIn(line).toList def printAllEntities(filename: String) = println(readFile(filename).map( _.foldLeft(HashSet[String]()){ (acc, line) => getEntities(line).foldLeft(acc)((ac2,ent) => ac2 + ent) }).map(_.mkString("\n")) .getOrElse("Error working with file " + filename)) def main(a: Array[String]) { printAllEntities("miarchivo.txt") } }
La regla es escribir código que sea entendible, sin caer en ser demasiado “choreros” si el lenguaje lo permite, pero tratando de usar las características que provee. Eso también implica agregar comentarios pertinentes en donde sean necesarios.
Mucha gente que comienza con Scala se desespera porque de repente se encuentra con una sintaxis que parece sacada de la manga, y sin comentarios adecuados sinceramente es difícil de comprender. Yo personalmente pasé por ahí: de repente me lanzaron a Scala sin haberlo visto antes, y lo que hice para sacar la chamba fue codificar estilo Java, usando variables, ciclos for y while, etc., etc., que también es perfectamente válido para el lenguaje, pero la idea es alejarse de esa sintaxis y usar lo que Scala provee.
Si alguien de aquí está aprendiendo o interesado(a) en aprender Scala, traten de ver código comentado o explicado; estudien primero las funciones que hay en la clase List y de ahí comiencen a moverse poco a poco. Ver la API de Scala ayuda siempre y cuando se tenga noción de la sintaxis de lenguaje, puesto que se usan implicit parameters que pueden asustar a más de uno. Entrar a conceptos más profundos de programación funcional debe ser mucho después. Algunos de ellos incluso llegarán solitos conforme se avance en el lenguaje.
RT @medinamanuel: Nuevo post en ¡Un mexicano en Japón! 「Un poquito de Scala」 http://t.co/DLeLLddaCT
>@medinamanuel Un poquito de Scala: Tengo rato queriendo poner ejemplos de código en Scala que sean relativam… http://t.co/aXiKpEtdOR
Un poquito de Scala http://t.co/CRE7adX7fT
RT @medinamanuel: Nuevo post en ¡Un mexicano en Japón! 「Un poquito de Scala」 http://t.co/DLeLLddaCT
¿Que tal, Manuel?
Se que muchos lectores vienen a este blog a buscar cosas sobre Japón y omiten esta clase de textos “mas técnicos” y sobre cosas “de informática”, pero gracias a ellos me he iniciado en la programación funcional (Empece con Haskell) y han servido como textos introductorios a investigaciones mas detalladas, dado que muchos de los conceptos son bastante complejos y definitivamente en una sola entrada de post no se pueden explicar por completo o garantizando el entendimiento, como si de se tratara de “una clase”.
Independientemente del contenido o temática, felicidades por el blog, ya que recopila mucha informacion indudablemente util o interesante para muchas personas. Saludos desde el D. F.
¡Muchas gracias!
En efecto, explicar todo lo que es programación funcional tomaría mucho espacio, y definitivamente no se puede profundizar tanto en una entrada. Me da gusto saber que lo que escribí te sirvió como introducción.
También muchas gracias por tus comentarios sobre el blog 🙂
¡Saludos!