El libro

Capítulo 19 · Kinds: un catálogo de álgebras

Este capítulo paga una promesa que el §2.6 dejó abierta: en kaikai el tipo no es la única etiqueta, y las familias de etiquetas — los kinds — tienen un mecanismo común que alguien te debía mostrar completo. Lo has venido tocando por partes. Cuando en el capítulo 10 escribiste fn promedio[u: Measure](...), esa anotación u: Measure no era un parámetro de tipo común: u no podía ser Int ni String ni ningún tipo, solo podía ser una unidad. Estabas cuantificando sobre otra familia de cosas, con otras reglas.

Y no hay uno solo. Los tipos que unifica el inferidor, las filas de efectos que se componen en cada firma, las unidades que multiplican y cancelan, las monedas del stdlib, las regiones de memoria del runtime: cada una es un kind distinto, con su propia álgebra. kaikai los declara a todos en un mismo lugar y con el mismo mecanismo, y este capítulo recorre ese catálogo completo.

Es el capítulo más abstracto del libro, y por eso viene al final: no necesitas nada de esto para escribir kaikai productivo. Pero si llegaste hasta aquí, apuesto a que la pregunta ya te apareció sola: ¿qué tienen en común las unidades del capítulo 10, los efectos del 12 y la memoria del 13? La respuesta es corta y creo que elegante. Vamos a verla.

19.1 Qué es un kind

Un tipo clasifica valores: 42 habita Int, "hola" habita String. Un kind clasifica un peldaño más arriba: sus habitantes no son valores sino símbolos que participan en los tipos. Int habita el kind Type. La unidad m habita el kind Measure. El efecto Stdout habita el kind Effect.

La consecuencia práctica la viste en el capítulo 10: un parámetro [u: Measure] solo acepta unidades, y el compilador razona sobre u con las reglas de las unidades — u^2 tiene sentido, u y kg unifican solo si son la misma. Compara con un parámetro [t] corriente, que acepta tipos y se razona con las reglas de los tipos. La anotación de kind le dice al compilador qué álgebra usar cuando le toque decidir si dos cosas son iguales.

Eso es todo el concepto: un kind es una familia de habitantes, más el álgebra con que el compilador los unifica. A esa álgebra kaikai la llama theory.

19.2 Las theories: álgebras de unificación

Cuando el compilador ve Real<m * s> y Real<s * m>, ¿son el mismo tipo? Para responder necesita saber que el producto de unidades conmuta. Cuando ve dos filas de efectos Stdout + Fail y Fail + Stdout, necesita saber que el orden de una fila no importa. Cuando ve dos regiones r1 y r2, necesita saber que jamás son iguales salvo que sean literalmente la misma.

Cada una de esas preguntas se responde con una theory: un conjunto de reglas de ecuación que el compilador aplica al unificar habitantes de un kind. Tres cosas la definen:

  • Es decidible. El algoritmo de unificación termina siempre, y rápido. No hay solver SMT ni búsqueda: son álgebras elegidas precisamente porque su unificación es un cálculo directo.
  • Se borra en runtime. Igual que las unidades del capítulo 10: la theory decide en compilación qué programas son legales, y desaparece del binario.
  • El catálogo es cerrado. Puedes declarar habitantes nuevos (unit parsec) y hasta kinds nuevos (§19.5), pero no puedes declarar una theory nueva. Si lo intentas, el compilador responde unknown theory. Esta es una decisión de diseño, no una limitación transitoria; el §19.8 la defiende.

19.3 El catálogo completo

El catálogo vive en stdlib/core/kinds.kai, y es corto. Este es un extracto (el archivo real trae un comentario por entrada):

# fragmento de stdlib/core/kinds.kai
theory HindleyMilner = builtin
theory EffectRow     = builtin
theory AbelianGroup  = { assoc, commut, inverse, identity }
theory Module        = { assoc, commut, inverse, identity }
theory Structural    = builtin

kind Type     : HindleyMilner with type
kind Effect   : EffectRow     with effect
kind Measure  : AbelianGroup  with unit
kind Currency : Module        with currency
kind Region   : Structural    with region

Cada kind nombra su theory y, tras el with, su palabra introductora: la declaración que acuña habitantes. type acuña habitantes de Type. effect acuña habitantes de Effect. unit acuña habitantes de Measure. Llevas todo el libro acuñando habitantes de kinds; solo faltaba el organigrama:

