Lecciones de aprender un lenguaje funcional

Esteban Manchado Velázquez

Los lenguajes funcionales son una familia de lenguajes que la mayoría de los programadores conoce de oídas, pero desgraciadamente no muchos conocen suficientemente bien. Y digo «desgraciadamente» porque, independientemente de que por una razón u otra los lenguajes funcionales son muy poco demandados en el mercado laboral, aprenderlos nos puede brindar muchas ideas interesantes, patrones, buenas costumbres y lecciones que podemos aplicar a muchos otros lenguajes.

Es difícil trazar una línea clara entre los lenguajes funcionales y los lenguajes no funcionales, pero podemos citar Lisp, Haskell, Erlang, Scala y Clojure como los lenguajes funcionales más populares actualmente. Muchos lenguajes de programación populares tienen algunos rasgos funcionales, como Javascript, Ruby y Python. Los lenguajes funcionales, o en general, de cualquier paradigma al que no estemos acostumbrados, nos pueden dar ideas que podemos aplicar no solamente en estos lenguajes que tienen rasgos funcionales, sino en casi cualquier lenguaje.

Aprender un lenguaje funcional lo suficiente como para tener unas nociones e inspirarse, no tiene por qué llevar mucho tiempo. Además, no sólo disponemos de Internet, sino también de excelentes libros que están pensados precisamente para programadores que vienen de otros lenguajes. El resto de este artículo explora algunas técnicas, buenas costumbres e ideas comunes en lenguajes funcionales, que podemos aplicar fácilmente en otros lenguajes. Por supuesto, algunas de estas lecciones se pueden aprender simplemente por experiencia, y no son necesariamente exclusivas de los lenguajes funcionales.

1. Los lenguajes son diferentes

Los lenguajes de programación son como los idiomas humanos en muchos sentidos: tienen sintaxis, expresiones comunes, son mejores o peores que otros para expresar ciertas ideas, se pueden «hablar» con mayor o menor fluidez e incluso se puede decir que sus «hablantes» tienen una cierta «cultura» diferente de los «hablantes» de otros lenguajes.

Así, cuando aprendemos un lenguaje nuevo es un error verlo como «una nueva sintaxis»: aprender un lenguaje bien, nos hace cambiar cómo pensamos y cómo resolvemos los problemas. Por ejemplo, digamos que empezamos a aprender Lisp y que sólo conocemos lenguajes imperativos «clásicos» como C o Java. Como parte de un programa que estamos escribiendo, tenemos una función que calcula el valor total a pagar por unos artículos. Los parámetros de entrada son el importe de cada artículo, el número de artículos, el porcentaje de impuesto, y el límite pasado el cual hay que aplicar el impuesto (p.ej.: tenemos 2 artículos a 5 euros cada uno, un impuesto del 10% y un límite de 8 euros; el precio final será 11. Si el límite fuese 15 euros, el precio final sería 10 porque no se aplicaría el impuesto). Una forma de escribirla en Lisp sería la siguiente:

(defun calculate-price-BAD (cost nitems limit percent-tax)
  (setq total-price (* cost nitems))
  (if (> total-price limit)
      (progn
        (setq tax (* total-price (/ percent-tax 100)))
        (setq total-price (+ total-price tax))))
  total-price)

Mal ejemplo de cómo resolverlo en Lisp (calculate-prices-bad.lisp)

Sin embargo, si escribimos la función así no aprendemos nada nuevo y mostramos nuestro «acento extranjero» al escribir Lisp. Este código no es legible ni para programadores de Java ni para programadores de Lisp, y además no aprovechamos las ventajas del lenguaje, mientras que sufrimos sus desventajas. Simplemente usamos Lisp como «un mal Java». Compárese el anterior código con el siguiente:

(defun calculate-price (cost nitems limit percent-tax)
  (let* ((total-price (* cost nitems))
         (tax         (* total-price (/ percent-tax 100))))
    (if (> total-price limit)
      (+ total-price tax)
      total-price)))

Solución usando Lisp de una forma más convencional (calculate-prices.lisp)

