Skip to content

Capítulo 03: Felicidad pura con funciones puras

Oh, volver a ser puro

Primero debemos aclarar el concepto de función pura.

Una función pura es aquella que, dada la misma entrada, siempre retornará la misma salida y no produce ningún efecto secundario observable.

Consideremos slice y splice. Ambas funciones realizan la misma operación fundamental - de maneras muy diferentes, por cierto. Decimos que slice es pura porque garantiza la misma salida ante la misma entrada. En cambio, splice modificará su array permanentemente, lo cual constituye un efecto observable.

js
const xs = [1,2,3,4,5];

// pure
xs.slice(0,3); // [1,2,3]

xs.slice(0,3); // [1,2,3]

xs.slice(0,3); // [1,2,3]


// impure
xs.splice(0,3); // [1,2,3]

xs.splice(0,3); // [4,5]

xs.splice(0,3); // []

En programación funcional rechazamos funciones problemáticas como splice que mutan datos. Esto es inaceptable, ya que buscamos funciones confiables que siempre retornen el mismo resultado, no funciones que dejen desorden como splice.

Veamos otro ejemplo.

js
// impure
let minimum = 21;
const checkAge = age => age >= minimum;

// pure
const checkAge = (age) => {
  const minimum = 21;
  return age >= minimum;
};

En la porción impura, checkAge depende de la variable mutable minimum para determinar el resultado. Esto es problemático porque incrementa la carga cognitiva al introducir dependencias externas.