KindTheoryIntroductorHabitantesQué decide la theory
TypeHindleyMilnertypeInt, String, los tuyosigualdad e inferencia de tipos
EffectEffectRoweffectStdout, Fail, los tuyosfilas: orden irrelevante, duplicados colapsan
MeasureAbelianGroupunitm, s, USD si quieresproducto, cociente y potencia de unidades
CurrencyModulecurrencyUSD, EUR, … (stdlib/money.kai)suma y escala; sin producto
RegionStructuralregionuno fresco por bloque regionidentidad: cada arena es solo ella misma

Tres theories dicen builtin: su motor es el compilador mismo. HindleyMilner es el inferidor de tipos que te acompaña desde el capítulo 3; EffectRow es la unificación de filas del capítulo 12; Structural es igualdad de símbolo, que el núcleo ya sabía hacer. Las otras dos se describen por propiedades algebraicas (asociativa, conmutativa, con inverso, con identidad), y la diferencia entre ambas — que a primera vista se ven idénticas — es sobre qué operación rigen esas propiedades. En AbelianGroup, los habitantes mismos forman un grupo bajo el producto: m * s, m^2, 1/s son habitantes nuevos derivados. En Module, la estructura es solo aditiva: las cantidades de un habitante se suman y se escalan por un número, pero los habitantes no se multiplican entre sí. USD^2 no es un habitante de Currency; no existe. Esa asimetría es deliberada y el §19.7 la explota.

Nota lo que no está en la tabla: nada tuyo. El catálogo completo del lenguaje cabe en una pantalla. Cinco kinds, cinco theories, y todo el libro que llevas leído está construido sobre ellos.

19.4 La misma forma, tres kinds

Lo que hace que esto sea un sistema y no cinco features apiladas es que la cuantificación funciona igual sobre cualquier kind. Compara estas tres firmas:

fn area_de[u: Measure](ancho: Real<u>, alto: Real<u>) : Real<u^2>
fn insertar[r: Region](t: Arbol<r>, k: Int) : Arbol<r>
pub fn convert[a: Currency, b: Currency](m: Money[a], rate: dec.Decimal) : Money[b]

La primera la escribiste en el capítulo 10. La segunda la vas a ver en el §19.6. La tercera viene tal cual de stdlib/money.kai. Las tres dicen lo mismo: “para cualquier habitante de este kind”. Y en las tres, el compilador aplica la theory del kind al verificar el cuerpo: en area_de puede formar u^2 porque AbelianGroup tiene producto; en insertar exige que el árbol que entra y el que sale vivan en la misma región porque Structural no unifica regiones distintas; en convert permite que a y b difieran porque son dos parámetros — la puerta explícita entre monedas del capítulo 10, ahora con su mecanismo a la vista.

El listado 19.1 es la primera firma, completa y corriendo:

# Listado 19.1 — ejemplos/cap19/01_generica_sobre_unidades.kai
unit m
unit s

fn area_de[u: Measure](ancho: Real<u>, alto: Real<u>) : Real<u^2> =
  ancho * alto

fn main() : Unit / Stdout = {
  let a1 = area_de(3.0<m>, 4.0<m>)      # Real<m^2>
  let a2 = area_de(3.0<s>, 4.0<s>)      # Real<s^2>
  println("#{a1}")
  println("#{a2}")
}
$ kai run ejemplos/cap19/01_generica_sobre_unidades.kai
12 m^2
12 s^2

Fíjate en la salida: el Show de un Real con unidad imprime la unidad, potencia incluida. “Segundos al cuadrado” es una unidad rara en la física de este mundo, pero el álgebra no opina de física: opina de consistencia.

19.5 Kinds propios

Measure no es especial. La declaración kind está disponible para ti, con las dos theories no-builtin como opciones. Un caso donde esto paga: separar sistemas de unidades que jamás deben mezclarse, ni siquiera con una conversión accidental.

# Listado 19.2 — ejemplos/cap19/02_kind_propio.kai
kind Metrica  : AbelianGroup with metrica
kind Imperial : AbelianGroup with imperial

metrica m
metrica s
imperial ft

