Capítulo 9 · Protocolos
Hasta aquí viste cómo agrupar datos (records, tipos suma) y cómo definir funciones que operan sobre esos datos. Lo que falta es responder una pregunta concreta: ¿cómo le agregas operaciones a un tipo desde fuera de su declaración?
Por ejemplo: Punto es un record con dos campos. Quieres que
se imprima como (3, 4) cuando aparece en una interpolación.
¿Modificas el tipo? ¿Pasas una función al println? ¿Defines
una variante de println solo para Punto? Ninguna escala.
La respuesta de kaikai son los protocolos: un contrato con nombre y un puñado de operaciones, que cualquier tipo puede satisfacer. Conceptualmente, los protocolos hacen lo que las interfaces de Go, los traits de Rust, los protocols de Clojure y Elixir, y la parte fácil de las typeclasses de Haskell. Pero kaikai elige un punto preciso del espacio de diseño: single-dispatch explícito, sin propagación de constraints, sin tipos de orden superior. Eso le saca complejidad al sistema de tipos a cambio de algunas cosas que no se pueden expresar, y este capítulo cubre las dos caras.
9.1 Por qué hay protocolos
Los protocolos resuelven tres dolores concretos, y vale la pena verlos uno por uno.
Imprimir tus tipos sin escribir cada vez. Cuando declaras
un record, querer imprimirlo en logs, en respuestas, en
errores, no debería costarte una función usuario_a_string
por cada tipo. Con Show implementado una vez, la
interpolación "#{usuario}" lo usa automáticamente.
Igualdad estructural sin esfuerzo. Cualquier tipo nuevo
necesita “esto es igual a aquello” en algún momento. Sin
protocolos, escribes eq_punto, eq_cuenta, eq_factura,
y vives el resto del proyecto recordando cuál nombre usaste
en cuál archivo. Con Eq implementado, la operación se llama
eq para todos.
Comparación, hashing, serialización. El mismo argumento del párrafo anterior, multiplicado por las tres operaciones estándar que casi todo tipo necesita en algún momento.
Sin protocolos, cada uno de los tres se resuelve con
convenciones de nombres caso por caso, o con match enormes
que enumeran cada tipo posible. Con un solo mecanismo, los
tres y los que vengan después se resuelven en una línea por
tipo.
9.2 Declarar un protocol y impl
La sintaxis es directa. Un protocolo declara un nombre y una o más operaciones:
protocol Show {
show(x: Self) : String
}
Self es un nombre reservado que se refiere al tipo que más
adelante implemente el protocolo. Cada operación menciona
Self al menos una vez: por eso se llama single-dispatch,
la operación se decide a partir de un único tipo, el de
Self.
Para implementarlo, escribes un impl ... for ...:
type Punto = { x: Int, y: Int }
impl Show for Punto {
fn show(p: Punto) : String =
"(" ++ int_to_string(p.x) ++ ", " ++ int_to_string(p.y) ++ ")"
}
Y a partir de ese momento, show(p) llama a tu
implementación cuando p : Punto:
fn main() {
let p = Punto { x: 3, y: 4 }
println(show(p)) # imprime "(3, 4)"
println("p está en #{p}") # interpolación: usa Show
}
Tres detalles que conviene fijar:
-
El cuerpo del
impllista las funciones del protocolo, una por una, con la sintaxis estándar defn. Cadafnen el bloque tiene que coincidir con la firma declarada en elprotocol, sustituyendoSelfpor el tipo concreto. -
Una sola implementación por par
(protocolo, tipo). Si hay dosimpl Show for Puntoen la misma compilación, el compilador rechaza con “duplicate impl”. No hay sobreescritura ni resolución contextual. -
La regla orphan: solo puedes implementar un protocolo
Ppara un tipoTsiPse declara en tu módulo oTse declara en tu módulo. Esto evita que dos paquetes externos definan implementaciones conflictivas para tipos que ambos importan. Es una limitación práctica, no del sistema de tipos.
9.3 Los cinco protocolos del stdlib
kaikai trae cinco protocolos en stdlib/protocols.kai que
vas a usar todo el tiempo. La tabla siguiente los lista con
sus operaciones:
| Protocolo | Operaciones | Para qué |
|---|---|---|
Show | show(x: Self) : String | Convertir a string para imprimir |
Eq | eq(a: Self, b: Self) : Bool | Igualdad |
Ord | cmp(a: Self, b: Self) : Int, min, max | Orden total |
Hash | hash(x: Self) : Int | Para tablas hash y conjuntos |
Serialize | to_string, from_string | Conversión texto ↔ valor |
Los tipos primitivos (Int, Real, Bool, String, Char)
ya tienen implementaciones para los cinco protocolos. Cuando
declaras un tipo nuevo, eliges qué protocolos vale la pena
implementar para él.
Ord merece una nota: tiene tres operaciones, no una.
cmp(a, b) : Int devuelve un entero negativo si a < b, cero
si son iguales, positivo si a > b. Las otras dos,
min(a, b) y max(a, b), devuelven uno de los dos
argumentos según el orden. Las tres se implementan juntas en
el mismo bloque:
impl Ord for Cuenta {
fn cmp(a: Cuenta, b: Cuenta) : Int =
if a.saldo < b.saldo { 0 - 1 }
else if a.saldo > b.saldo { 1 }
else { 0 }
fn min(a: Cuenta, b: Cuenta) : Cuenta =
if a.saldo < b.saldo { a } else { b }
fn max(a: Cuenta, b: Cuenta) : Cuenta =
if a.saldo > b.saldo { a } else { b }
}
Si Ord te da pereza implementar tres operaciones, espera a
§9.4: en muchos casos, el compilador las deriva por ti.
9.4 #[derive(...)] y cuándo usarlo
Para records donde la implementación obvia “delega en cada
campo” basta, kaikai te ofrece un atajo: la directiva
#[derive(...)] antes de la declaración de tipo.
#[derive(Show)]
type Persona = {
nombre: String,
edad: Int,
}
#[derive(Show, Eq)]
type Punto = {
x: Int,
y: Int,
}
#[derive(Show)] le dice al compilador “genérame un Show para
este record, recorriendo los campos y delegando en el Show
de cada tipo de campo”. El resultado para Persona:
$ kai run ejemplo.kai
Persona { nombre: Ada, edad: 30 }
El formato canónico, TipoNombre { campo: valor, ... },
es lo que el #[derive(Show)] produce, y es razonable para
debugging y logs. Si quieres otro formato (por ejemplo, el
clásico (3, 4) para un punto), escribes el impl a mano,
como en §9.1.
#derive funciona para los cinco protocolos del stdlib,
siempre que cada campo del record también implemente el
protocolo que estás derivando. Si tu record tiene un campo
cuyo tipo no tiene Show, #[derive(Show)] falla en compilación
con un mensaje que apunta al campo problemático.
La regla práctica:
- Empieza con
#derive: es la forma más rápida y casi siempre correcta para records. - Cambia a
implmanual cuando la implementación derivada no te sirve: formato distinto, igualdad solo por algunos campos, comparación según un campo específico (no el orden natural).
Ya viste #[derive(Show)] sobre Punto en el tour (§1.6); aquí
mostramos también la implementación manual y cuándo conviene
una sobre la otra.
9.5 Protocolos propios
Los cinco del stdlib son los más comunes, pero nada te impide declarar los tuyos. Es exactamente la misma sintaxis que usa el stdlib, pero en tu código:
protocol Drawable {
dibujar(x: Self) : String
}
type Circulo = { radio: Int }
type Cuadrado = { lado: Int }
type Triangulo = { base: Int, altura: Int }
impl Drawable for Circulo {
fn dibujar(c: Circulo) : String =
"Círculo de radio " ++ int_to_string(c.radio)
}
impl Drawable for Cuadrado {
fn dibujar(c: Cuadrado) : String =
"Cuadrado de lado " ++ int_to_string(c.lado)
}
impl Drawable for Triangulo {
fn dibujar(t: Triangulo) : String =
"Triángulo " ++ int_to_string(t.base) ++ "x" ++ int_to_string(t.altura)
}
A partir de ese momento, tres tipos distintos comparten la
operación dibujar. La función polimórfica dibujar se
resuelve estáticamente: el compilador sabe en cada llamada
qué impl usar a partir del tipo del argumento.
Drawable es un ejemplo de juguete; los casos reales que
verás en código kaikai incluyen Encodable para distintos
formatos, Loggable para que el sistema de logging sepa
representar tu tipo, Validable para reglas de validación,
etc. Cualquier “comportamiento que comparten varios tipos”
es candidato.
9.6 Por qué no hay typeclasses al estilo Haskell
Los protocolos de kaikai pueden parecerse a las typeclasses de Haskell (y se inspiran en ellas), pero son deliberadamente más simples. Tres cosas que kaikai no hace y que Haskell sí:
Sin constraints en firmas de funciones. Esta es la
diferencia más visible. En Haskell, una función que ordena
una lista declara que el tipo del elemento tiene que tener
Ord:
sort :: Ord a => [a] -> [a]
El Ord a => es la constraint. Cuando llamas a sort xs, el compilador busca por su cuenta el Ord para el tipo
de xs y se lo “inyecta” a la función sin que tú escribas
nada. La constraint viaja escondida, y se propaga: si
sort llama a otra función que también pide Ord, Haskell
encadena la búsqueda solo.
kaikai te deja escribir una cota en la firma (lo verás en §9.7), pero esa cota es una declaración de intención, no un resolver que viaja escondido. La diferencia se ve cuando ordenas una lista. En kaikai la función puede recibir el comparador como un argumento explícito:
fn sort_by[T](xs: [T], cmp: (T, T) -> Int) : [T] = ...
Y el call site nombra el comparador. Si Transaccion
implementa Ord, su cmp está disponible como una función
ordinaria, y la pasas:
list.sort_by(transacciones, cmp) # cmp viene de impl Ord for Transaccion
Cuesta poco escribirla, pero la distinción conceptual pesa: lo
que en Haskell viaja implícito, en kaikai lo escribes tú.
La función sort_by no “exige” que T tenga Ord: solo
exige que alguien le pase una función de comparación. Que
esa función venga de un impl Ord for T es decisión del que
llama, no de la firma de sort_by.
Sin tipos de orden superior (HKT). protocol Functor[F[_]]
no parsea. Los parámetros de tipo son siempre de primer
orden. Esto descarta una familia de abstracciones (Functor,
Monad, Applicative, etc.) que en Haskell son centrales y
que en kaikai se resuelven con efectos algebraicos (cap. 12)
y combinadores explícitos.
Sin propagación de constraints. Una función polimórfica
no “lleva” el Ord consigo a las funciones que llama. La
cota de §9.7 documenta qué pide esa función; no se propaga
sola a las que ella invoque.
¿Qué se gana con estas restricciones? Tres cosas:
- Compilación rápida. La inferencia de tipos sigue siendo Hindley-Milner extendido con efectos, sin el costo del resolver de constraints de Haskell.
- Errores claros. Si una función necesita
Ordy no lo recibe, el error apunta al sitio donde te falta pasar el comparador. No hay cadenas de “no instance forOrd (Maybe a)because ofOrd a”. - El call site dice qué hace. Cuando ves
list.sort_by(xs, cmp), sabes que se ordena concmp. Cuando vessort xsen Haskell, tienes que mirar la firma desortpara saber quéOrdse está usando.
¿Qué se pierde? Algunas abstracciones que en Haskell son elegantes, particularmente todo lo que vive sobre Functor y amigos. Ese trade-off es deliberado: las abstracciones que kaikai prioriza viven en el sistema de efectos (cap. 12), no en el sistema de tipos.
9.7 Cotas de protocolo en funciones genéricas
Hasta aquí los protocolos aparecían en dos lugares: el impl
que los implementa y el call site que llama la operación.
Falta un tercero. Una función genérica sobre un T cualquiera
puede exigir que ese T implemente un protocolo, y
escribirlo en la firma:
fn mostrar_dos[T: Show](a: T, b: T) : String =
"#{show(a)} y #{show(b)}"
La cota [T: Show] se lee “para cualquier T que implemente
Show”. Dentro del cuerpo, las operaciones del protocolo
están disponibles: show(a) despacha al impl Show del tipo
concreto que llegue en cada llamada.
fn main() : Unit / Stdout = {
Stdout.print(mostrar_dos(1, 2)) # 1 y 2
Stdout.print(mostrar_dos("x", "y")) # x y y
}
Si vienes de otro lenguaje con genéricos, esto te suena. En
Rust es where T: Trait; en Java, un genérico acotado
<T extends Comparable>. La idea es la misma (un parámetro
de tipo que no es “cualquier cosa” sino “cualquier cosa que
cumpla este contrato”), solo que en kaikai el contrato es un
protocolo.
Las cotas se apilan con + cuando el cuerpo usa más de un
protocolo, y cada parámetro de tipo lleva la suya:
fn etiquetar[T: Show + Eq](a: T, b: T) : String =
if eq(a, b) { "iguales: #{show(a)}" }
else { "#{show(a)} y #{show(b)}" }
fn par[T: Show, U: Show](a: T, b: U) : String =
"#{show(a)}, #{show(b)}"
Funciona con cualquier protocolo, del stdlib o propio. Una
función que toma el mayor de dos valores pide Ord; una que
renderiza pide tu Dibujable:
fn mayor[T: Ord](a: T, b: T) : T = max(a, b)
fn render[T: Dibujable](x: T) : String = "[" ++ dibujar(x) ++ "]"
Ahora bien, lee con cuidado, porque aquí es donde kaikai se
separa de Haskell aunque la sintaxis se parezca. La cota es
una declaración de intención, no la constraint con
propagación de §9.6. No es que el compilador resuelva un
diccionario y lo inyecte: lo que ocurre es que cada llamada
monomorfiza la función al tipo concreto, y ahí show, eq o
cmp se resuelven al impl que corresponde, el mismo
despacho estático de siempre. La cota documenta el contrato
en la firma y hace que el error, cuando un tipo no implementa
el protocolo, sea claro: no impl of 'Ord' for type 'X'.
Esto es la evolución natural de un genérico sobre un T
cualquiera: de “esta función sirve para cualquier tipo” a
“esta función sirve para cualquier tipo que sepa hacer show”.
La cota no te da el resolver implícito de Haskell ni la
propagación; te da el contrato escrito donde se lee, que casi
siempre es lo que querías.
9.8 Operadores: +, ==, < como protocolos
Una nota práctica: los operadores estándar son protocolos.
== es Eq.eq, < es comparación basada en Ord.cmp, +
es Add.add. Cuando declaras impl Eq for Cuenta,
automáticamente c1 == c2 (con c1, c2 : Cuenta) llama a tu
implementación.
#[derive(Eq)]
type Punto = { x: Int, y: Int }
fn main() {
let p = Punto { x: 3, y: 4 }
let q = Punto { x: 3, y: 4 }
if p == q { println("iguales") } # usa Eq.eq derivado
}
Esto unifica la sintaxis: + para Int, Real, vectores,
matrices, monedas, todos los que tengan impl Add for ....
== para todo lo que tenga Eq. La uniformidad no es
casualidad; es el primer beneficio de tener un mecanismo
único para “operaciones dispatched por tipo”.
Los operadores que kaikai trata como protocolos:
| Operador | Protocolo | Op |
|---|---|---|
==, != | Eq | eq |
<, <=, >, >= | Ord | cmp |
+, -, *, / | Add, Sub, Mul, Div | add, sub, mul, div |
++ | Concat | concat |
Los tipos primitivos los implementan todos. Los tipos tuyos los implementan cuando los declaras. Y cuando algún operador no tiene sentido para un tipo, simplemente no lo implementas, y el compilador rechaza esa expresión.
Ejercicios
9.1. Define type Distancia = { metros: Int } y dale un
impl Show para que show(d) produzca "42 m". Luego
prueba println("la distancia es #{d}") y comprueba que la
interpolación toma tu impl Show automáticamente.
9.2. Define type Carta = { palo: String, valor: Int } y
dale impl Ord que ordene por valor. Verifica con tres
cartas que cmp(c1, c2) devuelve los números esperados.
9.3. Toma cualquier record que hayas escrito en capítulos
anteriores y agrégale #[derive(Show, Eq)] arriba de la
declaración. Verifica que show y eq funcionan sin que
hayas escrito nada más.
9.4. Declara un protocolo propio Validable con una
operación validar(x: Self) : Result[String, Self] que
devuelva Ok(x) si el valor es válido o Err("razón") si
no. Implementa Validable para type Edad = { años: Int }
de manera que rechace edades negativas o mayores a 130.
9.5. Lee el código del stdlib en
stdlib/protocols.kai. ¿Cuántas operaciones tiene Hash?
¿Cómo se usaría para implementar una tabla de hash de
Cuenta? Diseña la firma de la función que harías para
“buscar una cuenta por id” en una tabla de hash hipotética:
¿qué tendría que estar implementado en Cuenta para que
funcionara?