Saltar al contenido
Computer Science Fundamentals
Código fuente
← Inicio

Programación funcional

100 preguntas

Correctas
0
Incorrectas
0
Sin responder
100
Dificultad
Número de preguntas

100 preguntas en este filtro

Resumen teórico

Imagina una máquina expendedora. Metes una moneda, aprietas un botón y cae una golosina. Aprieta el mismo botón con la misma moneda y cae la misma golosina. La máquina no recuerda lo de ayer, no cambia las otras máquinas y no le importa qué hora es. La programación funcional es una forma de escribir programas en la que las piezas se comportan así: pequeñas máquinas que reciben algo y devuelven algo.

Un programa se arma conectando esas máquinas, como los tubos de un parque acuático. Como cada máquina depende solo de lo que le metes, puedes probarla sola, llevarla a otro lugar, ejecutar muchas a la vez y aun así saber qué va a pasar. Por eso las ideas que nacieron en lenguajes como Haskell y Elixir hoy viven dentro de TypeScript, Rust y Java, y las vas a encontrar en casi todo el código moderno.

En este resumen aprenderás: qué es una función pura, por qué los datos que nunca cambian son un regalo, cómo funcionan map, filter y reduce, qué recuerda una closure, cómo una función puede llamarse a sí misma (recursión) sin romper la computadora, cómo unir funciones (composición y currying), cómo describir datos con tipos que no mienten (tipos de datos algebraicos y pattern matching), cómo hacer el trabajo solo cuando hace falta (evaluación perezosa), cómo tratar los valores ausentes y los errores como datos comunes (Option y Result), dónde deben vivir los efectos secundarios inevitables y cómo se ve todo esto en Elixir y TypeScript. Al final estarás listo para el quiz.

El panorama: programas hechos de funciones

Una es como una máquina con una ranura para los ingredientes y una bandeja para el resultado. Las cosas que metes son los argumentos (o parámetros). Lo que sale es el valor de retorno. En programación funcional queremos tanto a las funciones que las tratamos como a cualquier otra cosa, como un número o una palabra: podemos guardar una función en una variable, dársela a otra función o recibir una de vuelta como resultado. Un lenguaje que permite esto tiene funciones de primera clase.

Palabras que usaremos todo el tiempo
PalabraQué significaEjemplo
ArgumentoUn valor que le das a una funciónEn double(4), el 4
Valor de retornoLo que la función devuelvedouble(4) devuelve 8
Efecto secundarioCualquier cosa que la función hace al mundo exterior además de devolver un valorImprimir, escribir un archivo, cambiar una variable global
ExpresiónUn trozo de código que produce un valor2 + 3 o double(4)
InmutableNo puede cambiar después de creadoUna foto impresa: para cambiarla imprimes otra
Perezoso / ansiosoPerezoso (lazy) espera hasta que se necesita el resultado. Ansioso (eager) calcula de inmediatoUna receta que cocinas solo cuando llegan los invitados, frente a comida hecha de antemano

La programación funcional se apoya en dos grandes hábitos. El primero es escribir funciones puras, cuyo resultado depende solo de sus argumentos. El segundo es usar datos inmutables, que nunca cambian: para hacer un cambio creas un valor nuevo. Los efectos secundarios inevitables, como hablar con la red, se empujan a los bordes del programa.

  entrada --> [ limpiar ] --> [ filtrar ] --> [ sumar ] --> salida

  Cada caja es una función.
  Cada flecha lleva un valor, no una variable modificada.
Los datos fluyen por pequeñas máquinas
↑ Volver al índice

Funciones puras y transparencia referencial

Una función pura es una función que se comporta como las de las matemáticas. Dos reglas: (1) los mismos argumentos siempre dan el mismo resultado, y (2) llamarla no cambia nada que alguien pueda notar fuera de ella. El botón de una calculadora es puro: 7 por 6 es 42 hoy, mañana y en Marte.

Todo lo que una función hace además de calcular su resultado es un efecto secundario: escribir en la pantalla, en un archivo, en la red o en una base de datos; cambiar una variable global; o cambiar el argumento que recibió. Leer algo que puede cambiar también es un problema: el reloj, lo que escribe el usuario, un número aleatorio o una variable global mutable. Son entradas ocultas, porque no aparecen en la lista de parámetros. Los efectos secundarios son salidas ocultas.

¿Pura o no?
Función¿Pura?Por qué
(xs) => xs.length * 2SíSolo lee su argumento y devuelve un número nuevo
(x) => x + Math.random()NoEntrada oculta: un número aleatorio
() => Date.now()NoEntrada oculta: el reloj
(x) => { total += x; return total; }NoCambia una variable de afuera (salida oculta) y la lee (entrada oculta)
(xs) => { xs.push(0); return xs; }NoCambia su propio argumento
(price) => price * taxRate, con taxRate un let globalNoEntrada oculta: el global puede cambiar

Reemplazar una llamada por su valor

Una expresión es referencialmente transparente cuando puedes cambiarla por su valor y el programa no cambia. Así razonamos en álgebra: si y = 3 * 4, puedes escribir 12 donde veas y. Las funciones puras siempre lo permiten. Una función que cambia estado rompe la regla:

let calls = 0;
const next = (): number => {
	calls += 1;
	return calls;
};