Puede parecer trivial en este ejemplo, pero esta dependencia del estado es uno de los mayores contribuyentes a la complejidad sistémica (http://curtclifton.net/papers/MoseleyMarks06a.pdf). La función checkAge podría retornar resultados diferentes según factores externos, lo que no solo la descalifica como pura, sino que dificulta enormemente el razonamiento sobre el software.

Su versión pura, en cambio, es completamente autosuficiente. Al hacer minimum inmutable preservamos la pureza, ya que el estado nunca cambia. Para lograrlo, podemos crear un objeto congelado.

js
const immutableState = Object.freeze({ minimum: 21 });

Los efectos secundarios pueden incluir...

Profundicemos en estos "efectos secundarios". ¿Qué es este efecto secundario mencionado en la definición de función pura? Consideraremos efecto como cualquier operación en nuestra computación que no sea el cálculo de un resultado.

Los efectos no son inherentemente malos - los usaremos ampliamente en próximos capítulos. El problema radica en la parte secundaria. El agua sola no incuba larvas, es el estancamiento lo que genera plagas. De igual forma, los efectos secundarios son caldo de cultivo para problemas en nuestros programas.

Un efecto secundario es cualquier cambio en el estado del sistema o interacción observable con el exterior durante el cálculo de un resultado.

Los efectos secundarios incluyen, pero no se limitan a:

  • modificar el sistema de archivos
  • insertar registros en una base de datos
  • realizar llamadas HTTP
  • mutaciones
  • imprimir en pantalla / logging
  • capturar entrada de usuario
  • consultar el DOM
  • acceder al estado del sistema

La lista continúa. Cualquier interacción externa constituye un efecto secundario, lo que cuestiona la viabilidad de programar sin ellos. La filosofía funcional postula que estos efectos son causa principal de comportamientos erróneos.

No están prohibidos, pero debemos controlarlos. Aprenderemos cómo con funtores y mónadas más adelante. Por ahora, mantengamos estas funciones separadas de las puras.

Los efectos secundarios descalifican la pureza. Por definición, las funciones puras deben garantizar misma salida para misma entrada, imposible cuando dependen de factores externos.

Analicemos por qué insistimos en esta correspondencia entrada/salida. Abróchense los cinturones: volveremos a las matemáticas de secundaria.

Matemáticas de 8° grado

De mathisfun.com:

Una función es una relación especial entre valores: Cada valor de entrada produce exactamente un valor de salida.

Es decir, una relación binaria donde cada entrada tiene exactamente una salida (aunque diferentes entradas pueden compartir salida). El siguiente diagrama muestra una función válida de x a y:

conjuntos de funciones(https://www.mathsisfun.com/sets/function.html)

En contraste, esta relación NO es función porque la entrada 5 tiene múltiples salidas:

relación no funcional(https://www.mathsisfun.com/sets/function.html)

Las funciones pueden representarse como pares (entrada, salida): [(1,2), (3,6), (5,10)] (aquí la función duplica su entrada).

O en tablas:

Input Output
1 2
2 4
3 6

O como gráficas con x como entrada y y como salida:

function graph

Si la entrada determina la salida, no se necesitan detalles de implementación. Dado que las funciones son mapeos, podríamos usar literales de objeto con [] en vez de ().

js
const toLowerCase = {
  A: 'a',
  B: 'b',
  C: 'c',
  D: 'd',
  E: 'e',
  F: 'f',
};
toLowerCase['C']; // 'c'

const isPrime = {
  1: false,
  2: true,
  3: true,
  4: false,
  5: true,
  6: false,
};
isPrime[3]; // true

Obviamente preferimos calcular en vez de escribir manualmente, pero esto ilustra otra perspectiva. (¿Y los múltiples parámetros? Podemos agruparlos en arrays o usar currying (currificación), como veremos luego).

He aquí la revelación: ¡Las funciones puras SON funciones matemáticas, núcleo de la programación funcional! Estas "ángeles" ofrecen enormes beneficios. Veamos por qué vale la pena preservar la pureza.

Argumentos para la pureza

Cacheable

Primero: las funciones puras pueden cachearse por entrada (técnica llamada memoización):

js
const squareNumber = memoize(x => x * x);

squareNumber(4); // 16

squareNumber(4); // 16, returns cache for input 4

squareNumber(5); // 25

squareNumber(5); // 25, returns cache for input 5

Esta es una implementación simplificada; existen versiones más robustas.

js
const memoize = (f) => {
  const cache = {};

  return (...args) => {
    const argStr = JSON.stringify(args);
    cache[argStr] = cache[argStr] || f(...args);
    return cache[argStr];
  };
};

Nota: Podemos convertir funciones impuras en puras mediante evaluación diferida:

js
const pureHttpCall = memoize((url, params) => () => $.getJSON(url, params));

Aquí no hacemos el llamado HTTP inmediato, sino que retornamos una función que lo hará posteriormente. Esta función es pura porque siempre retorna la misma función de llamado HTTP para mismos url y params.

Nuestra función de memoización trabaja correctamente, aunque cachea las funciones generadas, no los resultados HTTP.

Aún no es muy útil, pero pronto veremos técnicas relevantes. La clave es que TODAS las funciones pueden cachearse, independientemente de su aparente complejidad.

Portabilidad / Auto-documentación

Las funciones puras son autónomas. Todas sus dependencias son explícitas en los parámetros. Esta transparencia facilita la comprensión y el mantenimiento.

js
// impure
const signUp = (attrs) => {
  const user = saveUser(attrs);
  welcomeUser(user);
};

// pure
const signUp = (Db, Email, attrs) => () => {
  const user = saveUser(Db, attrs);
  welcomeUser(Email, user);
};

Este ejemplo muestra cómo la función pura declara claramente sus dependencias (Db, Email, attrs). Su firma nos informa exactamente su comportamiento.

Aprenderemos técnicas para mantener la pureza sin solo diferir evaluación. Lo crucial es que la forma pura es mucho más informativa que su contraparte impura.

Al "inyectar" dependencias como argumentos, ganamos flexibilidad: Podemos cambiar la DB o cliente de email fácilmente reutilizando la función en nuevos contextos.

En JavaScript, portabilidad podría significar enviar funciones serializadas por sockets, o ejecutar código en web workers. Esta característica es poderosa.

A diferencia de los métodos imperativos arraigados en su entorno mediante estado y efectos, las funciones puras pueden ejecutarse en cualquier contexto.

¿Cuándo copiaste un método a otra app? Cita de Joe Armstrong (creador de Erlang): "El problema de los lenguajes OO es su ambiente implícito: Querías un plátano y obtuviste un gorila con el plátano... y toda la jungla".

Testeable

Las funciones puras facilitan testing: No necesitamos mockear gateways de pago ni verificar estado posterior. Solo evaluamos entrada vs salida.

La comunidad funcional ha creado herramientas como Quickcheck (fuera del alcance de este libro) que generan entradas aleatorias y verifican propiedades de salida.

Razonable

El mayor beneficio es la transparencia referencial: Podemos sustituir una expresión por su valor sin alterar el comportamiento.

Al carecer de efectos, las funciones puras solo afectan el programa mediante sus salidas. Esto garantiza transparencia referencial. Veamos:

js
const { Map } = require('immutable');

// Aliases: p = player, a = attacker, t = target
const jobe = Map({ name: 'Jobe', hp: 20, team: 'red' });
const michael = Map({ name: 'Michael', hp: 20, team: 'green' });
const decrementHP = p => p.set('hp', p.get('hp') - 1);
const isSameTeam = (p1, p2) => p1.get('team') === p2.get('team');
const punch = (a, t) => (isSameTeam(a, t) ? t : decrementHP(t));

punch(jobe, michael); // Map({name:'Michael', hp:19, team: 'green'})

decrementHP, isSameTeam y punch son puras y transparentes. Usamos razonamiento ecuacional (sustitución de iguales), reemplazando equivalentes. Es como evaluación manual del código.

Primero integramos isSameTeam:

js
const punch = (a, t) => (a.get('team') === t.get('team') ? t : decrementHP(t));

Con datos inmutables, reemplazamos equipos por sus valores reales:

js
const punch = (a, t) => ('red' === 'green' ? t : decrementHP(t));

Al ser falso, eliminamos la rama condicional:

js
const punch = (a, t) => decrementHP(t);

Al integrar decrementHP, vemos que punch simplemente resta 1 al hp.

js
const punch = (a, t) => t.set('hp', t.get('hp') - 1);

Esta capacidad de razonamiento es invaluable para refactorizar y comprender código. Usaremos estas técnicas constantemente.

Código paralelo

¡El golpe final!: Podemos ejecutar funciones puras en paralelo, pues no acceden a memoria compartida ni provocan condiciones de carrera.

Es viable en entornos server-side con threads y en browsers mediante web workers, aunque la complejidad de funciones impuras lo dificulta.

Resumen

Hemos visto qué son funciones puras y por qué son fundamentales en programación funcional. De ahora en adelante, escribiremos funciones puras siempre que podamos, usando herramientas específicas cuando sea necesario.

Sin herramientas adicionales, escribir programas puros es laborioso. Debemos pasar datos como parámetros, evitar estado y efectos. ¿Cómo lograrlo? Adquiriremos una nueva herramienta: la currificación.

Capítulo 04: Currificación