Este código se parece más a lo que muchos programadores de Lisp esperarían o escribirían ellos mismos, y a la mayoría de las personas que sepan un poco de Lisp les parecerá más legible y fácil de mantener. La razón es que estamos jugando con las fortalezas de Lisp, en vez de intentar adaptar el lenguaje a nuestros conocimientos previos.

Concretamente, el segundo ejemplo se aprovecha de dos detalles muy comunes en Lisp que no son tan comunes en lenguajes no funcionales:

  1. No usar variables, sino «valores con nombre». O, dicho de otra manera, evitar asignar más de una vez a cada variable. Esto se puede ver en el bloque let*: las dos «variables» del bloque, total-price y tax, nunca reciben otros valores. Así, el bloque let* contiene una lista de valores con nombres simbólicos, lo que hace el código más claro y mantenible.
  2. La construcción if sigue el patrón funcional de ser una expresión que devuelve un valor. En este caso, recibe una condición y dos expresiones: la primera se devuelve si la condición es verdadera, y la segunda si la condición resulta ser falsa. Funciona de una forma parecida al operador ternario de C y Java (valor = condicion ? valorsiverdadero : valorsifalso).

Sabiendo esto, y que la última expresión de una función Lisp es el valor devuelto por ésta, se puede entender el segundo ejemplo mucho mejor.

2. Pensar en el objetivo, no en el proceso

Aunque el ejemplo de arriba es bastante simplista, también ilustra que los lenguajes funcionales tienden a hacerte pensar más en el objetivo que en el proceso. Para algoritmos, generalmente una solución en un lenguaje funcional se parece más a la definición matemática. En el primer ejemplo, la implementación de la función está basada en el proceso de calcular. En el segundo, en el significado de las operaciones.

Si lo pensamos en castellano, el primer ejemplo sería algo así:

Primero multiplicamos cost por nitems para calcular el precio base, y lo guardamos en total-price. Si éste está por encima de limit, entonces primero calculamos tax a base de multiplicar total-price por percent-tax dividido por 100, y luego guardamos en total-price la suma del antiguo total-price más tax. El resultado es el valor guardado en total-price al final del proceso.

El segundo ejemplo, en cambio, sería algo así:

El resultado es la suma de total-price y tax si total-price está por encima de limit, o total-price en caso contrario. Definimos total-price como la multiplicación de cost por nitems, y tax como la multiplicación de total-price por percent-tax dividido por 100.

Nótese cómo en la segunda explicación podemos dejar para el final la explicación de los valores declarados en el bloque let*. En muchos casos, y siempre que hayamos escogido buenos nombres para esos valores, no hará falta leer el bloque let* para entender la función.

Usar variables que no cambian una vez les hemos asignado un valor, es una buena costumbre porque hace más fácil entender de dónde sale cada valor. Por esa razón, el lenguaje Scala distingue entre dos tipos de variables: var y val. El segundo tipo, que es el más usado con diferencia, declara una variable inmutable, por lo que el compilador nos asegura que no podemos asignar ningún otro valor una vez declarada.

Esto está relacionado con el siguiente apartado, «Diseño de abajo a arriba, funciones pequeñas»: para poder pensar en el significado, muchas de las operaciones tienen que abstraerse en pequeñas funciones.

3. Diseño de abajo a arriba, funciones pequeñas

Otra característica común de la programación en Lisp es intentar acercar el lenguaje a la tarea que se intenta resolver. Una de las maneras de hacerlo es escribir funciones que, aunque sean pequeñas y simples, escondan detalles de implementación que no nos interesen y que suban el nivel de abstracción. La lección aquí es que escribir funciones y métodos de una o dos líneas es útil si suben el nivel de abstracción. No se trata de «esconder código» para no tenerlo a la vista, se trata de hacer olvidar a uno mismo parte de la complejidad de la tarea que está realizando.