const a = next() + next(); // 1 + 2 = 3
calls = 0;
const x = next(); // 1
const b = x + x; // 1 + 1 = 2, no 3
La prueba de sustitución: llamar dos veces no es lo mismo que ponerle nombre al resultado una vez

La transparencia referencial permite buenos trucos. La memoización guarda el resultado de una llamada y lo devuelve cuando vuelven los mismos argumentos. Solo es correcta para funciones puras: si la función leyera el reloj, la caché devolvería una hora vieja; si escribiera en un archivo, la segunda llamada se saltaría la escritura sin avisar.

Cómo volver pura una función

Convierte la entrada oculta en un parámetro. Una función isExpired(coupon) que le pregunta al reloj qué hora es pasa a ser isExpired(coupon, now). Ahora la prueba pasa el instante que quiera y no necesita un reloj falso. El reloj real se lee en un solo lugar, en el borde del programa. La misma solución sirve para withTax(price): escribe withTax(price, taxRate).

  • Pruebas sin mocks. Un mock es un sustituto de algo que la prueba no controla, como el reloj o la base de datos. Una función pura no tiene nada de eso: arma valores, llámala y compara el resultado.
  • Determinista no es lo mismo que pura. Una función sum(xs) que además imprime su resultado siempre devuelve el mismo número, pero imprimir es un efecto, así que no es pura y una caché no puede saltarse su llamada.
  • La mutación local está bien. Si una función crea un array nuevo por dentro, lo llena con push en un bucle y lo devuelve, nadie de afuera lo nota. Sigue siendo pura, porque lo que cambió nunca escapó antes del final.
  • Pruebas basadas en propiedades. Generan cientos de entradas aleatorias y comprueban una regla como decode(encode(x)) == x. Cuando una falla, ejecutan la función muchas veces con entradas cada vez más pequeñas para hallar la más pequeña que falla. Esto solo funciona si cada ejecución depende únicamente de la entrada, así que las funciones puras son el blanco perfecto.
↑ Volver al índice

Inmutabilidad: datos que nunca cambian

Un valor es inmutable cuando nunca cambia después de creado. Las operaciones que parecen modificarlo devuelven un valor nuevo y dejan el antiguo como estaba. Piensa en una foto impresa: para ponerle un sombrero a la persona, imprimes una foto nueva. La del álbum sigue igual, así que nadie que la esté mirando se lleva una sorpresa.

Eso elimina una familia entera de errores, los de una parte del programa que cambia datos a espaldas de otra parte que los estaba usando. También ayuda a la concurrencia (): el peligro clásico es el estado mutable compartido, dos tareas tocando los mismos datos mientras al menos una escribe. Si los datos no pueden cambiar, se pueden compartir con libertad. Esta es una de las grandes razones por las que Elixir y Erlang escalan bien en muchos núcleos del procesador.

Inmutabilidad en TypeScript

HerramientaQué protegeQué no protege
constEl nombre no puede apuntar a otro valorEl interior de un objeto o array: xs.push(3) sigue funcionando
Object.freezeLas propiedades del primer nivel del objeto, en tiempo de ejecuciónObjetos y arrays anidados, salvo que congeles cada uno (deep freeze)
readonly number[] / Readonly<T>En tiempo de compilación, el verificador de tipos rechaza los métodos que cambian el arrayEl código que esquiva los tipos. No cuesta nada en tiempo de ejecución

Copiar con la sintaxis spread ({ ...a }) es superficial: crea un objeto externo nuevo, pero los campos que guardan objetos o arrays siguen apuntando a los mismos valores internos. Para actualizar datos anidados de forma inmutable, copia cada nivel del camino:

const a = { name: 'Ana', tags: ['x'] };
const bad = { ...a, name: 'Bia' };
bad.tags.push('y'); // ¡a.tags también cambió! mismo array

const good = { ...a, tags: [...a.tags, 'y'] }; // también un array nuevo
Copia superficial frente a copia de cada nivel

Inmutabilidad en Elixir

En Elixir todos los datos son inmutables. List.delete(list, 2) devuelve una lista nueva y no toca list; para conservar el resultado debes ligarlo a un nombre. Un nombre sí se puede religar (x = 2 después de x = 1), pero eso no es mutación: el valor antiguo sigue existiendo para quien ya lo tenía. Una función creada cuando x valía 1 sigue viendo 1, porque una función anónima captura valores, no nombres.

Por qué no es lento: compartición estructural

Copiar una lista grande en cada cambio sería terrible. El truco es que el valor nuevo reutiliza las partes del antiguo que no cambiaron. Una lista de Elixir es una cadena de celdas, cada una apuntando a la siguiente. Agregar al principio crea una sola celda nueva que apunta a la lista antigua. Agregar al final necesita una copia nueva de todas las celdas anteriores, porque cada una debe apuntar a una celda siguiente nueva.

  nueva: [0] --+
               v
  list:  [1] -> [2] -> [3] -> []

  1 celda nueva. La lista antigua queda intacta y compartida.
Agregar al principio comparte la lista antigua: [0 | list]
Celdas nuevas para agregar un elemento a una lista de 1.000 elementos
Agregar al principio: [x | list]
1 celdas
Agregar al final: list ++ [x]
1,000 celdas
↑ Volver al índice