fn velocidad(d: Real<m>, t: Real<s>) : Real<m/s> = d / t

fn main() : Unit / Stdout = {
  let v = velocidad(100.0<m>, 9.58<s>)
  println("#{v}")

  # Esto no compila: m vive en Metrica, ft vive en Imperial.
  #   fn mala(a: Real<m>, b: Real<ft>) : Real<m> = a + b
}
$ kai run ejemplos/cap19/02_kind_propio.kai
10.4384 m/s

Cada kind ... with palabra acuña también su propia palabra introductora: aquí metrica y imperial declaran habitantes igual que unit lo hace para Measure. Dos habitantes de kinds distintos nunca unifican, aunque ambos midan longitud. Dentro del capítulo 10, m + ft era un error de unidades; aquí es un error más profundo: ni siquiera hay un álgebra común donde plantear la pregunta. Es la clase de bug del Mars Climate Orbiter — libras-fuerza leídas como newtons — cerrada no con una convención de nombres sino con una frontera de kinds.

Los kinds aditivos también se pueden declarar (kind Puntos : Module with puntos): sirven para cantidades que se suman y escalan pero donde “puntos al cuadrado” sería un sinsentido — puntos de un juego, millas de viajero, créditos académicos. Las dos theories builtin restantes no aceptan kinds de usuario: si escribes kind Zona : Structural, el compilador te dirá que una theory builtin no puede clasificar un kind tuyo. Las regiones, los tipos y los efectos tienen exactamente un kind cada uno, y es del lenguaje.

19.6 Region: memoria como habitante

El capítulo 13 te debe una. Cuando dijimos que Perceus inserta increments y decrements en los puntos exactos donde los valores mueren, quedó una pregunta abierta: ¿y si un cálculo construye un millón de valores efímeros solo para plegarlos a un número? Cada celda paga su alta y su baja en el contador, y todo ese contabilismo es trabajo que un humano mirando el programa sabría innecesario: nada de esto sobrevive al cálculo.

El bloque region es la forma de decírselo al compilador:

# Listado 19.3 — ejemplos/cap19/03_region_scratch.kai
fn suma(xs: [Int]) : Int = match xs {
  []        -> 0
  [h, ...t] -> h + suma(t)
}

fn main() : Unit / Stdout = {
  let total = region {
    let a = [1, 2, 3, 4, 5]        # construida en la arena
    let b = [10, 20, 30]           # construida en la arena
    suma(a) + suma(b)              # resultado escalar
  }                                # la arena se libera aquí, de un golpe
  println("#{total}")
}
$ kai run ejemplos/cap19/03_region_scratch.kai
75

Todo constructor escrito léxicamente dentro del bloque asigna en una arena: un bloque de memoria que crece por bump — un puntero que avanza, sin contador alguno — y se libera entero al cerrar la llave. Las dos listas de arriba no pagan ni un increment ni un decrement. El escalar que sale cruza la frontera gratis.

¿Y si necesitas que la estructura cruce funciones antes de plegarse? Ahí aparece el kind. La forma region { r -> ... } liga un nombre para la región, y ese r es un habitante fresco de Region que los tipos pueden cargar:

# Listado 19.4 — ejemplos/cap19/04_arbol_en_arena.kai
type Arbol = Hoja | Nodo(Arbol, Int, Arbol)

fn insertar[r: Region](t: Arbol<r>, k: Int) : Arbol<r> =
  match t {
    Hoja -> Nodo(Hoja, k, Hoja)
    Nodo(izq, v, der) ->
      if k < v { Nodo(insertar(izq, k), v, der) }
      else if k > v { Nodo(izq, v, insertar(der, k)) }
      else { Nodo(izq, v, der) }
  }

fn sumar[r: Region](t: Arbol<r>) : Int =
  match t {
    Hoja -> 0
    Nodo(izq, v, der) -> sumar(izq) + v + sumar(der)
  }

fn construir[r: Region](t: Arbol<r>, n: Int) : Arbol<r> =
  if n == 0 { t } else { construir(insertar(t, n), n - 1) }

fn main() : Unit / Stdout = {
  let total = region { r ->
    let arbol = construir(Hoja, 100)
    sumar(arbol)
  }                                # 100 nodos liberados de un golpe
  println("#{total}")
}
$ kai run ejemplos/cap19/04_arbol_en_arena.kai
5050