Como ejemplo, sigamos con el caso anterior de los precios y los artículos. Una de las operaciones que hacemos es calcular un tanto por ciento. Si suponemos que es una operación que usaremos más de una vez, podría tener sentido abstraer ese cálculo. No nos vamos a ahorrar líneas de código, pero esta versión puede requerir menos esfuerzo mental y ser más fácil de leer (imagínese el siguiente ejemplo en castellano, tal y como hicimos en el anterior apartado):

; En el caso de Lisp, podríamos haber llamado a esta función '%', de modo que
; la llamada de abajo quedaría '(% percent-tax total-price)'
(defun percentage (percent amount)
  (* amount (/ percent 100)))

(defun calculate-price (cost nitems limit percent-tax)
  (let* ((total-price (* cost nitems))
         (tax         (percentage percent-tax total-price)))
    (if (> total-price limit)
      (+ total-price tax)
      total-price)))

Solución abstrayendo el cálculo de porcentajes (calculate-prices.lisp)

Calcular un tanto por ciento es trivial, y por escribir la función percentage no estamos ahorrando líneas de código, pero cada segundo que ahorramos en entender trivialidades al leer la fuente es un segundo más que podemos dedicar a asuntos más importantes. Y el tiempo que necesitamos para entender código sin las abstracciones apropiadas, con frecuencia crece exponencialmente, no linealmente, al añadir nuevas faltas de abstracción.

Otra ventaja de abstraer funciones de esta manera es que estas funciones normalmente son bastante fáciles de probar, porque tienden a tener interfaces sencillas y responsabilidades claras. En el caso de lenguajes que tienen una consola interactiva (como Lisp, Python, Ruby y otros) es fácil experimentar con la función y ver lo que hace, facilitando la escritura de pruebas unitarias en cualquier lenguaje. Especialmente si evitamos los efectos colaterales, como veremos en el siguiente apartado.

4. Efectos colaterales

Los llamados efectos colaterales son uno de los conceptos más importantes de la programación funcional, por no decir que el más importante. Es lo que diferencia los lenguajes puramente funcionales de los lenguajes funcionales no puros. Incluso los programadores de los lenguajes que no son puramente funcionales (como Lisp) generalmente intentan evitar efectos colaterales.

Un efecto colateral es cualquier cambio que una función produce fuera del ámbito de la función en sí. Por ejemplo, una función que modifique una variable que ha recibido como parámetro (es decir, «parámetros de entrada/salida») o que modifique variables globales o cualquier otra cosa que no sean variables locales a la función está produciendo efectos colaterales. Esto incluye cualquier tipo de entrada/salida, como leer o escribir ficheros o interactuar con la pantalla, el teclado o el ratón.

¿Por qué es tan importante evitar efectos colaterales? De nuevo, como en el caso de las pequeñas funciones que suban el nivel de abstracción, evitar un solo efecto colateral no es una ventaja muy grande. Sin embargo, evitar efectos colaterales como regla general hace que los programas sean más fáciles de entender y mantener, y que haya menos sorpresas. La razón es que evitar efectos colaterales garantiza que ningún error en la función pueda afectar a nada más. Si además no hacemos referencia a nada externo a la función, como variables globales, tenemos una garantía extra importantísima: la función es independiente del resto del código, lo que significa que ningún fallo del resto del programa puede afectar a nuestra función, y que podemos probar la función independientemente del resto del código, lo cual no sólo es práctico, sino que hace más fácil asegurarse de que cubrimos todos los casos posibles de la función con baterías de pruebas.

Veamos un ejemplo de efectos colaterales en Python. El método sort, desgraciadamente, modifica la lista sobre la que se llama. Esto puede producir sorpresas desagradables, como veremos en el primer ejemplo. Digamos que estamos escribiendo un programa para gestionar competiciones de carreras y escribimos una función best_time que recibe una lista de números y devuelve el menor (obviamos la existencia de la función min para hacer el ejemplo más ilustrativo):

def best_time_BAD(list):
  if len(list) == 0:
    return None
  list.sort()
  return list[0]

times = [5, 9, 4, 6, 10, 8]
best_time_BAD(times)  # Devuelve 4
print times           # ¡Esto imprime «[4, 5, 6, 8, 9, 10]»!