Funciones de orden superior: map, filter, reduce

Una función de orden superior es una función que recibe otra función como argumento, devuelve una función, o ambas cosas. Separa 'cómo recorrer los datos' de 'qué hacer con cada elemento'. La función de orden superior sabe recorrer. La función que le entregas dice qué hacer.

Los tres grandes
FunciónQué haceTamaño del resultadoEjemplo
mapAplica una función a cada elemento y junta los resultados en el mismo ordenIgual que la entrada: entran n, salen n[1, 2, 3].map(x => x * 2) da [2, 4, 6]
filterConserva solo los elementos para los que una prueba dice sí. No los modificaIgual o menor[1, 2, 3, 4].filter(x => x % 2 === 0) da [2, 4]
reduce (también llamada fold)Recorre los elementos llevando un acumulador, un resultado parcialCualquiera: un número, una lista, un objeto[1, 2, 3].reduce((acc, x) => acc + x, 0) da 6

En una cadena como xs.filter(...).map(...), cada paso recibe la colección que produjo el paso anterior. [1, 2, 3, 4] pasa por el filter de los pares y queda [2, 4], luego por el map por diez y queda [20, 40].

Cómo funciona reduce

reduce recibe un valor inicial para el acumulador y una función. Para cada elemento, la función recibe el acumulador y el elemento, y lo que devuelve se convierte en el siguiente acumulador. Seguir los acumuladores en papel es la forma más segura de calcular una reducción:

  inicio acc = 10
  elemento 1:  10 + 2 = 12
  elemento 2:  12 + 4 = 16
  elemento 3:  16 + 6 = 22   <- resultado
[1, 2, 3].reduce((acc, n) => acc + n * 2, 10)
  • Da siempre un valor inicial. Es la respuesta para la colección vacía: 0 para una suma, 1 para un producto, una lista vacía para unir listas. En JavaScript, [].reduce((a, b) => a + b) sin valor inicial lanza un TypeError.
  • El acumulador puede ser una estructura. Con un par como {count, sum} calculas varias cosas en una sola pasada por los datos, sin listas temporales.
  • Reduce es el general. map y filter son casos particulares en que el acumulador es una lista. Los seguimos usando porque le dicen al lector la intención: map significa que entran n y salen n.
  • El orden de los argumentos cambia según el lenguaje. En Elixir, Enum.reduce(list, initial, fn x, acc -> ... end) da primero el elemento y después el acumulador. Armar una lista con [x | acc] produce los elementos invertidos, porque agregar al principio es la operación barata.

Una trampa clásica

map llama a tu función con tres argumentos: el elemento, el índice y el array completo. parseInt(text, base) recibe la base como segundo argumento, así que ['1', '2', '3'].map(parseInt) da [1, NaN, NaN]. La forma segura es escribir la función completa: xs.map(s => parseInt(s, 10)), o usar Number.

Reducir en paralelo

Imagina dividir una lista larga en trozos, reducir cada trozo en un trabajador distinto y después combinar los resultados parciales en orden. La respuesta es igual a la de un solo trabajador únicamente si la función de combinación es asociativa: la forma de agrupar no importa. La suma, el producto, el máximo y la unión de listas son asociativos. La resta no: (10 - 3) - 2 = 5, pero 10 - (3 - 2) = 9. La función también debe ser pura, o el orden de ejecución se notaría en el resultado.

↑ Volver al índice

Closures: funciones con memoria

Cuando creas una función dentro de otra, y la interna usa variables de la externa, se lleva consigo el acceso a esas variables. Aunque la función externa ya haya terminado, las variables siguen vivas mientras exista la función interna. Ese par, el código más las variables que recuerda, es una closure.

function makeAdder(n: number): (x: number) => number {
	return (x) => x + n; // recuerda n
}
const add5 = makeAdder(5);
console.log(add5(10)); // 15
Una fábrica de funciones: cada llamada crea una función nueva con su propio n

Qué variables puede ver una función lo decide el , es decir, el lugar donde la función está escrita en el código fuente. Esa es la base de la aplicación parcial y del currying, que veremos más adelante.

Cada llamada tiene su propia memoria

Cada vez que se ejecuta la función externa, se crea un conjunto nuevo de variables. Dos closures nacidas de dos ejecuciones distintas tienen estados separados. Solo las closures nacidas en la misma ejecución comparten sus variables.

function counter(): () => number {
	let c = 0;
	return () => {
		c += 1;
		return c;
	};
}
const a = counter();
const b = counter();
a(); a();
console.log(b()); // 1: b tiene su propio c
Dos contadores, dos memorias independientes

Recuerda la variable, no una copia

En JavaScript y TypeScript una closure guarda una referencia a la variable. Si la variable cambia después, la función ve el valor nuevo. Así, un let rate capturado y reasignado es una entrada oculta de la función. Declararlo con const elimina la sorpresa.

La vieja trampa del bucle viene de aquí. Con var, la variable tiene alcance de función, así que todas las closures creadas en un bucle comparten una sola i, que vale 3 después del bucle. Con let, la variable tiene alcance de bloque, y el for crea un enlace nuevo en cada vuelta, así que cada closure recibe el suyo.

