Introducción a la programación funcional en JavaScript — Parte 2: Funciones Puras

Transparencia referencial y efectos colaterales

Lupo Montero
Laboratoria Devs
7 min readJul 2, 2018

--

Detalle de obra en proceso de Valeria Ghezzi

Ha pasado más de un año desde que publiqué “Introducción a la programación funcional en JavaScript — Parte 1”, así que esta segunda parte llega con un poquito de retraso… 🙈

En la primera parte hicimos un pequeño viaje de abstracción donde principalmente reemplazamos bucles por funciones y mencionamos los principios de la programación funcional. Pero nos faltó cubrir con más detalle los conceptos fundamentales del paradigma: “funciones puras”, “composición”, “manejo de estado”, “inmutabilidad”, “recursividad”…

En esta segunda parte vamos a concentrarnos en el concepto de funciones puras, y las próximas semanas continuaremos con la serie 🤞.

Qué es una función pura?

Como la mayoría de los principios de la programación funcional, el concepto de función pura es simple, y precisamente esa simplicidad hace que al principio sea difícil apreciar su belleza y los beneficios que ofrece. Más adelante vamos a ver como las funciones puras hacen que nuestro código sea más fácil de predecir, aislar, reusar y testear, más fácil de razonar con menos contexto... pero nos toca empezar por el principio… bueno, al grano.

Decimos que una función es pura cuando cumple los siguientes requisitos:

  • Dados los mismos inputs (argumentos) siempre retorna lo mismo (transparencia referencial).
  • No tiene efectos colaterales.

Transparencia referencial

Empecemos por el primer punto. El comportamiento de las funciones puras depende de una y solo una cosa: los argumentos pasados explícitamente a la función. Esto quiere decir que si proporcionas los mismos datos como argumentos o entrada, la función siempre debe producir el mismo valor de retorno. A esta propiedad se le conoce como transparencia referencial. Veamos un ejemplo:

// Impura
const time = () => Date.now();
time(); // => 1521674192729
time(); // => 1521674402875
// Pura
const sum = (a, b) => (a + b);
sum(2, 3); // => 5
sum(2, 3); // => 5

La primera función time() retorna un valor distinto para cada invocación, a pesar de que la invocamos con los mismos argumentos (sin argumentos), con lo cual no podemos predecir su valor de retorno. Por otro lado, cada vez que invocamos la función sum(a, b) con los mismos argumentos (sum(2, 3)) recibimos el mismo resultado (5). De hecho, podríamos hacer un find-and-replace y reemplazar todas las invocaciones a sum(2, 3) por 5 en nuestro programa y todo debería seguir funcionando igual (gracias a la transparencia referencial).

Si tuviéramos que escribir pruebas/tests para la función time, al no poder predecir el valor de retorno dados los argumentos, no podemos verificar que el valor de retorno sea un valor en particular. Quizás podríamos comprobar que time() de hecho sea una función, y que retorne un número, pero no el valor en sí. Escribamos un test usando Jest y asumiendo que hemos implementado y exportado la función time() en un archivo con el nombre time.js en el mismo directorio que nuestro test (seguiremos esta convención con el resto de los ejemplos).

Este test nos daría un mínimo de seguridad sabiendo que time() retorna un número, pero no podemos comprobar fácilmente que el número retornado sea correcto 😱

Por otro lado, si escribimos tests para la función sum(a, b), al ser una función pura, podemos comprobar los valores esperados y garantizar que nuestra función se comporta como esperamos.

Efectos colaterales

Cuando decimos que una función no tiene efectos colaterales (side effects), nos referimos a que no afecta estado fuera de su ámbito (scope), y que no tiene interacciones con las funciones que la invocan o el “mundo exterior” más allá de retornar un valor. En términos más concretos, esto significa que no depende de y/o muta variables declaradas fuera del cuerpo de la función.

Veamos algunos ejemplos para ilustrar a qué nos referimos. Imaginemos que tenemos que escribir una función parecida a un reductor de Redux, donde dada una acción (un objeto), calculamos el nuevo estado de nuestra aplicación. Empecemos con una implementación naive (e impura).

La función reducer() del snippet de arriba hace uso de una variable llamada state, que está declarada fuera del ámbito (scope) de la función, y depende del valor de esta variable para producir un resultado. Podemos decir que esta primera implementación de reducer() tiene efectos colaterales o secundarios porque al invocarla va a afectar estado fuera de su ámbito (la variable state). Esto hace que la función sea impura y el resultado de cada invocación dependa de la historia de invocaciones (cuántas veces se ha invocado la función anteriormente y con qué valores/argumentos) y del estado de la variable state.