Mira las firmas con los ojos del §19.4: insertar es genérica sobre la región exactamente como area_de es genérica sobre la unidad. Arbol<r> marca en el tipo que estos nodos viven en la arena r; el tipo Arbol se declara una sola vez, sin saber nada de regiones, y cualquier función puede volverse region-polimórfica anotando [r: Region]. Cien nodos, cero operaciones de contador, una liberación.

La theory Structural es la más simple del catálogo y aquí está el porqué: cada bloque region { r -> } acuña un habitante fresco, distinto de todos los demás. Dos regiones no unifican jamás. Eso es lo que impide que un Arbol<r1> se cuele en una arena r2 que se libera en otro momento: el error es de tipos, en compilación, con el mismo mecanismo que rechaza m + ft.

Dos letras chicas, las dos importantes:

  • Lo que escapa se copia. El valor del bloque cruza la frontera: si es un escalar, gratis; si es una estructura, se copia en profundidad a la memoria normal con RC antes de liberar la arena. Una región cuyo resultado es la estructura entera es más lenta que no usar región. El nicho es scratch que se pliega a poco.
  • La arena es léxica en la forma sin nombre. En region { ... } sin binder, solo los constructores escritos dentro del bloque asignan en la arena; un helper llamado desde el bloque asigna en la memoria normal. Para cruzar funciones, usa el binder y firmas [r: Region], como en el listado 19.4.

region es opt-in: el compilador nunca lo infiere por ti. El default del lenguaje sigue siendo el del capítulo 13 — Perceus, exacto y sin pausas — y region es la palanca que tiras cuando el perfil te muestra un cálculo que construye y descarta a paladas.

19.7 Dinero: el álgebra que falta a propósito

El capítulo 10 modeló monedas con unit USD, y funciona. Pero deja abierta una puerta curiosa: en Measure, los habitantes forman grupo bajo producto, así que USD^2 y USD*EUR son unidades perfectamente formables. Ningún programa contable sensato las produce a propósito — pero un bug sí puede, y el sistema de tipos las aceptaría con la solemnidad con que acepta m/s^2.

Para dinero, el stdlib usa el kind Currency, cuya theory Module simplemente no tiene producto de habitantes. El tipo Money[c: Currency] monta la moneda sobre Decimal — aritmética exacta de punto fijo, no punto flotante, que para dinero es lo único defendible:

# Listado 19.5 — ejemplos/cap19/05_dinero.kai
import money
import decimal as dec
import decimal_proto

fn main() : Unit / Stdout = {
  let a: Money[USD] = 10.50<USD>
  let b: Money[USD] = 4.50<USD>
  let total = a + b                       # misma moneda: Money[USD]

  let k: dec.Decimal = 3
  let triple = total * k                  # escalar: sigue en USD

  let tasa: dec.Decimal = 0.92
  let en_euros: Money[EUR] = money.convert(total, tasa)

  println("total  = #{money.to_string(total)} USD")
  println("triple = #{money.to_string(triple)} USD")
  println("euros  = #{money.to_string(en_euros)} EUR")
}
$ kai run ejemplos/cap19/05_dinero.kai
total  = 15.0 USD
triple = 45.0 USD
euros  = 13.800 EUR

Sumar la misma moneda, sí. Escalar por un número, sí — esa es la “multiplicación externa” de un módulo, en el sentido algebraico de la palabra. Convertir, solo por la puerta explícita de money.convert, con la moneda destino fijada por la anotación. ¿Y multiplicar dos dineros?

# Listado 19.6 — ejemplos/cap19/06_usd_por_eur.kai (no compila)
let u: Money[USD] = 10.00<USD>
let e: Money[EUR] = 5.00<EUR>
let sinsentido = u * e          # error: `EUR USD` no existe
$ kai build ejemplos/cap19/06_usd_por_eur.kai
error: operator `*` cannot combine `Currency` quantities: the
result unit `EUR USD` does not exist
  = note: `Currency` is a Module kind: a quantity is either
    scalar or carries exactly one habitant with exponent 1 —
    habitant products and powers are not expressible