const fs: Array<() => number> = [];
for (var i = 0; i < 3; i++) fs.push(() => i);
console.log(fs.map((f) => f())); // [3, 3, 3]
// con `let i`, imprimiría [0, 1, 2]
Una variable compartida frente a una variable por vuelta

Útil, con dos avisos

  • Estado privado sin clases (el patrón módulo). Una función declara una variable local y devuelve un objeto cuyos métodos la usan. Lo que no se devuelve queda fuera de alcance. Pero recuerda que ese estado mutable privado vuelve impuros los métodos.
  • Elixir captura valores. Una función anónima toma los valores de las variables de afuera cuando se crea, y religar un nombre dentro de ella no cambia nada afuera. Así, Enum.each(list, fn _ -> count = count + 1 end) deja count en 0, y el compilador avisa de que el nuevo count nunca se usa. Para acumular, usa Enum.reduce, donde el acumulador se pasa y se devuelve.
  • Fugas de memoria. Las variables capturadas viven mientras viva la closure. Una closure de larga vida, como un manejador de eventos, un temporizador o una entrada de caché, que captura un array enorme mantiene todo el array en memoria. Captura solo lo que necesitas (por ejemplo, guarda la longitud en una constante) o quita el manejador.
↑ Volver al índice

Recursión y llamadas de cola

Recursión significa que una función resuelve un problema llamándose a sí misma sobre una versión más pequeña del mismo problema. Imagina un juego de muñecas rusas: para contar las muñecas, abres la más grande, cuentas 1 y luego cuentas las muñecas de adentro, que es el mismo trabajo con un conjunto más pequeño. Una función recursiva correcta siempre tiene dos partes.

  1. Caso base: la versión tan pequeña que respondes directo (la lista vacía tiene 0 elementos; la muñeca más pequeña no tiene nada adentro).
  2. Paso recursivo: una llamada sobre una entrada más pequeña, de modo que cada llamada se acerque al caso base.

Sin caso base, o sin acercarse a él, la función nunca se detiene. ¿Por qué? Cada llamada necesita un marco (frame) en la pila de llamadas, una pila de notas que guarda los parámetros, las variables locales y el lugar al que volver. El marco solo se tira cuando la llamada retorna. Una recursión sin fin apila marcos hasta llenar la pila; en JavaScript ves RangeError: Maximum call stack size exceeded.

def sum([]), do: 0
def sum([head | tail]), do: head + sum(tail)

# sum([4, 5, 6]) = 4 + (5 + (6 + 0))
Dos cláusulas: el caso base y el paso

Para una lista de n elementos esto hace n + 1 llamadas: una por elemento y una para la lista vacía. Las sumas esperan en la ida y se hacen en la vuelta. Como todavía hay trabajo por hacer después de que cada llamada retorna, esta función no tiene recursión de cola.

Llamadas de cola

Una llamada está en posición de cola cuando es lo último que hace la función: lo que devuelve la llamada es exactamente lo que devuelve la función, sin suma, unión ni otro trabajo pendiente. Una función cuyas llamadas recursivas están todas en posición de cola tiene recursión de cola. Para llegar ahí, usa un acumulador: haz antes el trabajo pendiente y lleva el resultado parcial como argumento.

def len(list, acc \\ 0)
def len([], acc), do: acc
def len([_ | tail], acc), do: len(tail, acc + 1)

def fact(0, acc), do: acc
def fact(n, acc), do: fact(n - 1, acc * n)
Versiones con recursión de cola. Llama a fact(5, 1)

Con la optimización de llamadas de cola, el entorno de ejecución reemplaza el marco actual por el nuevo en lugar de apilar otro, así que una recursión de un millón de pasos usa una cantidad constante de pila, como un bucle. La máquina virtual de Erlang, donde corre Elixir, siempre lo hace. Entre los motores de JavaScript solo JavaScriptCore lo hace, y solo en modo estricto, así que en TypeScript portable (Node.js usa V8) una recursión de un millón de niveles todavía desborda. Ahí, escribe un bucle o usa reduce.

AspectoRecursión comúnRecursión de cola
Trabajo después de la llamadaSí (1 + len(tail))Ninguno
Pila con optimizaciónCrece con nConstante, O(1)
Argumento auxiliarNo hace faltaUn acumulador

Elixir no tiene while ni for con un contador que cambia, porque eso exige una variable mutable. La repetición es una función que se llama a sí misma con los valores nuevos como argumentos. En el código de todos los días rara vez la escribes a mano: Enum.map, Enum.reduce y las comprensiones for están construidos sobre ella.

↑ Volver al índice

Composición y currying

La composición une dos funciones en una nueva: la salida de la primera se convierte en la entrada de la segunda. Es como conectar tubos: la abertura por donde sale el agua de uno debe encajar en la abertura por donde entra al siguiente. En matemáticas, compose(f, g)(x) significa f(g(x)): la función escrita al final se aplica primero.

A mucha gente le confunde esa lectura de derecha a izquierda, así que las bibliotecas también ofrecen pipe, que aplica las funciones en el orden en que las escribes: pipe(f, g)(x) es g(f(x)), como una línea de montaje. pipe(f, g, h)(x) es h(g(f(x))).

const pipe =
	(...fns: Array<(n: number) => number>) =>
	(x: number): number =>
		fns.reduce((acc, f) => f(acc), x);