Sorpresa desagradable debida a un efecto colateral (best-time-bad.py)

Una forma de resolver este problema es usar la función sorted en vez del método sort:

def best_time(list):
  if len(list) == 0:
    return None
  return sorted(list)[0]

times = [5, 9, 4, 6, 10, 8]
best_time(times)  # Devuelve 4
print times       # Imprime «[5, 9, 4, 6, 10, 8]»

Mejor implementación, sin efectos colaterales (best-time-bad.py)

En Ruby normalmente se usa la convención de añadir un «!» al final del nombre del método si éste produce efectos colaterales (otra convención que se puede apreciar en el ejemplo es cómo los métodos que devuelven verdadero/falso terminan en «?»). El ejemplo de arriba se podría traducir a Ruby de la siguiente manera:

require 'pp'             # Pretty printer

def best_time_BAD(list)
  if list.empty?
    nil
  else
    list.sort!          # «sort!», ¡con efectos colaterales!
    list[0]
  end
end

times = [5, 9, 4, 6, 10, 8]
best_time_BAD(times)  # Devuelve 4
pp times              # Imprime «[4, 5, 6, 8, 9, 10]»

def best_time(list)
  if list.empty?
    nil
  else
    list.sort[0]       # «sort», sin «!»
  end
end

times2 = [5, 9, 4, 6, 10, 8]
best_time(times2)  # Devuelve 4
pp times2          # Imprime «[5, 9, 4, 6, 10, 8]»

Efectos colaterales en Ruby (best-time.rb)

Por último, evitar efectos colaterales permite a las funciones usar una técnica de optimización llamada «memorización» (memoization en inglés). Esta técnica consiste en recordar el valor retornado por la función la primera vez que se llama. Cuando se vuelve a llamar a la función con los mismos parámetros, en vez de ejecutar el cuerpo de la función, se devuelve el valor recordado. Si la función no produce ningún efecto colateral, esta técnica es perfectamente segura porque está garantizado que los mismos parámetros de entrada siempre producen el mismo resultado. Un ejemplo muy sencillo de memorización en Javascript es la siguiente implementación de la serie de Fibonacci:

var fibonacciCache = {0: 1, 1: 1};

function fibonacci(pos) {
  if (pos < 0) {
    throw "La serie de Fibonacci sólo está definida para números naturales";
  }

  if (! fibonacciCache.hasOwnProperty(pos)) {
    console.log("Calculo el resultado para la posición " + pos);
    fibonacciCache[pos] = fibonacci(pos - 1) + fibonacci(pos - 2);
  }

  return fibonacciCache[pos];
}

Implementación de la serie de Fibonacci con memorización (fibonacci.js)

Si se copia este código en una consola Javascript (digamos, Node) y se hacen distintas llamadas a la función fibonacci, se podrá comprobar (gracias a los mensajes impresos por console.log) que cada posición de la serie sólo se calcula una vez.

En lenguajes dinámicos como Python, Ruby o Javascript, es relativamente sencillo escribir una función que reciba otra función como parámetro y le aplique la técnica de «memorización». El siguiente apartado explora la técnica de manipular funciones como datos.

5. Funciones de orden superior

Otra de las características comunes de los lenguajes funcionales es tratar a las funciones como «ciudadanos de primera clase». Es decir, las funciones son valores más o menos normales que se pueden pasar como parámetros, asignar a variables y devolver como resultado de la llamada a una función. Las funciones que utilizan esta característica, es decir, que manipulan o devuelven funciones, reciben el nombre de funciones de orden superior. Afortunadamente, muchos lenguajes populares tienen este tipo de funciones.

La primera vez que uno se encuentra funciones de orden superior puede pensar que sus usos son limitados, pero realmente tienen muchas aplicaciones. Por un lado, tenemos las funciones y métodos que traiga el lenguaje de serie, por lo general de manejo de listas. Por otro, tenemos la posibilidad de escribir nuestras propias funciones y métodos de orden superior, para separar o reutilizar código de manera más efectiva.