Vale la pena leer ese error dos veces. No dice “operación prohibida por una regla especial para dinero”. Dice que el tipo resultado no se puede formar: en el álgebra de Currency no existe ningún habitante que sea “euros por dólares”. Es la diferencia entre un guardia en la puerta y un edificio sin esa puerta. El mismo mecanismo que le da al físico su kg·m/s^2 le niega al contador su USD*EUR — no hay dos sistemas de chequeo, hay dos theories en un catálogo.

Y esta es la respuesta a la pregunta que quedó flotando en el capítulo 10: ¿cuándo unit USD y cuándo Money[USD]? Si estás aprendiendo la mecánica de unidades o modelando magnitudes que sí multiplican (precio por energía: USD/kWh por kWh da USD), el kind Measure es tu herramienta. Si estás escribiendo el sistema contable, Currency te quita de encima una familia de tipos sin sentido y te regala Decimal de paso.

19.8 Theory cerrada, modelos abiertos

Cierro con la pregunta de diseño, porque sé que el lector que viene de Haskell la trae cargada: ¿por qué un catálogo cerrado? ¿Por qué no typeclasses, o higher-kinded types, o theories definibles por el usuario, y que cada quien arme su álgebra?

Porque cada entrada del catálogo compra su decidibilidad por separado. La unificación de AbelianGroup es aritmética de exponentes; la de Module, un chequeo de habitante y exponente 1; la de Structural, igualdad de símbolo. Cada una es un algoritmo pequeño, rápido, sin casos patológicos. Una theory arbitraria definida por el usuario sería un problema de unificación arbitrario — y la historia de los sistemas de tipos está llena de álgebras inocentes con unificación indecidible. El precio se pagaría donde kaikai no está dispuesto a pagarlo: en el tiempo de compilación y en la calidad de los errores, las dos cosas que este libro lleva dieciocho capítulos defendiendo.

La apuesta de kaikai es theory cerrada, modelos abiertos: el lenguaje trae las álgebras y garantiza que unifican rápido; tú traes los habitantes (unit parsec, currency CLP) y los kinds que esas álgebras admiten (kind Puntos : Module). Es la misma silueta de los protocolos del capítulo 9 — single-dispatch cerrado sobre un mecanismo simple, en vez de typeclasses abiertas sobre uno complejo — aplicada un piso más arriba.

Lo que el catálogo te da hoy ya lo viste: dimensiones para el físico, monedas para el contador, arenas para el que persigue microsegundos, y un solo modelo mental para los tres. Y el catálogo está diseñado para crecer: una entrada nueva es una theory con unificación decidible más una palabra introductora, y el resto del lenguaje — la cuantificación, la sintaxis <...>, el borrado en runtime — la recibe gratis. Qué entradas se ganan el lugar es una conversación de diseño, no de mecanismo. El mecanismo, como acabas de ver, cabe en una pantalla.

Ejercicios

19.1. Declara kind Millas : Module with millas y un habitante millas aereas. Escribe una función que sume millas de viajero y otra que las escale por un multiplicador de categoría. Verifica que Int<aereas> * Int<aereas> no compila. ¿Qué dice el error, y en qué se parece al del listado 19.6?

19.2. Toma la cartera multi-moneda del §10.7 y reescríbela con Money[c: Currency] en vez de Real<USD>. ¿Qué cambia en las firmas? ¿Qué error nuevo detecta el compilador que la versión con Measure dejaba pasar?

19.3. En el listado 19.4, cambia sumar(arbol) por arbol como valor del bloque region. Sigue compilando — pero mide con kai bench la versión original contra la nueva construyendo árboles de 10.000 nodos. Explica la diferencia con la letra chica del §19.6.

19.4. El listado 19.3 usa region { ... } sin binder. Extrae la construcción de las dos listas a una función auxiliar fn armar() : ([Int], [Int]) llamada desde dentro del bloque. ¿El programa sigue compilando? ¿Las listas siguen viviendo en la arena? Justifica con la letra chica del §19.6.

19.5. El capítulo 12 mostró que las filas de efectos ignoran el orden: Stdout + Fail unifica con Fail + Stdout. Escribe esa regla como propiedades algebraicas al estilo del catálogo ({ assoc, commut, ... }). ¿Qué propiedad no debe tener la theory de filas para que Fail + Fail colapse a Fail? ¿Por qué crees que EffectRow es builtin en vez de declararse por propiedades?

← Volver al índice