pipe((n) => n + 1, (n) => n * 2, (n) => n - 3)(4); // 7
pipe es un reduce sobre una lista de funciones: (4+1)=5, 5*2=10, 10-3=7

Esto muestra que las funciones son valores comunes: pueden estar en un array y recorrerse como cualquier otro dato. Usar reduceRight en lugar de reduce da compose.

Reglas de la composición

  • Los tipos deben encajar. Para f: A => B y g: B => C, la función compuesta tiene tipo A => C. El tipo intermedio B desaparece de la firma final, y el verificador de tipos se queja si los tubos no encajan.
  • Asociativa. (f ∘ g) ∘ h es igual a f ∘ (g ∘ h). Así puedes quitar los paréntesis y sacar un tramo del medio de un pipeline a una función con nombre sin cambiar el resultado.
  • Identidad. La función identidad x => x no hace nada, y componer con ella no cambia nada. Es el elemento neutro, como el 0 en la suma.
  • No es conmutativa. El orden importa. Con inc = n + 1 y dbl = n * 2, compose(inc, dbl)(5) es 11 y compose(dbl, inc)(5) es 12.

Currying y aplicación parcial

El currying (nombre del lógico Haskell Curry) cambia la forma de una función: (a, b) => ... pasa a ser a => b => .... Una función curried recibe un argumento a la vez, y cada paso devuelve una función que recuerda los argumentos anteriores mediante una closure.

const multiply = (a: number) => (b: number): number => a * b;

[1, 2, 3].map(multiply(3)); // [3, 6, 9]

const add = (a: number, b: number) => a + b;
const add10 = add.bind(null, 10); // aplicación parcial
Dar solo el primer argumento crea una función lista para usar

La aplicación parcial es dar solo algunos de los argumentos y conservar una función que espera el resto. Las dos ideas son primas: el currying cambia la forma de la función, la aplicación parcial es el acto de fijar argumentos. Una función curried facilita la aplicación parcial, pero también se puede hacer sin currying, con bind o con una pequeña lambda como n => multiply(3, n).

El pipe de Elixir

Elixir tiene el operador incorporado |>. En tiempo de compilación reescribe a |> f(b) como f(a, b): el lado izquierdo pasa a ser el primer argumento. La biblioteca estándar fue diseñada para esto: el dato principal es siempre el primer parámetro. Un pipeline se lee entonces en el orden en que ocurren las cosas, sin llamadas anidadas.

  anidado (se lee de adentro hacia afuera):   c(b(a(x)))

  pipe (se lee de arriba hacia abajo):        x
                                              |> a()
                                              |> b()
                                              |> c()
↑ Volver al índice

Tipos de datos algebraicos y pattern matching

Los tipos de datos algebraicos (ADTs) se hacen con solo dos operaciones. Un tipo suma significa 'o': el valor es exactamente una entre varias variantes, cada una marcada con una etiqueta. Un tipo producto significa 'y': el valor tiene todos sus campos al mismo tiempo. Una Shape que es un círculo o un rectángulo, nunca los dos, es una suma. Un punto con x e y es un producto.

type Shape =
	| { kind: 'circle'; r: number }
	| { kind: 'rect'; w: number; h: number };

function area(s: Shape): number {
	switch (s.kind) {
		case 'circle': return Math.PI * s.r ** 2;
		case 'rect': return s.w * s.h;
		default: {
			const unreachable: never = s;
			return unreachable;
		}
	}
}
Una unión discriminada: el campo kind es el discriminante

En TypeScript el tipo suma es la unión discriminada: todas las variantes tienen un campo en común (aquí kind) con un literal distinto en cada una. Cuando pruebas ese campo con if o switch, el compilador restringe el tipo (narrowing) dentro de cada rama, y así s.r solo se permite en el caso del círculo. En Elixir la misma idea es una tupla marcada con un átomo, como {:circle, r}.

Exhaustividad: el compilador como ayudante

never es el tipo sin valores. Cuando todas las variantes están tratadas, lo que queda en el default es never, y la asignación compila. Si alguien agrega una variante triangle, lo que queda deja de ser never, y el compilador señala cada switch que hay que actualizar. Esta es la .

¿Por qué 'algebraico'? Contando valores

Porque podemos contar los valores posibles como en la aritmética. En un producto las posibilidades se multiplican; en una suma se suman.

¿Cuántos valores distintos tiene cada tipo?
boolean
2 valores
boolean | null (2 + 1)
3 valores
[boolean, boolean] (2 x 2)
4 valores
{ a: boolean; b: 'x'|'y'|'z' } | null ((2 x 3) + 1)
7 valores

Comparar ese número con el número de estados reales de tu problema es una forma objetiva de juzgar un modelo. Una petición está cargando, o terminó con éxito y datos, o falló con un mensaje: 3 estados. Si la modelas como un registro con loading, data y error, todos opcionales, permites muchas combinaciones sin sentido, como 'cargando y con error'. Listar exactamente los estados válidos como un tipo suma hace que los estados ilegales sean irrepresentables: el compilador rechaza el inválido, y no hace falta ninguna prueba para él.