Veamos un ejemplo de lo primero en Ruby. Algunos de los métodos de la clase Array reciben una función como parámetro (en Ruby se los llama bloques), lo que permite escribir código bastante compacto y expresivo:

# Comprobar si todas las palabras tienen menos de 5 letras
if words.all? {|w| w.length < 5 }
   # ...
end

# Comprobar si el cliente tiene algún envío pendiente
if customer.orders.any? {|o| not o.sent? }
   # ...
end

# Obtener las asignaturas suspendidas por un alumno
failed_subjects = student.subjects.find_all {|s| s.mark < 5 }

# Dividir los candidatos entre los que saben más de
# dos idiomas y los demás
polyglots, rest = cands.partition {|c| c.languages.length > 2 }

# Obtener una versión en mayúsculas de las palabras
# de la lista
words = ["hoygan", "kiero", "hanime", "gratix"]
shouts = words.map {|w| w.upcase}

Métodos de orden superior en Ruby

El código equivalente que habría que escribir para conseguir el mismo resultado sin funciones de orden superior es bastante más largo y difícil de leer. Además, si quisiéramos hacer operaciones parecidas variando la condición (digamos, en una parte del código queremos comprobar si todas las palabras tienen menos de cinco letras, y en otra queremos comprobar si todas las palabras se componen exclusivamente de letras, sin números u otros caracteres) el código empeoraría rápidamente.

Escribir nuestras propias funciones tampoco tiene que ser difícil, ni usarse en casos muy especiales. Pueden ser usos tan comunes y sencillos como el siguiente ejemplo en Javascript:

// Queremos poder escribir el siguiente código
var comicCollection = new Database('comics');
comicCollection.onAdd(function(comic) {
    console.log("Nuevo cómic añadido: " + comic.title);
});
// La siguiente línea debería imprimir «Nuevo cómic...» en la consola
comicCollection.add({title:  "Batman: The Dark Knight Returns",
                     author: "Frank Miller"});

// La implementación de onAdd puede ser muy sencilla
Database.prototype.onAdd = function(f) {
    this.onAddFunction = f;
}
// La implementación de add también
Database.prototype.add = function(obj) {
    this.data.push(obj);
    if (typeof(this.onAddFunction) === 'function') {
        this.onAddFunction(obj);
    }
}

Funciones de orden superior en Javascript

A partir de Ecmascript 5, la clase Array añade varios métodos de orden superior que son comunes en la programación funcional.

6. Evaluación perezosa

La última característica de lenguajes funcionales que exploraremos es la evaluación perezosa. No hay muchos lenguajes que incluyan evaluación perezosa, pero se puede imitar hasta cierto punto, y saber cómo funciona puede darnos ideas e inspirarnos a la hora de diseñar nuestros propios sistemas. Uno de los relativamente pocos lenguajes que incluye evaluación perezosa es Haskell.

La evaluación perezosa consiste en no hacer cálculos que no sean necesarios. Por ejemplo, digamos que escribimos una función que genere recursivamente una lista de 10 elementos, y otra función que llame a la primera, pero que sólo use el valor del cuarto elemento. Cuando se ejecute la segunda función, Haskell ejecutará la primera hasta que el cuarto elemento sea calculado. Es decir: Haskell no ejecutará, como la mayoría de los lenguajes, la primera función hasta que devuelva su valor (una lista de 10 elementos); sólo ejecutará la función hasta que se genere el cuarto elemento de la lista, que es lo único que necesita para continuar la ejecución del programa principal. En este sentido, la primera función es como una expresión matemática: inicialmente Haskell no conoce el valor de la expresión, y sólo calculará la parte de ésta que necesite. En este caso, los cuatro primeros elementos.

