Programación Funcional

Publicado en

El tema eje de el presente número de SG es el manejo de datos a muy gran escala (y disculparán que no use la frase de moda “Big Data”, habiendo otras igual de descriptivas en nuestro idioma). Al hablar de muy gran escala tenemos que entender que pueden ser juegos de datos mucho mayores —por lo menos tres a seis órdenes de magnitud— de lo que acostumbramos analizar.

Dar un salto tan grande nos presenta retos en muy diversas esferas. Para ello tenemos que adecuar nuestros procesos de desarrollo, las herramientas que empleamos, el modelo con el cual solicitamos, procesamos y almacenamos la información, e incluso el hardware mismo. Enfocaré este texto al paradigma de programación funcional, el cual permite enfrentar a la concurrencia de una forma más natural y menos traumática de lo que acostumbramos.

Una de las razones por las que este tema ha armado tanto alboroto en el campo del desarrollo de software es que, si bien la capacidad de cómputo a nuestro alcance ha crecido de forma francamente bestial en el tiempo que nos ha tocado vivir como profesionales, nuestra formación sigue estando basada en el modelo de programación secuencial, que es muy difícil de escalar. Vamos, el problema no lo tienen las computadoras, sino nosotros los programadores: no sólo tenemos que explicar a la computadora lo que requerimos que haga, sino que además de ello tenemos que cuidar que no se tropiece —cual si fuera un cienpies— con su propia marcha. Siempre que hablemos de muy gran escala debemos hablar de un alto grado de paralelismo en nuestras aplicaciones. Y por muchos años, el paralelismo fue precisamente algo de lo que buena parte de los programadores buscaban escapar, por las complicaciones que conlleva ante una programación imperativa, necesariamente secuencial.

A mediados de la década pasada, los fabricantes de hardware cambiaron su estrategia abandonando la carrera de los megahertz y migrando a una estrategia de multiprocesamiento (CPUs múltiples empaquetados como una sola unidad). Con esto, aventaron la papa caliente hacia el lado de los desarrolladores: tendríamos que adecuarnos a una realidad de concurrencia verdadera y ya no simulada.

Por muchos años, vivimos en un mundo de falsa concurrencia: una computadora sólo podía hacer una cosa a la vez, dado que contaba con un sólo procesador. Por la velocidad de su operación y por el empeño que se puso en que los cambios de contexto fueran tan ágiles como fuese posible, nos daba la impresión de que varias cosas ocurrían al mismo tiempo.

El gran reto introducido por el paralelismo real es manejar correctamente escenarios mucho más complejos de condiciones de carrera que antes no se presentaban tan fácilmente. Antes del paralelismo, podíamos indicar al sistema operativo que nuestro programa estaba por entrar a una sección crítica, con lo cual éste podía decidir retirar el control a nuestro programa para entregarlo a otro si estaba cercano a finalizar su tiempo o darle una prórroga hasta que saliera de dicha sección.

Cuando hay más de un procesador, la situación se complica: el sistema operativo puede mantener en ejecución a uno de los programas para reducir la probabilidad de conflictos, pero se hizo indispensable hacer la colaboración entre procesos algo explícito. Claro, esto no fue un desarrollo repentino ni algo fundamentalmente novedoso. Los mutexes nos han acompañado por muy largos años y la programación multihilos nos ha regalado dolores de cabeza desde hace muchos años. Sin embargo, en sistemas uniprocesador, la incidencia de condiciones de carrera era suficientemente baja como para que muchos las ignoraran.

Una de las razones por las que la concurrencia nos provoca esos dolores de cabeza es porque nos hemos acostumbrado a enfrentarnos a ella con las herramientas equivocadas. Un tornillo puede clavarse a martillazos, y no dudo que haya quien use destornilladores para meter clavos, pero seremos mucho más efectivos si usamos la herramienta correcta.

Los lenguajes basados en programación funcional resuelven en buena medida los problemas relacionados con la concurrencia y pueden de manera natural desplegarse en un entorno masivamente paralelo. Sin embargo, requieren un cambio más profundo en la manera de pensar que, por ejemplo, la adopción de la programación orientada a objetos.