Pattern matching en Elixir

  • `=` es el operador de coincidencia, no de asignación. El lado izquierdo es un patrón: los literales deben ser iguales y las variables se ligan a las partes correspondientes. {:ok, value} = {:error, :timeout} falla con un MatchError. Escribir {:ok, value} = ... es una forma de afirmar que salió bien.
  • Las cláusulas se prueban en orden. Una función con varias cláusulas hace coincidencia en sus argumentos, y se ejecuta la primera cláusula que sirve. Pon los casos específicos primero y el _ que atrapa todo al final. El compilador avisa de una cláusula que nunca puede alcanzarse, pero no reordena nada.
  • Las listas son un tipo suma recursivo. Una lista es [] o [head | tail], un primer elemento enlazado al resto. Después de [head | tail] = [1], head es 1 y tail es [].
  • El operador pin `^`. Una variable en un patrón normalmente coincide con cualquier cosa y se religa. ^x no liga nada y exige un valor igual al que x ya guarda.
↑ Volver al índice

Evaluación perezosa

Normalmente un programa es ansioso (eager): calcula una expresión en el momento en que la ve. En la evaluación perezosa (lazy), el programa guarda solo la receta y la cocina cuando alguien pide el resultado. Eso evita trabajo inútil y permite describir secuencias sin fin, siempre que tomes solo una parte de ellas.

Pereza en los tres lenguajes

  • Elixir: el módulo Enum es ansioso, cada función recorre toda la colección y devuelve una lista terminada. El módulo Stream es perezoso: Stream.map(list, f) devuelve un stream, que solo describe el trabajo. El trabajo empieza cuando el stream se entrega a una función de Enum, como Enum.to_list o Enum.take.
  • JavaScript y TypeScript: un generator (function*) es una secuencia perezosa. Llamarlo no ejecuta nada y devuelve un iterador. Cada next() ejecuta el cuerpo hasta el siguiente yield, entrega el valor y se pausa. Por eso while (true) no es un problema: solo se calcula lo que se pide.
  • Java: un pipeline de stream tiene una fuente, cero o más operaciones intermedias (filter, map, sorted) y una operación terminal (collect, sum, forEach, reduce). Las intermedias son perezosas. Solo la terminal inicia el recorrido. Sin operación terminal, se procesan cero elementos.
function* naturals(): Generator<number> {
	let n = 1;
	while (true) {
		yield n;
		n += 1;
	}
}
const it = naturals();
it.next();
it.next();
console.log(it.next().value); // 3
Una secuencia infinita que no cuesta nada hasta que la pides

Vertical frente a horizontal

Un pipeline ansioso trabaja en horizontal: termina el paso uno para todos los elementos y luego el paso dos para todos. Un pipeline perezoso trabaja en vertical: jala un elemento por todos los pasos y luego el siguiente. Cuando el consumidor se detiene pronto, el perezoso hace solo el trabajo necesario y no crea listas intermedias.

  ansioso (todo el map, luego todo el filter):
      m1 m2 m3 m4  f1 f2 f3 f4

  perezoso (un elemento a la vez, se detiene al primer acierto):
      m1 f1 | m2 f2 | m3 f3 -> lo encontró, se detiene
      (el elemento 4 nunca se toca)
[1,2,3,4] |> Stream.map(imprime m) |> Stream.filter(imprime f; x > 2) |> Enum.take(1)

De la misma manera, 1..1_000_000 |> Stream.map(...) |> Enum.take(2) ejecuta la función solo con los dos primeros números. El consumidor define la cantidad de trabajo: Enum.take(2) jala un elemento a la vez y detiene el recorrido después del segundo.

↑ Volver al índice

Tipos Option y Result, functors y monads por intuición

¿Cómo debe decir una función 'no encontré nada' o 'algo salió mal'? Usar null o lanzar una excepción esconde ese hecho de la firma. Los lenguajes funcionales convierten la ausencia y el fallo en valores comunes del tipo, y así el compilador te obliga a tratarlos.

Option

Option (también llamado Maybe u Optional) es un tipo suma con dos variantes: Some(value) y None. Una función que devuelve Option<User> avisa, en su firma, de que el usuario puede no existir. TypeScript lo trae incorporado: con strictNullChecks activado, una función que devuelve string | undefined hace que n.length sea un error de compilación hasta que compruebes n. Sin la opción, undefined cabe en cualquier tipo y el error solo aparece en tiempo de ejecución.

Result

Result es la misma idea para los errores: Ok(value) o Error(reason). El fallo forma parte del contrato. En Elixir la convención es {:ok, value} o {:error, reason}, y quien llama decide con case o with. Las funciones que terminan en !, como File.read!, lanzan una excepción, para cuando el fallo es inesperado.

Functors: contenedores con un buen map

map(f) en un Option aplica f al valor de adentro, si lo hay, y no hace nada si el Option está vacío. Tu función solo conoce el caso feliz; el Option se ocupa del otro. Puedes pensar en un Option como una lista con cero o un elemento. Los arrays, Option, Result y las promises tienen un map así, y los llamamos functors: contenedores cuyo map transforma el contenido y conserva la forma. 'Bien portado' significa dos leyes: aplicar map con la función identidad no cambia nada, y aplicar map con f y luego con g es igual a aplicar map con la composición de f y g.

Monads: encadenar pasos que pueden fallar

