Capítulo 9 · Protocolos
Ya sabes agrupar datos en records y tipos suma, y escribir funciones que operan sobre ellos. Falta 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. Elegí la palabra “protocolo” y no “trait” ni “interfaz” porque describe mejor lo que pasa: dos partes que acuerdan cómo hablarse. 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 desde 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. En este capítulo conoces las dos caras.
9.1 Por qué hay protocolos
Los protocolos resuelven tres dolores concretos.
Imprimir tus tipos sin escribir cada vez. Imprimir un
record en logs, respuestas o 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, multiplicado por tres operaciones que casi todo tipo necesita tarde o temprano.
Sin protocolos, cada caso se resuelve con convenciones de
nombres o con match enormes que enumeran cada tipo posible.
Con un solo mecanismo, estos tres y los que vengan después se
resuelven en una línea por tipo.
9.2 Declarar un protocol y impl
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 =
"(#{p.x}, #{p.y})"
}
Desde ahí, 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
}
Cuatro 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 tipoTsi tu módulo declaraP, declaraT, o declara el tipo de alguno de los argumentos deP. 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. Y no hay excepción para los builtin:impl Show for Inten tu módulo no compila. -
El nombre se puede calificar. Cuando dos de tus imports exportan un protocolo con el mismo nombre, dices cuál:
impl dibujo.Render for Caja, y lo mismo dentro de un derive,#[derive(protocols.Show)]. Es la regla general del §8.2: el calificador es un desempate, y el nombre pelado está bien mientras nada colisione.
Con los builtin, la regla orphan aparece antes de lo que esperas: tarde o temprano quieres que un Int o un String se comporten distinto en tu dominio. El compilador te dice exactamente dónde estás parado:
error: orphan rule: `impl Show for Int` is in module `a`, but `Show` is declared in
`protocols` and `Int` is builtin; an impl must live in the module of its protocol, its
type, or a protocol argument's type
La salida es envolver el builtin en un tipo tuyo y darle el impl a ese:
type Puntaje = Puntaje(Int)
impl Show for Puntaje {
fn show(self: Puntaje) : String = match self { Puntaje(n) -> "#{n} puntos" }
}
Ahora el impl vive en el módulo de su propio tipo y la regla se cumple sola. De paso ganas lo que un Int desnudo no te daba: Puntaje y Edad dejan de ser el mismo tipo, y el compilador deja de aceptar que los sumes.
Un impl escrito a mano, y la interpolación que lo empieza a
usar sin que se lo pidas:
# ejemplos/cap09/01_protocolo_basico.kai
type Punto = { x: Int, y: Int }
# Implementación manual: damos al record un Show que produzca
# el formato cartesiano "(x, y)".
impl Show for Punto {
fn show(p: Punto) : String =
"(#{p.x}, #{p.y})"
}
fn main() {
let origen = Punto { x: 0, y: 0 }
let p = Punto { x: 3, y: 4 }
println(show(origen))
println(show(p))
# Como Show está implementado, la interpolación lo usa
# automáticamente:
println("p está en #{p}")
}
$ kai run ejemplos/cap09/01_protocolo_basico.kai
(0, 0)
(3, 4)
p está en (3, 4)
9.3 Los cinco protocolos del stdlib
kaikai trae en stdlib/protocols.kai cinco protocolos que
vas a usar todo el tiempo:
| 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 implementan los cinco. Para un tipo nuevo, eliges cuáles
vale la pena implementar, con una excepción: la igualdad no se
pide. == sobre un tipo de datos compara estructuralmente,
campo a campo y constructor a constructor, sin que declares
nada. impl Eq es para cuando quieres otra igualdad, no
para tener una. Lo único sin igualdad posible es un valor que
guarda una función: comparar dos funciones no significa nada, y
el compilador lo dice así.
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 { -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 te da pereza implementar tres operaciones, espera a §9.4: en muchos casos el compilador las deriva por ti.
Eq por id y Ord por saldo sobre el mismo tipo, que es
exactamente el caso donde derivar no sirve:
# ejemplos/cap09/02_eq_ord.kai
type Cuenta = {
id: Int,
saldo: Int
}
impl Eq for Cuenta {
fn eq(a: Cuenta, b: Cuenta) : Bool = a.id == b.id
}
# Ordenamos por saldo. Las tres ops del protocolo Ord viven
# juntas en la misma `impl`.
impl Ord for Cuenta {
fn cmp(a: Cuenta, b: Cuenta) : Int =
if a.saldo < b.saldo {
-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 }
}
fn main() {
let c1 = Cuenta { id: 1, saldo: 100 }
let c2 = Cuenta { id: 1, saldo: 999 } # mismo id, distinto saldo
let c3 = Cuenta { id: 2, saldo: 50 }
let mismo_id_12 = eq(c1, c2)
let mismo_id_13 = eq(c1, c3)
println("c1 == c2: #{mismo_id_12}") # true: el id manda
println("c1 == c3: #{mismo_id_13}") # false
let cmp12 = cmp(c1, c2)
let cmp13 = cmp(c1, c3)
println("cmp(c1, c2): #{cmp12}") # negativo: 100 < 999
println("cmp(c1, c3): #{cmp13}") # positivo: 100 > 50
let menor = min(c1, c3)
println("menor saldo: #{menor.saldo}") # 50
}
$ kai run ejemplos/cap09/02_eq_ord.kai
c1 == c2: true
c1 == c3: false
cmp(c1, c2): -1
cmp(c1, c3): 1
menor saldo: 50
9.4 #[derive(...)] y cuándo usarlo
Cuando basta la implementación obvia, la que delega en cada
campo, 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, Ord)]
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 }
Ese formato canónico, TipoNombre { campo: valor, ... }, sirve
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.2.
#[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 usaste #[derive(Show)] sobre Punto en el tour (§1.7);
ahora sabes también cuándo conviene el impl manual.
Dos tipos derivados, uno con Show y otro con Show y
Ord:
# ejemplos/cap09/03_derive.kai
#[derive(Show)]
type Persona = {
nombre: String,
edad: Int
}
#[derive(Show, Ord)]
type Punto = {
x: Int,
y: Int
}
fn main() {
let ada = Persona { nombre: "Ada", edad: 30 }
println(show(ada))
# Show automático en interpolación.
println("registro: #{ada}")
let p = Punto { x: 3, y: 4 }
let q = Punto { x: 3, y: 4 }
let r = Punto { x: 5, y: 5 }
# La igualdad no se deriva: `==` compara campo a campo.
println("p == q: #{p == q}")
println("p == r: #{p == r}")
# El orden sí, y queda lexicográfico por campos.
println("p < r: #{p < r}")
println("mayor: #{max(p, r)}")
}
$ kai run ejemplos/cap09/03_derive.kai
Persona { nombre: Ada, edad: 30 }
registro: Persona { nombre: Ada, edad: 30 }
p == q: true
p == r: false
p < r: true
mayor: Punto { x: 5, y: 5 }
Derives que no son protocolos: Json
No todo lo que deriva un record es uno de los cinco protocolos.
Hay dos derives más, de otra naturaleza: Layout, que fija el
orden de bytes de un record para serialización binaria (lo
encuentras en el cap. 19), y Json, que lleva el record al
formato JSON.
#[derive(Json)] genera dos cosas: to_json, que convierte tu
record a un valor JSON, y un shim <tipo>_of_json, que lo
reconstruye desde uno. Es la frontera de datos con el mundo:
APIs, archivos de configuración, mensajes.
El código generado usa el modelo de JSON que vive en
encoding.json_bind, así que el archivo que deriva tiene que
importarlo.
# Listado 9.6: ejemplos/cap09/06_json.kai
import encoding.json.{json_decode, json_encode}
import encoding.json_bind
#[derive(Json)]
type Nota = { titulo: String, prioridad: Int, etiqueta: Option[String] }
fn main() : Unit / Stdout {
let n = Nota { titulo: "comprar pan", prioridad: 1, etiqueta: None }
println(json_encode(to_json(n)))
match json_decode("{\"titulo\":\"llamar\",\"prioridad\":2}") {
None -> println("json inválido")
Some(v) -> match nota_of_json(v, "") {
Ok(m) -> println("#{m.titulo} (p#{m.prioridad})")
Err(e) -> println(json_error_show(e))
}
}
}
$ kai run ejemplos/cap09/06_json.kai
{"titulo":"comprar pan","prioridad":1,"etiqueta":null}
llamar (p2)
Las reglas del mapeo son las esperables, y el compilador las
aplica sin preguntar. Los nombres
de campo van al JSON tal cual, sin conversión de mayúsculas. Un
campo Option[T] acepta un null explícito y una clave ausente
por igual (ambos decodifican a None); cualquier otro campo es
obligatorio. Las claves que el record no declara se ignoran. Y
si algo falla al decodificar, el error trae la ruta JSON al nodo
culpable (address.boxes[2].zip: expected String, got Number), no
un “parse error” pelado.
Cuando el mundo externo no usa tus nombres, hay tres ajustes por campo, cada uno en su propio atributo:
#[derive(Json)]
type Config = {
#[json(rename = "user_name")] usuario: String,
#[json(default = 3)] reintentos: Int,
#[json(skip)] #[json(default = false)] cacheado: Bool,
}
rename cambia el nombre en el JSON; default = <expr> da un
valor cuando la clave falta (y vuelve opcional ese campo); skip
saca el campo del JSON por completo, y como al decodificar no hay
nada que leer, exige un default o que el campo sea Option. Un
campo que necesita dos ajustes (saltado y con default) se escribe con
dos atributos, como arriba.
Una limitación que conviene saber: #[derive(Json)]
funciona sobre records, no sobre tipos suma. Codificar una
unión etiquetada es una convención (¿el tag va en una clave
"type", en un envoltorio, adyacente?) y el derive no elige por
ti. Para eso, to_json/of_json a mano.
9.5 Protocolos propios
Nada te impide declarar tus propios protocolos, con la misma sintaxis que usa el stdlib:
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 #{c.radio}"
}
impl Drawable for Cuadrado {
fn dibujar(c: Cuadrado) : String =
"Cuadrado de lado #{c.lado}"
}
impl Drawable for Triangulo {
fn dibujar(t: Triangulo) : String =
"Triángulo #{t.base}x#{t.altura}"
}
Ahora tres tipos distintos comparten la operación dibujar,
que 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.
Un protocolo propio con tres implementaciones, una por tipo:
# ejemplos/cap09/04_protocolo_propio.kai
protocol Drawable {
dibujar(x: Self) : String
}
# Tres tipos distintos, tres implementaciones.
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 #{c.radio}"
}
impl Drawable for Cuadrado {
fn dibujar(c: Cuadrado) : String =
"Cuadrado de lado #{c.lado}"
}
impl Drawable for Triangulo {
fn dibujar(t: Triangulo) : String =
"Triángulo #{t.base}x#{t.altura}"
}
fn main() {
let c = Circulo { radio: 5 }
let s = Cuadrado { lado: 3 }
let t = Triangulo { base: 4, altura: 6 }
println(dibujar(c))
println(dibujar(s))
println(dibujar(t))
}
$ kai run ejemplos/cap09/04_protocolo_propio.kai
Círculo de radio 5
Cuadrado de lado 3
Triángulo 4x6
9.6 Por qué no hay typeclasses al estilo Haskell
Los protocolos de kaikai se inspiran en las typeclasses de Haskell, pero son deliberadamente más simples. Hay tres cosas que Haskell hace y kaikai no:
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ú.
sort_by no “exige” que T tenga Ord, solo 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.
Estas restricciones te dan 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.
A cambio se pierden algunas abstracciones que en Haskell son elegantes, particularmente todo lo que vive sobre Functor y amigos. Ese trade-off lo tomé con los ojos abiertos: las abstracciones que quise priorizar viven en el sistema de efectos (cap. 12) y no en el de tipos.
9.7 Cotas de protocolo en funciones genéricas
Hasta aquí los protocolos aparecían en el impl que los
implementa y en el call site que llama la operación. Falta un
tercer lugar: 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) ++ "]"
Lee con cuidado, porque aquí 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: el
compilador no resuelve un diccionario ni lo inyecta. 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'.
Es el paso natural desde 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”. Sin el
resolver implícito de Haskell ni la propagación, la cota deja
el contrato escrito donde se lee, que casi siempre es lo que
querías.
Las tres formas de cota en un programa: una del stdlib, una propia, y una que exige dos protocolos a la vez.
# ejemplos/cap09/05_cotas.kai
protocol Dibujable {
dibujar(x: Self) : String
}
type Circulo = { radio: Int }
impl Dibujable for Circulo {
fn dibujar(c: Circulo) : String = "círculo de radio #{show(c.radio)}"
}
fn mayor[T: Ord](a: T, b: T) : T = max(a, b)
fn render[T: Dibujable](x: T) : String = "[" ++ dibujar(x) ++ "]"
fn etiquetar[T: Show + Eq](a: T, b: T) : String =
if eq(a, b) {
"iguales: #{show(a)}"
} else {
"#{show(a)} y #{show(b)}"
}
fn main() : Unit / Stdout {
Stdout.print(show(mayor(3, 7)))
Stdout.print(show(mayor("a", "b")))
Stdout.print(render(Circulo { radio: 5 }))
Stdout.print(etiquetar(1, 1))
Stdout.print(etiquetar(2, 9))
}
$ kai run ejemplos/cap09/05_cotas.kai
7
b
[círculo de radio 5]
iguales: 1
2 y 9
9.8 Operadores: +, ==, < como protocolos
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.
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") } # Eq.eq estructural
}
Esto unifica la sintaxis: + para Int, Real, vectores,
matrices, monedas, todos los que tengan impl Add for ....
== para todo lo que tenga Eq. Esa uniformidad 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 tuyos, cuando
escribes el impl. Si un operador no tiene sentido para un
tipo, no lo implementas y el compilador rechaza la 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, Ord)] arriba de la
declaración. Verifica que show y cmp funcionan sin que
hayas escrito nada más, y que == ya funcionaba antes del
derive.
9.4. Declara un protocolo propio Validable con una
operación validar(x: Self) : Result[Self, String] 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?