Escribamos unos tests para ver como se comporta nuestra función:

En los tests de arriba, vemos que la primera invocación a reducer() con la acción { type: 'INCREMENT' } resulta en { count: 1 }, la segunda resulta en { count: 2 }, y la tercera, pasando la acción { type: 'DECREMENT' } retorna { count: 1 }. Este puede ser el comportamiento esperado, pero qué pasaría con la siguiente invocación en el siguiente test? El siguiente test necesita saber cuantas veces se ha invocado la función y con qué valores para poder determinar el resultado. Si comentáramos el primer test, el segundo se rompería, porque la historia de nuestra función ya no sería la misma. Este tipo de comportamiento puede ser difícil de depurar, ya que no podemos saber qué debe retornar una invocación sin saber antes todo lo que ha pasado en la ejecución de nuestro programa.

Podemos solucionar esto reemplazando la variable externa state con un argumento:

En esta nueva implementación hemos cambiado la responsabilidad de mantener el estado entre llamadas a quien llame a la función, de forma explícita a través de un argumento. Esto hace que nuestra función ahora sí sea predecible: cada llamada depende sólo de sus argumentos y no de la historia de invocaciones anteriores.

Ahora nuestros tests son independientes entre ellos, lo cual está mucho mejor, pero todavía hay algo que podemos mejorar: nuestra función está mutando el valor de state, que es un argumento, lo cual no es ideal porque ese objeto state fue declarado fuera de la función y puede estar siendo usado en otras partes. Como vemos en los tests de arriba, el objeto state está siendo mutado, state1 es una referencia que apunta al mismo objeto que state2, por eso pasa el test expect(state1).toBe(state2). Si quisiéramos ver si el estado ha cambiado estamos en problemas, ya que ambas variables apuntan a la misma referencia (state1 === state2). Esto se debe a que en JavaScript, cuando recibimos un objeto (Object, Array o Function) como argumento, lo que recibimos es una referencia al objeto y no una copia, así que cualquier cambio (mutación) que hagamos sobre ese objeto afectará al objeto original. Para más info sobre esto te recomiendo este otro artículo: “Por valor vs por referencia en JavaScript”.

Cambiemos esto para que nuestro reducer() produzca un nuevo objeto con el nuevo estado en vez de mutar el objeto que recibe como argumento.

Esta última implementación se comporta muy parecido a la anterior, pero ahora nunca modificamos el objeto state, sino que devolvemos uno nuevo con los cambios cuando así sea necesario.

Este último cambio parece trivial, pero al tratar el estado como inmutable, nos va a permitir saber si ha habido cambios en el estado simplemente comparando la referencia a state, viajar en el tiempo y mantener snapshots del estado a lo largo de la ejecución. Si ahora queremos saber si el estado ha cambiado, ya no tenemos que preocuparnos por el valor de state (o los valores de sus propiedades), sino que podemos comparar directamente si ambas referencias apuntan al mismo objeto: podemos simplemente comprobar si state1 === state2 (lo mismo que expect(state1).toBe(state2)) para ver si NO ha cambiado, o state1 !== state2 para comprobar que SI haya cambiado (expect(state1).not.toBe(state2)).

Con estos cambios, ahora nuestro reducer ya es una función pura y está lista para funcionar con Redux 😉

⚠️ Advertencia

Las funciones puras son más fáciles de predecir, aislar y testear al sólo depender de sus inputs y no tener efectos secundarios. Según va aumentando la complejidad de un programa, estas características son de un valor incalculable. Por ello, es una buena práctica tratar de hacer que nuestras funciones sean puras, pero también hay que tener en cuenta que no toda función puede, por naturaleza, ser pura. Hay ciertas funciones donde no nos queda más remedio que invocar funciones globales, tener dependencias implícitas o hacer I/O, lo cual por definición son efectos colaterales. Eso sí, como buena práctica tratamos de minimizar estos efectos colaterales y centralizarlos para que no queden repartidos (y escondidos) detrás de un montón de funciones.

Este post es parte de la serie Introducción a programación funcional en JavaScript. Si quieres continuar leyendo…

<< Parte 1: Introducción | Parte 3: Composición de funciones >>

--

--