¿Y si la función que le das a map también devuelve un Option, como parse: string => Option<number>? Entonces map(parse) produce Option<Option<number>>, una caja dentro de otra. flatMap (también llamado chain, bind o andThen) aplica la función y aplana el resultado. Los arrays se comportan igual: map con una función que devuelve arrays da un array de arrays, y flatMap da un array plano.

Por intuición, una monad es un contenedor con flatMap. Con map la forma es fija: solo cambia el contenido. Con flatMap la función devuelve un contenedor nuevo, así que un paso puede cambiar el rumbo de los siguientes: un Option puede quedar vacío, un Result puede volverse un error, un array puede crecer o encogerse.

type Result<T> = { ok: true; value: T } | { ok: false; error: string };
const chain = <A, B>(r: Result<A>, f: (a: A) => Result<B>): Result<B> =>
	r.ok ? f(r.value) : r;
const half = (n: number): Result<number> =>
	n % 2 === 0 ? { ok: true, value: n / 2 } : { ok: false, error: 'odd ' + n };

// 12 -> 6 -> 3 -> half(3) falla
chain(chain(chain({ ok: true, value: 12 }, half), half), half);
// { ok: false, error: 'odd 3' }
chain es el flatMap de Result: el primer error viaja hasta el final

En Elixir, with hace este trabajo. Mientras cada patrón coincide, la ejecución sigue y las variables anteriores quedan disponibles. En el primer paso que no coincide, el with devuelve ese valor y se salta el resto. El then de una promise cumple los dos papeles: si tu función devuelve un valor simple, actúa como map, y si devuelve otra promise, actúa como flatMap, esperándola, así que Promise.resolve(1).then(n => Promise.resolve(n + 1)) es una Promise<number>, no una promise de promise.

HerramientaFunción que recibeResultado
mapA => B (una función común)Mismo contenedor, contenido nuevo
flatMapA => Container<B> (puede fallar o cambiar la forma)Un contenedor plano
↑ Volver al índice

Gestión de los efectos secundarios

Un programa sin efectos secundarios sería inútil: no leería un archivo, no respondería a una petición web ni mostraría nada en pantalla. Por eso quienes programan en estilo funcional no intentan eliminar los efectos. Los empujan al borde. La idea central se llama núcleo funcional, cáscara imperativa (functional core, imperative shell).

  +--------- cáscara imperativa (fina) --------+
  |  reloj, aleatorio, archivos, red, base     |
  |        +--------------------------+        |
  | entra->|   núcleo funcional       |->sale  |
  |        |   puro: decide, calcula, |        |
  |        |   valida, formatea       |        |
  |        +--------------------------+        |
  +---------------------------------------------+
Efectos en el borde, decisiones en el medio

El núcleo recibe datos simples y devuelve datos simples. La cáscara toca el mundo: reloj, generador aleatorio, archivos, red, base de datos y pantalla. Una prueba práctica para clasificar una operación: ¿daría el mismo resultado si se repitiera con los mismos valores? Si no, pertenece a la cáscara.

La ganancia: pruebas fáciles

Las pruebas con efectos son lentas, frágiles y requieren preparación: levantar una base de datos, falsear el reloj, limpiar archivos. Cuando la lógica está en funciones puras, cada prueba es una llamada con valores y una comparación. Obtienes una base ancha de pruebas rápidas del núcleo y solo unas pocas pruebas de integración para la cáscara, que es tan fina y sin decisiones que bastan pocas.

Dos técnicas

Estado aleatorio explícito. Un generador de números pseudoaleatorios es una función determinista de su estado interno. La impureza viene de esconder ese estado en una variable global. Si lo haces explícito, next(state) devuelve {value, newState} y es pura. Guardar la semilla (el estado inicial) basta para reproducir una ejecución que falló. Erlang y Elixir siguen este modelo en :rand.uniform_s y funciones parecidas.

Efectos como datos. El núcleo no envía el correo. Devuelve una descripción de lo que hay que hacer, y la cáscara la ejecuta:

type Effect = { type: 'sendEmail'; to: string };

function decide(state: State, cmd: Command): Effect[] {
	return [{ type: 'sendEmail', to: 'ana@example.test' }];
}
// cáscara: for (const e of decide(s, c)) perform(e);
La decisión es un dato simple, así que las pruebas pueden compararla, registrarla o reproducirla

La descripción se puede comparar, registrar en un log, filtrar o reproducir. Es la idea detrás de Elm, Redux y los 'comandos' de los sistemas dirigidos por eventos.

¿Dónde vive el estado que cambia en Elixir?

Los datos son inmutables, pero un proceso servidor, como un GenServer, sí guarda un estado que cambia con el tiempo. Un proceso es un bucle: espera un mensaje, calcula la respuesta y el siguiente estado, y se llama a sí mismo de nuevo con ese estado, mediante una llamada de cola. El 'estado mutable' es solo la secuencia de argumentos de esas llamadas. El efecto queda confinado al proceso, el acceso se serializa con su buzón de mensajes, y los callbacks que calculan el siguiente estado pueden ser puros y probarse directamente.

↑ Volver al índice

Estilo funcional en Elixir y TypeScript

Elixir fue hecho para el estilo funcional, así que los buenos hábitos vienen gratis. TypeScript te deja escribir de las dos maneras, así que hay que elegir bien. Aquí están los detalles que más sorprenden.