¿Cuál es la diferencia? Aprendimos a programar de forma imperativa, con el símil de la lista de instrucciones para una receta de cocina. Los lenguajes puramente funcionales son mucho más parecidos a una definición matemática, en que no hay una secuencia clara de resolución, sino que una definición de cómo se ve el problema una vez resuelto y los datos se encargan de ir marcando el camino de ejecución. Los lenguajes puramente funcionales tienen una larga historia (Lisp fue creado en 1958), pero en la industria nunca han tenido la adopción de los lenguajes imperativos. Sin embargo, hay una tendencia en los últimos años de incorporar muchas de sus características en lenguajes mayormente imperativos.

La principal característica que hace diferentes a los lenguajes funcionales es que nos hacen pensar en definiciones matemáticas, ya que la llamada a una función no tiene efectos secundarios — ¿Han depurado alguna vez código multihilos para darse cuenta que el problema venía de una variable que no había sido declarada como exclusiva? Con la programación funcional, este problema simplemente no se presentaría. Esto lleva a que podamos definir (en AliceML) el cálculo de la serie de Fibonacci como:

fun !b 0 = 0
| !b 1 = 1
| !b n if (n > 1) = spawn !b(n-1) + !b(n-2);
| !b _ = raise Domain

A diferencia de una definición imperativa, la función es definida dependiendo de la entrada recibida y la última línea nos muestra el comportamiento en caso de no estar contemplado por ninguna de las condiciones. Y el puro hecho de indicar la palabra «spawn» indica al intérprete que realice este cálculo en un hilo independiente (que podría ser enviado a otro procesador o incluso a otro nodo para su cálculo).

Otra de las propiedades de estos lenguajes son las funciones de orden superior (funciones que toman como argumentos a otras funciones). Por ejemplo, en Haskell:

squareList = map (^2) list

Al darle una lista de números a la función squareList, nos entrega otra lista, con el cuadrado de cada uno de los elementos de la lista original. Y esto se puede generalizar a cualquier transformación que se aplicará iterativamente a cada uno de los elementos de la lista.

Hay varios tipos de funciones de orden superior, pero en líneas generales, pueden generalizarse al mapeo (repetir la misma función sobre los elementos de una lista, entregando otra lista como resultado) y la reducción (obtener un resultado único por aplicar la función en cuestión a todos los elementos de la lista). Y es, de hecho, basándose en juegos de mapeo/reducción que se ejecutan la mayor parte de las tareas intensivas en datos en Google.

Podemos encontrar frecuentemente otros dos patrones en estos lenguajes, aunque por simplicidad no los incluyo en estos ejemplos: por un lado, al no tener efectos secundarios, tenemos la garantía de que toda llamada a una función con los mismos argumentos tendrá los mismos resultados, por lo que un cálculo ya realizado no tiene que recalcularse, y podemos guardar los resultados de las funciones (especialmente en casos altamente recursivos, como éste). En segundo, la evaluación postergada: podemos indicar al intérprete que guarde un apuntador a un resultado, pero que no lo calcule hasta que éste sea requerido para una operación inmediata, por ejemplo para desplegar un resultado, o para asignarlo a un cálculo no postergable.

Una de las grandes desventajas que enfrentó la programación funcional es que los lenguajes funcionales puros crecieron dentro de la burbuja académica, resultando imprácticos para su aplicación en la industria del desarrollo. Esto ha cambiado fuertemente. Hoy en día podemos ver lenguajes que gozan de gran popularidad y han adoptado muchas construcciones derivadas de la programación funcional, como Python, Ruby o Perl. Hay lenguajes funcionales que operan sobre las máquinas virtuales de Java (Clojure) y .NET (F#). Por otro lado, lenguajes como Erlang, OCaml y Scheme se mantienen más claramente adheridos a los principios funcionales, pero con bibliotecas estándar y construcciones más completas para el desarrollo de aplicaciones.

El manejo de cantidades masivas de datos están llevando a un pico de interés en la programación funcional. No dejen pasar a esta interesante manera de ver al mundo. Puede costar algo de trabajo ajustar nuestra mente para pensar en términos de este paradigma, pero los resultados seguramente valdrán la pena.

Bio

Gunnar Wolf es administrador de sistemas para el Instituto de Investigaciones Económicas de la UNAM y desarrollador del proyecto Debian GNU/Linux. http://gwolf.org