Capítulo 13: Los monoides unifican todo
Combinación salvaje
En este capítulo examinaremos monoides mediante el estudio de semigrupos. Los monoides son la esencia pegajosa de la abstracción matemática. Capturan un concepto que abarca múltiples disciplinas, unificándolas figurativa y literalmente. Representan la fuerza fundamental que conecta todo cálculo posible: el oxígeno en nuestra base de código, el suelo donde se ejecuta, el entrelazamiento cuántico codificado.
Los monoides tratan sobre combinación. ¿Pero qué es combinación? Puede significar acumulación, concatenación, multiplicación, elección, composición, ordenación ¡e incluso evaluación! Veremos múltiples ejemplos, aunque solo rozaremos la superficie de esta poderosa abstracción. Sus instancias son abundantes y aplicaciones inmensas. Este capítulo busca construir intuición para que puedas crear tus propios monoides.
Abstracción de la suma
La suma posee propiedades interesantes que analizaremos mediante nuestra perspectiva abstracta.
Es una operación binaria: toma dos valores de un conjunto y devuelve otro valor del mismo conjunto.
// a binary operation
1 + 1 = 2Esta clausura bajo la suma permite encadenar operaciones, pues el resultado siempre pertenece al mismo dominio numérico.
// we can run this on any amount of numbers
1 + 7 + 5 + 4 + ...Además (¡vaya juego de palabras calculado!), la asociatividad nos permite agrupar operaciones libremente. Esta propiedad posibilita cómputo paralelo al dividir y distribuir tareas.
// associativity
(1 + 2) + 3 = 6
1 + (2 + 3) = 6No confundir con conmutatividad. Aunque aplicable a la suma, actualmente no nos interesa esta propiedad más específica.
¿Qué propiedades pertenecen realmente a nuestra abstracción? ¿Cuáles son específicas de la suma? Este análisis llevó a los pioneros matemáticos a definir jerarquías algebraicas.
La abstracción histórica derivó en el concepto de grupo, pero para nuestros fines prácticos nos enfocaremos en Semigrupo: tipos con método concat que implementa un operador binario asociativo.
Implementemos Sum para la suma:
const Sum = x => ({
x,
concat: other => Sum(x + other.x)
})Nótese que concat opera con otro Sum y siempre retorna Sum.
Usamos factoría de objetos para evitar el ritual del new (pues Sum no es pointed). Ejemplo de uso:
Sum(1).concat(Sum(3)) // Sum(4)
Sum(4).concat(Sum(37)) // Sum(41)Programamos contra interfaces respaldadas por siglos de teoría matemática. ¡Documentación gratuita!
Sum no es functor: al almacenar solo números, map carece de sentido aquí, pues no permite transformación de tipos.
La utilidad radica en intercambiar instancias para lograr distintos comportamientos:
const Product = x => ({ x, concat: other => Product(x * other.x) })
const Min = x => ({ x, concat: other => Min(x < other.x ? x : other.x) })
const Max = x => ({ x, concat: other => Max(x > other.x ? x : other.x) })Este patrón aplica más allá de números. Otros ejemplos:
const Any = x => ({ x, concat: other => Any(x || other.x) })
const All = x => ({ x, concat: other => All(x && other.x) })
Any(false).concat(Any(true)) // Any(true)
Any(false).concat(Any(false)) // Any(false)
All(false).concat(All(true)) // All(false)
All(true).concat(All(true)) // All(true)
[1,2].concat([3,4]) // [1,2,3,4]
"miracle grow".concat("n") // miracle grown"
Map({day: 'night'}).concat(Map({white: 'nikes'})) // Map({day: 'night', white: 'nikes'})El patrón emerge claramente: fusionamos estructuras, combinamos lógicas, construimos cadenas. Casi cualquier tarea puede moldearse bajo esta interfaz combinativa.
Map envuelve Object para añadir funcionalidad sin alterar el núcleo.
Todos mis functores favoritos son semigrupos.
Tipos como Identity (ex-Container) implementan tanto functor como semigrupo:
Identity.prototype.concat = function(other) {
return new Identity(this.__value.concat(other.__value))
}
Identity.of(Sum(4)).concat(Identity.of(Sum(1))) // Identity(Sum(5))
Identity.of(4).concat(Identity.of(1)) // TypeError: this.__value.concat is not a functionEs semigrupo solo si su __value lo es. Como un ala delta accidentada, hereda propiedades de su carga.
Otros tipos muestran comportamientos similares:
// combine with error handling
Right(Sum(2)).concat(Right(Sum(3))) // Right(Sum(5))
Right(Sum(2)).concat(Left('some error')) // Left('some error')
// combine async
Task.of([1,2]).concat(Task.of([3,4])) // Task([1,2,3,4])Esta utilidad se potencia al anidar semigrupos:
// formValues :: Selector -> IO (Map String String)
// validate :: Map String String -> Either Error (Map String String)
formValues('#signup').map(validate).concat(formValues('#terms').map(validate)) // IO(Right(Map({username: 'andre3000', accepted: true})))
formValues('#signup').map(validate).concat(formValues('#terms').map(validate)) // IO(Left('one must accept our totalitarian agreement'))
serverA.get('/friends').concat(serverB.get('/friends')) // Task([friend1, friend2])
// loadSetting :: String -> Task Error (Maybe (Map String Boolean))
loadSetting('email').concat(loadSetting('general')) // Task(Maybe(Map({backgroundColor: true, autoSave: false})))Ejemplo 1: IO con Either y Map para validación de formularios. Ejemplo 2: Combinación asíncrona con Task y Array. Ejemplo 3: Carga y fusión de configuraciones con Task, Maybe, Map.
Semigrupos capturan la lógica de combinación más eficientemente que chain o ap.
Cualquier composición de semigrupos forma un semigrupo: si combinamos las partes, combinamos el todo.
const Analytics = (clicks, path, idleTime) => ({
clicks,
path,
idleTime,
concat: other =>
Analytics(clicks.concat(other.clicks), path.concat(other.path), idleTime.concat(other.idleTime))
})
Analytics(Sum(2), ['/home', '/about'], Right(Max(2000))).concat(Analytics(Sum(1), ['/contact'], Right(Max(1000))))
// Analytics(Sum(3), ['/home', '/about', '/contact'], Right(Max(2000)))Usando Map, combinamos múltiples niveles:
Map({clicks: Sum(2), path: ['/home', '/about'], idleTime: Right(Max(2000))}).concat(Map({clicks: Sum(1), path: ['/contact'], idleTime: Right(Max(1000))}))
// Map({clicks: Sum(3), path: ['/home', '/about', '/contact'], idleTime: Right(Max(2000))})Podemos agregar tantos componentes como necesitemos. Es cuestión de escalar el sistema.
El comportamiento por defecto combina contenidos, pero existen alternativas. Por ejemplo, Stream:
const submitStream = Stream.fromEvent('click', $('#submit'))
const enterStream = filter(x => x.key === 'Enter', Stream.fromEvent('keydown', $('#myForm')))
submitStream.concat(enterStream).map(submitForm) // Stream()Flujos pueden combinarse fusionando eventos o priorizando contenidos semigrupolares. Casos alternativos (elecciones temporales, manejo de errores) requieren interfaces como Alternative.
Monoides por defecto
Nuestra abstracción inicial carecía de análogo al cero (ausente en mencionarse).
El cero actúa como elemento neutro o vacío en sumas. En términos abstractos, corresponde al valor vacío que preserva elementos en operaciones binarias:
// identity
1 + 0 = 1
0 + 1 = 1Definimos este concepto como empty en la nueva interfaz Monoid (nombre tan genérico como 'startup unicornio'): extiende semigrupos con elemento identidad.
Array.empty = () => []
String.empty = () => ""
Sum.empty = () => Sum(0)
Product.empty = () => Product(1)
Min.empty = () => Min(Infinity)
Max.empty = () => Max(-Infinity)
All.empty = () => All(true)
Any.empty = () => Any(false)¿Por qué es útil el vacío? Como preguntar por qué existe el cero. Es nuestra base silenciosa.
Cuando todo falla, ahí está el cero: tolerancia a errores, nuevo inicio, aniquilador y salvador.
En código, proveen valores por defecto:
const settings = (prefix="", overrides=[], total=0) => ...
const settings = (prefix=String.empty(), overrides=Array.empty(), total=Sum.empty()) => ...También retornan valores útiles en vacío:
sum([]) // 0Son el acumulador inicial perfecto...
Colapso controlado
concat y empty encajan perfectamente en reduce. Pero omitir empty genera riesgo:
// concat :: Semigroup s => s -> s -> s
const concat = x => y => x.concat(y)
[Sum(1), Sum(2)].reduce(concat) // Sum(3)
[].reduce(concat) // TypeError: Reduce of empty array with no initial valueJavaScript permite código inestable pero falla con arrays vacíos, requiriendo valores predeterminados tipo-safe. fold resuelve esto:
Definimos fold con valor empty obligatorio:
// fold :: Monoid m => m -> [m] -> m
const fold = reduce(concat)El empty inicial permite procesamiento estable, incluso con datos vacíos.
fold(Sum.empty(), [Sum(1), Sum(2)]) // Sum(3)
fold(Sum.empty(), []) // Sum(0)
fold(Any.empty(), [Any(false), Any(true)]) // Any(true)
fold(Any.empty(), []) // Any(false)
fold(Either.of(Max.empty()), [Right(Max(3)), Right(Max(21)), Right(Max(11))]) // Right(Max(21))
fold(Either.of(Max.empty()), [Right(Max(3)), Left('error retrieving value'), Right(Max(11))]) // Left('error retrieving value')
fold(IO.of([]), ['.link', 'a'].map($)) // IO([<a>, <button class="link"/>, <a>])En JavaScript debemos pasar empty manualmente. Tipados fuertes lo infieren automáticamente.
Casi monoides
Algunos semigrupos como First no pueden definir empty:
const First = x => ({ x, concat: other => First(x) })
Map({id: First(123), isPaid: Any(true), points: Sum(13)}).concat(Map({id: First(2242), isPaid: Any(false), points: Sum(1)}))
// Map({id: First(123), isPaid: Any(true), points: Sum(14)})Prioriza el primer ID en fusión de cuentas, sin valor vacío válido. Aun así mantiene utilidad.
Teoría unificadora
¿Teoría de grupos o categorías?
Las operaciones binarias son ubicuas en álgebra abstracta. Solo en teoría de categorías, con identidad (vacío), modelamos monoides como categorías de un objeto:
Los monoides forman categorías monomorficas: concat como composición, empty como identidad.
Composición como monoide
Endomorfismos (funciones a -> a) forman el monoide Endo bajo composición:
const Endo = run => ({
run,
concat: other =>
Endo(compose(run, other.run))
})
Endo.empty = () => Endo(identity)
// in action
// thingDownFlipAndReverse :: Endo [String] -> [String]
const thingDownFlipAndReverse = fold(Endo(() => []), [Endo(reverse), Endo(sort), Endo(append('thing down')])
thingDownFlipAndReverse.run(['let me work it', 'is it worth it?'])
// ['thing down', 'let me work it', 'is it worth it?']Concatenación vía compose mantiene coherencia tipográfica.
Mónadas como monoides
join opera como concatenación monoidal sobre endofunctores, formando categorías monádicas. La formulación exacta requiere ajustes técnicos, pero esta es la esencia.
Aplicativos como monoides
Los functores aplicativos (functores monoidales laxos) permiten derivar ap desde estructuras monoidales:
// concat :: f a -> f b -> f [a, b]
// empty :: () -> f ()
// ap :: Functor f => f (a -> b) -> f a -> f b
const ap = compose(map(([f, x]) => f(x)), concat)En resumen
Todo se interconecta mediante monoides, desde arquitecturas completas hasta mínimos datos. Modela combinaciones directas primero, luego expande a casos más creativos. ¡Sorpréndete con su versatilidad!