Métodos que copian y métodos que modifican en el lugar en JavaScript

Conoce a qué grupo pertenece cada método de array
Devuelve un array nuevo (seguro)Modifica el array en el lugar (cuidado)
map, filter, slice, concatpush, pop, splice
toSorted, toReversed (ES2023)sort, reverse

xs.sort(...) modifica xs y además lo devuelve. Así, const ys = xs.sort(...) te da un solo array con dos nombres, y xs === ys es verdadero. Usa xs.toSorted(...) u ordena una copia con [...xs].sort(cmp). Tipar los parámetros como readonly T[] hace que el compilador te impida mutar.

Otra trampa: sort sin función de comparación compara la forma de texto de los elementos. Así, [10, 9, 1].sort() da [1, 10, 9]. Una prueba con ejemplos de un dígito no lo notaría. Una propiedad como 'cada elemento es menor o igual que el siguiente', probada con entradas generadas, lo encuentra rápido.

Detalles de Elixir

  • Pipe. c(b(a(x))) se escribe x |> a() |> b() |> c(). El dato va primero y cada línea es un paso.
  • Operador de captura `&`. &(&1 * 2) crea una función anónima en la que &1 es el primer argumento. Con nombre y aridad, &String.upcase/1 convierte una función con nombre en un valor. Los dos sirven para pasar funciones a Enum.map y compañía: Enum.map([1, 2, 3], &(&1 * 2)) da [2, 4, 6].
  • `Enum` sobre mapas devuelve listas. Enum funciona con cualquier cosa que implemente el protocolo Enumerable (listas, mapas, rangos, streams), pero devuelve listas. Enum.map(%{a: 1}, fn {k, v} -> {k, v * 2} end) da [a: 2], una lista de pares. Para recibir un mapa de vuelta, usa Map.new(map, fn {k, v} -> {k, v * 2} end). Con más de una clave, no cuentes con el orden en que se recorre un mapa.
  • El `map` de TypeScript existe solo en arrays y siempre devuelve un array.
IdeaElixirTypeScript
Encadenar pasosx |> f() |> g()pipe(f, g)(x) (escribe el tuyo) o cadenas de métodos
Datos inmutablesSiemprereadonly, toSorted, copias con spread
RepeticiónRecursión, Enum, formap, reduce, bucles
Valor ausente o error{:ok, v} / {:error, e}, withT | undefined, un tipo Result
Tipo sumaTuplas marcadas con átomosUniones discriminadas
Secuencia perezosaStreamGenerators
↑ Volver al índice

Juntando todo

Todas las ideas se apoyan unas en otras. Los datos inmutables vuelven puras las funciones. Las funciones puras se pueden memoizar, probar sin mocks y ejecutar en paralelo. Como las funciones son valores, podemos pasarlas (orden superior), recordar variables (closures), fijar argumentos (currying) y conectarlas (composición). Sin contadores mutables, la repetición es recursión. Los tipos suma vuelven irrepresentables los estados ilegales, y Option y Result hacen visible el fallo. La pereza ahorra trabajo, y el núcleo funcional mantiene en la cáscara los efectos inevitables.

  datos inmutables --> funciones puras --> pruebas fáciles, paralelo, memoizar
          |                  |
          v                  v
   recursión, reduce    composición, currying, map/filter
          |                  |
          +----> ADTs, Option/Result <---+
                         |
                         v
          núcleo funcional + cáscara imperativa
Cómo se conectan las ideas
Referencia rápida
ConceptoRegla en una líneaTrampa típica
Función puraMismos argumentos, mismo resultado, sin efectosEntradas ocultas: el reloj, números aleatorios, globales
InmutabilidadCrea un valor nuevo, conserva el antiguoconst y el spread superficial no protegen datos anidados
reduceValor inicial + función de paso; el paso debe ser asociativo para repartir el trabajoOlvidar el valor inicial en una lista vacía
ClosureFunción + las variables donde nacióBucles con var; fugas por objetos grandes capturados
Llamada de colaÚltima acción de la función, así que nada queda pendienteSuponer que Node.js elimina las llamadas de cola
ComposiciónLa salida de una es la entrada de la siguiente; asociativa, no conmutativaConfundir el orden de compose y pipe
Suma / productoSuma = o, los valores se suman; producto = y, los valores se multiplicanCampos opcionales que permiten estados imposibles
PerezaCalcula solo lo que el consumidor jalaOlvidar la operación terminal
Result / flatMapEncadena pasos; el primer error se salta el restoUsar map donde hace falta flatMap
Núcleo y cáscaraDecide en código puro, actúa en el bordeMeter el reloj en el fondo de la lógica

Errores comunes

  • Creer que const vuelve inmutable un objeto. Solo impide reasignar el nombre.
  • Creer que una función determinista es automáticamente pura. Imprimir es un efecto.
  • Usar Enum.each con una variable para acumular en Elixir. Usa Enum.reduce.
  • Usar list ++ [x] en un bucle. Copia la lista entera cada vez.
  • Esperar que una función con recursión de cola sea segura en Node.js para un millón de pasos.
  • Olvidar que xs.sort() modifica xs.
Mira el video: Learning Functional Programming with JavaScript, Anjana Vakil (JSUnconf)↑ Volver al índice