¿Cuál es la ventaja de la evaluación perezosa? En la mayoría de los casos, eficiencia. En otros casos, legibilidad. Cuando no tenemos que preocuparnos por la memoria o ciclos de CPU usados por nuestra función, podemos hacer que devuelvan (teóricamente) listas o estructuras infinitas, las cuales pueden ser más fáciles de leer o implementar en algunos casos. Aunque no es el ejemplo más claro de legibilidad de evaluación perezosa, entender la siguiente implementación de la serie de Fibonacci, aclarará la diferencia con la evaluación estricta. Nótese que la función calcula la serie entera, es decir, una lista infinita:

fibs = 0 : 1 : zipWith (+) fibs (tail fibs)

Implementación de la serie de Fibonacci, en Haskell

Normalmente la función es imposible de entender de un primer vistazo si no estamos familiarizados con la programación funcional y la evaluación perezosa, pero hay varios puntos que nos ayudarán:

  1. tail lista devuelve la lista dada, saltándose el primer elemento. Es decir, si lista es (1 2 3), tail lista es (2 3).
  2. zipWith calcula, dada una operación y dos listas, una lista que tiene: como primer elemento, el resultado de aplicar la operación dada al primer elemento de las dos listas; como segundo el resultado de aplicar la operación al segundo elemento de las dos listas; etc. Así, zipWith llamado con la función suma y las listas (1 2 3) y (0 1 5) resultaría en (1 3 8).
  3. Cada elemento de la lista devuelta por fibs se calculará individualmente, y estará disponible en memoria sin necesidad de volver a ejecutar el código de la función.

Así, lo que ocurre es:

  1. Haskell empieza a construir una lista con los elementos 0 y 1. En este punto, fibs = (0 1).
  2. El tercer elemento será el primer elemento de la subexpresión zipWith …. Para calcularlo, necesitamos la lista fibs (por ahora (0 1), ya que sólo conocemos dos elementos) y tail fibs (por ahora (1)). Al sumar el primer elemento de cada una de esas listas (0 y 1), el resultado es 1. En este punto, fibs = (0 1 1) y la subexpresión zipWith … = (1).
  3. El cuarto elemento de fibs es el segundo elemento de zipWidth …. Para calcularlo necesitaremos el segundo elemento de fibs y el segundo elemento de tail fibs. El segundo elemento de tail fibs es el tercer elemento de fibs, que ya conocemos porque lo calculamos en el paso anterior. Nótese que no hace falta ninguna llamada recursiva porque los valores que necesitamos ya están calculados. La evaluación perezosa funciona como una función matemática: no hace falta que volvamos a calcular un valor si ya sabemos el resultado. En este punto, fibs = (0 1 1 2) y la subexpresión zipWith … = (1 2).
  4. Para el quinto elemento (el tercero de zipWidth), necesitaremos el tercer y cuarto elementos de fibs, que llegados a este punto ya conocemos porque los hemos calculado en los pasos anteriores. Y así sucesivamente.

Estos pasos no se ejecutan indefinidamente: se irán ejecutando hasta que se obtenga el elemento de fibs que se necesite. Es decir, si asignamos fibs a una variable pero nunca la usamos, el código no se ejecutará en absoluto; si usamos el valor del tercer elemento de la serie en algún cálculo, sólo se ejecutarán los dos primeros pasos descritos arriba; etc. En ningún caso se intenta ejecutar fibs hasta que devuelva «el valor completo».

La evaluación perezosa se puede ver como aplicar la técnica de «memorización» automáticamente a todo el lenguaje. Un posible uso es calcular tablas de valores que son lentos de calcular: en algunos casos podríamos cargar una tabla precalculada en memoria, pero el coste puede ser prohibitivo si la tabla es grande o potencialmente infinita.

7. Conclusión

Aprender lenguajes nuevos, especialmente de paradigmas con los que estamos menos familiarizados, nos puede enseñar muchas cosas sobre programación en general. Este proceso de aprendizaje nos hará mejores programadores, y muchas de esas lecciones serán aplicables a todos los lenguajes que conozcamos, no sólo a los lenguajes similares al que acabemos de aprender. En particular, los lenguajes funcionales son suficientemente accessibles y similares a los lenguajes más populares como para enseñarnos muchas lecciones útiles.