Hace unos 10 años atrás sufrí con un proyecto que involucraba la interacción de dispositivos biométricos y cámaras de seguridad. "¿Cuanto tomará este proyecto?" me preguntaron mis socios, "unas seis semanas, respondí ingenuamente", finalmente me tomó varios meses, dolores de cabeza y malos ratos.
Los principales problemas tenían que ver con una serie de bibliotecas y APIs que tenían serios problemas en el manejo de concurrencia.
No fue la primera vez que he lidiado con estos problemas en mi vida profesional, hace unos años atrás les conté "unas historias de depuración", entre las cuales destaca una que involucraba un "race condition". También, no hace mucho, me encontré con un problema de sincronización en una pequeña aplicación que desarrollé para despacho de emails.
En todos esos casos yo estimo que un 50% de los problemas fue mi falta de conocimientos adecuados sobre programación concurrente. A pesar de que en la universidad en cursos de sistemas operativos e incluso en seminarios, se te enseñan estos conceptos, no alcanzas a asimilarlos con la profundidad necesaria. Al menos la formación que te dan en la universidad te sirve de una guía, y se agradece, pero sería bueno contar con referencias prácticas y concretas sobre los problemas que enfrentas a tratar la programación concurrente. Otro aspecto importante es que lo que aprendí en los ochenta, no necesariamente aplica para la realidad del hardware y los sistemas actuales.
Por eso, cuando Ricardo Galli me envió una copia de su libro "Principio y Algoritmos de Concurrencia", decidí que además de leerlo lo promovería entre mis colegas y lectores. Es lo que estoy aprovechando de hacer ahora.
Hace unos días tuve una breve charla sobre el tema con quien fue mi profesor de un seminario de computación paralela, Jo Piquer (@jpiquer) y me atreví a recomendarle este libro, y espero que lo difunda entre sus colegas académicos interesados en este tema.
Pero lo más interesante de este libro es que está pensado en el programador profesional, que no necesariamente tuvo la formación universitaria en estos temas, o para quienes enfrentan estos problemas y necesitan actualizar sus conocimientos.
En mi caso lo leí con mucho interés y aprendí cosas muy interesantes.
La sección que me pareció más interesante fue la titulada "La realidad del hardware moderno", que fue muy reveladora y donde pude entender porqué cosas que en teoría deberían funcionar, no lo hacen como uno espera (sobre todo si intentas, quijotéscamente, crear tus propias bibliotecas y estructuras de datos concurrentes).
Ricardo es una persona que cree firmemente en la idea de que "compartir es bueno", lo demuestra su aporte a la comunidad del software libre en habla hispana (espero que disfrute el chiste de mi tweet citado al inicio de este post). Además es un gran escritor. No sé como lo hace, pero consigue hacer de un tema muy técnico algo ameno y entretenido.
Lo otro interesante es que para explicar los conceptos Ricardo recurre a diversos lenguajes al alcance de cualquier programador, sin recurrir a esotéricos lenguajes académicos. Los ejemplos están en C, Java, Python y Go y en un momento debe usar Ensamblador de ARM (en una Raspberry Pi!), pero no se asusten por esto último, si leen el libro la complejidad se maneja de forma gradual, y la razón de usar ensamblador es coherente con el problema que se está tratando de resolver.
Es un libro muy práctico, incluso en la primera parte, que aborda los fundamentos más teóricos, absolutamente necesarios para entender lo que viene.
Uno de los objetivos de Ricardo era poder escribir un "ebook técnico legible", y lo logra bastante bien. Cuando expone código lo hace con una brevedad que se agradece. Leer ejemplos que toman muchas páginas de código en una tableta, o una Kindle es una tortura. En este caso funciona bastante bien. La elección de Python para expresar código breve es la clave para este logro. En este punto tengo que felicitar a Ricardo, debe ser el único libro técnico de programación que he leído por entero en mi Kindle, sin tener que recurrir a mi Mac o al iPad para poder mantener todo el código a la vista. Esto les parecerá una minucia, pero si tienen la costumbre de leer muchos libros de programación en formato ebook, lo comprenderán y agradecerán.
Quizás sería interesante que Ricardo ampliara sus ejemplos en GitHub usando otros lenguajes modernos como Rust, que tiene un soporte de Canales algo diferente a Go y en Scala para explorar el modelo de actores. Supongo que eso lo abordará en una segunda edición (porque espero que le vaya tan bien, que se anime a escribir una segunda edición).
En resumen, considero que Ricardo ha escrito un libro fundamental en la formación de todo programador. Lo recomiendo y espero que podamos discutirlo juntos en los comentarios.
Si me gustan las canciones de amor y me gustan esos raros peinados nuevos, ya no quiero criticar, sólo quiero ser un enfermero.. -- Charly García
Siempre me pregunté qué quería decir Charly con eso de "sólo quiero ser un enfermero" (una frase de la canción "Esos raros peinados nuevos"). Resulta que hay una anécdota interesante al respecto.
Cuenta la historia que una vez Charly vio como un tipo estaba aspirando cocaína de forma exagerada. Le advierte que pare, que va a terminar del otro lado, y el tipo siguió como si nada. Era cantado que estaba a punto de caer en la Sobredosis. Pues no, le agarró un ataque de epilepsia, que junto a la dureza de la mandíbula por los saques que se había dado, hacía imposible enderezársela. Charly le dio unas buenas trompadas hasta que le rompió la mandíbula, le enderezó la lengua y le salvó la vida. Ese es el origen del Enfermero. Tiempo después, Charly sería el actor secundario de la Película "Lo Que Vendrá", por la cual fue premiado por la crítica de Nueva York al mejor actor secundario. Su papel, era el del enfermero
Si algo tiene Charly García es que en su momento supo muy bien re inventarse y adoptar nuevos estilos de música. En los ochenta superó la etapa esa en que se "fue con los hippies, tuvo un amor y también mucho más", con gran éxito.
Es lo que hay que hacer, creo yo, seguir haciendo algo nuevo.
Y si trabajás al pedo y estás haciendo algo nuevo, adelante. -- Charly García
Pero no les quiero hablar de Charly, aunque sería muy interesante, dadas las muchas anécdotas de este personaje. No, de lo que quiero hablar es de esos "raros lenguajes nuevos".
Estos son los mejores tiempos para ser desarrollador, pero también son los peores tiempos para ser desarrollador. Como dice Jano González, parece que tenemos un nuevo paradigma: "Hacker News Driven Development".
Todas las semanas, o quizás todos los días aparecen nuevos Frameworks, Lenguajes de Programación, Tecnologías en Hacker News y las nuevas generaciones de desarrolladores, afectadas por el síndrome de déficit atencional corren a re implementar su último proyecto con la herramienta que tenga más likes.
Pero, como dice Charly:
ya no quiero criticar, sólo quiero ser un enfermero
Así que me propongo aliviar un poco esa angustia de algunos desarrolladores que nos saben si vale la pena aprender alguno de esos nuevos lenguajes que están apareciendo o re apareciendo por todos lados.
Voy a asumir el siguiente desafío, voy a resolver 9 desafíos de programación en 9 lenguajes de programación.
La idea es explorar nuevos lenguajes y lenguajes que no son tan nuevos, pero que han reaparecido en el paisaje tecnológico.
Voy a escribir 9 artículos, en cada uno haré una reseña de uno de los lenguajes y luego voy a describir el desafío con mis observaciones sobre la experiencia de implementarlo en cada lenguaje.
Este es un proyecto largo, puede que tome varios meses, porque depende del tiempo que tenga disponible y no pretendo resolver desafíos sencillos.
La idea es crear desafíos que permitan destacar las bondades de algunos lenguajes y que sean prácticos, que muestren situaciones similares a problemas que uno enfrenta en el "mundo real".
Si lo que te gusta es gritar, desenchufa el cable del parlante. El silencio tiene acción, el mas cuerdo es el más delirante.
Existe un libro llamado 7 languages in 7 weeks, que me pareció insatisfactorio. Mi idea es más ambiciosa en alcance.
Si todo sale bien, puede que recopile todo en un libro (sería genial publicarlo en inglés, pero ahí necesito apoyo de algún amable lector).
El primer desafío ya fue completado y estoy trabajando en el segundo. Así que atentos al siguiente post, porque ahí empezará esta serie.
Para los curiosos, este proyecto ya está en Github en este repositorio: https://github.com/lnds/9d9l/ donde pueden revisar el código.
Nota para cerrar.
¿Por qué estos lenguajes en particular? ¿Consideré otros lenguajes?
Inicialmente tenía ganas sólo de revisar Go y Rust, dos interesantes lenguajes nuevos que han ganado bastante popularidad. Fue entonces que topé con el libro mencionado arriba (7 languages in 7 weeks) y decidí ampliar el desafío.
Tengo un especial interés por los lenguajes basados en la JVM, puesto que investigarlos puede ser útil para mi trabajo, eso me llevó a incluir Scala, Clojure y Kotlin (consideré Ceylon, pero en mi opinión tiene muy poca aceptación entre los desarrolladores).
La programación funcional es un paradigma que capta mucho mi interés, Clojure es un buen lenguaje funcional, pero es bueno considerar otros, así fue cuando incluí F#, un lenguaje desarrollador por Microsoft, pero que por fortuna es OpenSource y puede correr en Linux y Mac usando Mono. Y si vamos a estudiar programación funcional es importante que les muestre Haskell.
Trabajo principalmente en un Mac, así que debía incluir a Swift. Con eso son 8 lenguajes. Pero la curiosidad, el hecho de que al ser usado por Whatsapp se haya popularizado en el último tiempo y que haya inspirado a Scala para el modelo de actores y a lenguajes como Go y Rust en el uso de canales, me llevó a incluir al venerable Erlang en la lista.
Ahí están los nueve: Go, Rust, Scala, Clojure, Kotlin, F#, Haskell, Swift y Erlang.
Otros lenguajes que consideré:
- Elixir, construido sobre la VM de Erlang es un lenguaje que busca aprovechar esta VM con una sintáxis más sencilla que la de Erlang.
- D, es un lenguaje que conozco hace años, pero que nunca he tenido la oportunidad de explorar en profundidad, pero creo que Rust llegará a ocupar una mejor posición en el nicho de la programación de sistemas.
- Ceylon, un lenguaje de VM que al igual que Scala y Kotlin pretende superar a Java en términos de simplicidad de programación y escalabilidad. Sospecho que fuera de Redhat no mucha gente lo usa. No lo vemos aparecer en rankings como TIOBE o RedMonks. Creo que la popularidad de las herramientas de Jetbrains va a dar más impulso a Kotlin (sobretodo entre los programadores de Android).
- ES6, Javascript es quizás el lenguaje más importante en la actualidad, junto con Java. Puedes hacer de todo con JavaScript y sería interesante estudiar las nuevas características que trae Ecma Script 6, otra cosa que no tiene mi lista de lenguajes es soporte para Prototipos, que es la característica más interesante de Javascript desde el punto de vista de la programación orientrada a objetos. Sin embargo, sospecho que algunos de los problemas que pretendo resolver pueden ser bastante complicados de configurar en un ambiente basado en Javascript (quizás estoy equivocado, así que si algún lector se alienta a seguir mis desafíos e implementarlos en ES6 sería interesante)
- Python (3.5), es uno de mis lenguajes favoritos, pero la idea es privilegiar lenguajes emergentes o poco conocidos
- Objective C, creo que con Swift basta..
- Ruby, jamás.
- PHP, menos.
¿Qué lenguajes considerarían ustedes? Si quieren pueden sumarse a este proyecto aportando soluciones en otros lenguajes.
Desde que Kernighan & Ritchie publicaron "The C Programming Language" ha sido, casi de rigor, que toda introducción a un nuevo lenguaje de programación parta con el famoso "Hello World". Un programa muy sencillo que es más o menos así:
main() {
printf("hello, world\n");
}
Esta tradición está bien para un principiante, pero también es útil para verificar que hemos instalado el compilador y/o el ambiente del lenguaje que queremos aprender.
Pero para un profesional, que quiere aprender y evaluar un nuevo lenguaje de programación, nos gustaría contar con un test que permita responder las siguientes interrogantes:
¿Cómo se controla el flujo de un programa? Es decir, cómo se expresa la toma de decisiones, cómo se escribe un ciclo (loop), cómo se define el inicio y el fin de un ciclo. ¿Acepta recursividad el lenguaje? ¿Cómo se define una simple función o sub rutina?
Por supuesto que primero hay que conocer el tipo de paradigma principal de ese nuevo lenguaje (funcional, orientado al objeto, lógico, imperativo, etc)., pero eso lo tenemos más o menos claro cuando elegimos el lenguaje en cuestión, lo que queremos es poder escribir un programa que permita probar lo básico, los elementos mínimos que componen todo programa, sus estructuras de control y la forma en que podemos dividir y organizar el código.
Dijkstra
Para escribir cualquier programa en una Máquina de Turing basta tener dos instrucciones: if y goto. Pero todos odiamos los goto, por eso, bajo la luz de Dijkstra y la idea de "programación estructurada", nuestros antepasados nos entregaron los loops (while, for, repeat, etc).
Pero otro grupo, bajo la inspiración de McCarthy descubrió que estas cosas se pueden hacer de otra manera y en vez de pensar en condiciones y saltos podemos ver un programa como un flujo de datos que van pasando de función a función, flujos que transforman lo datos de entrada en datos de salida.
McCarthy
Así que a grande rasgos, y considerando sólo la sintáxis básica, tenemos lenguajes que siguen la linea de Dijkstra de la programación estructurada y lenguajes que siguen la vía de McCarthy y se construyen mediante la composición de funciones (y la manipulación de listas o secuencias).
Para evaluar la médula de la sintáxis se me ocurrió que el desafío adecuado para el proyecto "9 desafíos en 9 lenguajes de programación" (que describí en mi post anterior) consiste en un juego interactivo.
Un juego simple, por supuesto, en que la interacción se haga a través de la consola básica del sistema operativo (por ejemplo, el shell).
Juegos por SMS
Hace varios años atrás desarrollé juegos con los que las personas interactuaban vía SMS (mensajería de texto en celulares).
Seguramente los conocen: "manda la palabra XXX vía mensaje de texto al número 9999 y ya estás participando" o "Vota por tu artista favorito X enviando un mensaje al 9999".
La idea era que el público enviara un SMS y jugara, para matar el tiempo en una época sin Smartphones. Aparte de pasar el rato la gente vaciaba sus billeteras, porque cada interacción con el juego costaba varios pesos (entre $80 y $250 de la época). Pero esa era la idea del "modelo de negocios" (y de todos los modelos de negocios, exprimir tu billetera para poder pasarle ese dinero a los inversionistas).
Uno de los juegos que implementé fue Black Jack, otro se llamaba Toque y Fama.
Lo "bonito" de Toque y Fama es que puedes estar mucho rato jugando y por lo tanto enviando muchos mensajes ($$$$) al servidor, tratando de adivinar la cifra. (Ustedes pensarán que nos hicimos ricos con esto, pero no, la verdad es que en Chile la mayor parte de la gente en esa época tenía planes de prepago y no iba a gastar sus pocas "luquitas" en leseras, aunque, el horóscopo y los relatos eróticos (por SMS!) tuvieron mayor éxito, pero esa es una historia para otra oportunidad.
Toque y fama es un juego muy simple:
El servidor genera una secuencia de 5 dígitos distintos, por ejemplo: 4,8,1,3 y 5.
El usuario debe tratar de adivinar los números en el orden correcto. Si envía un SMS con "12345", el servidor le responderá "Tienes 3 Toques y 1 Fama", porque los números 1,3 y 4 están en la lista, pero en posiciones incorrectas ("toques"), por otro lado el 5 está en la lista y en la posición correcta ("fama").
Luego el jugador entusiasmado decide probar con la secuencia "40321" (este es un jugador obsesivo y sin una estrategia clara, un ingenuo que puede llegar a gastar mucho dinero en SMS, los inversionistas están felices).
Al recibir el SMS 40325 el servidor responde con el mensaje "Tienes 1 Toque y 2 Famas". Y así sigue el pobre incauto enviando mensajes hasta que logra las 5 Famas y gana (o se le acaba el dinero del plan, pero ese es su problema, no nuestro).
El desafío número 1
El primer objetivo de este desafío es implementar "Toque y Fama" en Clojure, Erlang, F#, Go, Haskell, Kotlin, Rust, Scala y Swift.
Por supuesto no vamos a implementar un ESME para jugar usando nuestros celulares, la entrada al sistema la vamos a reemplazar con la consola del sistema operativo (conocida en otro círculos como entrada estándar o STDIN).
Una sesión de nuestro juego se verá así:
Bienvenido a Toque y Fama. ========================== En este juego debes tratar de adivinar una secuencia de 5 dígitos generadas por el programa. Para esto ingresas 5 dígitos distintos con el fin de adivinar la secuencia. Si has adivinado correctamente la posición de un dígito se produce una Fama. Si has adivinado uno de los dígitos de la secuencia, pero en una posición distinta se trata de un Toque. Ejemplo: Si la secuencia es secuencia: [8, 0, 6, 1, 3] e ingresas 40863, entonces en pantalla aparecerá: tu ingresaste [4, 0, 8, 6, 3] resultado: 2 Toques 2 FamasIngresa una secuencia de 5 dígitos distintos (o escribe salir):
12345
Ganaste! Acertaste al intento 6! La secuencia era [9, 1, 7, 6, 5].
Coloqué en negrita las entradas del jugador.
No es la idea de este proyecto, ni de estos artículos, el enseñar a programar en cada uno de estos lenguajes, para eso hay mucho material en internet y una gran cantidad de libros. Al final de esta serie de artículos publicaré referencias bibliográficas para que puedan aprender el lenguaje que les parezca más interesante.
En este primer desafío otro objetivo es revisar como expresamos las estructuras de control (loops, ifs, etc) y como se definen las sub rutinas.
Por supuesto que se deben hacer más cosas para resolver este ejercicio: leer y escribir desde la consola, manipular strings, generar números aleatorios, etc. Pero en cada capítulo de este proyecto voy a concentrarme en distintos aspectos de la programación en general y como se resuelven en cada uno de los 9 lenguajes. En esta oportunidad son las estructuras de control y la declaración de funciones o sub rutinas.
Sugiero obtener y compilar el código del lenguaje en que estén interesados y luego estudiarlo. Por cada uno agregué una breve nota en el directorio doc.
Tipos de Lenguajes
Ya les hablé de que hay lenguajes que vamos a llamar Dijkstreanos que usan ifs, loops y esas cosas y lenguajes que vamos a llamar McCartheanos que prefieren la recursividad y el pattern matching. Para simplificar las cosas los vamos a llamar lenguajes tipo D y tipo M(*).
Esta es una división que se me ocurrió ahora, de puro bacán que soy, así que no la busquen en ningún libro, ni en Wikipedia, porque no existía hasta que se me ocurrió (puede que un día la academia la adopte y me entreguen el PhD que me merezco por este brillante aporte a la ciencia de la computación).
Los lenguajes tipo D son normalmente imperativos (son explícitos en cómo ejecutar las operaciones), permiten la mutabilidad de estado, permiten organizar el código en módulos, clases, objetos, interfaces, prototipos, traits, etc.
Los lenguajes tipo M son más declarativos (se interesan en el qué se requiere computar, no cómo), favorecen la inmutabilidad, son funcionales, favorecen la composición de funciones, operan sobre estructuras de datos como listas, secuencias, etc.
En este proyecto tenemos:
Lenguajes Tipo D: Go, Kotlin, Rust y Swift.
Lenguajes Tipo M: Clojure, Haskell y Erlang.
Sin embargo, todos los tipo D en este proyecto soportan recursividad, sólo que no siempre es la forma más eficiente de implementar algunas cosas en estos lenguajes.
Por otro lado, Scala tiene la particularidad de que podemos escribir programas enteros siguiendo el estilo de los lenguajes Tipo M. Por esto vamos a crear un tercer grupo de lenguajes, que llamaremos Tipo O, en honor a Martin Odersky. F# también tiene muchas de estas características. Son Lenguajes que tienen elementos de los lenguajes tipo D y M.
A continuación revisaremos brevemente las estructuras de control de cada uno de estos lenguajes y posteriormente veremos cómo se declaran las funciones, para finalizar con algunas conclusiones del ejercicio.
Evaluación la sintáxis de los lenguajes
Estructuras de Control
La selección nos permite decidir cuales acciones se deben ejecutar en un programa en base a una condición. Como los IFs en muchos lenguajes, o los switchs/case que permiten selección entre múltiples casos.
Veamos qué descubrimos con este ejercicio.
- Lenguajes Tipo D (Go, Rust, Swift, Kotlin)
Todos usan un if derivado de la sintáxis de C. Go, Rust y Swift eliminan los paréntesis en la expresión evaluada, aunque Kotlin lo conserva.
Ejemplos:
En Go, un fragmento de la función que valida la entrada.
Notar que no colocamos un else después del primer if, lo mismo podríamos haber hecho en Go y Rust.
En Kotlin if también es una expresión.
En Go y Swift if es una sentencia (statement), en cambio en Rust, Scala, Kotlin, Erlang, F# y Haskell es una expresión.
La diferencia es la siguiente:
// Esto es seudo lenguaje // Cuando if es una sentencia
var max : Int; if a > b then max = a else max = b;
// cuando if es una expresión
max := if a > b then a else b;
Para los que han desarrollado en C o C++ el if como expresión reemplaza al operador ternario "?:".
Tipo O:
En Scala el if tiene una sintaxis similar a la de C pero con la particularidad de que además es una expresión, como ya dijimos anteriormente.
El if en Scala está usando operaciones sobre colecciones como en los lenguajes funcionales.
En este caso aprovechamos de destacar una característica de los lenguajes Tipo O, que mezclan conceptos de programación orientada al objeto con programación funcional. La variable accion es un string, en Scala un string es un objeto que corresponde a una secuencia de caracteres y por lo tanto una colección. Todas las colecciones tienen el método exists, el que recibe una función (las funciones se pueden pasar como argumentos a otras funciones en Scala) y retorna true si algún elemento de la colección cumple la condición (en este caso que el carácter no sea un dígito). Algo similar hacemos en el else al aplicar un map sobre la variable accion.
Podríamos haber escrito el método validar de esta otra forma:
Y ahí queda más claro que estamos ante el uso de funciones anónimas sobre listas, algo habitual en un lenguaje M, pero aplicadas a un objeto, algo típico de los lenguajes tipo D.
En Haskell existe un if, que también es una expresión. Pero existen maneras de hacer selección a nivel de la declaración de las funciones mediante pattern matching.
Acá se define la función validar, que recibe n, que es número entero que indica el largo que debe tener la secuencia de dígitos. El argumento xs es la secuencia de caracteres ingresados por el jugador.
En este caso estamos ante la presencia de guards, que son expresiones que van entre | y =, por ejemplo este fragmento:
| length num /= n = []
Define una condición que se debe cumplir para asignar el valor [] (lista vacía) como resultado de la función validar.
(En Haskell el operador "distinto a" se escribe /=, en C corresponde a !=).
El siguiente fragmento es una especie de "else":
| otherwise = num
Que indica que si otras expresiones en los guards no se han cumplido entonces use el siguiente valor como resultado de la función.
Pero también podemos usar if como expresión:
where num = if not (all isDigit xs) then [] else remover_dups xs
La sentencia where declara num como una variable, la que tendrá el valor [] (lista vacía) si la secuencia de dígitos contiene al menos un carácter que no sea un dígito, o de lo contrario tendrá todos los dígitos de la secuencia (removiendo los duplicados).
El caso de Clojure es más sencillo, existe una función llamada if cuya forma es:
(if condicion valor_true valor_false)
Por ejemplo, si queremos determinar el valor máximo entre dos números
(if (> a b) a b)
Clojure es un lenguaje basado en LISP así que usa la notación prefija agrupando entre paréntesis.
Esto asusta a muchos programadores (incluso el mismo Dijskstra odiaba esta notación ver [1]), veamos la función validar en Clojure:
Una manera de evitar caer en el infierno de los paréntesis y es lo que trato de hacer cuando he tenido que usar algún dialecto de Lisp, es dividir una función en pequeñas funciones. Esto es lo que se llama una estructuración bottom up, escribir pequeñas piezas que resuelven una parte del problema y luego integrarlas en la solución final.
Si no hubiera hecho esto la función validar sería así:
Es por esto que los programadores Erlang acostumbrar colocar la condición negada como la condición final, que es lo que hacemos en el fragmento de más arriba.
Otra forma de expresar el salto condicional es usando guards, como en Haskel, por ejemplo con esta función que determina si un carácter es un dígito:
En este for recorremos el string accion dejando en cada ciclo en la variable c el carácter y en la variable i la posición que ocupa c dentro de la cadena. La variable i tendrá los valores 0,1,2,... en la medida que se recorra el string.
En C tendríamos que haber hecho algo así:
for (i = 0; i < strlen(accion); i++) { char c = accion[i]; .... }
En Rust y en Swift se logra lo mismo usando Enumerates
En el primer for usamos it como un objeto con dos atributos: .index y .value, en el segundo y tercer for los usamos como tuplas, como en los otros lenguajes
En Scala podemos simular lo mismo con el método zipWithIndex disponible en las colecciones, pero además podemos crear dos for anidados en la misma sentencia:
En realidad en Clojure se usa la recursividad para implementar loops, pero si no se tiene cuidado podemos provocar un "stack overflow" (recordemos que Clojure usa la JVM). Para ayudar al compilador, Clojure implementa la función (recur) que permite implementar "tail recursion", algo que veremos en detalle más adelante. Del mismo modo, la función (loop) ayuda al compilador de Clojure para implementar ciclos de forma eficiente.
Lo interesante de la solución en Clojure es que no usa recursividad en ninguna función y utiliza la función (loop) sólo para el ciclo principal del programa.
La solución en Erlang, está implementada como un "descenso recursivo". No es que me haya propuesto escribir el código de esta manera, pero fue la forma en que se me fue ocurriendo como solucionar este problema.
En esencia la solución Erlang va descendiendo por las distintas funciones a partir de la función jugar:
- main
-- jugar
--- continuar
---- "error" -> jugar---- revisar_jugada
------ "Ganaste" -> <fin del programa>------ -> jugar
Creo que esta manera de resolver el problema se da por la influencia de Prolog en el lenguaje, esto es algo que veremos si se ratifica en los desafíos que vienen.
La interrogante que debo resolver es qué pasa con esta solución, el instinto me lleva a pensar que debería provocarse un "stack overflow", pero es algo que debo probar. Si algún lector conoce más de Erlang agradecería sus comentarios sobre esa solución, es mi primer programa en este lenguaje y no tengo claro si hay algún tipo de optimización tipo "tail recursion" que aplique a mi solución (sospecho que no, pero es algo que debe aclararse).
No copio el código en Erlang acá por su extensión, pueden verlo en detalle acá.
En el caso de Haskell la única alternativa es la recursividad (hay formas de simular loops usando "Monads", pero no quiero asustarlos con eso todavía).
A diferencia de la solución en Clojure, en que calculo los toques y las famas en 2 funciones diferentes, acá me propuse calcular ambas cifras en sólo 1 función. La solución no es la más eficiente pero muestra cómo pueden hacer "loops" en Haskell:
Una función que calcula los toques y famas comparando dos secuencias de números
La función toques_y_famas recibe 3 parámetros: la secuencia de números ingresada por el usuario y los parámetros 2 y 3 corresponden inicialmente a la secuencia generada por el programa (la cifra que se debe adivinar).
La linea 1 del código de arriba muestra el caso particular en que la entrada del jugador es vacía (primer parámetro = []). En este caso hay cero toques y cero famas.
Luego hacemos nuevamente un descenso recursivo. La notación (n:ns) descompone una lista en su cabeza (n) y su cola (ns). Por ejemplo, si la lista es [1,2,3,4,5] el patrón (n:ns) hace calzar n con 1 y ns con [2,3,4,5].
El algoritmo expresado en ese código en Haskell es el siguiente:
1. Si la lista ingresada por el jugador está vacía, entonces hay 0 toques y 0 famas.
2. En caso contrario, tome la lista ingresada por el jugador y descompóngala en cabeza y cola (n y ns). Tome la secuencia generada por el programa y haga lo mismo (obteniendo x y xs).
3. Si la cabeza de ambas listas son iguales entonces se tiene 1 fama y por lo tanto se incrementa la cantidad de famas (f) en 1 devolviendo el par (t, 1+f).
4. De lo contrario, si la cabeza de la lista ingresada por el jugador (n) está en la secuencia generada por el programa (ys) entonces eso se debe contar como un toque (t) y se devuelve (t+1, f).
5. Si no se cumple ni 3 ni 4 se devuelve el valor t y f acumulado.
6. Acá viene la magia: t y f se han calculado previamente a partir de ns y xs.
El paso 6 es lo que está expresado en la sentencia where al final de la definición de la función.
Esta solución en Haskell fue la que inspiró las soluciones en Erlang y F#:
Este código sólo funciona correctamente si ambas secuencias son del mismo largo (algo de lo que nos preocupamos en otras etapas del programa, pero que se podría manejar en el código).
Esta solución no es la más eficiente, por supuesto.
¿Se les ocurre una solución más eficiente para comparar ambas secuencias en los lenguajes tipo M? (Pista: vean la solución en Scala).
Declaración y llamada de subrutinas
Uso el término subrutina para englobar los conceptos de funciones, métodos, procedimientos, etc.
En Clojure la forma de declarar una función es:
(def funcion (fn [args] cuerpo))
Por ejemplo:
(def mutiplica (fn [a b] (* a b)))
Hay una macro que permite simplificar la declaración
(defn multiplica [a b] (* a b))
Clojure es un lenguaje homoicónico, lo que significa que la forma de representar el código es igual a la forma de representar la estructura de datos básica.
En Clojure y en la familia de lenguajes basados en LISP, la estructura de datos elemental es la lista, así que en Clojure en rigor no existe una sintaxis como tal. Lo que hay son listas que son procesadas por el interprete de Clojure.
De este modo para llamar a la función (multiplica) lo que hacemos es crear una lista, donde la cabeza es el nombre de la función:
(multiplicar 2 500) ; retorna 1000
Clojure maneja otra estructura que es el vector. Un vector se expresa colocando los elementos entre corchete, por ejemplo: [1 2 3]. En Clojure la coma es equivalente a un espacio, así que podemos escribir [1, 2, 3].
Entonces, siguiendo el principio de homoiconicidad, los parámetros de una función se expresan como un vector y la definición de la función es una lista. Una lista puede tener otras listas o vectores como elementos.
Veamos todo esto reflejado en la función (gano?) que evalúa si el jugador ganó la partida:
El nombre de una función debe empezar por un carácter no numérico, y puede estar compuesto por letras, números y los símbolos *, +, !, -, _, ',y ?.
En este caso llamamos a la función (gano?) usando el signo de interrogación. Los parámetros se escriben como un vector [num sec tam], los vectores se usan para crear variables auxiliares usando la forma let. Todo esto muestra la homoiconicidad, en que el código de expresa usando las estructuras de datos básicas (listas y vectores).
En Haskell el nombre de la función va seguido de sus parámetros, los que se separan simplemente con el espacio en blanco:
multiplicar a b = a * b
Para llamar a la función colocamos su nombre seguido de los parámetros, otra vez separados sólo por espacios (sin usar paréntesis ni comas):
multiplicar 2 500
Es mejor pensar en Haskell que lo que tenemos es una ecuación o identidad, de este modo podemos entender este fragmento
mil = multiplicar 2 500
Hay más sobre las funciones en Haskell y algo hemos visto, como los guards y la cláusula where:
El estilo de declaración en F# es similar, pero usamos la palabra reservada let para iniciar una declaración:
let multiplicar = a * b
Y para ejecutar la función la llamamos igual que en Haskell.
En Erlang las cosas son bastante diferentes. En este lenguaje una función es secuencia de cláusulas separadas por punto y coma (';'), terminada con un punto ('.'):
fact(N) when N>0 -> % encabezado de la primera cláusula N * fact(N-1); % cuerpo de la primera cláusula fact(0) -> % encabezado de la segunda cláusula 1.
El cuerpo de una cláusula puede contener una lista de cláusulas separadas por comas (','), por ejemplo, nuestra función main se compone de sólo una cláusula, con varias sub cláusulas en el cuerpo de la función:
En esto se nota el ancestro común (C). Sin embargo, todos estos lenguajes colocan el tipo de los parámetros después del identificador del mismo, como en Pascal. Sólo Go omite los dos puntos (':').
En otro artíclo hablaremos sobre los tipos de las funciones y las variables.
Conclusiones
El siguiente cuadro resume la cantidad de líneas de código para cada implementación:
Las he ordenado de menor a mayor. Estos números pueden variar más adelante si es que reviso el código. Los datos actualizados siempre estarán en el archivo README.md del repositorio.
El orden en que resolví estos problemas es el siguiente:
Aunque no medí el tiempo de desarrollo (algo que espero realizar en los futuros desafíos), me atrevo a clasificar del siguiente modo las soluciones según el tiempo invertido en desarrollar cada una (codificar, probar y depurar):
orden según el tiempo invertido en solucionar el problema (de menos a más) 1 Scala 2 Kotlin 3 Swift 4 Go 5 Clojure 6 F# 7 Rust 8 Haskell 9 Erlang
El mayor tiempo invertido en Erlang se debe a lo diferente que es este lenguaje con respecto a los demás.
La solución en Go permitió guiar el desarrollo de las soluciones en casi todos los demás lenguajes tipo D. Aunque escribí el código de Rust primero, el uso de ranges de Go me llevó a re escribir la solución Rust. La escritura del programa en Haskell tuvo varias iteraciones.
Si tuviera que implementar esto para un juego por SMS usaría seguramente Erlang o Go, porque son lenguajes apropiados para ese tipo de aplicaciones. Pero si la restricción es que sólo corra en la linea de comandos, quizás lo menos complicado sería hacerlo en Go.
Sin embargo, la solución que encuentro más elegante es la que escribí en Clojure, no tiene loops (salvo el del ciclo principal del programa), no hay recursividad y todo se resuelve con operaciones con listas. Hay otra forma más eficiente de solucionarlo con Clojure y creo que la implementaré en otra ocasión (al igual que en Haskell).
Por ahora hasta acá queda el desafío 1. Espero sus comentarios y retro alimentación.
(*) Por Dijkstra y McCarthy. Al parecer hubo cierta polémica entre ellos, aunque McCarthy fue más reservado en sus comentarios que Dijkstra.
[1] En su discurso para recibir el Premio Turing, Dijkstra escribió: LISP ha sido descrito humorísticamente como "la forma más inteligente de desaprovechar un computador". Pienso que la descripción es un gran cumplido porque transmite un sabor pleno de liberación: ha asistido a un número de nuestros más dotados compañeros humanos a pensar en ideas previamente imposibles."
La palabra Bug, literalmente "bicho", usada para denotar un error informático, tiene su origen en el hallazgo de una polilla por parte de los operadores del Mark II, el hecho fue reportado por la famosa Grace Hopper.
La fotografía de abajo corresponde a una página de la bitácora de operaciones del Harvard Mark II. La nota de Hopper hace mención al "primer caso real de bicho encontrado", denotando que el término bug es mucho más antiguo, el mismo Tomas Alva Edison usa la palabra bug para hablar de fallas mecánicas en sus inventos.
El primer bug
En programación nos encontramos con bugs todo el tiempo. Hay toda clase de bugs, bugs que ocurren porque nos encontramos con una condición no prevista, bugs por un error en la implementación del algoritmo, bugs originados por una mala definición, o por asumir condiciones que no se dan, etc.
Los bugs vergonzosos son aquellos debidos a la mala programación.
En 2014, para el 11º Simposio USENIX de Diseño e Implementación de Sistemas Operativos, un grupo de investigadores de la Universidad de Toronto presentaron el resultado de su investigación sobre sistemas distribuidos en el mundo real.
El Paper de este estudio se llama: "Simple Testing Can Prevent Most Critical Failures" y lo pueden encontrar acá, por si quieren profundizar en el tema.
Los investigadores eligieron los siguientes sistemas:
- Cassandra, la base de datos NoSQL desarrollada originalmente por Facebook, mantenida por la Fundación Apache, y que es usada entre otros por Apple (con más de 100.000 nodos) y NetFlix.
- HBase, otra importante base de datos NoSQL, usada por Spotify y Facebook.
- Hadoop, en particular las componentes MapReduce y HDFS.
-Redis, el famoso ¨servidor de estructuras de datos", usado en multitud de servicios, como GitHub, Twitter, Pinterest o StackOverflow.
De los cinco sistemas analizados, cuatro están escrito en Java y uno en C. Estamos hablando de un estudio del software que es parte de la infraestructura crítica de importantes y masivos servicios de internet.
Estos sistemas son "tolerantes a fallas", es decir, están diseñados para ser resilientes a errores en el entorno, sobrecarga de trabajo, etc. Durante su trabajo, los investigadores lograron provocar un total de 17,216 fallas al sistema, pero 48 fueron catastróficas, es decir, lograron dejar inoperante el sistema. (En un caso la recuperación implicó establecer sesiones SSH manuales a más de 4.000 nodos para detener los procesos).
Lo interesante del estudio es que descubrieron que muchos de los errores catastróficos eran del estilo:
try { .... } catch (Throwable t) { // TODO
LOG("se produjo un error...");
}
El 92% de los errores corresponden a un manejo incorrecto de errores, pero que eran conocidos por los programadores, por estar explícitamente señalados en el código.
El 35% son errores triviales: 25% causados por ignorar el error, 8% por abortar durante la excepción, 2% simplemente tienen un comentario TODO (por hacer, o pendiente, en inglés) en el código.
El 57% de los errores son específicos del sistema, el 23% son detectables fácilmente detectables y el 34% son complejos. La figura de abajo, sacada del paper, resume la distribución de los errores catastróficos:
División de errores
Quiero que reflexionemos sobre esto, estamos hablando de errores triviales, que son manejados de manera descuidada.
También estamos hablando de proyectos Opensource, y con esto tenemos otro desmentido de esa falacia, convertida en mantra por algunos fanáticos, llamada la Ley de Linus:
«Dado un número suficientemente elevado de ojos, todos los errores se vuelven obvios.»
(La ley no fue formulada por Linus Torvalds, sino que por Eric Raymond en su ensayo "La Catedral y el Bazar").
De seguro en su trabajo y en el código propio han visto cosas de este estilo. Nuestros programas están llenos de cosas similares, no tiraré la primera piedra, porque me confieso culpable, y muchas veces he dejado "esto para después" y siempre me ha explotado en la cara. Traten de evitarlo.
Pero lo que no tiene perdón, ni excusas a "estas alturas del partido" es el Bug, o mejor dicho, la "Vergüenza del Bisiesto".
El 29 de febrero de este año, más de 1.200 maletas no llegaron a sus aviones por un error de año bisiesto en el sistema de control de la transportadora de equipajes del Aeropuerto de Düsseldorf.
El Bug del año bisiesto se presenta de 2 formas, la más conocida se da en febrero, pero el 31 de diciembre también ocurren problemas. Muchos programa asumen que el último día del año corresponde al día 365, cosa que no se da en un año bisiesto, que tiene 366 días.
El error del Zune tiene que ver con este otro aspecto, este es el infame código que congeló estos dispositivos:
01 year = ORIGINYEAR; /* = 1980 */ 02 while (days > 365) { 03 if (IsLeapYear(year)) { 04 if (days > 366){ 05 days -= 366; 06 year += 1; 07 } 08 } 09 else { 10 days -= 365; 11 year += 1; 12 } 13 }
El 31 de diciembre de 2008 la variable days tenía el valor 366 y como 2008 es bisiesto, el programa entraba dentro de la condición de if de la linea 03, pero acá no hay ninguna manera de alterar el valor de days, que queda con el valor 366 para siempre, convirtiendo todo este código en un loop infinito.
Pero este problema se da también en nuestro país, este año he escuchado de 3 casos. De seguro ustedes conocen varios y sería interesante oir de algunos en los comentarios.
¿Por qué pasa esto?
Dice en la Biblia:
"Enséñanos a contar bien nuestros días, para que nuestra mente alcance sabiduría." -- Salmos 90:12
Aunque no comparto la fé del viejo Rey Salomón, sí estoy de acuerdo en que es necesario que los programas aprendan a contar bien los días.
Este salmo aparece citado como epígrafe en uno de los artículos más importantes que he leído y que creo que debería leer todo aquel que quiera llamarse a si mismo programador. Se trata de "Calendrical Calculations" un paper publicado en Software Practice and Experience en septiembre de 1990.
Es un paper hermoso, con código expresado en Lisp, lo que le da más estilo aún 😜. Posteriormente los autores expandieron el artículo en forma de libro (en Amazon). En este artículo aparece todo lo que se debe saber sobre el manejo de fechas.
En Calendrical Calculation vemos esta pieza de código, para determinar el último día de un mes:
Como no quiero provocarles urticaria, les voy a mostrar el código en C:
int last_day_of_gregorian_month(int year, int month) { int leap = (year%4 == 0 && year%100 != 0) || year%400 == 0; int days[] = {31,28+leap,31,30,31,30,31,31,30,31,30,31}; return days[month-1];
}
Un año es bisiesto si es divisible por 4, pero se deben excluir las centurias (divisibles por 100), con excepción de las centurias divisible por 400. Es por esto que el año 1.900 no fue bisiesto, pero sí el 2.000.
De seguro el lenguaje que usas tiene funciones para poder calcular esto, por ejemplo en Python:
Lo importante es que verifiquen que las bibliotecas del lenguaje tengan esto bien implementado.
Así que esperemos que no tengamos que volver sobre este tema en cuatro años más, porque significa que somos incapaces de aprender nada.
Sólo piensen en el tiempo perdido, estrés, y dinero que sufrieron los programadores, ingenieros de sistemas en cada uno de los casos que les mencioné antes.
Jaco Pastorius es uno de esos músicos que te deja una profunda impresión cuando lo escuchas. En particular, no volverás a percibir el sonido del bajo de la misma forma después de escucharlo. Su estilo ha influenciado a grandes del rock y el jazz, como el gran Flea de Red Hot Chili Peppers, o Geddy Lee de Rush, quien coloca a Jaco en la cima más alta.
“Sometimes a little bit of his fairy dust might rub off on you when you pretend to play like he does.”
¿Qué tiene que ver esto con estos raros lenguajes nuevos?
En realidad nada. O muy poco, pero a mi me gusta Jaco Pastorius y su música fue la principal banda sonora mientras resolvía el segundo desafío de esta tarea, que me he auto impuse de visitar nueve lenguajes de programación en nueve ejercicios.
Este segundo desafío, se llama Weather Report (reporte del tiempo), para homenajear a la famosa banda de jazz fusión, en donde tocó Jaco al inicio de su carrera.
Es un mundo concurrente
Creo que una de las características más importantes de un lenguaje moderno tiene que ver con la manera en que aborda la programación paralela.
La programación paralela es una forma de programación concurrente, en que aprovechamos las capacidades de los sistema multiprocesador.
Programación Concurrente: es la composición de módulos que se ejecutan independientemente, de forma asíncrona y no determinista. Programación Paralela: el paralelismo es una forma de ejecutar programas concurrentes. La programación concurrente es una forma de estructurar los programas, no el número de procesadores que usa para su ejecución.
"Los problemas de procesos concurrentes no son exclusividad del procesamiento paralelo, también ocurren con un único procesador. Los estudios de concurrencia y paralelismo son diferentes. El primero se ocupa de la correcta composición de componentes no deterministas, el segundo de la eficiencia asintótica de programas de comportamiento determinista."
Como les dije antes, vamos a resolver un problema de programación paralela. Los aspectos más característicos de programación concurrente los estudiaremos más adelante en otros desafíos.
Consultando el reporte del tiempo
El desafío 2 consiste en construir una aplicación que obtenga el clima de distintas ciudades, usando la API de OpenWeatherMap.org.
Se debe crear un programa que reciba a través de la línea de comandos una lista de ciudades. Este programa debe realizar una consulta al del sitio OpenWeatherMap.org para obtener el informe del tiempo para cada una de las ciudades. El resultado se debe ordenar de forma descendente, desde la mayor a la menor temperatura. El resultado debe contener, la ciudad, la temperatura máxima y las condiciones del clima. Además el programa debe informar el tiempo cronológico ocupado para descargar la información. Si se pasa el parámetro -p el programa hace la consulta "en paralelo" para cada ciudad.
Por ejemplo, este es el resultado de la ejecución del programa al momento de escribir estas lineas:
$ weather Berlin Santiago Boston Madrid Concepcion Mexico Mexico 26.00 nubes rotas Santiago 15.00 cielo claro Concepcion 15.00 nubes rotas Madrid 10.70 nubes dispersas Boston 10.00 cielo claro Berlin 03.00 cielo claro tiempo ocupado para generar el reporte: 00:00:02.105
Si lo ejecuto con el parámetro -p:
$ weather -p Berlin Santiago Boston Madrid Concepcion Mexico Mexico 26.00 nubes rotas Santiago 15.00 cielo claro Concepcion 15.00 nubes rotas Madrid 10.80 nubes dispersas Boston 10.00 cielo claro Berlin 03.00 cielo claro tiempo ocupado para generar el reporte: 00:00:01.546
Notarán que en este caso la versión paralela del programa toma un tiempo menor (1,546 segundos versus 2.105 segundos).
La siguiente tabla muestra los tiempos de ejecución de la versión Go del programa, ejecutándolo de manera secuencial y paralela (con el parámetro -p). Los tiempos están en segundos, partiendo con 1 ciudad hasta llegar a 10 ciudades. Fueron probados en un Macbook Pro con un procesador Intel I7 de 4 cores.
La columna s muestra el speedup, o factor de mejora, que mide cuanto mejora el tiempo de ejecución en paralelo con respecto a la ejecución secuencial.
Estos valores no deben considerarse una medición precisa, es necesario hacer muchas más para determinar un valor adecuado de speedup. Pero nos da una idea del efecto de paralelizar la consulta.
Si tenemos 4 cores, y asumimos que el 90% del proceso se puede ejecutar en paralelo, tenemos un límite teórico de 3,07 de speedup, de acuerdo a la Ley de Amdahl.
Con los valores de la tabla de arriba calculé el Speedup Teórico de acuerdo a las leyes de Amdahl y de Gustafson, que incluyo sólo como dato anecdótico, en este caso consideré el valor p = 0,90 para el cálculo de este parámetro:
Si no entienden de que se trata todo esto les sugiero leer mis posts sobre la Ley de Amdahl (acá, acá y acá), o mejor aún, el capítulo respectivo en mi libro 😜.
El parámetro p de la ley de Amdahl es la proporción del programa que se ejecuta en paralelo. Para entender por qué consideré un valor de p = 0,90 les voy a describir la forma general de mi solución a este desafío.
Consultas en paralelo
Si resolvemos el problema para consultar el clima de una lista de ciudades en forma secuencial, una solución posible se puede expresar con el siguiente algoritmo (escrito en seudo código):
Sea N la cantidad de ciudades. Sea Cities[0..N-1] un arreglo con los nombres de las ciudades. Sea Reporte[0..N-1] un arreglo con los reportes del tiempo para cada ciudad. Sea ArgV[] un arreglo con los argumentos del programa recibidos en la linea de comandos.
for i = 0; i < N; i++ { Cities[i] = ArgV[i] } for i = 0; i < N; i++ { Reports[i] = CallApiOpenWeatherMap(Cities[i]) } SortInPlaceByTemp(Reports)
for i = 0; i < N; i++{ PrintReport(Reports[i]) }
Gráficamente este programa se ejecuta de la siguiente manera:
ejecución secuencial del programa
Cada círculo representa una sub rutina, en este caso, el circulo de color celeste representa las llamadas a la API de OpenWeatherMap. El resultado de esta llamada lo guardamos en un elemento de nuestro arreglo de reportes, que representamos con cajas grises. Después ordenamos ese arreglo de reportes, esto está representado por el círculo de color naranja con la palabra sort.
Podemos mejorar el tiempo si ejecutamos las llamadas a la API de forma paralela, de este modo:
En teoría, tendremos una fracción del tiempo usada en la operación en paralelo y una fracción secuencial que corresponde al ordenamiento de los datos (sort). ¿Cuál es la proporción del tiempo ocupada en las llamadas en paralelo? Ese es el parámetro p, yo le asigné un valor de 0,9, porque asumo que el 90% del tiempo se gasta en consultar la API y ordenar ocupa el otro 10%. Esto es un valor arbitrario, determinarlo en forma precisa escapa a los objetivos de este ejercicio, pero ustedes pueden intentar determinarlo con mayor precisión.
¿Cómo reflejamos esta paralelización en nuestro algoritmo?
Del siguiente modo:
Sea N la cantidad de ciudades. Sea Cities[0..N-1] un arreglo con los nombres de las ciudades. Sea Reporte[0..N-1] un arreglo con los reportes del tiempo para cada ciudad. Sea ArgV[] un arreglo con los argumentos del programa recibidos en la linea de comandos. for i = 0; i < N; i++ { Cities[i] = ArgV[i] } par for i = 0; i < N; i++ { Reports[i] = CallApiOpenWeatherMap(Cities[i]) } SortInPlaceByTemp(Reports) for i = 0; i < N; i++{ PrintReport(Reports[i]) }
El truco fue cambiar el for central por un par for.
yaaaa?!!
Ya sé que esto lo encuentran sospechoso, no se preocupen, se los voy a explicar.
Hay algunos lenguajes que tienen construcciones similares a lo que he llamado par for.
Este par for es una estructura de control que ejecuta en paralelo las instrucciones del cuerpo del ciclo. Hay compiladores de Fortran y C para super computadores que tienen construcciones similares a esta.
En un supercomputador con muchos procesadores lo que hace una estructura de control como esta es ejecutar el cuerpo del ciclo en un procesador distintos. Esta estructura de control se encarga de sincronizar el término de cada una de las ejecuciones secuenciales.
Pero esto está disponible en uno de los lenguajes que estamos revisando. La solución en Swift del desafío 2 (que pueden ver acá) usa algo muy similar al seudo código.
var reports = [ApiResult?]() for i in 1...cities.count { reports.append(nil) } let globalQueue = dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0) dispatch_apply(cities.count, globalQueue) { i in let index = Int(i)+2 // cities start from 2 let city = cities[index] let rep = callApi(city, apiKey:apiKey) reports[Int(i)] = rep } // sort...
En Swift usamos Grand Central Dispatch, que es un modelo de concurrencia desarrollado por Apple,
Lo que hacemos es crear una cola de tareas global (globalQueue). Sobre esta cola despacharemos tareas (tasks).
La función dispatch_apply() es similar a la estructura de control par for, ejecutará una clausura (el código entre { }) en forma concurrente. Esta función se encarga de esperar que terminen todas las tareas.
La ventaja de este problema es que es muy fácil de paralelizar. Sólo debemos tener cuidado de esperar que las descargas de información finalicen antes de ordenar e imprimir el resultado. Es por esto que podemos en nuestro for acceder al arreglo reports tranquilamente.
Pero las cosas no son tan sencillas si usamos otros lenguajes tradicionales tendríamos que hacer algo del estilo (en seudo código):
for city in cities { task { city => val report = callApi(city) lock(reports) reports[city] = report unlock(reports)
} } wait_for_tasks() sortInPlace(reports)
Acá task es una suerte de thread o tarea que se ejecuta en forma concurrente. Notar que tenemos que asegurar acceso exclusivo al arreglo reports para actualizarlo y tenemos que invocar algún tipo de primitiva que nos asegure que las tareas han finalizado (wait_for_tasks()).
Las ventajas de la programación funcional concurrente
Pero veamos el problema con los ojos de la programación funcional. Este problema tiene una forma clásica, conocida como map-reduce.
Funcionalmente lo que tenemos que hacer es mapear cada ciudad con su reporte y luego ordenar (reducir).
En seudo código funcional, este problema se puede escribir así:
print $ sort $ (map apiCall cities)
Esto es en realidad un seudo Haskell, que dice que debemos imprimir el resultado de ordenar el mapping de ciudades a sus reportes (el reporte se obtiene con la función apiCall).
Y para paralelizar esto simplemente hacemos:
print $ sort $ (pmap apiCall cities)
La función pmap es un map pero ejecutado en paralelo.
Yaaaa_ ¿Otra vez????
Sí, parece magia, pero las soluciones en Clojure y Scala de este problema son así de sencillas:
Scala:
def parFetch(cities:List[String]) : Unit = { val reports = (cities.par map (city => apiCall(city))).toList printReports(reports) }
Esta solución aparte de concisa nos permite abstraernos de las sincronización y todas las complicaciones de la programación concurrente. La inmutabilidad permite ver este problema como un flujo de información que va pasando de función a función. La abstracción pmap es el equivalente al par for en el mundo funcional.
Pero hay otra forma de resolver este problema y es con canales, siguiendo el estilo CSP Communicating Sequential Process (propuesto originalmente por Hoare en 1978).
Acá lo que hacemos es lo siguiente:
ch := new_channel(type: Report) for _ := 0 to N-1 { city := cities[i] task { chan_put (ch, apiCall(city)) } } for _ := 0 to N-1 { report.append(chan_get(ch)) }
sort_in_place(report)
print(report)
Piensen en el channel como una cola de mensajes. En este caso task { } ejecuta una tarea de forma concurrente. Dentro de esta tarea colocamos en el canal ch el resultado de invocar la API de OpenWeatherMap, usando la primitiva chan_put(). Esta es una operación que se ejecuta de forma sincronizada.
Posteriormente vaciamos esta cola de mensajes (el canal) usando la primitiva chan_get(), colocando cada mensaje (que corresponde al reporte del clima) en una lista report.
Hay que notar que el orden en que sacamos los reportes del canal no tiene por que coincidir con el orden en que ejecutamos las tareas. Es decir, si el arreglo de ciudades contiene los valores {Boston, Santiago, Madrid, Londres}, en el segundo loop podríamos obtener los reportes de {Santiago, Londres, Boston, Madrid} o cualquier otro orden.
Esta es la forma en que resolvemos este problema en Go y Rust.
Go:
ch := make(chan WeatherReport) for _, city := range cities { go fetch(city, ch) // fetch call api and put response in ch } forrange cities { rep := <- ch reports = append(reports, rep) }
Rust:
let (tx, rx) = mpsc::channel(); for city in cities { let tx = tx.clone(); let city = city.clone(); thread::spawn(move || { let report = api_call(&city, &api_key); tx.send(report).unwrap(); }); } letmut reports : Vec<ApiResult> = Vec::with_capacity(cities.len()); for _ in cities { let rep = rx.recv().unwrap(); reports.push(rep); }
La solución en Rust tiene más "ruido" que la versión en Go debido a las reglas de "borrowing" de este lenguaje, algo que veremos en detalle en un artículo específico sobre este lenguaje.
Cinco soluciones
En este artículo les he mostrado 5 soluciones de este problema:
El plan original, bueno el plan modificado del original, era implementar cada uno de los 9 desafíos en un mes. Pero la verdad es que he tenido un inicio de año bastante ocupado y mi tiempo para dedicarle a este proyecto se ha visto afectado. La primera parte de este desafío la publiqué el 31 de marzo, así que me ha tomado ¡dos meses completar el desafío!
Si bien, no son tantas las horas efectivas dedicadas a resolver el problema, la dificultad está en la dedicación para poder sentarme, investigar y escribir el código. Espero poder retomar el ritmo de publicación en los próximos desafíos.
Pero lo importante es terminar lo empezado, así que acá va la segunda parte del Reporte del Clima.
Lee Sklar
Leland Bruce "Lee" Sklar debe ser el más famoso de los bajistas menos famosos del mundo del rock. De seguro lo han visto acompañando a grandes artistas, como Phil Collins o Toto. ¡Ha participado en más de 2.000 álbumes!
Lee Sklar no tiene la fama de Pastorious, Flea o Geddy Lee, pero es un bajista muy buen, por algo ha sido invitado a participar en más de 2.000 álbumes, y fue elegido por Toto para acompañarlos hace unos años atrás.
Sklar es como los lenguajes que vamos a revisar a continuación, tienen menos momentum, son conocidos por unos pocos entusiastas, son de nicho, pero tienen características notables, que los hacen resaltar, aunque sea por lo pintoresco (como la famosa barba de Sklar).
En la primera parte, resolvimos el problema de consultar el estado del clima usando la API del sitio OpenWeatherMap.org. Para esto usamos como lenguaje de implementación Scala, Go, Clojure, Rust y Swift. Los más conocidos y usados de nuestra lista de 9 lenguajes.
En esta segunda parte vamos a revisar las soluciones en F#, Haskell, Erlang y Kotlin. En una tercera parte, que publicaré en una semana más, revisaré otros aspectos de este desafío (como la llamada a la API usando HTTP, y el parsing de Xml).
Concurrencia en F#
Si recuerdan, tenemos dos maneras de ejecutar el programa, de manera secuencial y en paralelo, tal como lo describí en estos dibujos
Ejecución Secuencial
Ejecución en Paralelo
Vimos que lenguajes como Clojure y Scala tienen una manera muy elegante de realizar esto.
Notar que en vez de llamar a la función api_call, llamamos a api_call_async. La diferencia entre ambas funciones es la siguiente
let api_call city = api_call_n city 10
let api_call_async city = async { return api_call_n city 10 }
la función api_call_n invoca la API para la ciudad que recibe como parámetro. El número 10 corresponde a la cantidad máxima de reintentos para llamar la api. Esta función retorna el reporte del clima para la ciudad.
La diferencia es que api_call_async no ejecuta el código de inmediato, este se ejecutará al pasar por Async.Parallel. La función Async.RunSynchronously espera el resultado de la ejecución en paralelo y lo entrega a List.ofArray (porque la función Async.Parallel nos entrega un arreglo con los resultados.
Esto es más complejo que con Clojure o Scala, pero es simple y elegante.
Concurrencia en Haskell
Las soluciones en Haskell son muy similares, la versión secuencial es esta:
import qualified Control.Monad.Parallel as P
....
process_par api_key args = do lreps <- preps print_reports $ sortBy cmp_rep lreps where preps = P.mapM (make_report . (api_call api_key)) args
La diferencia está destacada en negritas.
La versión secuencial mapea la función compuesta (make_report . (api_call api_key) con args.
La versión paralela hace exactamente lo mismo, pero hace un mapeo en paralelo usando Control.Monad.Parallel.
Para entender a profundidad este problema hay que dominar el concepto de Monadas, pero intentaré simplificar la explicación.
Vamos primero por la versión secuencial:
La declaración de la función es:
process_seq api_key args =
Define una función que recibe la clave de la API y una lista de argumentos args (la lista de ciudades).
Luego ejecutamos lo siguiente usando un bloque do[1]:
do lreps <- preps
print_reports $ sortBy cmp_rep lreps
Lo primero es rescatar la lista de reportes en lreps, esta viene en la variable preps que será definida por la sentencia where de más abajo. Luego de rescatar la lista de reportes en lreps, los ordenamos usando la función sortBy (que usa la función cmp_rep, que compara reportes). La lista ordenada es el argumento para función print_reports.
La sentencia where mapea la lista de ciudades a una lista reportes:
where preps = mapM (make_report . (api_call api_key)) args
Dividamos esto paso a paso. Recordemos que args es la lista de ciudades. Lo que tememos acá es equivalente a esto:
map F cities
Acá F es una función compuesta: F = make_report . (api_call api_key)
El operador . permite componer funciones en Haskell, en general, si tienes una función f y otra función g, entonces (f . g) x es equivalente a hacer (f (g x).
¿Por qué usamos mapM en vez de map?
Porque api_call retorna un valor de tipo IO String (esta es una monada), la función make_report recibe un argumento de tipo IO String y retorna un valor de tipo IO WeatherReport (otra monada). La función mapM es como la función map, pero opera con listas de monadas.
En realidad mapM recibe algo del estilo [m t] y retorna m [t], es decir, recibe una lista de monadas de T, y retorna una monada de una lista de T.
La definición de mapM es:
mapM :: Monad m => (a -> m b) -> [a] -> m [b]
Esto explica por qué hacemos lreps <- preps. La función sortBy requiere una lista pura, como preps es una monada que contiene una lista, lo que hacemos con esta asignación es sacar la lista fuera de la monada.
En general, si tenemos una monada m v, cuando hacemos x<- m v, asignamos v a x.
¿Qué es una monada? Es la pregunta común de quienes empiezan a aprender Haskell.
¿Recuerdan cuando en el juego de toque y fama hicimos lo siguiente?
acc <- getLine
Lo que ocurre es que getLine no es una función pura, algo que no nos gusta en Haskell, una función pura no tiene efectos laterales, pero getLine es una función que interactúa con el mundo exterior, es lo que se llama una acción destructiva (ver revelaciones). Haskell nos protege del mundo exterior a través de la monadas. En particular la función getLine en vez de retornar un String lo que hace es retornar una monada IO String.
A mi me gusta pensar en las monadas como en cajas. En Haskell para conversar con el mundo exterior usamos estas cajas. La función getLine retorna una caja que se llama IO cuyo contenido es un String.
Nuestro programa usa muchas monadas puesto que debe interactuar con el sitio web de OpenWeatherMap, debe leer variables de ambiente del sistema operativo, escribir en pantalla en reporte del tiempo, medir el tiempo de ejecución de una función, etc.
Si no entienden mucho, no se preocupen, no traten de entender de inmediato el concepto de monada, simplemente escriban varios programas en Haskell que usen funciones de entrada/salida, como en este ejercicio, todo irá calzando con el tiempo. De todas maneras, voy a escribir después un post especial sobre Haskell donde explicaré este concepto.
Erlang
Erlang implementa el modelo de actores para ejecutar concurrencia. Lo que haremos es crear actores que ejecutan sólo una acción. Tradicionalmente un actor en Erlang se mantiene en un loop recibiendo mensajes, en este caso sólo esperamos un mensaje y terminamos.
Es un simple mapeo de ciudades a reportes, usando una función declarada inline. Esta función crea la Url, invoca la api y luego extrae el reporte (que es el resultado).
La función recolectar recibe una lista de handles de actores. Esta función le envía un mensaje a cada actor:
ReqId ! {self(), get_result}
El mensaje tiene dos partes, el identificador del proceso principal (self()) y el átomo get_result.
Recordemos que cada actor responde con el mensaje {xml, Xml, Error}.En este dibujo traté de explicar lo que pasa. El lado izquierdo inicializa los actores (circulos pequeños de color verde). El lado derecho, explica lo que ocurre después. En este caso se inicia enviando el mensaje get_result a cada actor creado. Cada actor responde con el resultado de invocar la api {xml, Xml, Error}.
Kotlin
Con este lenguaje decidí usar un mecanismo similar a la solución en Scala.
El código final es básicamente así:
// Solución secuencial
val reports = cities.map(::apiCall) printReports (reports)
La solución paralela quedó así:
// Solución paralela
val reports = cities.par().map(::apiCall) printReports(reports.unpar().toList())
reports.executorService.shutdown()
En realidad Kotlin no tiene la primitiva par() para listas. Para hacerlo usé código publicado por Holger Brandl acá.
Este código extiende las colecciones con la primitiva par().
/** A delegated tagging interface to allow for parallized extension functions */ class ParCol<T>(val it: Iterable<T>, val executorService: ExecutorService)
: Iterable<T> by it
/** Convert a stream into a parallel collection. */ fun<T> Iterable<T>.par(numThreads: Int = Runtime.getRuntime().availableProcessors(), executorService: ExecutorService = Executors.newFixedThreadPool(numThreads))
: ParCol<T> { return ParCol(this, executorService); }
El código usa la clase ExecutorService de Java 8. Este es un servicio que permite trabajar con threads y código concurrente.
Entonces cuando hacer lista.par(), lo que hacemos es crear una instancia de la clase ParCol que contiene la lista y le agrega un ExecutorService.
Otra extensión que agregamos es la función map():
fun<T, R> ParCol<T>.map(transform: (T) -> R): ParCol<R> { val destination = ArrayList<R>(if (this is Collection<*>) this.size else 10) val futures = this.asIterable().map {
executorService.submit {
destination.add(transform(it))
}
} futures.map { it.get() } // this will block until all are done return ParCol(destination, executorService) }
Acá creamos una lista de futures que son los que aplican la función transform() a cada elemento de la colección.
Luego nos quedamos esperando la ejecución de cada uno de las llamadas a transform().
La función map() crea finalmente una nueva instancia de la clase ParCol, esta vez con el resultado de la computación de transform() sobre todos los elementos de la colección inicial.
Para recuperar el resultado usamos la función unpar(), que retorna la lista contenida dentro de la instancia de ParCol.
fun <T> ParCol<T>.unpar(): Iterable<T> { return this.it; }
Esta implementación nos da la pista de cómo poder implementar parmap en Java 8. ¿Alguno se animaría a solucionar este desafío usando Java 8? Si es así lo invito a agregar su propuesta mediante un pull request a mi repositorio: https://github.com/lnds/9d9l
Comparemos los resultados en Kotlin, para medir que tan eficiente es esta solución:
$ java -jar weather-1.0-SNAPSHOT-all.jar Santiago Boston Londres Valdivia Antofagasta
Boston max: 32,0 min: 12,2 actual: 17,6 niebla Antofagasta max: 17,0 min: 17,0 actual: 17,0 nubes rotas Valdivia max: 12,0 min: 12,0 actual: 12,0 llovizna Santiago max: 12,0 min: 12,0 actual: 12,0 chubasco de ligera intensidad Londres max: 8,5 min: 8,5 actual: 8,5 nubes dispersas tiempo ocupado para generar el reporte: 0:00:1,372
En Paralelo:
$ java -jar weather-1.0-SNAPSHOT-all.jar -p Santiago Boston Londres Valdivia Antofagasta Boston max: 32,0 min: 12,2 actual: 17,6 niebla Antofagasta max: 17,0 min: 17,0 actual: 17,0 nubes rotas Valdivia max: 12,0 min: 12,0 actual: 12,0 llovizna Santiago max: 12,0 min: 12,0 actual: 12,0 chubasco de ligera intensidad Londres max: 8,5 min: 8,5 actual: 8,5 nubes dispersas tiempo ocupado para generar el reporte: 0:00:0,516
La versión secuencial en mi equipo toma 1,372 segundos, la versión paralela demora 0,516 segundos (menos de la mitad del tiempo!).
Con esto terminamos la implementación de este desafío en 9 lenguajes. Sólo para cerrar todo publicaré otro artículo con los detalles que no cubrí, que corresponden a cómo descargar desde una URL (usando HTTP) y parsear un archivo XML.
Cuatro Soluciones
En este artículo he mostrado 4 soluciones adicionales a este problema.
Este artículo termina la descripción del segundo desafío en esta serie de nueve.
Recordemos que el desafío consiste en construir una aplicación que obtenga el clima de distintas ciudades, usando la API de OpenWeatherMap.org. Cnstruimos un programa que recibe a través de la línea de comandos una lista de ciudades. Este programa ejecuta una consulta al sitio OpenWeatherMap.org para obtener el informe del tiempo para cada una de las ciudades. El resultado queda ordenado de forma descendente, desde la mayor a la menor temperatura. El programa despliega la siguiente información por cada ciudad: el nombre, la temperatura actual, la máxima y la mínima más las condiciones del clima. Finalmente el programa debe informa el tiempo ocupado para descargar la información. Si se pasa el parámetro -p el programa hace la consulta "en paralelo" para cada ciudad.
Para ocupar esta API hay que inscribirse en el sitio y obtener una clave (Key) para poder usarla. Como este es un dato privado, definimos una variable de ambiente en el sistema operativo donde dejaremos el valor de esta clave. Esta variable se llama WEATHER_API_KEY. La forma de rescatar el valor de esta variable es bastante simple en cada lenguaje:
let api_key = env::var("WEATHER_API_KEY").expect("debe definir WEATHER_API_KEY");
En este caso la función env::var() retorna un valor de tipo Result, que es un tipo algebraico, la función puede contener el valor Ok(r) o Err(E). El método expect() de la clase Result evalúa el resultado, si es Ok(r) retorna r, de lo contrario detiene el programa llamando a la función panic con el mensaje "debe definir WEATHER_API_KEY.
Swift
let apiKey = NSProcessInfo.processInfo().environment["WEATHER_API_KEY"]!
Notar que hay un símbolo ! al final de la expresión, esto porque en el arreglo enviroment los elementos son de tipo String?, es decir, pueden contener un String o null. Nosotros queremos que este valor no sea nulo, y esto lo forzamos colocando el ! al final. Si la variable no está definida el programa se detendrá con un error.
F#
let api_key = Environment.GetEnvironmentVariable("WEATHER_API_KEY")
Erlang
-define(API_KEY, os:getenv("WEATHER_API_KEY")).
En este caso es una macro que se define en el encabezado del programa.
Haskell
import System.Environment do api_key <- getEnv "WEATHER_API_KEY"
Kotlin
val apiKey = System.getenv("WEATHER_API_KEY")
Ahora un ejercicio, determina qué pasa cuando no se define la variable de ambiente WEATHER_API_KEY y cómo reacciona cada implementación.
Para probar esto se debe obtener la API Key en el sitio OpenWeatherMap.Org.
Descarga de una URL
La API puede retornar el resultado en formato XML o JSON. Elegí la opción XML para explorar como procesar este formato en distintos lenguajes. En otro desafío más adelante veremos como analizar información en formato JSON.
Veamos como se ejecuta la API en cada uno de los lenguajes, primero nos concentraremos en descargar el XML desde una URL, después veremos cono analizar el XML.
En Go el manejo de errores se hace mediante la siguiente convención, junto con el resultado se retorna una variable que indica si hubo error. En este caso, la función http.Get(url) retorna nil si todo funciona bien. La variable resp contiene el estado de la consulta y el cuerpo con la información.
Para obtener el XML hacemos: body, err := ioutil.ReadAll(resp.Body).
Esto deja en body todo el texto del XML, el que es procesado después por la función ParseCurrentWeather.
En retrospectiva la forma en que implementé esto en Go no es la mejor, ¿puedes re escribir el código de modo que sea más adecuado a lo usual en Go? Si te animas, puedes hacer un pull request.
Clojure
(defn parse-xml [source] (try (xml/parse source) (catch org.xml.sax.SAXParseException e {:tag :error :content "xml format"}) (catch java.io.IOException e {:tag :io-error}) (catch Exception e {:tag :general-error :content e})))
Acá matamos dos pájaros de un tiro. La función xml/parse descarga un texto xml desde una url. Notar que hay un try, puesto que Clojure usa funciones de Java las que producen excepciones cuando hay fallos.
Scala
val response = Try(Source.fromURL(url))
La clase Try es un tipo algebraico, que retorna Succes(xml) si no hay problema al descargar el Xml desde la url. El otro valor será Failure(e) donde e es la excepción que se produce.
Rust
Para Rust usamos un crate llamado Hyper:
extern crate hyper; let client = Client::new(); match client.get(&url).header(Connection::close()).send() { Err(_) => ..., Ok(mut req) => { let mut body = String::new(); req.read_to_string(&mut body).unwrap(); match Document::parse(body.as_bytes()) { ....
Notar que manejamos los dos casos, la falla (Err) y el éxito (Ok) usando la sentencia match.
Acá usamos un objeto de la clase Client para llamar a la API.
Swift
iflet myURL = NSURL(string: url) { ... }
Acá usamos una construcción de Swift, si NSURL falla retornará nil, si eso pasa el cuerpo del if no se ejecutará.
F#
En el caso de F# usamos una biblioteca llamada FSharp.Data
open FSharp.Data open FSharp.Data.HttpRequestHeaders let res = Http.RequestString("http://api.openweathermap.org/data/2.5/weather", httpMethod = "GET", query=["q", city; "mode",
"xml"; "units", "metric"; "lang", "sp"; "appid", api_key], headers = [Accept HttpContentTypes.Xml])
Erlang
llamar_api(Url, N) -> {Res, Response} = httpc:request(get, {Url,[]}, [{timeout,1000}], []), case Res of ok -> {{_,Code,_}, _, Body} = Response, if Code < 400-> {Body, ""}; Code > 399 -> {[], "Error consultando API, revise api key"} end; error -> if N =:= 0 -> {[], "Api no disponible"}; N > 0 -> ... end end.
De manera similar a Go, la llamada a httpc:request devuelve el resultado más la respuesta. Si la respuesta es correcta (Res = ok), analizamos Response, el que descomponemos de este modo:
{{_,Code,_}, _, Body} = Response
Code es el código que retorna el protocolo HTTP (si es un valor menor a 400 no hay errores). En ese caso Body contiene el texto con el XML.
Haskell
import Network.HTTP
api_call api_key city = simpleHTTP (getRequest url) >>= getResponseBody where url = "http://api.openweathermap.org/data/2.5/weather?q="++city++"&mode=xml&units=metric&lang=sp&appid="++api_key
La llamada simpleHTTP (getRequest url) retorna una monada de tipo IO (Result (Response ty)).
La función getResponseBody es declarada como:
getResponseBody :: Result (Response ty) -> IO ty
como getResponseBody no acepta de tipo IO (Result (Response ty)) usamos el operador >>= que basicamente saca de la monada IO el contenido y lo pasa a otra función que retorna una monada IO.
Kotlin en realidad otra manera de usar Java, así que es bastante simple. La diferencia es que URL en Java no tiene el método readText(), este amplía esa clase con el método readText().
Analizando XML
El análisis, o parsing de XML es lo más interesante de este ejercicio, así que si resististe hasta este punto ahora verás algo que vale la pena, porque muestra cómo cada lenguaje revela sus características únicas en las bibliotecas que implementan el "parsing" de XML.
XML de la API
Para futuras referencias este es un ejemplo de un resultado al llamar a la API:
En este caso se definen 4 estructuras. Current contiene las otras 3 estructuras: City, Temperature, Weather. Estos se obtienen de los elementos con el respectivo nombre. Por ejemplo, el fragmento:
City City `xml:"city"`
Indica que la variable de instancia City se obtiene del elemento city del xml.
Luego usamos la función xml.Unmarshal() para transformar el String con el xml en un objeto de tipo Current. Con esto obtener los datos que necesitamos para nuestro reporte es muy sencillo.
Clojure
La función xml/parse retorna una estructura. Para extraer cada elemento del XML hacemos:
import scala.xml._ val current = xml \\ "current" if (current.isEmpty) Error("error parsing xml response", city) else { val city = xml \\ "city" \ "@name" val temp = xml \\ "temperature" \ "@value" val min = xml \\ "temperature" \ "@min" val max = xml \\ "temperature" \ "@max" val weather = xml \\ "weather" \ "@value" ... }
Esto es bastante simple comparado con los dos lenguajes anteriores.
Rust
Para este caso usamos un crate llamado treexml:
externcrate treexml; use treexml::Document; match Document::parse(body.as_bytes()) { Err(_) => ..., Ok(doc) => { let root = doc.root.unwrap(); let temp = root.find_child(|tag| tag.name == "temperature").unwrap().clone(); let weather = root.find_child(|tag| tag.name == "weather").unwrap().clone(); let min_temp: f32= temp.attributes["min"].parse().unwrap(); let max_temp: f32= temp.attributes["max"].parse().unwrap(); let cur_temp: f32= temp.attributes["value"].parse().unwrap(); ... }
Document::parse() es la función que convierte un stream de bytes en un documento xml. A partir de ahí usamos el objeto retornado por esta función para poder obtener los elementos que necesitamos.
Notar como transformamos Strings en números de punto flotante.
El método parse de la clase str infiere el tipo al que debe convertirse a partir del lado izquierdo de la expresión, porque parse se declara de este modo:
fn parse<F>(&self) -> Result<F, F::Err> where F: FromStr
Es una función genérica. De este modo, si s es de tipo str entonces en esta expresión:
F v = s.parse().unwrap();
El compilador infiere que el resultado debe ser de tipo F, pero F debe implementar el trait FromStr.
parse() retorna un valor de tipo Result. La función unwrap() que pertenece a la enum Result, retorna el valor Ok(), si el valor es Err() genera un panic, deteniendo el programa.
Swift
let xmlDoc = try NSXMLDocument(contentsOfURL: myURL, options:0) iflet root = xmlDoc.rootElement() { let cityName = root.elementsForName("city")[0].attributeForName("name")!.stringValue! let temp = (root.elementsForName("temperature")[0].attributeForName("value")!.stringValue! as NSString).doubleValue let max = (root.elementsForName("temperature")[0].attributeForName("max")!.stringValue! as NSString).doubleValue let min = (root.elementsForName("temperature")[0].attributeForName("min")!.stringValue! as NSString).doubleValue let weatherConds = root.elementsForName("weather")[0].attributeForName("value")!.stringValue!
Todo esto está dentro de un bloque do .. catch, puesto que se puede producir una excepción dado el try que está en la asignación a xmlDoc.
El resto del código es bastante fácil de deducir. Notar que volvemos a usar la construcción if let.
F#
Esta es, para mi, la solución más simple de implementar, primero definimos un tipo XmlProvider pasándole un string con el ejemplo del XML que queremos analizar:
let xml = Result.Parse(res) let name = xml.City.Name let temp = xml.Temperature.Value let max = xml.Temperature.Max let min = xml.Temperature.Min let cond = xml.Weather.Value
Esto es bastante "mágico", gracias al buen diseño de la biblioteca FSharp.Data.
Erlang
En este caso usamos las funciones de dos módulos, primer xmerl_scan:string que analiza un string y la convierte en un Xml que puede ser analizado usando xmerl_xpath:string. Que usa las convenciones de XPath para recorrer el xml.
extraer_reporte(Xml, _, _) -> {Root,_} = xmerl_scan:string(Xml), Ciudad = extraer_valor(xmerl_xpath:string("//city/@*", Root), name), TempAttrs = xmerl_xpath:string("//temperature/@*", Root), %% truco para el caso en que la temperatura es entera {Temp,_} = string:to_float(extraer_valor(TempAttrs, value) ++ ".0"), {Max,_} = string:to_float(extraer_valor(TempAttrs, max) ++ ".0"), {Min,_} = string:to_float(extraer_valor(TempAttrs, min) ++ ".0"), Cond = extraer_valor(xmerl_xpath:string("//weather/@*", Root), value), {ok,Ciudad,Temp,Max,Min,Cond}.
Como xmerl_xpath retorna una estructura más compleja de lo que necesitamos, definí una función auxiliar que se llama extraer_valor:
extraer_valor([], _) -> error; extraer_valor([{xmlAttribute,A,_,_,_,_,_,_,Value,_}|T], Attr) -> if A =:= Attr -> Value; A =/= Attr -> extraer_valor(T, Attr)
la función xmerl_xpath>string retorna una lista de elementos, de los cuales filtramos los que son de tipo xmlAttribute.
Haskell
Esta es la implementación más dificil de entender, mucho más que lo que acabamos de ver en Erlang.
Este es el fragmento que extrae los elementos que necesitamos:
name val = (xmlRead "city" "name" $ val) temp val = read (xmlRead "temperature" "value" $ val) :: Float max val = read (xmlRead "temperature" "max" $ val) :: Float min val = read (xmlRead "temperature" "min" $ val) :: Float weather val = (xmlRead "weather" "value" $ val)
Hacen uso de la función xmlRead que se define así:
Esta es una función que retorna otra función (usando currying) cuyos dos primeros argumentos son el nombre del elemento que estamos buscano y el atributo dentro de ese elemento. El tercer argumento será el xml.
Por eso cuando hacemos:
xmlRead "city" "name" $ val
Lo que hacemos es aplicar la función xmlRead con argumentos "city" y "name" al xml val.
xmlRead es una composición, notar el uso del operador punto (.) para armar una función compuesta. Esto hay que leerlo de derecha a izquierda.
parseXML analiza el string y devuelve una estructura de datos con la descripción del xml. De esto solo nos interesan los elementos, lo que hacemos con la llamada a la función onlyElems.
De estos elementos filtramos todos los elementos cuyo nonbre sea elem usando la función filterElementsName.
concatMap (map (fromJust.findAttr (unqual attr)) buscará en profundidad dentro de estos elementos los atributos que tengan el nombre que nos interesa.
Finalmente seleccionamos el primero de estos elementos usando head.
(En nuestro caso esta lista siempre tiene sólo un elemento, pero debemos usar head de todas maneras para extraer sólo ese valor).
Yo sugiero probar este código parte por parte usando el REPL de haskell para entenderlo. Pueden hacer cabal repl y probar.
Kotlin
Comparado con el resto de los lenguajes, la manera de analizar el XML en Kotlin es aburrida, y de bajo nivel, porque simplemente usamos la API de Java 1.8:
val factory = DocumentBuilderFactory.newInstance() val builder = factory.newDocumentBuilder() val xmlStrBuilder = StringBuilder() xmlStrBuilder.append(response) val str = xmlStrBuilder.toString() val bytes = str.toByteArray(Charset.forName("UTF-8")) val input = ByteArrayInputStream(bytes) val doc = builder.parse(input) val root = doc.documentElement val cityName = root.getElementsByTagName("city").item(0).attributes.getNamedItem("name").textContent val temp = root.getElementsByTagName("temperature").item(0).attributes.getNamedItem("value").textContent val min = root.getElementsByTagName("temperature").item(0).attributes.getNamedItem("min").textContent val max = root.getElementsByTagName("temperature").item(0).attributes.getNamedItem("max").textContent val conditions = root.getElementsByTagName("weather").item(0).attributes.getNamedItem("value").textContent
Conclusiones
Para terminar, estos son los resultados en términos de líneas de código para cada lenguaje, ordenados de menos a más:
Claramente hay abstracciones mejores que otras y estas no dependen directamente del lenguaje, sino de su entorno, sus bibliotecas y en algunos casos la comunidad. Los lenguajes basados en JVM heredan también muchas funcionalidades empaquetadas. Estoy seguro que la solución en Kotlin desde cero habría sido mucho más larga.
No hay que engañarse por lo breve de la solución de Haskell, es cierto que es tan breve como la de F#, pero es mucho más compleja de entender.
Si Jorge Luis Borges hubiera sido programador habría programado en Haskell, porque al igual que los cuentos del escritor argentino, en Haskell podemos escribir muy pocas lineas, pero con una enorme densidad de conceptos. Hay cuentos de Borges de muy pocas páginas pero que presentan un desafío intelectual mayor, lo mismo pasa con Haskell.
El caso de F# es interesante y en este caso la simpleza de la solución está a la par de las lineas de código. El problema es que la manera de definir el parsing de XML requiere un ejemplo concreto del XML, esto obliga a diseñar un buen ejemplo para cubrir muchos casos, algo que puede ser difícil de lograr. Creo que los autores de FSharp.Data han realizado un maravilloso trabajo, que se va a destacar en los próximos desafíos.
Supe de una joven cuyo padre le prohibió estudiar ingeniería informática por ser una "carrera sólo para hombres". Por desgracia, esta percepción errada sigue aferrada en la mente de muchas personas y peor aún, en la mente de muchos de mis colegas.
Miren este gráfico, obtenido del blog de Bob Martin (Uncle Bob), que muestra la participación femenina en distintas carreras, incluyendo computación.
¿qué pasó con la participación de mujeres en computación?
Se observa con claridad la caída preocupante en la participación de mujeres en ciencias de la computación.
¿Por qué pasa esto?
¿Qué hace que muchos colegas piensen que esta es una profesión sólo para hombres?
Dirijo un equipo de trece personas, seis mujeres y siete hombres, eso no ha sido por azar, hay una decisión conciente de mantener un equipo con una distribución equitativa por género, es una decisión que tomé hace años y que hasta ahora me ha dado buenos resultados.
Expliqué las razones hace años en este otro post, creo que es lo que debemos hacer. La informática no es un área de dominio exclusivo de los hombres, nunca lo fue.
Margareth Hamilton, parada junto a la versión impresa del código fuente del Apolo 11, el que fue publicado hace poco en GitHub.
Esta fotografía se hizo famosa hace un par de años, es Margaret Hamilton, directora del equipo de ingenieros de software que escribieron el código de la misión del Apolo 11 que llevó a la tripulación, dirigida por Neil Armstrong, sanos y salvos de ida y vuelta a la Luna, tal como lo comprometió el presidente Kennedy.
Estas fotografías son menos conocidas, corresponden a las mujeres que realizaron los cálculos para varias misiones de la NASA.
Las mujeres del Jet Propulsion Laboratory que ayudaron a lanzar los primeros satélites norteamericanos, las misiones lunares y las primeras exploraciones planetarias. Se les conocía como las "computadoras humanas", esta fotografía es de 1953.El grupo dirigido por Macie Roberts, ella se encuentra hacia el extremo derecho y arriba, de pie conversando con una de las mujeres del equipo. La fotografía es de 1955.
Durante el proyecto Manhattan, un grupo de mujeres, dirigidas por Richard Feynmann, operaron como computadoras humanas para realizar los complejos cálculos para construir la bomba atómica. Esas mujeres posteriormente programaron los computadores digitales que las reemplazaron.
Katherine Johnson
Acá tenemos a Katherine Johnson, matemática y física afroamericana que participó de este grupo de mujeres que calculaban y después programaron para la NASA. En 2015 ella recibió la Medalla Presidencial norteamericana de la Libertad de manos del Presidente Obama, en reconocimiento.
Katherine Johson recibiendo el reconocimiento de manos de Barack Obama
Estas son las programadoras originales del Eniac, que muestran que las mujeres estuvieron desde el principio involucradas en la computación. Hay un documental sobre ellas, por si les interesa conocer más, en este link: https://vimeo.com/ondemand/eniac6
Al parecer lo que sostienen en las manos son distintas generaciones de un bit
"El ritmo y naturaleza de los cambios del mundo digital presenta un desafío complejo. Tranformación Digital es cambiar profundamente para enfrentar este desafío. Es aprender a transformarnos y adaptarnos. (Y sacarle el apellido digital a las cosas: todo va hacia lo digital, y no es una dimensión paralela)"
Todas estas afirmaciones son interesantes y vale la pena hacer un análisis de estas en base todo lo que hemos escrito en este blog en todo este tiempo.
Agosto es el mes de aniversario de La Naturaleza del Software, son once años de existencia. Esta vez decidí escribir un post especial, más extenso para celebrar este cumpleaños.
Este concepto de transformación digital me da una buena excusa es revisitar esta idea que exploré hace unos años. Este artículo sostiene en esencia que lo que dice Andreessen es obvio puesto que al final todo es software, así que lo que estamos observando es inevitable.
Pero vamos por partes, hagamos una exploración mas fundamental, o filosófica si quieren, de todo esto.
1) La complejidad como dificultad esencial en la construcción del software
Las dificultades accidentales, como las técnicas, las herramientas, los modelos, etc, se pueden resolver en el tiempo con suficientes avances en investigación.
Portada de la edición de abril de 1987 de la revista Computer, donde se publicó el artículo de Brooks: No Silver Bullet
Los problemas esenciales son insolubles por definición. Brooks identifica cuatro dificultades esenciales para construir software: Complejidad, Conformidad, Necesidad de cambios e Invisibilidad.
- Complejidad. Las entidades de software son más complejas por su tamaño que cualquier otra construcción humana, porque dos partes no son iguales entre sí. Si lo fueran los programadores nos encargamos de juntar dos cosas similares en una, como una subrutina, una función un objeto o una componente. Siempre estamos agrupando cosas similares, con lo que el software, de manera paradojal, sólo crece en complejidad. Los computadores, los móviles y todos los dispositivos digitales que hemos construido tienen una cantidad enorme de estados posibles. Los sistemas de software tienen varios órdenes de magnitud estados que cualquier computador. Y los problemas no son sólo técnicos, también surgen problemas de gestión de esta complejidad.
- Conformidad. Los científicos también enfrentan la complejidad, como veremos más adelante. La física trata con objetos de gran complejidad incluso a nivel de partículas elementales, es cosa de ver los esfuerzos enormes para entenderlas reflejadas en estructuras enormes como el Super Colisionador de Hadrones, en el CERN. La máquina más compleja jamás construida. Pero los físicos buscan explicaciones simplificadas de la naturaleza, expresada en leyes entendibles por el ser humano (aunque sólo sean accesibles unos pocos individuos). Pero el ingeniero de software no puede contar con la esperanza del físico, porque gran parte de la complejidad es arbitraria, forzada por la norma que debe ser cumplida o la conformidad con interfaces de sistemas externos, diseñados por otras personas o instituciones.
- Necesidad de cambios. Existe una permanente presión por introducir cambios. Lo mismo pasa con los objetos físicos, como autos, edificios o computadores. Pero las cosas manufacturadas rara vez son modificadas después de ser construidas. Son reemplazadas por modelos posteriores. Los cambios en el mundo de los objetos materiales es menos frecuente que en el software. En parte porque el software encapsula su función y la función es la parte que más siente la presión del cambio. Después de todo el software es pensamiento puro y por tanto infinitamente maleable. El cambio también es el producto del éxito del software. Cuando un sistema es útil sus usuarios lo prueban en nuevos casos, llevándolo al extremo, a los bordes del dominio original. El usuario quiere cambios para ajustarlo a los nuevos usos del software. Por otro lado, el software sobrevive la vida normal de la infraestructura que lo soporta. En el paper de Brooks el autor sólo nombra a la infraestructura física (computadores, impresoras), pero hoy es peor, el sistema operativo, las bases de datos, el middleware que soporta al software, evolucionan también y el sistema debe sobrevivir estos cambios.
- Invisibilidad. El software es invisible e invisualizable. La realidad del software no está plasmada en el espacio tridimensional físico. Cuando hacemos diagramas de la estructura del software empezamos a lidiar con una serie de gráficos que pueden representar el control de flujo, el flujo de datos, las dependencias entre componentes, las secuencias de tiempo, etc. Muchas de estas representaciones no son planas, y ni siquiera jerárquicas. Al final la única representación fidedigna es el código y sabemos que los que son capaces de leerlo y entenderlo son muy pocos, y con mucho esfuerzo en algunos casos.
Por último, podemos observar que en realidad la complejidad sólo aumenta y que las otras tres dificultades esenciales son consecuencia de esta y a la vez aportan a la mayor complejidad.
Entonces, nuestra disciplina se trata de desarrollar técnicas para abordar la complejidad.
Cuando se dice "el ritmo y naturaleza de los cambios del mundo digital presenta un desafío complejo", lo que se hace es recoger esta dificultad esencial de la que nos habla Brooks. Los cambios del mundo digital, que es el mundo cuyos cimientos están en el software, presentarán siempre desafíos complejos.
2) ¿Qué es la Complejidad?
Cuando decimos que algo es complejo queremos sugerir que es el resultado inevitable de combinar los elementos, y que esto no implica una falta o un fallo, como cuando decimos “esta receta es compleja”.
Por otro lado, el término "complicado" lo aplicamos a lo que presenta gran dificultad para entender, resolver o explicar, por ejemplo “un complicado proceso judicial”.
Recordemos el Conjunto de Mandelbrot que se aprecia en esta imagen:
Es indudable que esta es una imagen compleja. Se trata de un fractal, una figura geométrica que tiene la particular propiedad llamada autosimilaridad, esta nos dice que si hacemos un zoom en algunas de las partes de la imagen obtendremos imágenes tan hermosas y complejas como esta, pero estas imágenes serán similares a la original. Decimos que esta es una imagen compleja porque tiene infinitos detalles.
Y sin embargo, para los matemáticos la información contenida en este conjunto es esencialmente cero.
Podemos definir el conjunto de Mandelbrot mediante una breve relación matemática, incluso podemos escribir un programa que calcule este conjunto en menos de 50 líneas de código (dependiendo del lenguaje de programación).
La imagen original de arriba tiene 800 x 600 pixeles, cada pixel tiene 24 bits de información, es decir, si almacenamos la imagen en un archivo, este contendrá 11.520.000 bits. Sin embargo el programa, escrito en C, que describe esta imagen ocupará apenas unos 64.000 bits.
El matemático ruso Andrei Kolmogorov inventó el concepto de Complejidad Descriptiva, o Entropía Algorítmica para tratar de medir los recursos computacionales necesarios para describir un objeto.
Por ejemplo, observen estas dos cadenas de letras:
Ambas tienen el mismo largo, 64 caracteres. Sin embargo, la primera cadena se puede expresar como: “ab 32 veces”, con sólo 11 caracteres. Sin embargo, para la segunda cadena parece que no hay otra forma más corta de representarla que escribirla por extensión.
Kolmogorov diría que la primera cadena es menos compleja que la segunda.
Ahora, volvamos al conjunto de Mandelbrot. Resulta que el programa que permite calcular los puntos del conjunto tiene un parámetro, que indica la cantidad de iteraciones necesarias para determinar el siguiente pixel a dibujar. Si elegimos dibujar un segmento más detallado del conjunto de Mandelbrot, en otras palabra hacer un zoom, este parámetro debe ser mayor, lo que implica que el computador consume más tiempo y recursos para generar el detalle de una parte más interna del conjunto. Queda claro que la definición de complejidad algorítmica, tomada como el largo del programa no refleja el uso real de la CPU en este problema.
La pregunta es si esta definición de complejidad es adecuada. Porque de acuerdo a la noción de Kolmogorov el conjunto de Mandelbrot no es una cosa compleja, y ya hemos visto que en términos de gasto computacional (gasto de energía, y de tiempo de proceso) no parece ser simple.
Por otro lado, un objeto aleatorio, como la cadena “4c1j5b2p0cv4w1x8rx2y39umgw5q85s7uraqbjfdppa0q7nieieqe9noc4cvafzf”, tienen una complejidad de Kolmogorov altísima (o máxima) pero no es una cosa muy atractiva para el estudio.
Un televisor mostrando sólo ruido estático no es muy interesante, al final sólo es simple ruido, pero ese ruido aleatorio es complejo en términos informáticos, si seguimos la idea de Kolmogorov.
3) La simplicidad de las leyes de la naturaleza
"Sin las matemáticas no podemos penetrar en profundidad en la filosofía. Sin la filosofía no podemos penetrar en profundidad en las matemáticas. Sin ambas no podemos penetrar profundamente en nada." - Leibniz
Gregory Chaitin, estudiando las misma nociones de complejidad de Kolmogorov, encontró en los trabajos de Leibniz una de las primeras consideraciones sobre complejidad.
En los "Discursos sobre la metafísica", Leibniz se pregunta sobre la naturaleza de los "datos experimentales" que representan unas manchas de tinta derramadas sobre una pieza de papel.
Consideremos el conjunto finito de puntos obtenidos de esta forma, y preguntémonos si este obedece una ley natural. Bien, para Leibniz no basta con que exista una ecuación matemática que pase por todos los puntos, porque siempre existe esa ecuación. El conjunto de puntos obedece una ley sólo si hay una ecuación simple que pase por todos los puntos.
Otro ejemplo de la preocupación de este filósofo por la complejidad se presenta cuando en 1714 en su trabajo "Principio de la Naturaleza y la Gracia" se pregunta, “¿por qué hay algo, en vez de la nada, si la nada es más simple que algo?”.
En términos modernos lo que el filósofo racionalista quiere saber es de donde viene la complejidad observada en el mundo. Para Liebniz la respuesta es Dios. Dios ha creado el mejor mundo posible y que toda la riqueza y diversidad que observamos en el universo es el producto de un conjunto simple, bello y elegante de ideas. Dios maximiza la riqueza del mundo, al tiempo que minimiza la complejidad de las leyes que lo determinan. En términos científicos modernos, el mundo es entendible, comprehensible y la ciencia es posible, porque existe una ley que puede ser entendida.
El cosmólogo Max Tegmark argumenta que es más simple considerar un ensamblaje de todas las leyes posibles y todos los posibles universo que considerar un universo en particular. En otras palabras, el multiverso es más fundamental que la pregunta sobre las leyes de nuestro universo particular. Para ilustrar la idea, el conjunto de todos los números positivos 1,2,3,… es muy simple, aunque un positivo particular, como 12478329212991232 puede ser arbitrariamente complejo.
Es decir, es más simple pensar en el multiverso que en tratar de justificar la complejidad de nuestro universo. Todos los universos posibles existen, y el nuestro no tiene nada de particular si lo vemos desde esa perspectiva, aparece como complejo, y especial, simplemente porque vivimos en él.
Pero volvamos a Leibniz. En la Monadologia Liebniz discute los medios para realizar una prueba matemática. Allí él anota que para probar una sentencia complicada se divide en sentencias más simple, hasta alcanzar proposiciones que son tan simples que se hacen evidentes en si mismas y no requieren ser probadas.
Diversos epistemólogos han analizado las ideas de Leibniz, y han observado que esta idea crucial de complejidad de Leibniz es algo muy difícil de clarificar.¿Cómo medimos la complejidad de una ecuación y por lo tanto la complejidad de las leyes naturales?
Si proponemos que el largo de la ecuación es una medida, eso es algo que cambia con el tiempo. Además, ¿cuales son los elementos básicos que debe tener una ecuación? Si la naturaleza ha de regirse por leyes simples, como exige Leibniz, tenemos que saber cómo podemos medir la simplicidad de estas leyes, bueno, Chaitin tiene una propuesta.
4) El Universo es Digital
Acabamos de ver que Leibniz plantea que la naturaleza ha de obedecer leyes simples, bellas y elegantes, es decir, leyes comprensibles, sino la ciencia no es posible. El problema filosófico es saber qué entendemos por leyes simples, ¿la simplicidad de las ecuaciones que las describen? y ¿qué pasa con todo el conocimiento previo que hay que tener para comprender esas ecuaciones?
En la década de 1960 tres matemáticos, Solomonoff, Kolmogorov, y Gregory Chaitin propusieron la Teoría Algoritmica de la Información o TAI (Chaitin era adolescente cuando formuló estas ideas).
Según algunos la TAI da una respuesta precisa a la definición de ley exigida por los filósofos de la ciencia. Esta respuesta se obtiene cambiando el contexto. En vez de considerar que los datos experimentales son puntos, y las leyes deban ser ecuaciones, en la TAI se hace todo digital, todo pasa a ser combinaciones de 0s y 1s. Para la TAI una ley de la naturaleza es una pieza de software, un algoritmo de computador, y en vez de tratar de medir la complejidad de la ley por el tamaño de las ecuaciones, consideramos el tamaño de los programas, el número de bits en el software que implementan nuestra teoría.
Esto se expresa en el siguiente diagrama:
Ley: Ecuación → Software, Complejidad: Tamaño de las ecuaciones → Tamaño del programa, Bits de software.
Para la Teoría Algorítmica de la Información la labor científica se modela así:
Teoría (01100…11) → COMPUTADOR → Datos Experimentales (110…0).
En este modelo, ambas la teoría y la data son cadenas finitas de bits. Una teoría es el software para explicar la data, y esto significa que el software produce o calcula la data en forma exacta, sin errores. En otras palabras, en este modelo una teoría científica es un programa cuya salida (output) es la data, software auto contenido, sin ninguna entrada (input).
Comparado con las observaciones de Leibniz, en que siempre hay la posibilidad de obtener una ecuación complicada para cualquier conjunto arbitrario de datos, en este modelo siempre hay una teoría con el mismo conjunto de bits que la data que explica, porque el software siempre contiene la data que está tratando de calcular como constante, evitando así cualquier cálculo. En ese caso no hay una ley, no es una teoría real. En este modelo decimos que la data sigue una ley, es decir, puede ser entendida, sólo si el programa para calcularla es más pequeño que la data que se trata de explicar.
En palabras de Chaitin: “entendimiento es compresión, comprensión es compresión, una teoría científica unifica muchos fenómenos que parecen disparatados y muestra que estos reflejan un mecanismo interno común”.
Si el mundo que observamos, la complejidad que observamos, es producto de las leyes de la naturaleza, y estas son software, entonces todo es entendible mediante el software.
Si esto es así, entonces la mejor teoría es aquella con el programa más corto que produzca, en forma precisa, la data observada. Esta sería la versión en términos de la teoría algorítmica de la información, de la Navaja de Ockham. Con estas nociones se puede definir la complejidad en términos matemáticos, y empezar a probar cosas con respecto a las leyes de la naturaleza.
Lo primero que se nota es que las cadenas más finitas de bits no tienen leyes, son irreducibles en términos de algoritmos, son aleatorios en el sentido que hemos definido, porque no hay una teoría más pequeña que la data en si misma. Es decir, el programa más pequeño que produce la salida es del mismo tamaño que la salida.
Lo segundo que se nota, es que no se puede estar seguro de que se ha encontrado la mejor teoría. Para entender esto último necesitamos conocer mejor uno de los grandes logros del pensamiento lógico.
5) Los límites de lo que podemos saber
"Como sabemos, Hay conocimientos conocidos. Hay cosas que sabemos que sabemos. También sabemos que hay conocimientos desconocidos. Es decir sabemos que hay cosas que no sabemos. Pero hay también desconocidos desconocimientos, Aquellos que no sabemos que no los sabemos" - Donald Rumsfeld
Consideren esta imagen:
Si Pinocho dice que su nariz crecerá, pero no lo hace, estaría mintiendo, pero cuando Pinocho miente su nariz crece, pero al crecer su nariz entonces estaría diciendo la verdad. Luego, ¿qué diablos debería pasar después de que Pinocho dice esta frase?
Esta es una reformulación de la vieja Paradoja del Mentiroso, lo interesante es que esta paradoja juega un papel fundamental en la solución de un importante problema de la lógica matemática, y de paso en el desarrollo de la computación moderna.
A fines del siglo XIX y principios del siglo XX la escuela formalista era un grupo de matemáticos que consideraban la siguiente afirmación como fundamental:
”La matemática es un juego —carente de significado— en el que uno lo practica con símbolos carentes de significado de acuerdo a unas reglas formales establecidas de antemano”.
David Hilbert, un matemático alemán, formado esta corriente de pensamiento, se encontraba molesto debido a las paradojas que aparecían en los trabajos e intentos de axiomatización de las matemáticas de ese tiempo, como por ejemplo, la monumental obra de Russel y Whitehead: ”Principia Mathematica”. Esta magna obra de tres volúmenes, plasmaba el tremendo esfuerzo de estos dos pensadores, un trabajo de años, que alcanza su cima cuando en la página 379 del primer volumen se establece la prueba de que 1+1 = 2, ¡la que se termina de demostrar en la página 86 del segundo volumen!
Hilbert era un hombre de carácter fuerte que se resistía a considerar que la mente tuviera alguna clase de limitación. En 1880 el fisiólogo y sicólogo alemán Emil du Bois-Reymond planteó en una famosa charla en la academia de ciencias de Berlin, que hay problemas que ni la ciencia ni la filosofía podrían resolver jamás. Esto lo resumió en la frase en latín “Ignoramus et Ignorabimus” (“no sabemos y no podremos saber”).
Para Hilbert esa concepción era errada, en 1930 replicó:
“No debemos creer a esos, quienes hoy, con apoyo filosófico y tono deliberado, profetizan la caída de la cultura y aceptan el ignorabimus. Para nosotros no hay ignorabimus, y en mi opinión para nadie en las ciencias naturales. En oposición al necio ignorabimus nuestro eslogan deberá ser Wir müssen wissen — wir werden wissen! (“¡debemos saber - sabremos!”).
Lo que no sabía el famoso matemático es que mientras él pronunciaba estas palabras un callado colega austriaco destrozaba todo su programa con un brillante teorema.
Hilbert había propuesto en 1920 un programa de investigación para resolver las paradojas de las matemáticas y establecer un sistema formal que permitiera darle bases sólidas a esta disciplina.
Lo que Hilbert pedía que las matemáticas tuvieran, entre otros, estos atributos:
- Formalismo, es decir, todas todas las afirmaciones matemáticas deberían ser escritas en un lenguaje preciso y formal, y manipuladas de acuerdo a reglas bien definidas.
- Integridad, debía mostrarse una prueba de que todas las afirmaciones matemáticas pueden ser probadas en el formalismo.
- Consistencia, Hilbert pedía una prueba de que ninguna contradicción puede ser obtenida en el formalismo de las matemáticas.
- Decibilidad debería haber un algoritmo para decidir la verdad o falsedad de cualquier afirmación matemática.
Kurt Gödel descartó los tres primeros atributos solicitados por Hilbert, no es posible formalizar totalmente las matemáticas, y por lo tanto estas no pueden tener la propiedad de integridad, y además no pueden ser consistentes. Para esto usó una variante de la paradoja del mentiroso.
En esencia lo que hizo Gödel fue formular una proposición que dice “Esta proposición es indemostrable”. Si la proposición es indemostrable, entonces es cierta, y por lo tanto la formulación de la aritmética es incompleta (porque hay una proposición no demostrada), por otro lado, si la proposición se puede demostrar, es decir, es falsa, entonces la formulación de la teoría es inconsistente. La prueba de Gödel involucra la construcción de una suerte de máquina lógica, un computador rudimentario que escribe sentencias lógicas siguiendo ciertas reglas.
El cuarto atributo, conocido como problema de la decibilidad o Entscheidungproblem. Lo que pide Hilbert es proporcionar un mecanismo que permita determinar si una afirmación dada es válida dentro de un sistema formal, o si existe un modo de decidir si una sentencia matemática puede ser demostrada a partir de un conjunto de axiomas y siguiendo las reglas de la lógica.
La respuesta que otorgaron Alonzo Church primero, y en forma independiente y muy poco tiempo después Alan Turing fue que no era posible. Y con estos resultados el programa de Hilbert fracasó.
Turing fue más tarde a estudiar con Alonzo Church y demostraron que ambas pruebas eran equivalentes, lo novedoso del método de Turing es que para desarrollar su prueba construyó una máquina ideal que simula los estados por los que pasamos al razonar matemáticamente.
Esta máquina es en realidad la descripción de un programa informático, Turing encontró la forma de construir el hardware y el software en la misma idea. El hardware que de sustrato a las instrucciones que vienen contenidas en el software.
Los resultados de Gödel, Church y Turing abarcan mucho más que la teoría de números y la aritmética, gracias a la introducción precisa del concepto de algoritmo computacional. En la actualidad el teorema de Gödel se considera aplicable a todo sistema axiomático formal y por extensión los resultados de Turing y Church imponen también un límite a nuestras capacidades computacionales.
6) Conocimiento e innovación
El famoso economista, y acérrimo defensor del liberalismo económico clásico, Friedrich Hayek afirmó que el Teorema de Gödel no es sino un caso especial de un principio más general, que aplica a todos los procesos conscientes y en particular a todos los procesos racionales, es decir el principio de que entre sus determinantes siempre habrá reglas que no pueden ser declaradas o que incluso son inconscientes.
Michael Polanyi, el gran pensador de origen húngaro, nacionalizado británico, traza el límite a la capacidad de articular el conocimiento humano basándose en el descubrimiento de Gödel.
Polanyi niega la posibilidad de llegar a la verdad de manera mecánica siguiendo el método científico. Todo conocimiento, no importa cuan formalizado esté, depende de compromisos.
Polanyi argumenta que los supuestos que subyacen a la filosofía crítica no sólo son falsos, pues socavan los compromisos que motivan nuestros logros más altos. Él aboga por que reconozcamos que creemos más de lo que podemos demostrar y sabemos más de lo que podemos decir.
Un conocedor no se aparta del universo, sino que participa personalmente en este. Nuestras habilidades intelectuales son guiadas por compromisos apasionados que motivan el descubrimiento y su validación. Para Polanyi, un gran científico no sólo identifica patrones, sino que escoge preguntas significativas con una alta probabilidad de encontrar una resolución exitosa.
Los innovadores arriesgan su reputación comprometiéndose con una hipótesis. Polanyi pone de ejemplo a Copérnico, afirmando que este llegó a la relación real entre la Tierra y el Sol no como consecuencia de seguir un método, sino que a través de "la mayor satisfacción intelectual que deriva del panorama celestial visto desde el Sol en vez de la Tierra."
Para Polanyi, nuestra conciencia tácita nos conecta, aunque de manera falible, con la realidad. Al contrario de su colega y amigo Alan Turing, Polanyi rechazaba la idea de que las mente podía ser reducida a un conjunto de reglas. Polanyi aboga por el concepto de Emergencia, la idea de que existe un proceso a través del cual entidades, patrones o regularidades mayores surgen de entidades menores más simples que no exhiben las misma propiedades.
Polany afirma que hay varios niveles de realidad y causalidad. Esta idea se basa en el supuesto de que las condiciones de borde otorgan grados de libertad, los que en vez de ser aleatorios, están determinados por realidades de más alto nivel, cuyas propiedades dependen, pero son distintas, de los niveles inferiores de los cuales emergen. Un ejemplo de una realidad de alto nivel funcionando como una fuerza causal hacia las capas interiores es la consciencia generando significado (intencionalidad).
Sucede que la primera persona en crear funciones recursivas fue Gödel. La idea del combinador de punto fijo es un derivado directo del trabajo de Gödel. ¿Por qué Graham decidió llamar así a una incubadora de negocios? Porque en esencia Y-Combinator es un programa que corre otros programas, pero hay una relación más interesante entre emprendimiento y el teorema de Gödel.
Para entender esto, consideren este ejemplo, que tomaremos prestado a Max Skibinsky, un hacker y ex socio de Mark Andreessen.
Imaginen el Monopolista Máximo, que quiere encontrar todas las formas de extraer dinero y valor del mercado global y mantenerlo dentro de su imperio comercial. Es como Bill Gates pero con esteroides. ¿Cuál sería su "sistema formal" de economía y negocios? Cualquier cosa que él desee: la Librería del Congreso de Estados Unidas completa, o todas las ediciones de The Economist desde que el mundo es mundo. Todos los tratados de economía escritos desde las tablillas de greda hasta las versiones online.
Puede tener todos los sistemas formales y expresivos que quiera, teniendo el cuidado de agregarlo de modo de evitar contradicciones. Puede depositar este conocimiento en un googolplex de computadores para compilar y analizar estos datos para obtener toda la lista de sentencias verdaderas derivadas de este sistema. En el fondo, obtendría todas la formas de obtener dinero, crear nuevas startups lucrativas, y todas la forma de generar valor. Más aún, este señor tiene todo el dinero suficiente para instalar estas startups y convertirlas en negocios operacionales.
En otras palabras, nuestro Monopolista Máximo no se detendrá para obtener cualquier prometedora idea de negocios para si mismo, e invertirá casi todos sus recursos ilimitados ante la oportunidad. Este descripción no está lejos de la realidad, cuando piensan en Apple, Alphabet (Google), Facebook y Amazon. Estas compañías han construido impresionantes sistema semi formales a través de los años que llevan operando. Estos sistemas están optimizados para una cosa: como extraer el máximo valor de mercado de la innovación tecnológica. Sus sistemas son inmensos, código fuente de programas, guías de marketing, "playbooks", guías de contratación e incontables documentos. Este es el sistema que sus empleados usan colectivamente en su trabajo diario y es la forma en que se entrenan a los nuevos reclutas. Y sobre todo esto tenemos decenas de miles de millones de dólares en el banco necesarios para llevar a cabo cualquier conclusión arrojada por sus sistemas semi formales.
Y aún así, a pesar de todo ese poderoso conocimiento formalizado, una y otra vez aparecen startups innovadoras de la nada. Siempre pillan a los incumbentes ciegos en algún punto y les hacen pagar miles de millones de dólares para adquirir estos nuevos descubrimientos. Ya sea Alphabet o Facebook, a cualquiera le gustaría dar con estas ideas primero y capturar toda la utilidad de estas innovaciones para si mismos, en vez de tener que pagar millones por adquirir una startup disruptiva. ¿Por qué no pueden hacerlo entonces? Lo que impide a estas poderosas compañías que lleguen a la billonaria o trillonaria capitalización es el teorema de Gödel.
Lo que Gödel ha demostrado es que cualquier tipo de Monopolista Máximo está condenado a perder. Hay sentencias verdaderas en su sistema, es decir, hay nuevas ideas y modelos de negocios de startups, que nunca podrán ser probados (demostrados) dentro de sus sistema formal, por amplio que este sea. No importa cuantas bibliotecas de conocimiento se agregue a su sistema, cuanto poder computacional se agregue al sistema de descubrimiento formal, aún así nunca, jamás, descubrirá todas las sentencias verdaderas (es decir, los modelos rentables) de su sistema formal. El Monopolista Máximo siempre perderá al final porque no puede cubrir todas las sentencias. Nunca podrá completar su Imperio de hacer dinero.
8) Negocios y Software
La historia es un proceso de creación de sistemas formales que sirven un propósito específico. Desde el Código de Hamurabbi hasta los terabytes de información que permiten que Facebook prediga tus intereses y te presente noticias y avisos relevantes.
Esta creación de sistemas formales debe asumir algunos compromisos, simplificaciones lo suficientemente buenas de una variedad infinita, mientras se trata de capturar y mantener la complejidad del dominio que se modela. La única forma que tenemos de llevar nuestra vida es simplificando el mundo alrededor nuestro, es el conocimiento personal del que hablaba Polanyi.
Todas las reglas, modelos mentales, convenciones, etiquetas, prácticas, etc, han sido desarrolladas con el simple propósito de atrapar a la infinita complejidad dentro de una abstracción simple con la que podamos lidiar. Es la manera que tiene nuestro cerebro de hacer atajos para contar con un modelo finito de la realidad, más que la realidad infinita en si misma (¿será que el ideal de Chaitin sea imposible y al final la realidad sea totalmente aleatoria y por lo tanto inasible?).
Lo que Gödel, Türing y otros han probado de manera tan decisiva es que nunca tendremos sistemas ideales. Todas nuestras aplicaciones, programas y sistemas nunca capturaran toda la variedad de infinitas posibilidades.
A pesar de todas sus limitaciones, los sistemas formales, y por extensión el software son objetos totalmente necesarios para modelar el mundo. Nuestros cerebros sólo pueden operar con una cantidad pequeña y limitada de axiomas y reglas simples. Esa es la forma en que operamos. Pero aún así, el primer paso para evitar errores es reconocer las limitaciones naturales de los sistemas formales, que es que nunca podrán representar de forma completa la realidad.
Una compañía es un sistema semi formal para extraer dinero del mercado. Las startups pueden saltarse la parte de generar utilidad durante algunos años, pero al final lo que diferencia a los ganadores de los perdedores es la capacidad de obtener suficiente rentabilidad del modelo de negocios que han descubierto o desarrollado.
Recordemos el caso de Microsoft. A fines del segundo milenio era para todos los efectos prácticos un monopolio tecnológico. El sistema de conocimiento organizado de esta empresa en 1998 abarcaría miles de páginas impresas imposibles de dominar por una persona. En ese instante y dado el tamaño de la empresa se requerían diversas guías para los empleados de cada división: ¿cómo vender licencias OEM? ¿Cómo mejorar Office para mantener a los sistemas operativos de la competencia fuera de mercado?, etc. Con decenas de miles de reglas Microsoft estaba en una posición única de extraer asombrosos márgenes de su posición dominante en sistemas operativos y software de oficina.
Imaginen por un momento que un viajero del tiempo del futuro se aparece a Bill Gates en 1999 y le dice:
"¡Soy del futuro! ¡Debes vender palabras claves que calcen con consultas de búsqueda en la web!"
¿Qué haría Bill Gates con esta información? La sintaxis del requerimiento es familiar, sabe lo que es una búsqueda en la web y lo que significan palabras claves. Hay algunas pocas empresas de búsqueda por ahí como Lycos y Altavista, pero su capitalización es menos que el presupuesto para bebidas gaseosas de la compañía. Son compañías con problemas. ¿Por qué vender palabras claves de búsqueda?
Todo lo que busque Bill en su sistema semi formal, o las consultas que haga con sus expertos será incompleto. La probabilidad de que la sentencia que demuestra que vender palabras claves para la búsqueda sea verdadera es muy baja, a pesar de toda la información con la que cuenta Bill Gates. Su sistema en ese tiempo abarcaba los dominios de venta de hardware, computadores, ventas OEM, y software.
El modelo de Google es imposible de analizar para Microsoft.
Corresponde que Larry Page y Sergey Brin formalicen el sistema de búsquedas en la web y la venta de palabras claves. Los fundadores parten con con su imaginación y el sueño de "organizar la información del mundo". Como esta sentencia se vuelve muy rentable se inicia un proceso en cascada de formalización de este dominio. Nuevos términos y axiomas se agregan al sistema de Google.
Pasa el tiempo y en 2003 nuestro viajero del tiempo se aparece a Larry Page con nuevas palabras proféticas:
"¡Soy del futuro! ¡Deberían organizar a los amigos en grafos sociales entre toda la gente!"
Larry examina su sistema formal, puede acceder también al sistema formal de Microsoft de los noventa, y aún así ninguno de los dos sistemas es capaz de explicar el significado de la profecía. Hay algunas pocas redes sociales por ahí, Friendster y MySpace, su capitalización es menos que todo el presupuesto en sushi de Google. Larry entiende la sintaxis de la sugerencia, pero carece de las herramientas para demostrar la ganancia de este nuevo modelo de negocios.
Es el turno de Mark Zuckerberg de formalizar el sistema sobre construir grafos sociales y encontrar formas óptimas de extraer valor de estos. Podemos seguir así tanto hacia el futuro como al pasado (aunque un viaje hasta los tiempo de Tesla y Edison tiene el problema adicional que habría que explicar la sintaxis a estos innovadores para que entienda conceptos como el computador personal o la internet).
9) Toda empresa es de software, o lo será
"Tu piensas que sabes cuando aprendes, y estás más seguro cuando puedes escribir, aún más cuando enseñas, pero estás cierto cuando puedes programar." - Alan Perlis, epigrama 116
Hemos visto como el concepto de Complejidad nos llevó por un viaje hacia los fundamentos del conocimiento humano. Aprendimos que hay límites a lo que podemos conocer, pero por paradójico que parezca, es esta limitación la fuente de toda innovación disruptiva.
Si observamos el desarrollo de las grandes empresas actuales veremos que entre las más grandes y con mayores recursos en el mundo, es decir, con la mayor capitalización, están empresas tecnológicas, como Apple, Alphabet, Amazon, Facebook.
Esta es una tendencia que seguirá ocurriendo por el simple hecho de que son estas empresas las que están en mejores condiciones de construir sistemas semi formales de descubrimiento de oportunidades de negocios. No sólo tienen grandes recursos, sino que bastas bases de datos y sistemas de conocimientos que les permiten explorar nuevos modelos de negocios, dentro de los límites del Teorema de Gödel por supuesto.
Sin embargo, hemos encontrado que es posible encontrar espacios no cubiertos por estos bastos sistemas formales. Ese es el espacio de la innovación. Son los puntos ciegos que estas empresas no pueden cubrir los que presentan las oportunidades para crear valor.
Puesto que, como hemos visto, la necesidad de entender, de asir la complejidad del mundo moderno, requiere de sistema formales de información y de inferencia de reglas que permitan operar dentro de los dominios de cada mercado, es que surge como conclusión evidente que la capacidad de construir estos sistemas formales es esencial para la sobrevivencia de la empresa moderna. Esa habilidad es la habilidad de construir y controlar el software.
Así que cuando nuestros amigos de Continuum afirman que:
"Tu empresa ya es una empresa de software. O lo será. O Morirá."
Están haciendo explícita la conclusión de este extenso artículo. Todo es software y por tanto toda empresa lo es.
El desafío está en saber cómo desarrollar software, esa será una de las competencias core de toda empresa exitosa del futuro. Pero, tal como hemos visto, desarrollar software es lidiar con la complejidad.
Por otro lado, la realidad cambia y es inabarcable por nuestro modelos formales del mundo, sabemos que, por el Teorema de Gödel, nunca podremos estar listos para entender y modelar por completo todas las oportunidades de negocios que se nos presentan. El reconocer esta limitación es la que nos prepara para afrontar el desafío de la transformación digital. La transformación y adaptación al cambio consiste precisamente en ampliar o desarrollar nuevos sistemas formales para superar estas limitaciones.
Lo que he intentado mostrar en este artículo es que detrás del concepto de transformación digital hay fundamentos epistemológicos profundos, cuyo conocimiento no solo es interesante y enriquecedor, sino que da una nueva perspectiva a esta visión de desarrollo de negocios.
---
Créditos de la imagen del título: "Electric Sheep - Generation 245 - Sheep 6221". Original art by Scott Draves and the Electric Sheep.
"Aquí estoy porque he venido porque he venido aquí estoy y si no hubiera venido no estaría donde estoy"
En medio siglo pasan tantas cosas, es impresionante, es imposible recordarlas todas
El experimentar la belleza de ver nacer a tu hija. Ver de frente a la muerte y sacarle la lengua, cagado de miedo, eso sí. Ver partir a los viejos, y sentir rabia por la pérdida de los más jóvenes. Amar, perder el amor y volver a encontrarlo. Amar de nuevo, sin miedo y sin esperar nada y recibir infinito cariño de vuelta.
"Goza de la vida con la mujer que amas, todos los días de la vida de tu vanidad que te son dados debajo del sol. "
Tenía treinta y nueve cuando empecé a escribir en este blog. A los treinta y nueve se fue mi viejo. Pienso en la inmensa oportunidad que he tenido y agradezco cada día aprovechándola, "carpe diem!".
Aunque la edad a la que te vas no importa tanto como la huella que dejas. Es difícil superar a mi padre en eso, pero estamos en este mundo para cumplir nuestro destino, no el deseo de nuestros padres, ni para competir con su memoria.
Amo a mis hijos, pero como dijo el poeta, puedo darles el amor pero no mis pensamientos.
"Porque ellos tienen sus propios pensamientos. Ustedes pueden alojar sus cuerpos pero no sus almas. Porque sus almas viven en la casa del día que viene, la cual ustedes no pueden visitar, ni siquiera en los sueños."
Al cumplir cincuenta años no hay tiempo ni espacio para recordar todas las experiencias que has acumulado, pero si puedes reflexionar un poco sobre lo que hemos aprendido, porque parece que para eso venimos a este mundo.
Durante el último año he vuelto a ser universitario, estudiar me ha revitalizado. Descubrí lo vago que fui cuando joven y la claridad que te da la experiencia. Es injusto para los jóvenes que los evalúen, dejémoslo rodar y que vuelvan a rendir los exámenes cuando se sientan preparados.
En realidad, pensándolo mejor, las calificaciones no importan, lo que vale es el amor por tu profesión. La mejor nota es el reconocimento de tus clientes y de tus pares.
"Ama y haz lo que quieras!", decía Agustín de Hipona, pero también dijo: "pobre no es el que tiene menos, sino que el necesita infinitamente para ser feliz".
No se puede ser feliz. Querer ser feliz es una ilusión estúpida que lleva al sufrimiento. Si piensas que la vida es un proceso, lo que llaman felicidad no es más que uno de los estados posibles por los que puedes pasar.
Puedes estar feliz, en un momento, pero no puedes ser feliz todo el tiempo. No busques lo imposible, maximiza la cantidad de veces que pases por ese estado.
La Primera Ley Mentar, tal como la cita Paul Atreides a la reverenda madre Gaius Helen Mohan, dice:
"Un proceso no puede ser entendido deteniéndolo. El entendimiento debe moverse con el flujo del proceso, debe unirse a este y fluir con el mismo.”
Vive el proceso, no te afanes en entenderlo de forma estática.
Hay dos clases de personas, los que ven como el mundo es y los que lo ven como debería ser. A los primeros los llaman positivos, a los segundos normativos. Sé positivo, se logran más satisfacciones, se ahorran rabias y frustraciones. Pero no dejes de amar a los que quieren que el mundo mejore.
La adaptación es la clave de la evolución. No hay progreso en la evolución. Esas idea de que al evolucionar se pasa a un estadio mejor no es lo que dijo Darwin. La especie más evolucionada del planeta no es el ser humano, son las bacterias, piensa en eso. El cocodrilo lleva sesenta millones de años igual, no necesita cambiar. Eso también es evolución.
No odies al ser humano, no seas misantropo. Somos los que deterioramos el mundo, es cierto, pero podemos mejorarlo. El odio no consigue nunca nada. Hay normativos que dicen, "cuando tengamos el poder lograremos los cambios que harán este mundo mejor", ellos ignoran que el poder todo lo corrompe. Nunca se construye sólo desde el poder. Recuerda lo que dijo el maestro Suzuki:
El amor es afirmación, una afirmación creativa; nunca es destructivo ni aniquilador, pues, a diferencia del poder, todo lo abraza y todo lo perdona. el amor penetra su objeto y se hace uno con él, mientras que el poder, siendo característicamente dualista y discriminador, aplasta cualquier objeto que se alce contra él o bien lo conquista y lo esclaviza bajo su yugo. Amor y creatividad son dos aspectos de una misma realidad, pero, frecuentemente, la creatividad está separada del amor. Cuando se lleva a cabo esta ilegítima separación, la creatividad viene a asociarse con el poder. El poder pertenece realmente a un orden inferior al amor y la creatividad, pero cuando se adueña de ésta última, se convierte en agente extremadamente peligroso de toda clase de males. Esta noción de poder nace inevitablemente de una interpretación dualista de la realidad. Cuando el dualismo se niega a reconocer la presencia de un principio integrador subyacente, su inmediata tendencia a la destrucción se manifiesta de forma brutal y arbitraria.
Así que ese es el mejor regalo que se puede dar a cualquiera, el amor. Es la mayor fuerza constructora del universo.
Les deseo que se sientan tan amados como yo me siento, cuando lleguen a mi edad.
Este es mi regalo de este año para ustedes, una canción de Robert Palmer, Every Kind of People:
Durante la década de 1970 y parte de la década de 1980, Donald Fagen y Walter Becker, los fundadores de Steely Dan, aparte de ser reconocidos como grandes músicos y compositores, adquirieron fama por su excesivo perfeccionismo.
Se dice, pero no he encontrado evidencia, que en uno de sus álbumes pidieron excusas por la calidad de la grabación, la que no cumplía con sus altos estándares de calidad. Esta obsesión se notó, por ejemplo, en la producción del álbum Gaucho, para el cual terminaron trabajando con 42 diferentes músicos para producir el séptimo álbum de la banda.
En un momento contrataron nada menos que a Mark Knopfler, lider de "Dire Straits", para que aportara con un solo de guitarra. Fagen y Becker quedaron impresionados con el guitarristas después de escuchar el álbum "Sultans of Swing", e invirtieron el dinero que no tenían para contar con Knopfler en el disco, después de varias horas de grabación lo que quedó fueron apenas unos segundos que se escuchan al inicio de la canción "Time Out of Mind".
La perfección es inalcanzable, por supuesto, pero aún así a veces nos obsesionamos por tratar de lograrla. Para Dijkstra, por ejemplo, la perfección en el código estaba en la belleza y elegancia del mismo. ¿Pero qué hay del desempeño del software? Quizá el programa más elegante no es el más eficiente en tiempo de ejecución.
Durante la grabación de "Katty Lied"[1], Fagen y Becker experimentaron una de las mayores frustraciones de su historia. La tecnología que eligieron para grabar, aún siendo de vanguardia, falló causando una serie de inconvenientes técnicos, al grado que abandonaron el proceso de edición y se rehusaron a escuchar el producto final. Fue el guitarrista Denny Dias quien se encargó de terminar el proceso de edición, en una heroica jornada (documentada acá: http://steelydan.com/dennys3.html).
Uno de los problemas que enfrentaron, después de corregir el sonido de las cintas, fue que al transferir la grabación al vinilo notaron que la calidad se deterioraba. Otro problema es que el sonido no era el mismo y dependía del reproductor. Así que tuvieron que volver a trabajar en la mezcla para lograr un sonido que fuera reproducible en un "fonógrafo promedio". Una tarea que resultó imposible, así que terminaron conformándose con lograr que el disco fuera aceptable para la mayor cantidad de reproductores.
En el desarrollo de software nos topamos con problemas similares, nuestro programa debe ser compatible con diversos ambientes, sistemas operativos, dispositivos, tipos de CPU, etc. Cómo lograr un desempeño adecuado en cada uno de los ambientes es un desafío análogo al que tuvieron que enfrentar estos ingenieros de sonido.
Optimizar hasta que no duela
"La optimización prematura es la raíz de todo mal", es la cita de Donald Knuth más mencionada cuando hablamos de optimizar código. En realidad la cita completa dice lo siguiente:
"No hay duda que el grial de la eficiencia lleva al abuso. Los programadores gastan una enorme cantidad de tiempo pensando, o preocupándose, de la velocidad de secciones no críticas de sus programas, y esos intentos de eficiencia en realidad tienen impactos negativos fuertes cuando consideramos la depuración y mantención. Deberíamos olvidar pequeñas optimizaciones, digamos el 97% del tiempo: la optimización prematura es la raíz de todo mal.
Pero aún así no deberíamos dejar pasar nuestras oportunidades en ese 3% crítico. un buen programador no debe ser arullado por la complacencia ante tal razonamiento, debe ser sabio para mirar cuidadosamente al código crítico, pero sólo después que este código ha sido identificado. A menudo es un error hacer juicios a priori sobre las partes de un programa que son realmente críticas, puesto que la experiencia universal de los programadores que han usado herramientes de medición ha sido que sus intuiciones a menudo fallan." [2]
Este artículo corresponde a la tercera etapa de mi desafío de aprender 9 lenguajes de programación a través de nueve problemas.
Esta vez aproveché el problema para explorar un poco más ciertas características de los lenguajes, tratando de optimizar las soluciones para lograr el máximo de velocidad durante la ejecución.
Un problema de la vida real
Este problema en particular está basado en una situación real que se dio en mi trabajo. Un grupo de analistas había desarrollado una serie de macros en Excel (apoyados con algo de código VBA) para procesar un conjunto de archivos. El problema es que esta solución tomaba varias horas para un archivo de unas miles de lineas, y en operación real los archivos tendrían varios millones de registros, lo que hacía inviable procesarlos. No teníamos en ese momento capacidad para atender este requerimiento, así que decidí crear un pequeño utilitario en C para reemplazar las macros en Excel como solución temporal (con el tiempo, se desarrollaron aplicaciones que reemplazaron estos procesos "manuales").
Este ejercicio reproduce más o menos el mismo problema y su enunciado es el siguiente:
Se debe construir un filtro que reciba un archivo de vectores y los consolide en un archivo de salida.
La invocación del programa es la siguiente:
$ ordenar_vector archivo_entrada archivo_salida
Si no se le entregan argumentos al programa este debe salir con un mensaje de error.
Al finalizar debe desplegar el tiempo, en minutos y segundos, empleado en procesar todo el archivo de entrada.
La entrada consiste en un archivo en que cada línea se divide en:
Encabezado: 9 dígitos
Detalle: que consiste en 6 vectores
Vector: que contiene en 23 elementos que corresponden a periodos calendario (mes de algún año)
Periodo: un número de 6 dígitos, puede ser 000000 o un número de la forma AAAAMM donde AAAA es un año y MM un mes.
La operación que se debe realizar es la siguiente:
Se deben consolidar todos los periodos de los 6 vectores en un vector de a lo más 23 elementos.
Si los periodos se repiten se debe dejar sólo 1.
Los periodos se deben ordenar de mayor a menor.
La salida debe ser la siguiente:
Encabezado: 9 dígitos que se copian de la entrada
Marca: una letra que puede tener los valores S, N ó D.
Vector: un vector de a lo más 23 periodos.
Se debe considerar lo siguiente:
Si el vector consolidado tiene más de 23 elementos se debe colocar la marca S y el vector se debe llenar de espacios en blanco.
Si el vector consolidado tiene cero elementos (porque vienen sólo 0s en los periodos) se debe colocar la marca N.
Si el vector tiene menos de 24 elementos se debe colocar la marca D.
Hay que considerar lo siguiente:
Si una linea tiene un largo distinto al esperado se debe reportar el error indicando el número de linea (numeradas a partir de 0).
Si no es posible abrir un archivo se debe reportar el error.
Para efectos de referencia, incluí una solución en C que es muy similar al programa que implementé originalmente.
Para efectos de prueba, ejecuté todas las soluciones usando un archivo de un millón de lineas. Hay un script en Perl que permite generar archivos de prueba, que están incluidos en el repositorio GitHub de este proyecto.
La "diversión" de este desafío consistió en reducir el tiempo de ejecución de cada solución y tratar de superar la solución en C.
Si compilas mi solución en C sin ningún tipo de optimización, el tiempo de ejecución para un millón de lineas es de 8.8 segundos en mi notebook[3]. Con la opción -O3 el tiempo de ejecución se reduce a 3.54 segundos.
Lo sorprendente fue encontrar una solución en Go más rápida que se ejecuta en apenas 2.92 segundos!
Este es el ranking medido en mi PC, para un millón de lineas:
Este tipo de operaciones no es algo para lo que Erlang (y sus bibliotecas estándares) están diseñado (aunque sospecho que es posible construir una solución más rápida usando binaries, en vez de strings, pero no conozco suficiente de Erlang para demostrarlo).
Cómo resolver este problema
La forma general para resolver este problema se puede expresar en el siguiente algoritmo:
- por cada linea en el archivo de entrada: - si el largo de linea no es el apropiado
=> imprimir error indicando el numero de linea - si el largo de linea es el que corresponde => - dividir la linea en 6*23 periodos (23 strings de tamaño 6) - descartar los periodos que sólo contengan ceros ("000000") - descartar los periodos duplicados - ordenar los periodos de mayor al menor - Finalmente escribir en la salida lo siguiente: - si la cantidad de periodos que quedan es 0 colocar una N y
rellenar la salida con blancos - si la cantidad de periodos que quedan es mayor que 23
colocar una S y rellenar la salida con blancos
- en cualquier otro caso colocar una D, luego concatenar los
periodos y rellenar con blancos
La solución más breve
En términos de cantidad líneas de código este es el ranking:
Notar que nuevamente Kotlin está en el tercer lugar.
Ordenando los vectores en C, Rust, Go y Swift
En C, Rust, Go y Swift la solución se construyó de modo similar.
C
En C creamos un área de trabajo la que ordenamos "in place" usando un algoritmo de sort de inserción.
El área de trabajo es un arreglo de periodos (de ancho 6, definido en la macro VECTOR_ELEM_SIZE).
El código para ordenar el vector queda así:
int ordena_vector(char* vector){ char vector_trabajo[VECTOR_SIZE*CANTIDAD_INSTITUCIONES][VECTOR_ELEM_SIZE]; int i, j; int n = 0; char* p = vector; char* vend = vector+(VECTOR_SIZE*VECTOR_ELEM_SIZE); memset((char*)vector_trabajo, '0', VECTOR_SIZE*CANTIDAD_INSTITUCIONES*VECTOR_ELEM_SIZE);
// por cada elemento del vector
for (p = vector; p < vend && *p != ' '; p+= VECTOR_ELEM_SIZE) { // sort por insercion for (i = 0; i < n && strncmp(p, vector_trabajo[i], VECTOR_ELEM_SIZE) < 0; i++) ; if (i == n) { if (strncmp(p, vector_trabajo[n], VECTOR_ELEM_SIZE) != 0) memmove(vector_trabajo[n++], p, VECTOR_ELEM_SIZE); } else { if (strncmp(p, vector_trabajo[i], VECTOR_ELEM_SIZE) != 0) { for (j = VECTOR_SIZE-1; j > i; j--) memmove((char*)vector_trabajo[j], vector_trabajo[j-1], VECTOR_ELEM_SIZE); memmove(vector_trabajo[i], p, VECTOR_ELEM_SIZE); n++; } } } // for
memset(vector, ' ', VECTOR_ELEM_SIZE*VECTOR_SIZE+1); for (i = 0, p = vector+1; i < n; i++, p+= VECTOR_ELEM_SIZE) memmove(p, (char*)vector_trabajo[i], VECTOR_ELEM_SIZE); return n;}
Este función retorna la cantidad de elementos que quedan en el vector. Acá vector es el string leído desde el archivo de entrada.
Una optimización obvia sería usar el mismo buffer de entrada com área de trabajo y ordenarlo. Ese es un bonito desafío en si mismo que queda propuesto.
Otra cosa, este código, al momento de escribir esto, tiene algunos bugs, te desafío a indicar cuáles son.
Rust
Para la solución Rust imité lo que hace la solución en C.
fn ordenar_vector(vector:&[u8], result:&mut [u8]) { let mut n = 0; let mut trabajo = ['0' as u8; TAM_VECTOR_ENTRADA]; for p in vector.chunks(TAM_PERIODO) { if p == CERO { continue; } let mut i = 0; let mut q = 0; while i < n && p < &trabajo[q..q+TAM_PERIODO] { i += 1; q += TAM_PERIODO; } // busca si p está en el arreglo if i < n && p == &trabajo[q..q+TAM_PERIODO] { continue; } // si ya existe lo ignora // inserta p en el arreglo if i == n { let q = n * TAM_PERIODO; &trabajo[q..q+TAM_PERIODO].clone_from_slice(p); } else { for j in (i+1..ELEMENTOS_VECTOR).rev() { let q = j*TAM_PERIODO; unsafe { ptr::copy_nonoverlapping(&mut trabajo[q-TAM_PERIODO], &mut trabajo[q], TAM_PERIODO) } } let q = i*TAM_PERIODO; trabajo[q..q+TAM_PERIODO].clone_from_slice(p); } n += 1; } // retorna el resultado if n == 0 { result[0] = 'N' as u8; } else if n > ELEMENTOS_VECTOR { result[0] = 'S' as u8; } else { result[0] = 'D' as u8; for i in 0..n { let p = i*TAM_PERIODO; result[p+1..p+1+TAM_PERIODO].clone_from_slice(&trabajo[p..p+TAM_PERIODO]) } }
}
El tipo de datos u8 corresponde a un byte sin signo. En Rust los vectores tienen un método bastante conveniente llamado chunks(n), que permite dividir el vector en subvectores de tamaño n. En este loop la variable p es cada periodo del vector (uno de los chunks).
El equivalente a memove() en C se logra en Rust usando rangos.
Por ejemplo, la expresión:
&trabajo[q..q+TAM_PERIODO].clone_from_slice(p);
Es la que permite copiar en ese segmento del arreglo de trabajo desde p.
Hay que recordar que Rust tiene unas estrictas reglas para copiar datos de un area de memoria a otra y las reglas de ownership obligan a "clonar" bytes de un arreglo a otro[4].
Es por esta razón que para mover dentro de un arreglo debemos usar código unsafe:
También se podría optimizar aún más el código haciendo un sort in place en sólo un buffer, lo que queda propuesto.
Go
En Go la solución es igual de sencilla y similar a las versiones en Rust y C
func ordenar_vector(buf []byte, result []byte) { n := 0 trabajo := make([]byte, TAM_VECTOR_ENTRADA, TAM_VECTOR_ENTRADA) for i := 0; i < TAM_PERIODO; i++ { cero[i] = '0' } for i := 0; i < TAM_VECTOR_ENTRADA; i ++ { trabajo[i] = '0' } for p := 0; p < TAM_VECTOR_ENTRADA; p += TAM_PERIODO { periodo := buf[p:p+TAM_PERIODO] if bytes.Equal(periodo, cero) { continue } i := 0 q := 0 for i < n && bytes.Compare(periodo, trabajo[q:q+TAM_PERIODO]) < 0 { i++ q += TAM_PERIODO } if i < n && bytes.Equal(periodo, trabajo[q:q+TAM_PERIODO]) { continue } if i == n { q := n*TAM_PERIODO copy(trabajo[q:q+TAM_PERIODO], periodo) } else { for j := ELEMENTOS_VECTOR-1; j > i; j-- { q := j*TAM_PERIODO copy(trabajo[q:q+TAM_PERIODO], trabajo[q-TAM_PERIODO:q]) } q := i*TAM_PERIODO copy(trabajo[q:q+TAM_PERIODO], periodo) } n++ } if n == 0 { result[0] = 'N' } else if n > ELEMENTOS_VECTOR { result[0] = 'S' } else { result[0] = 'D' copy(result[1:n*TAM_PERIODO+1], trabajo[0:n*TAM_PERIODO]) } }
Usamos rangos para operar con secciones del arreglo de entrada. Copiar segmentos del vector al área de trabajo es bastante simple:
copy(trabajo[q:q+TAM_PERIODO], periodo)
Los rangos, tanto en Rust como en Go van desde el indice inicial hasta el valor anterior del indice final, por ejemplo, vector[0..6] devuelve 6 elementos, desde el 0 al 5 inclusive.
Swift
El código en Swift recibe los dos vectores que son declarados externamente:
func ordenarVector(_ buf: [Int8], _ trabajo : inout [Int8]) -> Int { var p = posVector var n = 0 let tope = largoLinea-1 while p < tope { if buf[p..<p+tamPeriodo] == ceroData { p += tamPeriodo continue } var i = 0 var q = 0 while i < n && buf[p..<p+tamPeriodo].lexicographicallyPrecedes(trabajo[q..<q+tamPeriodo]) { i += 1 q += tamPeriodo } if i < n && buf[p..<p+tamPeriodo] == trabajo[q..<q+tamPeriodo] { p += tamPeriodo continue } if i == n { q = n * tamPeriodo for k in 0..<tamPeriodo { trabajo[q+k] = buf[p+k] } } else { var j = tamVector-1 while j > i { q = j * tamPeriodo for k in 0..<tamPeriodo { trabajo[q+k] = trabajo[q-tamPeriodo+k] } j -= 1 } q = i * tamPeriodo for j in 0..<tamPeriodo { trabajo[q+j] = buf[p+j] } } n += 1 p += tamPeriodo } return n }
Swift tiene operaciones para copiar segmentos (slices) de un arreglo, uno puede escribir lo siguiente:
trabajo[q..<q+tamPeriodo] = bug[p..<p+tamPeriodo]
Pero noté que el compilador genera código muy ineficiente para estas operaciones, pues construye un objeto para cada segmento que luego es liberado. Es por esto que opté por escribir estos loops para copiar los elementos de un vector:
for j in 0..<tamPeriodo { trabajo[q+j] = buf[p+j] }
Esperemos que futuras versiones del compilador de Swift resuelvan este problema y generen código más eficiente.
Solucionando con la JVM
Hay tres lenguajes basados en la JVM en este desafío, Clojure, Scala y Kotlin.
Clojure
Mi primera solución fue en Clojure, que es un lenguaje dinámico funcional. El código para ordenar el periodo es bastante sencillo:
(defn agregar-periodo [^String linea ini fin lista] (if (.regionMatches linea ini ceros 0 tam-periodo) lista (conj! lista (subs linea ini fin)))) ; lista debe ser un set (defn extraer-periodos [^String linea] (loop [ini pos-vector fin pos-segundo-periodo lista (transient #{})] (if (= ini tope-linea) (persistent! (agregar-periodo linea ini fin lista)) (recur (+ ini tam-periodo) (+ fin tam-periodo) (agregar-periodo linea ini fin lista)))))
(defn ordenar-periodos [^String linea] (let [periodos (extraer-periodos linea) n (count periodos)] (cond (zero? n) (str "N" relleno-vector) (> n elementos) (str "S" relleno-vector) :else (str "D" (s/join (take elementos
(concat (sort #(compare ^String %2 ^String %1) periodos)
(repeat relleno))))))))
Lo primero que hay que notar es que ordenamos el vector sólo al final, después de que hemos determinado que la cantidad de periodos es mayor que cero y menor igual que 23.
La función extraer periodos va insertando cada periodo en un set, lo que permite eliminar duplicados. Por cierto, sólo agregamos el periodo si este es distinto a cero.
Notar que usamos un set creado con la función (transient), que nos permite un mayor desempeño.
Pero la verdadera optimización vino cuando usé la función (.regionMatches), esta es una función de la clase String de Java. El uso de esta función permite evitar la creación de substrings, lo que influye en el desempeño final de esta solución.
La función regionMatches() permite comparar una región del string, esto nos permite saber si la sub sección del string es cero de manera bastante rápida.
Esta función fue usada posteriormente en Kotlin y Scala.
Scala
El ordenamiento de Scala usa un conjunto ordenado, con esto el programa mantiene un arreglo sin duplicados y ordenado desde el principio.
def ordenarPeriodos(linea: String) : String = { val encabezado = linea.slice(0, posVector) val myOrdering = Ordering.fromLessThan[String](_ > _) var periodos = SortedSet.empty[String](myOrdering) var pos = posVector while (pos < largoLinea) { if (!linea.regionMatches(pos, ceros, 0, tamPeriodo)) periodos += linea.slice(pos, pos+tamPeriodo) pos += tamPeriodo } val len = periodos.size encabezado + ( if (len == 0) "N" + (" " * tamRelleno) else if (len > tamVector) "S" + " " * tamRelleno else "D" + periodos.mkString + " "*(tamRelleno-len*tamPeriodo) ) }
Notar como usamos regionMatches() para evitar insertar los ceros.
Kotlin
La solución en Kotlin es la más rápida en la JVM y quizás la razón sea que el ordenamiento lo hacemos al final, esto es más rápido que la solución en Scala, que está insertando en una estructura que se mantiene ordenada. La optimización en Scala es obvia, y queda propuesta como ejercicio.
fun ordenarVector(linea:String) : String { val encabezado = linea.substring(0, posVector) val periodos = HashSet<String>() for (i in posVector until largoLinea step tamPeriodo) { if (!linea.regionMatches(i, ceros, 0, tamPeriodo, true)) periodos.add(linea.substring(i, i+tamPeriodo)) } if (periodos.size == 0) return encabezado+"N"+ relleno else if (periodos.size > tamVector) return encabezado+"S"+relleno else { return encabezado+"D"+(periodos.sortedDescending().joinToString("")).padEnd(tamVector* tamPeriodo) } }
Tres lenguajes funcionales más
Las soluciones que quedan están en lenguajes funcionales, Haskell, F# y Erlang.
Haskell
La solución es Haskell debe leerse de abajo para arriba para entenderse:
clasificar_resultado $ ordenar_periodos $ chunksOf tam_periodo resto
Lo que hacemos es dividir los periodos en segmentos de tamaño 6 (tam_periodo):
chunksOf tam_periodo resto
Luego ordenar_periodos ordena de forma descendente, eliminando duplicados (nub), filtrando sólo los periodos válidos.
Una optimización obvia sería ordenar sólo en el caso "D", lo que también es un ejercicio que pueden probar ustedes.
F#
El ordenamiento de los periodos es bastante simple:
let ordenar_periodos (linea:string) = let periodos = separar_periodos linea |> Seq.distinct |> Seq.toList let len = Seq.length periodos if len = 0 then "N".PadRight(PAD_SIZE) else if len > ELEMENTOS_VECTOR then "S".PadRight(PAD_SIZE) else ("D" + (periodos |> Seq.sortDescending |> String.Concat)).PadRight(PAD_SIZE)
La función separar_periodos fue programada de manera imperativa para poder lograr un mejor performance, esto permitió reducir el tiempo varios segundos:
let separar_periodos (linea:string) = seq { let mutable p = POS_VECTOR while p < LARGO_LINEA do if no_es_cero linea p then yield linea.Substring(p, TAM_PERIODO) p <- p + TAM_PERIODO }
Esta implementación de F# pudo haber sido escrita de forma más compacta, pero tuve que recurrir a varias optimizaciones usando código más imperativo. La primera solución en F# se ejecutaba en más de 20 segundos. Con estas optimizaciones llegamos a menos de 9 segundos.
Erlang
La solución en erlang es recursiva, y usa una estructura de arreglos ordenados. Esto fue lo más rápido que pude lograr, con tiempos menores a 50 segundos.
La versión inicial tomaba más de tres minutos para un archivo de un millón de lineas, uno de los impactos más grandes fue usar archivos en modo raw, puesto que Erlang usa otro proceso para ejecutar todo el IO a menos que los archivos sean abiertos en modo raw. Después de eso las mejoras fueron marginales.
Quizás con un equivalente a la función regionMatches de JVM esta versión podría bajar de los 20 segundos. Sospecho que usando binaries también, pero eso requiere conocimientos más avanzados de Erlang.
ordenar_vector(Vector) -> Encabezado = substr(Vector, 1, ?POS_VECTOR), Periodos = separar_periodos(substr(Vector, ?INI_VECTOR, ?LARGO_VECTOR), new(), ?LARGO_VECTOR), Largo = size(Periodos), if Largo =:= 0 -> [Encabezado|?N_RELLENO]; Largo > ?ELEMENTOS_VECTOR -> [Encabezado|?S_RELLENO]; true -> P = reverse(to_list(Periodos)), L = (?TAM_RELLENO-len(P)*?TAM_PERIODO) - 1, [Encabezado, "D", P, chars(32, L)] end. separar_periodos(Linea, Periodos, ?TAM_PERIODO) -> if Linea =:= ?CEROS -> Periodos; true -> add_element(Linea, Periodos) end; separar_periodos(Linea, Periodos, Largo) -> Periodo = substr(Linea, 1, ?TAM_PERIODO), Resto = substr(Linea, ?TAM_PERIODO_MAS_1), if Periodo =:= ?CEROS -> separar_periodos(Resto, Periodos, Largo-?TAM_PERIODO); true -> separar_periodos(Resto, add_element(Periodo, Periodos), Largo-?TAM_PERIODO) end.
Conclusión
Este fue un ejercicio bastante largo, tomándome unas 48 horas de trabajo a lo largo de cuatro meses, la razón es que me empeñé en lograr la solución más rápida de cada lenguaje. La vara contra la que me medí fue lograr tiempos de ejecución menores a los 10 segundos. En retrospectiva puedo ver que es posible mejorar aún más varias de las soluciones. Ya quiero avanzar hacia otras cosas, así que ese desafío queda para ustedes, siempre pueden hacer un pull request con mejores soluciones o proponer soluciones en otros lenguajes.
[1] Katty Lied es el cuarto álbum de estudio de Steely Dan. Entre otras cosas se destaca por contar con la participación por primera vez de Michael McDonald en los coros y del gran baterista, y fundador de Toto, Jeff porcaro, que en ese tiempo tenía apenas 20 años de edad.
[2] Structured Programming with Goto Statements, hay una copia del artículo acá: http://web.archive.org/web/20130731202547/http://pplab.snu.ac.kr/courses/adv_pl05/papers/p261-knuth.pdf
[3] Macbook pro 2016, Intel i7, 2.6 Ghz, 16 Gb RAM, MacOS Sierra, Disco SSD. Las pruebas se ejecutaron sin tener ningún otro proceso activo, desconectados de internet y con anti virus deshabilitado
[4] Ver https://doc.rust-lang.org/book/ownership.html
Aunque la frase en inglés, "fake it 'til you make it", se usa como un término asociado con la autoayuda, voy a hacer una interpretación más literal de para explicar lo que acabo de hacer.
Hace unos 5 año atrás declaré en este blog mis intenciones de construir un nuevo lenguaje de programación. De hecho el repositorio Github del mismo tiene más tiempo.
Llamé a este lenguaje Ogú, como referencia al personaje creado por Themo Lobos, el simpático cavernícola amigo de Mampato. También les conté cómo conocí a la hija del ilustrador para conseguir permiso para usar una imagen de Ogú como mascota del proyecto.
Pasaron los meses, los años, algunos amigos me echaban bromas, y el proyecto parecía no avanzar. Diseñar un lenguaje de programación es complejo, pero además este en particular es bastante ambicioso y para serles franco, no tenía idea de lo que estaba haciendo.
El diseño sufrió varios cambios, entre medio aprendí mucho más de programación funcional y fui aumentando mi visión crítica hacia la programación orientada al objeto. Por años probé diversas herramientas para poder construir el lenguaje, simplifiqué el diseño, fue perdiendo su sabor orientado al objeto y volviéndose más un lenguaje funcional, porque es el paradigma que me parece más poderoso para este tiempo.
A fines del año pasado que decidí que durante los meses de noviembre a marzo intentaría un nuevo asalto para lograr sacar una primera versión de Ogú.
Pero no lograba avanzar. Llegaba enero y apenas tenía un compilador que timidamente podía generar código para algunas expresiones simples y escribir un simple "hello world" en la consola.
Hay varias razones por la que este proyecto no puede pasar de marzo, simplemente después no tendré el tiempo suficiente ni la dedicación para sacarlo adelante y no quiro que pase otro año sin avanzar. Además le prometí a Themo que Ogú, el lenguaje de programación, sería una herramiente de enseñanza, y es una promesa que quiero cumplir. Así que para mi es importante tener una primera versión de Ogú que pueda ser usable, y espero que con ayuda de más personas el lenguaje pueda evolucionar para poder ser una herramienta de enseñanza y de programación útil.
Así que en enero estaba empezando a desesperarme hasta que tuve una epifanía.
Un día estaba tratando de depurar la gramática y la generación del código y decidí escribir código para visualizar el AST generado por el compilador para poder entender cómo operaban las expresiones en Ogú y resolver algunas ambiguedades. La forma de representar el AST más común es como listas. Por ejemplo, la expresión:
funcion a + 5
Se representa así en el AST:
(funcion (+ a 5))
Esto es parece mucho a LISP. La razón es porque LISP tiene la sintaxis que tiene debido a que los desarrolladores de ese lenguaje eran tan vagos que se dieron cuenta que podían pedirle a los programadores que escribieran el AST por ellos y así no tenían que desarrollar un front-en para su compilador, y simplemente construyeron el backend del AST (para que nadie sospechara del ardid le cambiaron el nombre al AST y le pusieron S-Expression).
Luego pensé, "si puedo convertir mi gramática a S-Expressions, entonces voy hacer lo mismo que hicieron los creadores de LISP, pero al revés!". Constuiré un front-end que convierta un programe en Ogú en un programa en LISP y dejaré que el backend de LISP ejecute los programas.
Por otro lado, un requerimiento inicial de Ogú es que corra en la JVM, esto porque pienso usar su ecosistema y además puedo de este modo llegar a dominar el mundo del desarrollo enterprise desplazando a Java y Scala.
Una vez que tracé mi plan maestro, al que llamé "fake it 'til you make it", usé el mejor interprete de LISP que existe para la JVM (y el único que conozco), Clojure, para contruir mi compilador e interprete más una biblioteca runtime inicial (la de Clojure, por supuesto).
En menos de 60 horas de trabajo distribuidas en unos 2 meses pude construir la primera versión de Ogú: Plunke, en honor al científico loco creador de Ferrilo el Autómata, otro personaje de Themo Lobos.
El Profesor Plunke y Ferrilo (creaciones de Themo Lobos)
Así que ahí tienen, en este nuevo mundo de fake news, tengo mi propio fake programming language, pero uno bastante entretenido y que me permite experimentar con varias ideas que tengo.
Esta es la versión 0.1 de Ogú, hay mucho que recorrer, esta versión sacrifica algunas cosas. Por ejemplo, es un lenguaje dinámico, la propuesta inicial era contar con verificación estática de tipos, pero creo que es algo que hay que validar, quizás la solución es algo intermedio y puede que por ahí existan algunos aportes de Ogú a los lenguajes de programación. Hay conceptos que llevan en mi mente varios años, que también espero plasmar en Ogú.
Pero la construcción de lenguaje de programación, con la ambición de Ogú no es tarea para afrontarla sólo. Esta etapa "fake" del lenguaje debe ser superada. El compilador tiene que vivir por si mismo, hay que construir bibliotecas de funciones, crear compiladores para otros runtime (.Net por ejemplo, o Javascript), quizás crear un compilador para código nativo. Construir frameworks para que el lenguaje sea útil y usado por muchos programadores.
Para eso necesito ayuda. Por ahora necesito que bajen el compilador, lo prueben, escriban programas en Ogú y me reporten errores en GitHub. Y me ayuden a crear documentación en español e inglés. Soy ambicioso con este proyecto, me gustaría que saliera en Hacker News, y que GitHub indexara programas escritos en Ogú. Pero para eso, insisto, necesito ayuda. Si les entusiasma, contáctense conmigo, clonen el proyecto y hagan Pull Request en GitHub.
union [3, 6..999] [5, 10..<1000] |> sum |> println!
Calcular el factorial:
fact 0 = 1 fact 1 = 1 fact n = n * fact (n - 1)
Quicksort
quicksort [x & xs] = smallerSorted ++ [x] ++ biggerSorted where smallerSorted = quicksort [a | a <- xs & a <= x] biggerSorted = quicksort [a | a <- xs & a > x]
El juego de Toque y Fama
def ingresar tam = prompt! #"Ingresa una secuencia de ${tam} dígitos distintos (o escribe salir):"
def famas num xs = zip num xs |> filter \x -> first x == second x |> count
def toques num xs = num |> filter \x -> (elem x xs) |> count
def validar n xs = if (length num) == n then num else [] where num = xs |> filter is-digit? |> map to-digit |> uniq
let tam = 5
let sec = shuffle [0..9] |> take tam
println! $ fmt "Bienvenido a Toque y Fama.==========================\n\nEn este juego debes tratar de adivinar una secuencia de ${tam} dígitos generadas por el programa.\n
Para esto ingresas ${tam} dígitos distintos con el fin de adivinar la secuencia.\n
Si has adivinado correctamente la posición de un dígito se produce una Fama.\n
Si has adivinado uno de los dígitos de la secuencia, pero en una posición distinta se trata de un Toque.\n\n
Ejemplo: Si la secuencia es : [8, 0, 6, 1, 3] e ingresas 40863, entonces en pantalla aparecerá:\n
tu ingresaste [4, 0, 8, 6, 3]\nresultado: 2 Toques 2 Famas\n\n\n"
loop intentos = 1, accion = ingresar tam in if accion == "salir" || empty? accion then println! "\ngracias por jugar, adios." else let num = validar tam accion in
if empty? num then begin println! "error!\n" repeat inc intentos, ingresar tam end else begin println! "tu ingresaste " num let (toques, famas) = (toques num sec, famas num sec) in begin
println! "resultado: " (toques - famas) " Toques " famas "Famas" if famas == tam then println! "Ganaste! Acertaste al intento " intentos "! La secuencia era " sec else repeat inc intentos, ingresar tam end end
Ogú tiene mucha influencia de Haskell, Clojure por supuesto, F#, Elixir y Pascal (por los begin end ;).
Voy a estar escribiendo sobre Ogú en las próximas semanas y por supuesto resolveré los desafíos en Ogú también. Así que estén atentos y espero sus comentarios y aportes.
El 31 de diciembre de 1984 fue una fecha que cambió para siempre la vida de Rick Allen, el baterista de Deff Leppard. En un accidente automovilístico, el "dio del trueno", como le dicen sus fans, perdió su brazo izquierdo.
Para un joven y brillante baterista la amputación significaba el fin de su carrera, pero alentado por sus compañeros y con la ayuda de Jeff Rich (baterista de Status Quo), Allen trabajó en diseñar un kit electrónico, con este hizo su debut en 1986 en el festival Monstes of Rock en Castle Donington.
Rich usa cuatro pedales para su pie izquierdo, para reemplazar su miembro faltante, con los que gatilla el sonido del hit hat, bombo, caja y un tom.
Pero ocurrió que después del accidente de Allen la banda ingresó en su periodo más exitoso. Hysteria, el cuarto álbum de estudio de Deff Leppard fue el número uno en los rankings Billboard 200 y UK Album Charts de 1987. La historia está muy bien relatada en la película "Hysteria, the Def Leppard Story".
La decisión de Rick Allen de usar la tecnología y modificar su forma de tocar la batería, para continuar con su carrera, es algo digno de estudiar en detalle. No todos nos enfrentamos a decisiones tan vitales como la que enfrentó este músico, pero en el mundo de la tecnología el cambio repentino es una constante.
Una lección del caso de Allen que podemos obtener es que la tecnología puede ser una gran ayuda para superar nuestras limitaciones físicas.
Rick Allen, baterista de Def Leppard
Kotlin
Los lenguajes de programación son una de las tecnologías que inventamos para superar la limitación que impone darle instrucciones a una Máquina de Turing. Lo que buscamos es poder expresarnos de la manera más cercana a como pensamos, y no de la forma secuencial que nos impone el modelo de Von Neumann, por ejemplo.
Es por esto que existen tantos lenguajes de programación, buscan superar alguna limitación.
Java es uno de los lenguajes más populares en este momentos, uno de los que tiene mayor penetración y representación en el mercado del desarrollo de software, de acuerdo a diversos índices, como el Ranking en RedMonk , o en Indice TIOBE.
Pero todos los que hemos programado en Java sabemos que no es un lenguaje cómodo, tiene alguna decisiones de diseño que aún generan grandes trastornos (como veremos en un ejemplo más adelante en este artículo).
La Java Virtual Machine fue creada para soportar el lenguaje Java, la idea de Gosling era portar esta máquina a diversos ambientes, principalmente dispositivos con limitados recursos. Pero Java está en casi todas partes, y distribuido masivamente en millones de dispositivos con Android.
Cuando Microsoft inventó .Net decidió tomar un camino inverso en cierta manera, al de Sun, su CLR es una máquina virtual pensada para trabajar con varios lenguajes, y cuando salió al mercado incluía a Visual Basic y C#.
Con el tiempo, la gente que usaba Java decidió construir otros lenguajes que aprovecharan la JVM, pero tratando de superar las limitaciones de Java.
Kotlin es uno de esos intentos. Un lenguaje que lleva ya seis años de desarrollo, y se encuentra en su versión 1.1.
No voy a escribir una elegía a Kotlin, Steve Yegge ya lo hizo en otro lado.
Es un lenguaje interesante, que hay que observar, creo que va a ganar tracción después del impulso que le dio oficialmente Google al reconocerlo como lenguaje oficial para Android.
Este artículo es una introducción al cuarto desafío de mi serie sobre estos "raros lenguajes nuevos", así que vamos a eso, y después volvemos a Kotlin.
Syntactic Sugar
Pour your sugar on me I can't get enough - Def Leppard
Syntactic Sugar causes cancer of semicolons - Alan Perlis
En los lenguajes de programación hablamos de Syntactic Sugar para referirnos a sintaxis diseñada para hacer las cosas más fáciles de leer, o de entender.
La idea del desafío cuatro es explorar esa Syntactic Sugar, junto con otras decisiones de diseño, que le dan alguna ventaja a cada uno de los lenguajes, y que explican porque hay tanta variedad de los mismos. También nos permite entender en qué estaban pensando sus autores cuando los crearon.
Esta vez, resolveré el problema un lenguaje a la vez y documentaré la solución por cada uno de los lenguajes en un post. Así que este cuarto desafío constará de nueve partes (o quizás 10 u 11).
El cuarto desafío
En esta oportunidad construiremos un programa que comprime información. Para esto usaremos un algoritmo clásico llamado Huffman Coding.
Al menos teóricamente, el Huffman Coding es la compresión que asegura que cada símbolo es represantado con la cantidad óptima de bits, de acuerdo a la teoría de la información de Shannon (de la que hemos hablado antes).
En el Huffman Encoding lo que hacemos es contar la frecuencia de los símbolos en el texto que queremos comprimir, en base a eso construimos una tabla de representación que minimice la cantidad de bits para cada uno. Así los símbolos más frecuentes requieren menos bits que los símbolos que se presentan menos veces en el texto.
Una descripción del código de Huffman más detallada está en Wikipedia, se las dejo para que la estudien si les interesa: Huffman Coding. Lo que importa ahora es cómo implementamos esto en diversos lenguajes. Así que iremos viendo esto a lo largo de los artículos.
Volvamos a Kotlin
Para construir una solución a este desafío necesitamos construir algunas estructuras de datos muy interesantes, como una Cola de Prioridad y un Trie. Esto último nos permite ver donde un lenguaje como Kotlin tiene ventajas sobre Java y nos ayuda a cometer menos errores, y escribir menos código.
Para explicarlo voy a tomar una desviación y les voy a proponer otro ejercicio. Supongamos que tenemos un árbol binario. En cada nodo de este árbol almacenamos un número, y queremos calcular la suma de estos números y los valores máximos y mínimos de este árbol.
Así, que nuestro objetivo es construir una estructura que cumpla lo siguiente:
- Arbol es una estructura de arbol binario.
- Un nodo tiene dos hijos siempre, y un valor numérico.
- Una hoja sólo tiene un valor numérico.
- Arbol.sum(): obtiene la suma de los valores en los nodos y las hojas.
- Arbol.max(): obtiene el máximo valor entre los nodos y las hojas.
- Arbol.min(): obtiene el mínimo valor entre los nodos y las hojas.
Un árbol binario con números en sus nodos y hojas
Una solución en Java es la siguiente:
public class Tree { int value; Tree left; Tree right; public Tree(int value) { this(value, null, null); }
public Tree(int value, Tree left, Tree right) {
this.value = value;
this.left = left;
this.right = right;
}
public int sum() {
return this.value + this.left.sum() + this.right.sum();
}
public int max() {
return Math.max(this.value, Math.max(this.left.max(), this.right.max()));
}
public int min() {
return Math.min(this.value, Math.min(this.left.min(), this.right.max()));
}
}
Y podemos crear el árbol de la figura de este modo:
Tree arbol = new Tree(30, new Tree(70), new Tree(15, new Tree(42, new Tree(54),
new Tree(66)), new Tree(25, new Tree(89), new Tree(72)))); System.out.println(arbol.sum()); // 463 System.out.println(arbol.max()); // 89 System.out.println(arbol.min()); // 15
Hay varios problemas con esta implementación, por ejemplo, ¿qué pasa con este código?:
Tree arbol = new Tree(30, new Tree(70), null);
Ups, una NPE! Entonces tenemos que parchar nuestro código de este modo:
public int sum() { if (this.left == null && this.right == null) { return this.value; } else if (this.left == null) { return this.value + this.right.sum(); } else { return this.value + this.left.sum() + this.right.sum(); } }
Y así para max y min, pero ¿estamos seguros de que ese código está bien?
Consideren esta solución en Kotlin:
interface Tree { fun sum() : Int fun min() : Int fun max() : Int }
class Node(val value:Int, val left:Tree, val right:Tree) : Tree { override fun sum() : Int = value + left.sum() + right.sum() override fun max() : Int = Math.max(value, Math.max(left.max(), right.max())) override fun min() : Int = Math.min(value, Math.min(left.min(), right.min())) }
class Leaf(val value:Int) : Tree { override fun sum() : Int = value override fun min() : Int = value override fun max() : Int = value }
No sólo el código es más compacto, sino que en Kotlin es imposible que podamos introducir un null en un árbol.
En Kotlin está prohibido tener variables sin inicializar por default.
Pero a veces es necesario introducir nulls en nuestras estructuras, y en ese caso, Kotlin introduce una Syntactic Salt, es decir, sintáxis que dificulta escribir cosas peligrosas, o malas prácticas.
Si quisieramos que nuestros nodos aceptara nulls entonces habría que declararlos así:
class Node(val value:Int, val left:Tree?, val right:Tree?) : Tree
Y tendríamos que manejar todas las condiciones de borde, ugh!
Así que Kotlin tiene esa ventaja incorporada y hay que aprovecharla.
Tony Hoare se lamenta de haber inventado Null al grado de llamarlo su error de "los mil millones de dólares". Esta es una de las causas de mayores errores en nuestro código en Java. Kotlin en su modo normal impide eso, si quieres usar nulls debes tener bien claro qué estás haciendo y la sintaxis del lenguaje te lo indica en todo momento.
Un bug sutil
Pero hay una decisión de diseño en Kotlin que se refleja en un bug sutil, pero que me complicó cuando implementé la primera solución al desafío.
El bug se hace evidente en este código:
fun main(args:Array<String>) { var list = ArrayList<Int>() list.add(0) list.add(0) list.remove(list.size-1) o remove(int index) list.forEachIndexed { i, v -> println("list(" + i +") = "+v) }
}
Uno esperaría que se imprimiera sólo uno elemento, pero se imprimen dos!
¿Por qué pasa esto?
Porque en Kotlin Int se traduce en el tipo Integer de Java, pero Java maneja eso como una clase distinta del tipo nativo int.
Por desgracia, la clase ArrayList tiene el método remove sobrecargado, del siguiente modo:
void remove(int index); void remove(Object o);
Cuando pasas un Int en Kotlin el compilador utiliza la versión que recibe un objeto.
Así la llamada list.remove(list.size-1) se traduce como list.remove(new Integer(1)), como el número 1 no está en la lista tenemos que no se elimina nada de la lista, y no es la intención del programador.
La solución es usar el método removeAt(), pero en otras situaciones este bug se puede dar sin que nos demos cuenta. El compilador de Kotlin debería mejorar en este sentido con algún warning.
A diferencia de Scala, Kotlin construye sus colecciones ampliando las disponibles en Java. En Scala las colecciones son distintas, y existe un método de interoperar con Java algo más complicado, así que este sutil error no se da.
Huffman Coding en Kotlin
La ventaja de Kotlin en este ejercicio se muestra en la implementación del algoritmo de encoding.
En esencia el algoritmo para construir el árbol de codificación de Huffman es el siguiente:
1. Crear un nodo hoja para cada símbolo, asociando un peso según su frecuencia de aparición e insertarlo en la lista ordenada ascendentemente.
2. Mientras haya más de un nodo en la lista:
2.1 Eliminar los dos nodos con menor probabilidad de la lista.
2.2 Crear un nuevo nodo interno que enlace a los nodos anteriores, asignándole como peso la suma de los pesos de los nodos hijos.
2.3 Insertar el nuevo nodo en la lista, (en el lugar que le corresponda según el peso).
3. El nodo que quede es el nodo raíz del árbol.
Para implementar esto en Kotlin, el árbol se representa así:
abstract class HuffTree(val frequency : Int)
class HuffLeaf(frequency: Int, val symbol: Char) : HuffTree(frequency) { fun symbolIndex() : Int = ... }
class HuffNode(val left: HuffTree, val right: HuffTree)
: HuffTree(left.frequency+right.frequency)
Y la construcción del árbol queda así:
fun buildTree(freqs : IntArray) : HuffTree { // la lista ordenada la implementamos como un Heap
val heap = HuffHeap()
// Crea una hoja por cada símbolo con su frecuencia
freqs.forEachIndexed { sym, freq -> if (freq > 0) { heap.insert(HuffLeaf(freq, sym.toChar())) }
}
// mientras haya más de un nodo en la lista
while (heap.size() > 1) { val a = heap.extract() val b = heap.extract() heap.insert(HuffNode(a, b))
}
return heap.extract() }
Si bien esto queda muy elegante, la implementación del Heap requiere que usemos un arreglo donde pueden haber nulls, y acá es donde aparecen las syntactic salt que mencionamos anteriormente:
class HuffHeap { var heap = arrayOfNulls<HuffTree>(maxSymbols+1) var last = 0 fun insert(tree:HuffTree) { if (full()) { throw ArrayIndexOutOfBoundsException() } last++ heap[last] = tree var j = last while (j > 1){ if (heap[j]!!.frequency < heap[j/2]!!.frequency) { val tmp = heap[j]!! heap[j] = heap[j / 2]!! heap[j / 2] = tmp } j /= 2 } } ....
l declarar heap como arrayOfNulls, obtenemos un arreglo de tipo Array<HuffTree?>.
Esto quiere decir que cada elemento del arreglo puede contener una referencia a un HuffTree y esta puede ser null.
De ahí que tengamos que hacer heap[j]!!.frequency para acceder a la frecuencia de un elemento del Heap.
Por construcción y si está bien implementado el TDA Heap, no hay modo de que heap[j] sea null, así que la sintaxis !! nos dice que es seguro usar ese valor y que no es un null.
Otra ventaja de Kotlin, y esto sí es un Syntactic Sugar, es que después de evaluar por un tipo podemos eliminar los engorrosos type casting de java, esto se ve en la función buildCodes()
fun buildCodes(tree : HuffTree, codes: Array<String>, prefix : StringBuffer) { when (tree) { is HuffLeaf -> codes[tree.symbolIndex()] = prefix.toString() is HuffNode -> { ... } } }
Acá usamos la estructura de control when, que es una especia de switch, pero más poderoso, en este caso validamos el tipo de tree. Noten que si sabemos que tree es una HuffLeaf podemos invocar al método symbolIndex() que sólo existe en esa clase.
Esto ocurre en Swift, pero la verdad es que Swift parece haber tomado esta característica de Kotlin.
El código completo está en mi repositorio en GitHub. Hice dos implementaciones, una más orientada al objeto que la otra.
La diferencia del enfoque está expresada en estos dos fragmentos de código que implementan la misma función:
// función decompress versión 1 fun decompress(inputFile: String, outputFile: String) { val input = BitInputStream(BufferedInputStream(FileInputStream(inputFile))) val output = BitOutputStream(BufferedOutputStream(FileOutputStream(outputFile))) val huffTree = readTree(input) val length = input.readInt() for (i in 0..length-1) { var node = huffTree while (node is HuffNode) { val bit = input.readBoolean() node = if (bit) node.right else node.left } if (node is HuffLeaf) output.write(node.symbol) } output.close() }
// función decompress versión 2 private fun decompress(inputFile: String, outputFile: String) { val input = BitInputStream(BufferedInputStream(FileInputStream(inputFile))) val output = BitOutputStream(BufferedOutputStream(FileOutputStream(outputFile))) val huffTree = readTree(input) val length = input.readInt() (0..length-1).forEach { output.write(huffTree.readChar(input)) } output.close() }
En la versión 2 la case HuffTree se comporta cómo dicta el paradigma orientado al objeto y cada clase que la implementa sabe como comportarse según el contexto en que opera, esto es lo que se conoce como polimorfismo.
La gran diferencia es que HuffTree se declara de este modo:
abstract class HuffTree(val frequency : Int) { abstract fun writeTo(output: BitOutputStream) abstract fun dumpCodes(codes: Array<String>, prefix : StringBuffer) abstract fun readChar(input: BitInputStream) : Char }
Y para entender decompress hay que considerar que en HuffNode la función readChar se implementa así
// implementación en HuffNode override fun readChar(input: BitInputStream) : Char { val bit = input.readBoolean() return if (bit) right.readChar(input) else left.readChar(input) }
En cambio en HuffLeaf...
override fun readChar(input: BitInputStream) : Char { return this.symbol }
Todo esto para mostrarle que si bien Kotlin tiene el operador is, y when y que elimina cast, hay formas más adecuadas de desarrollar esto, o de lo contrario su código se transforma en una enorme secuencia de ifs o whens realizando type casting implícitos.
Conclusión
Kotlin es un lenguaje muy práctico, permite escribir código bastante compacto y expresivo. Muchas de sus construcciones además permiten escribir código más seguro. En Scala hay cosas similares, pero requieren más verbosidad (estoy pensando en los Options para evitar los nulls), pero esto es algo que vamos a discutir en el segudo artículo de esta serie.
Tiene estructuras que permiten ahorra typecasting y que permite ejecutar sentencias según el tipo de datos que ser recibe, pero esto puede llevar a malos hábitos de programación, siempre que sea posible usen el paradigma orientado a objetos adecuadamente, y eso pasa por usar polimorfismo.
Kotlin es un lenguaje moderno, entretenido, fácil de aprender, con la dosis adecuada de Sal y Azucar sintáctico se puede desarrollar casi cualquier cosa. Pero al igual que ocurre con la sal y azucar en nuestro alimentos, el abuso puede causar serios problemas de salud, así que úsenlo con moderación.
Rock and Roll is a risk You Risk Being Ridiculed - Brendan, en Sing Street
Hay una escena en Sing Street en que el hermano mayor le dice al protagonista que el rock se trata de arriesgarse, arriesgarse incluso a ser ridiculizado. Hay miles de bandas de cover, le dice, todo el mundo hace bandas de cover y en estas siempre hay un hombre de edad mediana arrepentido de no haber tenido las bolas de componer su propia canción.
El mundo está lleno de blogs que son como las bandas de cover, orientadas al clickbait, recogiendo lastimeramente los centavos que les dejan los anuncios. Algunos que sólo se dedican a copiar y traducir de mala manera los contenidos generados en
otras latitudes. Pero aún así hay algunos exitosos y la gente que vive de eso, sin duda.
Pero ¿dónde está el aporte original? ¿Dónde está el riesgo
? ¿Por qué hay tan poco contenido sobre tecnología en español que sea original, o al menos intente ser distinto?
Eso es lo que he tratado de hacer
todos estos años, escribir sobre la tecnología y la ciencia desde mi perspectiva. Escribir mi propia canción de rock, arriesgarme a ser ridiculizado.
Pero escribir tiene un costo de oportunidad, y un blog tiene otras necesidades extra. Por ejemplo, hace años decidí abandonar Wordpress, y no encontré ninguna plataforma que me diera lo que yo necesitaba, así que desarrollé mi propio servidor de blogs, pero eso requirió esfuerzo extra que nunca consideré cuando me embarqué en esto.
You can never do anything by half. Do you understand that? - Raphina en Sing Street
Les contaré una anécdota de
juventud. Cuando tenía unos dieciséis años con unos amigos de colegio formamos nuestra propia banda de covers (ya sé, ya sé, pero sigan conmigo).
Conseguimos una presentación en un evento que se realizaba en Chuquicamata, habían artistas conocidos invitados de "la capital", pero podíamos abrir el espectáculo con tres canciones. Era una oportunidad para tomar y practicamos como micos por semanas. Sucedió que en ese tiempo mi viejo se enfermó y mientras él viajaba a Santiago para empezar el tratamiento de su cáncer, yo me esforzaba en lograr algo que estaba fuera de mis escasas posibilidades como baterista.
Habíamos decidido empezar nuestra presentación con una versión instrumental de Maniac, un conocido tema de la banda sonora de Flashdance, una pieza que al inicio tiene una secuencia de percusión electrónica que debía emular con un hit hat y un cencerro envuelto en tela. Cuando cuentas sólo con una vieja batería de jazz de la banda del colegio tienes que ser creativo.
Llegó el día, y mientras
mi viejo reposaba en la Clínica Indisa, esperando recuperarse, yo tocaba con todas mis ganas frente a dos mil personas.
¡Cómo le pegué a eso
s platos ese día!
Recuerdo que estaba tan nervioso que sentía agarrotados los brazos y las piernas. Avanzaban los compases, pum pum, "she´
s a maniac, maaaniaaac..."
Terminó nuestra pequeña presentación y sentí el aplauso de la audiencia. No sé cuantos de ustedes han sentido el aplauso de miles de desconocidos por algo que ejecutaste o realizaste con nada más que tus manos, tu esfuerzo y las ganas de agradar. Pero lo mejor vino uno
s minutos después, cuando salimos.
Había un grupo de jóvenes en la calle, conocíamos a algunos y nos acercamos, estaban afuera del pequeño estadio
techado, escuchando por los parlantes que se habían colocado para quienes no podían entrar. Fue en ese momento que uno de mis amigos les preguntó si esta
ban desde el principio, y qué opinaban de la banda que abrió el espectáculo, haciéndonos un guiño y sonreímos. Entonces uno de ellos dijo: "¡Sí, yo los escuché, eran muy buenos y el baterista tocaba de manera espectacular!".
Leo ese último párrafo y no puedo dejar de pensar en mi vanidad, creo que e
n ese momento fue que se instaló en mi personalidad, jajajaja. Pero pónganse en mi lugar, ¿no sentirían un tremendo orgullo después de escuchar una frase
como aquella? Esa noche los miles de aplausos recibidos pesaban tanto como la opinión de ese chico en mi corazón.
No pude dejar de pensar en
mi viejo, y más tarde en mi cama lloré antes de quedarme dormido. Lloré de alegría y de dolor, mi viejo se moría y al mismo tiempo recibía el reconocimiento de unos pocos jóvenes de mi ciudad. La vida es así, hay muchos eventos de alegría triste y de tristeza alegre en mi vida, junto con todos los otros
momentos que vale la pena recordar.
¿Y por qué les cuento esto?
Porque sólo quería compartir con ustedes mi intención de seguir arriesgándome a ser ridiculizado.
Este sitio va a tener un nuevo aire en su décimo segundo año. Volveré a escribir con más regularidad y para que ve
an que esto va en serio, he inscrito este blog en Patreon, y porque necesito de sus aplausos también.
El micromecenazgo, o crowfounding, es uno de los mecanismos de financiación colectiva de proyectos. Mantener este blog tiene costos alternativos, y costos operativos y en otros años he buscado formas de financiarlos, incluso hubo un tiempo en que es
te blog mantenía anuncios.
El problema con los anuncios es que ensucian el sitio. Recuerdo haber escrito un post con una crítica a los evangelizadores que desprecian a la ciencia y mi sitio se llenó de anuncios de corte religioso.
Pero también veo el micromecenazgo como una manera de crear lazos con ustedes mis lectores, un modo de saber quienes son y sentirlos más cerca. Es cómo si me invitaran a tomar una cerveza o un almuerzo, tal como varios lo han hecho a lo largo de estos años. Tengo lectores de México, Argentina
y España que nunca he conocido en persona, con quienes he tenido intercambio via email o por redes sociales, que ya están dispuestos a aportar.
Estoy seguro que varios de ustedes quieren ayudar a mantener a "La Naturaleza del Software". Algunos me han escrito contándome como uno de mis posts les permitió abrir una discusión con sus colegas para mejorar, o simplemente se sintieron identificados con alguna anécdota personal. Ahora los invito a aportar con una pequeña colaboración monetaria.
Recompensas, recompensas
Lo mejor es que todos los que aporten recibirán un regalo periódico en su buzón de correo electrónico. A través de Patreon compartiré contenidos exclusivos, que no serán publicados en este sitio, junto con adelantos de algunos de mis proyectos (como
mis próximos libros).
Esta es mi invitación, cada vez que veo una notificación de Patreon en
mi corazón crece el orgullo del reconocimiento, que me motiva a seguir mejorando en esto, como aquella noche de 1984.
Puedes aportar en tres nivel
es, desde un dólar hasta veinticinco dólares, obteniendo distintos tipos de recompensas por tu apoyo.
Pero aunque no hayan aportes, el compromiso d
e aumentar la frecuencia de posteos se mantiene, al menos tendrán un artículo a la semana a partir de hoy.
Bienvenidos a la nueva era de La Naturaleza del Software, esto será muy entretenido, lo prometo.
"Un dromedario es un caballo diseñado por un comité" -- antiguo aforismo mentat, anterior a la Yihad Butleriana
"Nunca hay tiempo para hacer las cosas bien a la primera, pero siempre hay tiempo para volver a hacerlas" -- Anónimo, citado por Melvin Conway
Mi más memorable encuentro y realización de la profundidad de la Ley de Conway fue hace un par de años, cuando una decisión de arquitectura se vio afectada por la organización del datacenter en que alojamos nuestras aplicaciones. Tal cómo lo escuchan, debimos modificar la arquitectura de despliegue de nuestro sistema por la organización de servicios del datacenter.
"No puedes tener una base de datos en el mismo servidor de aplicaciones, porque... las bases de datos las administran los DBA, las capas aplicativas los operadores de sistemas". Pero... "Las bases de datos las administran los DBA, las capas aplicativas los operadores de sistemas".
En fin, dejaremos la discusión sobre "las buenas prácticas" para otro momento.
La Ley de Conway
La Ley de Conway, es una observación del programador, hacker, y PhD en Computer Sciences Melvin Conway, que dice (citado de su paper):
"Las organizaciones que diseñan sistemas (en el amplio sentido usado aquí) están obligados a producir diseños que son copias de las estructuras de comunicación de estas organizaciones".
Cuando Melvin Conway envió su paper "How Do Committees Invent?" a la prestigiosa publicación Harvard Bussiness Review fue rechazado sobre la base de que no logró probar su tesis. Luego de este rechazo la envió a Datamation. El artículo es de 1968, y es una observación bastante razonable y bien argumentada, en mi opinión.
Para Conway el diseño de un sistema es la actividad intelectual que intenta crear un todo a partir de diversas partes que lo comparten. Ya sea la creación de un sistema de armas, la recomendación para enfrentar un desafío social o programar un computador. Para el autor estas actividades son esencialmente lo mismo.
Lo normal es que la organización que se hace cargo de idear este sistema busca crear un documento que contenga un cuerpo estructurado y coherente de información. Esto es lo que se llama el diseño del sistema. El destinatario de este documento es un "sponsor", quien desea que se realice una actividad guiada por el diseño del sistema. Por ejemplo, una autoridad que encarga una legislación para evitar la recurrencia de un desastre reciente, y para esto establece un equipo que explique la catástrofe y proponga soluciones preventivas. También puede ser un empresario que necesita comercializar un nuevo producto y designa un equipo de planificación para especificar cómo este se debe introducir en el mercado.
La organización que diseña puede o no estar involucrada en la construcción del sistema. Esto afecta las decisiones de diseño, pues el saber que uno estará involucrado en la construcción nos lleva a tomar ciertas decisiones distintas a si sabemos que ese peso recae en otros hombros.
La actividad de diseñar requiere tomar decisiones de manera continua y estas decisiones son también decisiones que el diseñador hace para su propio futuro. Acá los incentivos que existen en el ambiente pueden atentar contra las intenciones del sponsor.
Una de las observaciones claves que hace Conway es que la mera elección del equipo implica decisiones básicas de diseño del sistema final. En otras palabras, el diseño de un sistema es reflejo del grupo de personas que lo va a diseñar.
Una vez que se elige la organización del equipo de diseño podemos delegar actividades a los sub grupos dentro de esta organización. Con cada delegación lo que hacemos es estrechar las alternativas de diseño posible, en función de las capacidades de los sub grupos o personas elegidas.
Junto con la delegación aparecen los problemas de coordinación. La necesidad de coordinar disminuye la productividad de los grupos más pequeños, pero es la única posibilidad de que los grupos de trabajo separados puedan consolidar sus esfuerzos para un diseño de sistema unificado.
Para Conway el ciclo de diseño de un sistema es así:
Establecimiento de las fronteras del sistema de acuerdo a ciertas reglas básicas
Elección un concepto preliminar de sistema
Organización de la actividad de diseño y delegación de tareas de acuerdo a este concepto
Coordinación entre las tareas delegadas.
Consolidación de los sub diseños en un diseño único.
Es posible que la actividad de diseño no siga esta secuencia. Por ejemplo, uno puede reorganizarse tras el descubrimiento de un nuevo concepto de diseño que sea evidentemente superior. Claro que la reorganización no es algo agradable, y a menudo suele ser también costosa.
Lo que sí es cierto que este proceso está siempre repitiéndose, después de todo, como observa Conway, "nunca hay tiempo suficiente para hacer algo bien, pero siempre hay tiempo para volver a hacerlo".
Cualquier sistema de envergadura está estructurado de sub sistemas menores, que se encuentran inter conectados. La descripción del sistema, parte por la descripción de sus conexiones con el mundo exterior y luego profundiza en cada sub sistema y el modo en que están inter conectados, bajando de nivel, cada vez, reduciendo el alcance hasta llegar a las partes más simples que pueden ser entendidas sin mayor sub división.
Lo mismo puede decirse de una teoría, que también puede ser subdividida en sub teorías y explicada de una forma similar.
Un sistema o teoría puede ser representado como un grafo. Cada nodo es un sub sistema que se comunica con otros sub sistemas a través de los nodos o aristas. A estas aristas las llamamos interfaces.
Un ejemplo de Grafo Lineal usado para representar un sistema, expuesto por Conway en su artículo original: "How Do Committees Invent?".
Lo interesante, y es lo que Conway intenta demostrar en su artículo, es que uno puede reemplazar en el grafo la palabra sistema por la palabra comité, sub sistema por sub comité y las interfaces por coordinadores, y de este modo replicar la estructura organizacional que diseñó el sistema.
Consideren el caso de las interfaces. Tomemos dos nodos X e Y de un sistema. Estos pueden estar conectados entre sí por un arco. Si hay una arista entonces dos grupos de diseño X e Y (no necesariamente distintos) deben haber negociado y haber llegado a un acuerdo sobre la especificación de la interfaz para permitir la comunicación enter los dos nodos correspondientes. Si no hay una rama entre los nodos X e Y significa que no hubo nada que negociar entre ambos grupos de diseño, es así de simple.
La estructura del sistema, entonces, refleja la estructura de la organización que lo diseña. En términos matemáticos, hay un homomorfismo entre el grafo del diseño de un sistema y el grafo de la estructura organizacional que lo diseñó.
El ejemplo clásico que expone Conway es el siguiente:
Un equipo de desarrollo tiene ocho personas y se les encarga producir un compilador para COBOL y otro para ALGOL. Después de una estimación inicial de esfuerzo y tiempo, cinco personas son asignadas al trabajo en COBOL y tres al trabajo en ALGOL. El compilador de COBOL corre en cinco fases, mientras que el compilador de ALGOL corre en tres fases.
O sea, si usted tiene un sistema en tres capas es porque tiene su equipo dividido en tres, un equipo para el front-end, otro para la capa de negocio y otro para la capa de datos. Una aplicación dividida en esta forma será desplegada en tres piezas distintas.
Una aplicación monolítica suele ser el producto del trabajo inicial de sólo un desarrollador que luego es "transferida" a un equipo mayor, el que rápidamente empieza a dividirla de forma modular para poder abordar su evolución.
Conway nos recuerda que la estructura de un sistema grande tiende a desintegrarse durante el desarrollo cualitativamente más que los sistemas pequeños.
Para entender esto hay que entender por qué los sistemas grandes se desintegran, y esto tiene que ver directamente con el homomorfismo que hemos identificado.
Los sistemas se desintegran en tres pasos:
Primero, al darse cuenta los diseñadores iniciales que el sistema será grande, junto con ciertas presiones de su organización, se vuelve irresistible la tentación de asignar demasiada gente al esfuerzo de diseño.
Segundo, la aplicación de las prácticas convencionales de gestión a una organización de diseño grande causa que su estructura de comunicación se desintegre.
Tercero, el homomorfismo asegura que la estructura del sistema reflejará la desintegración que ha ocurrido en la organización de diseño.
De seguro esto lo han experimentando muchas veces. Los equipos de diseño van desintegrándose lentamente cuando aún el diseño no ha terminado del todo, puesto que sus miembro son requeridos en otras labores. Esta desintegración se refleja en la estructura que ha adquirido el sistema y la sufren quienes están desarrollando o manteniendo el sistema.
Cuando un sistema es grande lo más probable es que solicitemos contar con un equipo mayor. Después de todo queremos delegar el esfuerzo para atacar la complejidad aparente del sistema, que ya ha alcanzado nuestros límites de comprensión. Tenemos un plazo que cumplir, así que queremos sub dividir más la labor para sacarla adelante. Pero el diseñador inicial se encuentra en la encrucijada de lidiar con el diseño complejo o fragmentar su conocimiento del mismo mediante la delegación. Pero el administrador fuerza a la delegación, pues no puede mantener recursos ociosos.
Otro ejemplo es cuando se debe subcontratar una tarea de diseño difícil y hay dos opciones, un contratista pequeño y nuevo con una aproximación que parece interesante a un costo menor al presupuestado, o una organización establecida más convencional con una tarifa más "realista".
El administrador sabe que si el contratista más pequeño falla será acusado de desperdiciar los recursos, pero si el contratista más "establecido" falla será una evidencia de que el problema realmente es muy difícil.
El problema es que los criterios contables, que guían las prácticas convencionales de gestión, nos dicen que si hablamos de esfuerzo humano este debe medirse en el costo de las horas de dedicación.
La falacia detrás de este cálculo es que establece una propiedad lineal que dice que dos personas trabajando por un año o cien personas trabajando por una semana son recursos de igual valor.
Sabemos que dos personas y cien personas no pueden tener la misma estructura organizacional, y el homorfismo de Conway nos dice que no diseñarán sistema similares, luego los esfuerzos no son siquiera comparables. La experiencia nos dice que si elegimos bien, y si sobreviven a la experiencia, dos personas nos darán un mejor sistema.
Si se tratara de pelar papas, levantar muros, o recoger la siembra del trigo, podemos usar la linealidad y asumir que traer más personas ayuda a reducir tiempos. Pero para diseñar sistemas complejos, eso no es cierto. Pero aún así, se insiste en usar este criterio lineal en la planificación y diseño de nuestros sistemas.
Conway nos advierte también de que una vez que el prestigio y poder del administrador queda atado a su presupuesto, este tenderá a expandir su organización. "Una vez que la organización existe debe ser usada, por supuesto. Probablemente el factor común único detrás de tantos sistemas mal diseñados, ahora en existencia, ha sido la disponibilidad de una organización con necesidad de trabajar."
La segunda fase proceso de desintegración empieza en cuanto comienza la delegación. Sabemos que el número de caminos posibles de comunicación en una organización es proporcional al cuadrado de la cantidad de personas pertenecientes a esta. Incluso en organizaciones pequeñas es necesario restringir el grado de comunicación entre las personas para lograr que avancemos (algo que no se da mucho, con nuestra tendencia a realizar reuniones ampliadas para tomar cualquier decisión).
Entonces, para poder restringir estos canales de comunicaciones, las prácticas normales de administración llevan a establecer estructuras jerárquicas, donde las comunicaciones fluyen de arriba hacia abajo y viceversa. El grafo se convierte en un árbol, y el sistema empieza a adquirir la estructura jerárquica similar (otra cosa que también tiende a fallar cuando en las organizaciones las decisiones quedan estancadas en los niveles superiores y no bajan las comunicaciones hacia los implementadores en los niveles inferiores).
Conclusiones
Conway nos dice que las organizaciones que diseñan sistemas producirán sistemas que asemejan las estructuras de comunicación de las mismas. Esto tiene importantes implicancias en la gestión del diseño de sistemas.
La conclusión evidente es que la flexibilidad de la organización es importante para tener diseños efectivos, porque sabemos que el primer diseño nunca es el mejor posible,
Conway en 1968 recomienda que recompensemos a aquellos administradores que mantienen sus organizaciones livianas y flexibles. Que no se debe basar la gestión del diseño en la idea de que agregar más personas agrega más productividad. Para él esta filosofía es clave para que el diseño de tecnología sea una labor confiable.
Vamos a cumplir cincuenta años de la publicación del artículo de Conway y aún seguimos atrapados en las mismas trampas que identificó este autor. Seguimos obteniendo dromedarios, cuando lo que queríamos eran caballos.
El video de más abajo, junto con la hermosa imagen fractal, que acompañan este post, son visualizaciones del código fuente de un sistema en los que mi equipo ha trabajado desde 2015. Corresponde a la suma de seis repositorios, que corresponden a seis micro servicios, que interactúan entre si para dar forma a un sistema mayor.
visualización de código generado por mi equipo
Las visualizaciones las obtuve usando el utilitario Gource, a partir del repositorio Git que usamos en la empresa.
Una visualización más detallada con los nombres de los usuarios la pueden ver acá: https://vimeo.com/223929447.
Si quieres hacer lo mismo con el código escrito por ti o por tu equipo, puedes descargar e instalar este utilitario. Usarlo es bastante sencillo. Te invito a hacerlo, e incluso a compartir tus visualizaciones en los comentarios.
En mi caso ejecuté la siguiente secuencia de comandos en mi Mac para combinar los 6 repositorios:
Notar que usa también el utilitario ffmpeg. Para agregar la música (The Holy Drinker, de Steven Wilson), usé iMovie.
La opción gource --output-custom-log genera un archivo con cuatro campos por registro o linea, con la siguiente forma:
1431611496|jorellana|A|/application.properties
El primer campo es la fecha en formato Unix, el segundo campo es el nombre del usuario, luego viene una letra que indica la acción: A, M o D, por Agregar, Modificar o Borrar (Delete). El cuarto campo es el archivo (el path completo).
Hay un quinto campo opcional, que corresponde al color expresado en formato RGB hexadecimal. Gource no genera este campo, así que genera el color de manera automática durante la generación del video.
Para crear una visualización en que los colores de los archivos reflejen a su creador, agregué el código de color. Para esto usé este pequeño script en perl:
use Digest::MD5 qw(md5 md5_hex md5_base64); while (<>) { chomp(); my ($a,$b,$c,$d) = split(/\|/, $_); $e = substr(md5_hex($b), 0, 6);; print("$a|$b|$c|$d|$e\n"); }
Cómo verán agregué un código de color basado en el hash MD5 del nombre del usuario (tomando los primeros 6 caracteres), con esto el archivo queda así:
Cómo pueden ver se puede personalizar la visualización para lograr efectos interesantes.
¿Y para qué sirve todo esto?
Mostrar este video al equipo puede ser una experiencia muy motivante.
Pero además la exploración del código mediante esta herramienta te permite obtener algo de información sober la estructura del código y el proceso de construcción. Por ejemplo, puedes ver cual es la contribución de cada miembro del equipo, obtener una idea de cómo se organiza el código, cuanto desorden hay en la estructura, etc.
El video de arriba omite varios detalles, puesto que el objetivo es más bien artístico.
Abajo hay otra visualización más típica, en este caso se trata del código de 9 desafíos en 9 lenguajes (https://github.com/lnds/9d9l). En este caso dejé visible todos los parámetros de visualización disponibles (salvo la fecha):
visualización de https://github.com/lnds/9d9l
Pueden notar información más útil para un arquitecto o un desarrollador de software, este video entrega intuiciones de cómo se organiza el código, quién aporta qué al proyecto, etc.
Gource tiene un modo interactivo, donde puedes avanzar o retroceder, colocar el mouse sobre cada punto o rama, con lo que puedes ver de qué se trata. Esto te permite discutir o razonar sobre la estructura física que va tomando el código en tu proyecto. Las limitaciones de esta herramienta están dadas por la capacidad de abrir diálogos entre los miembros del grupo.
Hay otras formas de visualizar el código, obtener métricas, indicadores de calidad y complejidad. Por ejemplo, con herramientas como SonarQube o Codacy, pero hablaremos de esas herramientas en un próximo artículo.
Por ahora los dejo con estos videos, y los invito a compartir sus visualizaciones en los comentarios.
La historia del rock está llena de anécdotas que reflejan el choque de egos entre artistas, como cuando Jimmy Hendrix le roba el acto de quemar la guitarra a Pete Townsend, en el Festival de Monterrey de 1967. Hay otra involucra a Yes y Deep Purple.
La historia transcurre en la décima edición del National Jazz & Blues Festival en Plumpton, en Inglaterra, en agosto de 1970. Los organizadores habían decidido que Deep Purple cerrara el espectáculo, algo que molestó a Yes, quienes querían clausurar el evento. Así que la banda liderada por Jon Anderson decidió retrasar su llegada, obligando a los organizadores a solicitar a Deep Purple que ejecutaran su show antes.
Pero Ritchie Blackmore planeó una venganza notable. Después de interpretar casi íntegramente su álbum "In Rock", seleccionaron para el cierre de su espectáculo interpretar el clásico Paint In Black de los Rolling Stones. Durante la ejecución del tema Blackmore ordenó a sus "roadies" que vaciaran combustible sobre los equipos, luego encendió un fósforo y lo arrojo al suelo, y siguió tocando su guitarra mientras empezaba un incendio sobre el escenario.
Yes logró tocar último, pero los asistentes quedaron impresionados al punto que todos comentaban la espectacular puesta en escena de Deep Purple. Así que Ritchie Blackmore tuvo su dulce venganza, y consiguió que Deep Purple acaparara los comentarios de los asistentes a esa jornada relegando a Yes al segundo plano.
Yes es una banda con una historia compleja, con diversas formaciones, agrupaciones, reagrupaciones, incluso en un momento hubo dos Yes operando en paralelo.
Esto se refleja también en su música, que alcanzó los máximos puntos de complejidad interpretativa con la incorporación del tecladista Rick Wakeman. Yes ha reunido a grandes músicos, y servido de inspiración a diversas bandas del rock progresivo.
Cuando decidí escribir este cuarto desafío pensé que escribiría un artículo por cada una de las soluciones en cada uno de los lenguajes. Pero también decidí asociar un grupo o artista de rock o pop a cada lenguaje.
Esta vez es el turno de Scala.
Yes es una banda compleja, del mismo modo que Scala es un lenguaje complejo.
En el primer post de esta serie asocié, accidentalmente en realidad, a Def Leppard con Kotlin. Esta vez consulté a mi amigo Ubaldo Taladriz, quien lleva más horas de vuelo que yo usando Scala. Le pedí su opinión sobre qué banda le parecía más adecuada para reflejar a este lenguaje. Y llegamos a la conclusión de que tenía que ser algo que reflejara la complejidad, pero además fuera un grupo de gran calidad, con buenas canciones.
Las composiciones de Yes tienen esa componente, sobretodo en álbumes como Fragile, o Close to the Edge. Pero Yes también ha seguido derroteros más populares, como en los álbumes de 90125, o Big Generator.
El registro de Yes va desde clásicos como Close to the Edge, Starship Trooper, RoundAbout, hasta hits como Owner of a Lonely Heart.
En Scala podemos escribir código muy simple y cercano a Java. Pero a diferencia de Java, es más difícil escapar del paradigma orientado al objeto en Scala.
El desafío Huffman
Recordemos que en este desafío estoy implementando la compresión de archivos usando la codificación de Huffman. Pueden leer la explicación de este desafío en mi anterior artículo.
Mi primera solución en Scala es una simple traducción de la versión en Kotlin descrita anteriormente.
Por supuesto, hacer esto es un tanto decepcionante. Por ejemplo, consideren este fragmento de código:
import scala.util.control.Breaks._
....
def extract() : HuffTree = { if (empty()) { throw new ArrayIndexOutOfBoundsException() } val min = heap(1) heap(1) = heap(last) last -= 1 var j = 1 breakable { while (2 * j <= last) { var k = 2 * j if (k + 1 <= last && heap(k + 1).frequency < heap(k).frequency) { k += 1 } if (heap(j).frequency < heap(k).frequency) { break } val tmp = heap(j) heap(j) = heap(k) heap(k) = tmp j = k } } min }
En Scala no existe la primitiva break, que existe en Kotlin para interrumpir un loop.
En Scala, esto se simula en el package scala.util.control.Breaks, que define un DSL para esto.
Los diseñadores de Scala decidieron que este tipo de estructuras introduce más problemas, y tienen razón en mi opinión. A veces esta interrupción de un loop hace que razonar sobre el comportamiento de una función sea más difícil.
El código anterior es parte de la implementación de un Heap, como estructura de datos.
No es muy interesante en el sentido de que no destaca muchas de las características de Scala.
Así que escribí una segunda solución, en la que usé un estilo más funcional para una parte crucial del código.
En vez de implementar un Heap, hice una interpretación más directa del algoritmo:
Por cada tupla (carácter, frecuencia) armamos un nodo hoja y los dejamos en una lista ordenada por el valor frecuencia en forma ascendente.
Tomamos los dos primeros elementos de la lista los combinamos en un nodo interno, donde la frecuencia del nodo es la suma de las frecuencias de las dos hojas que contiene. Insertamos este nodo en la lista, manteniendo el orden.
Repetimos hasta que sólo quede un árbol en la lista.
Para esto nuestro modelo de datos es el siguiente:
abstract class HuffTree(val frequency: Int) case class HuffLeaf(override val frequency: Int, symbol: Char) extends HuffTree(frequency = frequency) case class HuffNode(left:HuffTree, right: HuffTree) extends HuffTree(left.frequency+right.frequency)
Creamos una clase abstracta y dos case clases, una para Hoja y otra para Nodo.
Las "case classes" son ideales para trabajar con estructuras de datos inmutables, cómo las listas. De este modo la primera parte del algoritmo queda así:
freqs es una lista de pares (carácter, frequencia), que se construye al leer el archivo. Al estar en una lista puedo mapear cada par insertándolos en un nodo hoja. Luego se ordena la lista con el método sortWith.
Esto nos permite eliminar la necesidad de crear una estructura de datos como el Heap, de la solución inicial.
Ahora bien, ¿cómo implementamos los pasos 2 y 3 repetidamente?
La solución funcional a esto es muy elegante.
Lo primero que haremos es lo siguiente: crearemos una función que nos indique si la lista, que contiene todos los nodos (o árboles) sólo contiene un elemento:
Esta función recibe una lista de árboles y retorna verdadero sólo si la lista contiene un sólo elemento. Recordemos que iniciamos con una lista de hojas (leaves) obtenida anteriormente.
Lo otro que haremos es una función de combinación, esta implementa en esencia el paso 2 de nuestro algoritmo:
def combine(trees: List[HuffTree]) : List[HuffTree] = trees match { case left :: right :: tail =>
(HuffNode(left, right) :: tail).sortWith((l1,l2) => l1.frequency < l2.frequency) case _ => trees }
La función combine recibe una lista, si la lista tiene al menos dos elementos, tomamos los dos primeros elementos para crear un nodo interno:
case left :: right :: tail => (HuffNode(left, right) :: tail)
Este fragmento de código es un "pattern matching", en este caso el patrón es left :: right :: tail. Este patrón corresponde a los dos primeros elementos de la lista y tail representa el resto de los elementos.
Lo que viene después de => es una transformación, en este caso convertimos el patrón left::right::tail en HuffNode(left, right)::tail, es decir, reemplazamos los dos primeros elementos por un nodo interno que contiene a estos elementos como hijos. Y a este nodo le concatenamos el resto de la lista (tail).
Por supuesto, esto no es suficiente, hay que mantener ordenada la lista resultante, y eso se logra agregando la llamada a sortWith.
Entonces, la función combine lo que hace es ejecutar el paso dos de nuestro algoritmo, tomar dos elementos iniciales de una lista de nodos (ordenada en orden ascendente de frecuencias) y generar una nueva lista ordenada, en que los dos primeros nodos han sido reemplazados por un nodo interno que los contiene.
Ahora falta la repetición, y para eso usamos la función until:
@tailrec def until[A](singleton: A => Boolean, combine: A => A)(data: A) : A = if (singleton(data)) data else until(singleton, combine)(combine(data))
Esta es una función recursiva y genérica. Recibe una tupla consistente en la función condicional que nos permite establecer la condición de término de la recursión, en este caso es singleton, Además recibe una función de combinación. Todo esto será aplicado sobre data, que es la estructura que recorreremos (en este caso una lista, pero puede ser cualquier estructura de datos que soporte las operaciones singleton y combine.
La anotación @tailrec indica a Scala que puede optimizar el código de esta recursión mediante la técnica de tail call.
Así que crear el árbol final a partir de la lista de hojas es tan simple como esto:
until(singleton, combine)(leaves).head
De este modo, la función que construye el árbol de Huffman es la siguiente:
Por supuesto esta implementación es más lenta que las solución 1, en este caso la elegancia de la solución tiene un costo de performance, pero no es tan caro como pueda parecer. Al final de esta serie evaluaremos el desempeño de las distintas soluciones.
Hay una otras maneras de solucionar esto usando Scala de una forma más funcional aún, usando otras estructuras de datos propias de Scala, pero se los dejo como ejercicio.
Close to the edge
Scala es uno de esos lenguajes que permite acercarse a los extremos, "close to the edge", hacia un estilo más funcional, lo que permite escribir código que tiene cierta elegancia, concisión, e incluso una formalidad matemática mayor, que permite simplificar el razonamiento sobre el flujo de la información durante la ejecución.
Con esta construcción
@tailrec def until[A](singleton: A => Boolean, combine: A => A)(data: A) : A = if (singleton(data)) data else until(singleton, combine)(combine(data))
hemos creado un lenguaje de dominio específico para procesar datos. Scala nos permite crear nuevas estructuras para operar sobre una gran cantidad de estructuras de datos. Tenemos el poder de extender los límites del lenguaje.
Down by the river
Usar estructuras inmutables es lo que ayuda. La programación funcional puede ser vista como el flujo de datos a través de diversos filtros, una corriente de información, donde cada función es un filtro, una cañería, que transforma la data en cada paso.
Un flujo de datos desde un arreglo de bytes para terminar en una lista de nodos.
Y cuando hacemos:
until(singleton, combine)(leaves).head
también trabajamos con un flujo controlado de datos, un rio de información.
Así que la elección de Yes para hablar de Scala resultó muy adecuada.
El código está disponible en mi repositorio GitHub y pueden hacerme las consultas que estimen conveniente. Recibo también pull request con mejoras y aportes.
A veces los ingenieros estamos tan enfocados en la funcionalidad que no nos preocupamos de la usabilidad. A ver si me explico, ponemos el foco en la definición del caso de uso, nos centramos en darle al usuario lo que nos pide, pero no lo que necesita.
Pero, ¿hacemos una observación de cómo usan nuestro sistema los usuarios? Tengo varias anécdotas de cómo nos hemos dado cuenta que nuestro usuarios realizan tareas manuales para acomodar el input a nuestros sistemas, tareas que no serían necesarias si nos hubieran descrito cómo llegaba la información en realidad, en vez de solicitar un formato ideal de cómo supuestamente debía llegar.
Es por eso que estoy empeñado en que el desarrollador/programador se acerque más a interactuar directamente con el usuario. Es más, siempre he pensado que es buena idea que el programador use los sistemas que construye bajo las misma condiciones que tiene el usuario. Así se descubren bugs, o funcionalidades incompletas, o inadecuadas.
Les voy a contar una historia, que es famosa a nivel de las personas que se dedican a la ingeniería de usabilidad (sí, hay una ingeniería para eso y es muy importante).
Se trata del botón de los trescientos millones de dólares.
Lo que viene es una suerte de traducción del artículo original (Spool coloca un enlace a una traducción al español en su post, pero francamente la encontré muy mala, pero como advertencia, esta no es una traducción ni literal ni completa):
Cómo al cambiar un botón las utilidades anuales de un sitio se incrementaron en 300 millones de dólares
Es difícil imaginar un formulario más sencillo: dos campos, dos botones y un enlace. Aún así, resulta que este formulario impedía que los clientes adquirieran productos de un gran sitio de comercio electrónico, llegando a la cifra de US$ 300.000.00 al año. Lo peor es que los diseñadores del sitio no tenían idea de que esto fuera un problema.
El formulario en cuestion constaba de los campos Direccion de Correo Electrónico (Email Address), Contraseña (Password). Los botones era "Ingresar" (Login) y "Registrar" (Register). El link era "Olvidé mi Clave" (Forgot Password). Se trataba del formulario de ingreso al sitio. Un formulario que los usuarios encuentran todo el tiempo. ¿Cómo podían tener problemas con esto?
El problema no era tanto con la disposición del formulario como con la manera en que esta actuaba. Los usuarios se enfrentaban a esta después de haber llenado su carro de compra con los productos que querían comprar, y al momento de presionar el botón Checkout. Este formulario de ingreso aparecía antes de que pudieran ingresar la información de pago.
El equipo consideró que este formulario permitía al cliente frecuente comprar más rápido. Los clientes primerizos no tendrían problemas para registrarse porque, después de todo, volverían por más y apreciarían lo expedito del proceso en las siguientes compras. Todos ganan, ¿verdad?
“No estoy acá para establecer una relación”
Se condujeron tests de usabilidad con personas que necesitaban comprar productos del sitio. Se les solicitó que trajeran sus listas de compra e incluso se les pasó el dinero para hacer las compras. Todo lo que se les pedía es que completaran la compra.
Lo primero que se descubrió fue lo errado que estaban con respecto a los clientes que venían por primera vez. A ellos les molesta registrarse. Resentían tener que registrarse cuando encontraban la página. En palabras de un cliente: "no estoy acá para entrar en una relación, sólo quiero comprar algo".
Algunos clientes primerizos no recordaban si era su primera vez, incluso quedaban frustrados al fallar con cada combinación común entre email y clave. Los observadores quedaron sorprendidos con tanta resistencia al registro.
Sin saber lo que estaba involucrado con el registros, todos los usuarios que presionaron el botón lo hicieron con un sentido de desesperación. Varios verbalizaron que lo único que los dueños del sitio era obtener su información para molestarlos después con mensajes de marketing que ellos no deseaban. Algunos imaginaron propósitos negativos y un obvio intento de invadir su privacidad. (En realidad, el sitio no les pedía nada en el registros que no fuera necesario para completar la orden de compra: nombre, dirección de despacho, dirección de facturación e información de pago).
Tampoco era bueno para los clientes frecuentes
Los clientes frecuentes tampoco estaban felices. Salvo por unos pocos que recordaban su información de login, muchos se estancaban ante el formulario. No podían recordar la dirección de correo electrónico o la contraseña que habían usado. Recordar el email que usaron para registrarse era problemático, muchos tenían múltiples direcciones de correo electrónico o la habían cambiado a lo largo de los años.
Cuando un cliente no podía recordar el correo y la contraseña, hacían intentos de adivinarlos múltiples veces. Estos intentos rara vez tenían éxito.Algunos eventualmente presionaban el link para recuperar la contraseña, lo que se vuelve en otro problema si puedes recordar cual fue la dirección de correo con la que te registraste originalmente.
Posteriormente a este estudio se realizó un análisis de la base de datos del retailer, sólo para descubrir que el 45% de todos los clientes tienen múltiples registros en el sistema, algunos llegando a 10. También analizaron cuanta gente pidió recuperar las contraseñas, para encontrar que se llegó a sobre 160.000 por día. El 75% de estas personas nunca trataron de completar la compra.
El formulario, cuya intención era facilitar la compra, resultó ser de ayuda para un pequeño porcentaje de los clientes que lo encontraban. (Incluso para muchos de estos clientes no recibieron mucha ayuda, dado que les tomó esfuerzo adicional actualizar la información incorrecta, como los cambios de dirección o de tarjeta de crédito). Al contrario de lo esperado, el formulario estaba evitando las ventas, una gran cantidad de ventas.
La corrección de US$300,000,000
Los diseñadores corrigieron el problema de forma simple. Eliminaron el botón Registro (Register). En su lugar colocaron un botón Continuar (Continue), con un simple mensaje: "No requiere crear una cuenta para realizar una compra en nuestro sitio. Simplemente presione Continuar para proceder con el Checkout. Para hacer futuras compras aún más rápido, puede crear una cuenta durante el checkout."
Los resultados: el número de clientes comprando aumentó en un 45%. Las compras extra resultaron en 15 millones de dólares el primer mes. En ese primer año el sitio recibió un adicional de US$300.000.000.
El autor de esto cuenta que en su contestadora telefónica aún conserva el mensaje que recibió del CEO de este retailer. Es un mensaje muy simple: "Spool! You're the man!" (Spool, eres el hombre!). No era necesario un mensaje más complejo, después de todo sólo habían cambiado un botón.
Hoy La Naturaleza del Software cumple doce años de existencia. Es increíble que aún siga con la motivación de escribir, siendo que en 2005 nacieron miles de blogs, de los cuales quedan muy pocos.
Pero acá estamos, vivitos y coleando, después de doce años. Para celebrarlo les dejaré una selección de doce artículos, uno por año, espero que los disfrute, y espero seguir doce años más con esto:
2016: Toda empresa es de software, o lo será, una reflexión sobre las leyes de la computación, la complejidad y el emprendimiento. O de otra manera, cómo el teorema de Gödel garantiza que siempre habrá algo para emprender.
2015: Revelaciones, una noche recibí una visita y fui arrebatado a un estado de iluminación apocalíptico.
2014: VICA, Volatilidad, Incerteza, Complejidad y Ambigüedad.
Hay empresas, o mejor, para ser más precisos, hay gerentes, ejecutivos, empresarios, que oyen hablar de "Transformación Digital" en algún seminario y llegan a hablar con su encargado TI (CIO, CTO, Gerente de Finanzas (sí, eso sigue pasando)) y exigen que se establezca una estrategia de Transformación Digital.
- ¿Y eso qué es? - No sé, ¡pero lo queremos ya!
Y sin demora se recurre a las consultoras reconocidas, o a las grandes empresas de tres letras, que llegan con sus armadas de especialistas, expertos en las mejores prácticas, a ofrecer las bondades de la transformación digital (lease, venta de productos y facturación de horas de consultoría).
Y uno de los tema favoritos del último tiempo parece ser DevOps.
¡Hay que crear una unidad de DevOps! ¡Debes reemplazar a tus ingenieros de sistemas por DevOps!
Y ni siquiera se han enterado de que DevOps es una filosofía, no un cargo o una tarea que se deba ejecutar.
!Es que se trata de automatizar, de optimizar procesos! "Ustedes deben usar esta herramienta para agilizar sus proceso de desarrollo y apuntar a la transformación digital."
Y para "facilitar la toma de decisión", estos consultores proponen hacer una prueba de concepto.
Y ocurre esto:
Recordemos, ¿qué nos dice el primer principio del Manifiesto Ágil?
Individuos e Interacciones, por sobre procesos y herramientas.
Así que amigos, no se trata de comprar las últimas tecnologías, o de automatizar por automatizar. Se trata de entender cómo se relacionan los equipos en pos de obtener un producto, o brindar un servicio.
No dejes que te pase lo que le ocurre a Charlot en Tiempos Modernos, tú puedes hacerlo mejor.
No siempre la solución está en esas prestigiosas consultoras, busca un Meetup, o una comunidad de desarrolladores en tu ciudad, encontrarás gente dispuesta a ayudarte, y a aprender.
¡Experimenta!
Prueba cosas nuevas con tu equipo, lee, estudia por tu cuenta, no esperes que todo venga desde arriba.
El verdadero cambio ocurre cuando tú te haces cargo de tu vida, y esa es la transformación que vale la pena.