Capítulo 5 · Sum types, uniones y `match`
Si vienes de un lenguaje imperativo, en este capítulo cambias
la forma de modelar datos. Los tipos suma y el match que los
acompaña reemplazan a la mitad de las jerarquías de clases, los
enum con flags, los instanceof y los visitor patterns que
probablemente has venido escribiendo. Una vez que los manejas,
no quieres soltarlos.
El capítulo va de menos a más. Empiezas por los sum types
básicos, pasas por la recursión en tipos, recorres match en
todas sus formas, llegas a las uniones de tipos existentes (una
idea poco común en lenguajes mainstream) y cierras con un
evaluador de expresiones aritméticas con errores tipados.
5.1 Tipos suma con |
Un tipo suma declara que un valor puede ser uno de varios constructores distintos. La sintaxis es directa:
type Color = Rojo | Verde | Azul
Color es un tipo. Rojo, Verde, Azul son sus tres
constructores. Un valor de tipo Color es exactamente uno
de los tres. No hay un cuarto valor escondido ni un null
acechando ni un “color desconocido”: el tipo enumera sus
habitantes y ahí se acaba la historia.
Si vienes de un lenguaje con enum, esto se le parece, pero
con dos diferencias: los constructores pueden cargar datos,
y el compilador verifica que los uses todos cuando
decides según el constructor.
Primero, los datos. Un constructor puede recibir parámetros posicionales, igual que un record con campos numerados:
type Forma
= Circulo(Real)
| Rectangulo(Real, Real)
| Triangulo(Real, Real, Real)
Circulo(2.0) es un valor de tipo Forma con un dato real
adentro; Rectangulo(3.0, 4.0) lleva dos y
Triangulo(3.0, 4.0, 5.0), tres. Los tres son Forma, pero
el compilador sabe cuál es cuál y te obliga a manejarlos por
separado cuando los consumes.
fn area(f: Forma) : Real =
match f {
Circulo(r) -> 3.14159 * r * r
Rectangulo(w, h) -> w * h
Triangulo(a, b, c) -> {
let s = (a + b + c) / 2.0 # Herón
real_sqrt(s * (s - a) * (s - b) * (s - c))
}
}
match es una expresión que decide según el constructor. Cada
rama es un patrón seguido de -> y la expresión que esa rama
produce. Los patrones desempacan los datos al mismo tiempo: en
Circulo(r), la r queda amarrada al Real que venía
adentro del constructor. No hay un cast aparte ni un
acceso por índice; el patrón hace el trabajo. La rama del
triángulo aplica la fórmula de Herón con real_sqrt, la raíz
cuadrada que viene incluida sin import.
Tres detalles que vas a usar siempre:
-
El compilador verifica exhaustividad. Si quitas la rama
Triangulo(...)y el compilador sabe quef : Formapuede ser un triángulo, no compila. Esto te salva de bugs sutiles cuando agregas un constructor nuevo: todos losmatchque no lo cubren se vuelven errores que apuntan exactamente a dónde falta atención. -
Los constructores son nombres como cualquier otro. Cuando declaras
type Color = Rojo | Verde | Azul, kaikai crea cuatro símbolos:Colores el tipo, yRojo,Verde,Azulson tres tipos a la vez (cada uno con un solo habitante, equivalente al de un valor). En el capítulo 9 verás cómo este detalle te deja extender un sum type con protocolos definidos para sus constructores individualmente. -
El operador
|siempre significa unión de tipos. Lo usas a fondo en §5.5:Color = Rojo | Verde | Azul,ErrorEval = ErrorAritmetico | ErrorAmbienteyResultado = Ok(Int) | Err(String)son la misma construcción. La diferencia entre “declarar tipos nuevos” y “componer tipos existentes” la decide el compilador inspeccionando si los nombres del lado derecho ya están declarados.
El caso de un solo miembro
Hay una esquina que muerde temprano. Con dos o más miembros no
hay ambigüedad, pero con uno solo, type Estado = Activo es
un alias: le estás dando otro nombre a un tipo llamado
Activo, no declarando un constructor. Es la misma forma con
que se escribe type Metros = Int, y el compilador no puede
adivinar cuál querías.
La barra inicial resuelve el empate:
type Evidencia = | MismoBytecode # un miembro: suma, el ctor existe
type Color = Rojo | Verde # dos o más: sin barra inicial
type Metros = Int # sin barra, un nombre: alias
Si te equivocas, el error te lo dice:
error: unknown type `Activo`
--> x.kai:1:15
|
1 | type Estado = Activo
| ^
= help: to declare a one-member sum, write `type Estado = | Activo`
Un constructor sin payload nunca lleva paréntesis: se escribe
Activo, no Activo().
5.2 Constructores con y sin payload
Un constructor puede:
- No cargar datos (
Rojo,Verde,Azul). Es un valor único del tipo, sin parámetros. - Cargar uno o más datos posicionales (
Circulo(Real),Rectangulo(Real, Real)). El constructor se aplica como una función a los datos para producir el valor.
La gramática no limita cuántos datos carga un constructor, pero si pasas de tres o cuatro posicionales conviene declarar un record y ponerlo en el payload. La diferencia entre
type Evento
= Login(String, String, Int, Bool)
| Logout(String, Int)
y
type DatosLogin = { usuario: String, ip: String, timestamp: Int, exitoso: Bool }
type DatosLogout = { usuario: String, timestamp: Int }
type Evento = Login(DatosLogin) | Logout(DatosLogout)
es que el segundo se lee mejor en cualquier lugar donde
construyas o destructures un evento. La regla práctica: si los
datos se nombran solos por su posición (un punto es (Real, Real)), quedan posicionales. Si los datos requieren que el
lector recuerde el orden, conviene un record.
Los dos sabores de constructor en un programa: tres casos sin payload y tres que llevan datos.
# ejemplos/cap05/01_sumas_basicas.kai
type Color = Rojo | Verde | Azul
type Forma
= Circulo(Real)
| Rectangulo(Real, Real)
| Triangulo(Real, Real, Real)
fn area(f: Forma) : Real =
match f {
Circulo(r) -> 3.14159 * r * r
Rectangulo(w, h) -> w * h
Triangulo(a, b, c) -> {
# Fórmula de Herón, con s como semiperímetro.
let s = (a + b + c) / 2.0
real_sqrt(s * (s - a) * (s - b) * (s - c))
}
}
fn nombre(c: Color) : String =
match c {
Rojo -> "rojo"
Verde -> "verde"
Azul -> "azul"
}
fn main() {
println("color: #{nombre(Rojo)}, #{nombre(Verde)}, #{nombre(Azul)}")
let c = Circulo(2.0)
let r = Rectangulo(3.0, 4.0)
let t = Triangulo(3.0, 4.0, 5.0)
println("círculo: área = #{area(c)}")
println("rectángulo: área = #{area(r)}")
println("triángulo: área = #{area(t)}")
}
$ kai run ejemplos/cap05/01_sumas_basicas.kai
color: rojo, verde, azul
círculo: área = 12.56636
rectángulo: área = 12
triángulo: área = 6
5.3 Recursión en tipos
Aquí empieza lo poderoso. Un constructor de un tipo suma puede mencionar al tipo en sus campos. Eso te da árboles, listas, grafos pequeños y la representación de cualquier estructura con anidamiento.
Un árbol binario:
type Arbol
= Hoja
| Nodo(Int, Arbol, Arbol)
Arbol es Hoja (sin datos) o Nodo con un entero y dos
subárboles. Los subárboles son del mismo tipo Arbol, así que
pueden ser a su vez Hoja o Nodo, recursivamente, hasta la
profundidad que quieras.
Una función sobre un árbol también es recursiva: el caso base
es Hoja, el caso recursivo desciende por los hijos.
fn altura(t: Arbol) : Int =
match t {
Hoja -> 0
Nodo(_, izq, der) -> 1 + max_int(altura(izq), altura(der))
}
El patrón Nodo(_, izq, der) ignora el dato del nodo (la
altura no lo necesita) y amarra los dos subárboles a izq y
der; la función recurre sobre cada uno y suma uno al máximo.
Lo mismo aplica a un AST de expresiones aritméticas:
type Expr
= Lit(Int)
| Suma(Expr, Expr)
| Mul(Expr, Expr)
| Neg(Expr)
fn eval(e: Expr) : Int =
match e {
Lit(n) -> n
Suma(a, b) -> eval(a) + eval(b)
Mul(a, b) -> eval(a) * eval(b)
Neg(x) -> -eval(x)
}
Construir una expresión es transparente: Mul(Neg(Suma(Lit(2), Lit(3))), Lit(4)) es la representación literal de -(2 + 3) * 4, que evaluada da -20. El árbol y el código que lo recorre
se escriben prácticamente solos, una vez que sabes mirarlo
así.
Esto reemplaza a las jerarquías de clases con visitor patterns
de los lenguajes orientados al objeto. La diferencia clave: en
kaikai, agregar un
nodo nuevo a Expr se hace en una línea, y todos los match
sobre Expr en el código se vuelven errores de compilación que
apuntan a los lugares que necesitan atención. En un visitor
pattern, agregas un método nuevo en cada subclase y el
compilador no te ayuda a no olvidar ninguno.
Un árbol y una expresión aritmética, cada uno con su función recursiva:
# ejemplos/cap05/02_recursivos.kai
type Arbol
= Hoja
| Nodo(Int, Arbol, Arbol)
fn altura(t: Arbol) : Int {
case Hoja -> 0
case Nodo(_, izq, der) -> 1 + max_int(altura(izq), altura(der))
}
fn max_int(a: Int, b: Int) : Int =
if a > b { a } else { b }
# AST de expresiones aritméticas
type Expr
= Lit(Int)
| Suma(Expr, Expr)
| Mul(Expr, Expr)
| Neg(Expr)
fn eval(e: Expr) : Int =
match e {
Lit(n) -> n
Suma(a, b) -> eval(a) + eval(b)
Mul(a, b) -> eval(a) * eval(b)
Neg(x) -> -eval(x)
}
fn main() {
# Árbol: 1
# / \
# 2 3
# /
# 4
let t = Nodo(1, Nodo(2, Nodo(4, Hoja, Hoja), Hoja), Nodo(3, Hoja, Hoja))
println("altura = #{altura(t)}")
# Expresión: -(2 + 3) * 4
let e = Mul(Neg(Suma(Lit(2), Lit(3))), Lit(4))
println("eval = #{eval(e)}")
}
$ kai run ejemplos/cap05/02_recursivos.kai
altura = 3
eval = -20
5.4 match: patrones, guardas, exhaustividad
Ya usaste match. Aquí va completo.
Un match toma una expresión escrutinio (lo que se está
inspeccionando) y una serie de arms, cada uno con un
patrón seguido de -> y la expresión que esa rama
produce. La rama cuyo patrón calza con el valor del escrutinio
es la que se ejecuta; su valor es el valor del match.
Los patrones que kaikai acepta:
- Literales:
0,"hola",true. Calzan con el valor exacto. - Constructores:
Some(x),Lit(n),Suma(a, b). Calzan con el constructor y atan los datos a las variables. - Records:
Punto { x, y },Punto { x: 0, y: _ }. - Listas:
[],[h, ...t],[unico],[primero, segundo, ...]. - Wildcard:
_. Calza con cualquier cosa, no ata nada. - Variable: cualquier identificador no declarado. Calza con cualquier cosa y ata el valor a esa variable.
- Constantes:
MAX,red.MAX. Calzan con los valores iguales a la constante. Un calificador elige la de un módulo en particular.
Los patrones se anidan: Some(Punto { x, y }) calza con un
Some que contiene un Punto, y desempaca x e y en una
sola pasada.
Atar o comparar: la regla del identificador
Las dos últimas entradas de esa lista son la misma sintaxis haciendo cosas opuestas:
# ejemplos/cap05/06_patrones_constante.kai
const PUERTO_HTTP: Int = 80
const PUERTO_HTTPS: Int = 443
fn esquema(p: Int) : String =
match p {
PUERTO_HTTP -> "http"
PUERTO_HTTPS -> "https"
_ -> "desconocido"
}
PUERTO_HTTP aquí no es una variable que se trague cualquier
puerto y lo ate. Es una prueba de igualdad contra 80. La regla
es: un identificador en un patrón ata, salvo que nombre una
constante en scope; ahí compara. No es cosa de mayúsculas: una
constante en minúscula compara igual.
Entonces, introducir una constante puede cambiar el sentido de
un patrón ya escrito. Si pusiste un brazo
limite -> ... con la intención de atar, y después alguien
declara const limite, ese brazo se convierte en una
comparación sin que nadie lo toque.
Para el sentido más común de esa equivocación el compilador te cubre. Una atadura pelada calza con todo, así que cualquier brazo posterior queda muerto, y lo dice:
error: unreachable match arm: previous arm matched every value
--> x.kai:4:5
|
4 | _ -> "inalcanzable"
| ^
= note: an earlier unguarded `_` or bare binding already covers this case
= help: remove this arm, or move it before the catch-all
Si querías comparar y la constante no estaba en scope, este es el error que vas a ver, y apunta al lugar correcto.
Guardas
Un patrón puede ir seguido de if y una condición, una
guarda, que se evalúa después del match estructural. Si
la guarda es falsa, la rama no se considera y se sigue con la
siguiente:
fn signo(n: Int) : String =
match n {
0 -> "cero"
k if k > 0 -> "positivo"
_ -> "negativo"
}
El patrón k if k > 0 calza con cualquier entero, lo ata a
k, y entonces evalúa k > 0. Si es verdad, ejecuta la rama;
si no, sigue. La rama final con _ no tiene guarda y calza
con todo lo restante: los enteros que no son cero ni
positivos.
Las guardas son convenientes pero no participan en la
verificación de exhaustividad: el compilador no puede saber que
k > 0 y k < 0 se complementan, así que necesita un patrón
final sin guarda que cubra el caso “todo lo demás”. Si lo
omites, no compila.
Exhaustividad
El compilador verifica que todos los habitantes posibles
del tipo del escrutinio estén cubiertos. Si tu Expr tiene
cuatro constructores y tu match cubre tres, no compila, y
el mensaje no se queda en “falta algo”: te dice qué falta,
qué cubriste y cómo arreglarlo:
error: non-exhaustive match on Expr: missing Neg
--> evaluador.kai:12:3
|
12 | match e {
| ^
= note: missing variant: `Neg`
= note: covered: Mul, Suma, Lit
= help: add an arm `Neg -> ...` or a wildcard `_ -> ...`
El wildcard _ cubre todo lo que las ramas anteriores no
hayan cubierto, así que un match con un _ final es
trivialmente exhaustivo. Pero usar _ en lugar de enumerar
los casos silencia la ayuda del compilador: cuando agregues
un constructor a Expr, los match con _ final lo absorben
sin quejarse y pierdes el aviso.
La regla práctica: usa _ solo cuando de verdad no te
interesa distinguir el resto. Si los casos son tres y los tres
te importan, escribe los tres.
Patrón constante, guarda y comodín en el mismo match:
# ejemplos/cap05/03_match_guardas.kai
fn signo(n: Int) : String =
match n {
0 -> "cero"
k if k > 0 -> "positivo"
_ -> "negativo"
}
# Guardas con bindings y patrones
type Punto = { x: Int, y: Int }
fn cuadrante(p: Punto) : String {
let Punto { x, y } = p
if x == 0 and y == 0 {
"origen"
} else if x > 0 and y > 0 {
"I"
} else if x < 0 and y > 0 {
"II"
} else if x < 0 and y < 0 {
"III"
} else if x > 0 and y < 0 {
"IV"
} else {
"sobre un eje"
}
}
fn main() {
println("signo(-5) = #{signo(-5)}")
println("signo(0) = #{signo(0)}")
println("signo(7) = #{signo(7)}")
println("cuadrante(3,4) = #{cuadrante(Punto{x: 3, y: 4})}")
println("cuadrante(-1,-1) = #{cuadrante(Punto{x: -1, y: -1})}")
println("cuadrante(0,0) = #{cuadrante(Punto{x: 0, y: 0})}")
}
$ kai run ejemplos/cap05/03_match_guardas.kai
signo(-5) = negativo
signo(0) = cero
signo(7) = positivo
cuadrante(3,4) = I
cuadrante(-1,-1) = III
cuadrante(0,0) = origen
5.5 Uniones de tipos existentes
Hasta ahora todos los | que escribiste tenían nombres
nuevos del lado derecho (Rojo, Verde, Lit,
Circulo) que kaikai auto-declara como constructores. Pero
el operador | no exige nombres nuevos. Si los nombres del
lado derecho ya están declarados como tipos, kaikai
construye una unión que puede llevar cualquier valor de
esos tipos.
Es la herramienta clave para componer errores entre capas:
type ErrorIdentidad = CuentaNoExiste | KycVencido | Congelada
type ErrorAuth = SaldoInsuficiente | LimiteDiario
type ErrorConsulta = ErrorIdentidad | ErrorAuth
ErrorConsulta es la unión de los dos tipos previos. Un valor
de ErrorConsulta es cualquier valor de ErrorIdentidad
o cualquier valor de ErrorAuth. No hay envoltorio nuevo:
CuentaNoExiste ya era un valor válido, y ahora es también un
valor válido de ErrorConsulta.
Ese paso se llama upcast implícito: una variable tipada
ErrorIdentidad calza donde se espera ErrorConsulta, sin
conversión:
let id_err: ErrorIdentidad = CuentaNoExiste
let qb_err: ErrorConsulta = id_err # OK, sin ceremonia
En otros lenguajes esto te obliga a escribir constructores
wrapper (QBIdentity(IdentityError)), implementaciones de
From o conversiones manuales con map_err. En kaikai no hay
ninguna de esas tres cosas: el compilador sabe que
ErrorIdentidad es un componente de ErrorConsulta y reescribe
el upcast por ti.
Pattern matching sobre uniones
Un match sobre un valor de unión tiene dos sabores que
kaikai te deja mezclar libremente.
El primero, ya conocido, es enumerar todos los constructores individualmente:
fn describir(e: ErrorConsulta) : String =
match e {
CuentaNoExiste -> "id: cuenta no existe"
KycVencido -> "id: KYC vencido"
Congelada -> "id: cuenta congelada"
SaldoInsuficiente -> "auth: saldo insuficiente"
LimiteDiario -> "auth: límite diario"
}
Esto funciona, pero es tedioso para uniones grandes. Y peor,
si un componente crece (agregas RegulatoryHold a
ErrorAuth), el match se vuelve un error de compilación
en cinco lugares en vez de uno.
El segundo sabor es el patrón de narrowing bind : ComponentType, que calza cuando el valor pertenece al
componente nombrado y lo amarra bajo ese tipo más estrecho:
fn describir(e: ErrorConsulta) : String =
match e {
ie : ErrorIdentidad -> "id: " ++ id_str(ie)
ae : ErrorAuth -> "auth: " ++ auth_str(ae)
}
Cada rama delega en una función que conoce ese componente
específico. Si ErrorAuth crece, solo cambia auth_str. El
match sobre ErrorConsulta ni se entera.
Las dos formas también se mezclan en un mismo match cuando
quieres extraer un caso específico y delegar el resto:
match e {
CuentaNoExiste -> "id: específicamente, cuenta no existe"
ie : ErrorIdentidad -> "id: " ++ id_str(ie) # KycVencido, Congelada
ae : ErrorAuth -> "auth: " ++ auth_str(ae)
}
El compilador trata las ramas de narrowing como si cubrieran todos los constructores de su componente, así que la exhaustividad cierra correctamente.
Una limitación deliberada: el upcast no encadena
El upcast implícito vale solo un paso. Imagina tres tipos encadenados, cada uno conteniendo al anterior:
type ErrorIdentidad = CuentaNoExiste | KycVencido
type ErrorAuth = SaldoInsuficiente | LimiteDiario
type ErrorConsulta = ErrorIdentidad | ErrorAuth
type ErrorRuteo = NoEncontrado | ServidorCaido
type ErrorApp = ErrorConsulta | ErrorRuteo
fn manejar_app(e: ErrorApp) : String = "..."
Y un valor del tipo más interno:
let id: ErrorIdentidad = CuentaNoExiste
manejar_app(id) # ERROR: ErrorIdentidad no es componente directo
# de ErrorApp.
Aunque lógicamente todo ErrorIdentidad es un ErrorApp (vía
ErrorConsulta), el compilador no busca esa cadena. Para
pasar de ErrorIdentidad a ErrorApp tienes que escribir el
salto intermedio explícito:
let q: ErrorConsulta = id # un paso: ErrorIdentidad → ErrorConsulta
manejar_app(q) # otro paso: ErrorConsulta → ErrorApp
Lo dejé así a propósito. Subtipado encadenado vuelve la inferencia de tipos frágil y los mensajes de error confusos. La regla “un paso” mantiene a kaikai con un sistema de tipos predecible y con mensajes que apuntan al lugar correcto.
Dos familias de error declaradas por separado, unidas en una
firma, y el match que las distingue:
# ejemplos/cap05/04_uniones.kai
type ErrorIdentidad = CuentaNoExiste | KycVencido | Congelada
type ErrorAuth = SaldoInsuficiente | LimiteDiario
# Union de dos tipos ya declarados.
type ErrorConsulta = ErrorIdentidad | ErrorAuth
fn id_str(e: ErrorIdentidad) : String =
match e {
CuentaNoExiste -> "cuenta no existe"
KycVencido -> "KYC vencido"
Congelada -> "cuenta congelada"
}
fn auth_str(e: ErrorAuth) : String =
match e {
SaldoInsuficiente -> "saldo insuficiente"
LimiteDiario -> "límite diario"
}
# Match con narrowing: cada componente se delega a su handler.
fn describir(e: ErrorConsulta) : String =
match e {
ie : ErrorIdentidad -> "id: " ++ id_str(ie)
ae : ErrorAuth -> "auth: " ++ auth_str(ae)
}
fn main() {
# Upcast implícito: ErrorIdentidad <: ErrorConsulta
let e1: ErrorConsulta = CuentaNoExiste
let e2: ErrorConsulta = LimiteDiario
println(describir(e1))
println(describir(e2))
}
$ kai run ejemplos/cap05/04_uniones.kai
id: cuenta no existe
auth: límite diario
5.6 Errores como uniones, sin wrappers
Donde más rinden las uniones es en los errores. Cuando una función puede fallar de varias maneras que se descomponen en categorías claras, el patrón natural es:
- Cada categoría es un sum type pequeño.
- El error compuesto es la unión de las categorías.
- Cada función devuelve
Result[T, ErrorCompuesto]. - El operador
!propaga el error de cualquier capa hasta elResultcompuesto, vía el upcast implícito.
fn check_identidad(req: Req) : Result[Cuenta, ErrorIdentidad] = ...
fn check_auth(c: Cuenta) : Result[Aprobado, ErrorAuth] = ...
fn consultar_saldo(req: Req) : Result[Saldo, ErrorConsulta] {
let cuenta = check_identidad(req)! # ErrorIdentidad <: ErrorConsulta
let app = check_auth(cuenta)! # ErrorAuth <: ErrorConsulta
Ok(cargar_saldo(app))
}
Cada ! desempaca el Ok y propaga el Err con el upcast
correcto al Result[_, ErrorConsulta] que devuelve la función
externa. Cero wrappers, cero map_err, cero From. La firma
de consultar_saldo documenta exactamente qué errores puede
emitir, y el compilador se asegura de que todos estén
cubiertos cuando alguien la consume.
Lo que ! hace por dentro
La primera vez ! parece mágico. Es un return temprano
disfrazado de operador. La línea
let cuenta = check_identidad(req)!
equivale exactamente a:
let cuenta = match check_identidad(req) {
Ok(x) -> x # desempaca y sigue
Err(e) -> return Err(e) # sale de consultar_saldo con ese error
}
Sobre Option[A] el desugar es análogo: Some(x) desempaca,
None provoca return None.
Tres cosas que fijar:
- No es un
panicni una excepción. El programa no aborta; la función actual termina normalmente devolviendo elErro elNonea quien la llamó. El control sale un solo nivel, no más allá. - Solo funciona si la función actual devuelve un tipo
compatible. Si tu función devuelve
Int, no puedes usar!sobre unResultadentro: elreturn Err(e)no tendría dónde aterrizar. El compilador te lo dice claro. - El upcast sucede en el
return. Cuandocheck_identidad(req)devuelveResult[_, ErrorIdentidad]peroconsultar_saldodeclaraResult[_, ErrorConsulta], elreturn Err(e)aplica el upcastErrorIdentidad <: ErrorConsultaal pasar. Por eso!y las uniones de errores se llevan tan bien: cada nivel de la cascada absorbe el error de la capa inferior sin escribir conversiones.
Si vienes de Rust, este es exactamente el operador ?. Si
vienes de Swift, es lo que try con throws hace. Si vienes
de Haskell, es la do-notation sobre Either colapsada en un
solo símbolo.
Compáralo con la versión imperativa típica:
# Python: nada en el tipo de retorno te dice qué puede fallar
def consultar_saldo(req):
cuenta = check_identidad(req) # puede tirar AccountNotFound, KycExpired...
app = check_auth(cuenta) # puede tirar InsufficientBalance...
return cargar_saldo(app)
El que llama a consultar_saldo en Python tiene que leer el
código de las funciones internas (o la doc, si la hay) para
saber qué excepciones esperar. En kaikai, la firma se lo dice.
5.7 Caso de estudio: evaluador con errores tipados
Cerramos el capítulo con un caso integrador. Vas a construir un evaluador de expresiones aritméticas con dos categorías de error:
- Aritméticos: división por cero, raíz de un número negativo.
- De ambiente: variable no definida.
Los dos forman una unión, ErrorEval, y el evaluador devuelve
Result[Real, ErrorEval]. El código completo está en
ejemplos/cap05/05_evaluador.kai; aquí recorres las partes
interesantes.
El AST
type Expr
= Lit(Real)
| Var(String)
| Suma(Expr, Expr)
| Mul(Expr, Expr)
| Div(Expr, Expr)
| Raiz(Expr)
Seis constructores, cuatro de ellos recursivos. Var(String)
introduce una novedad respecto al evaluador del cap. 1: ahora
las expresiones pueden referenciar variables, así que el
evaluador necesita un ambiente que asocie nombres a
valores.
Los errores
type ErrorAritmetico = DivCero | RaizNegativa(Real)
type ErrorAmbiente = NoDefinida(String)
type ErrorEval = ErrorAritmetico | ErrorAmbiente
Tres tipos: dos categorías y la unión. Tres constructores en
total, pero distribuidos en categorías que tienen sentido por sí
mismas. RaizNegativa(Real) carga el valor que se intentó pasar
a la raíz, útil para el mensaje de error final.
NoDefinida(String) carga el nombre de la variable que faltaba.
El ambiente
type Env = [(String, Real)]
fn lookup(env: Env, nombre: String) : Result[Real, ErrorEval] {
case [], _ -> Err(NoDefinida(nombre))
case [(k, v), ...], n when k == n -> Ok(v)
case [_, ...resto], n -> lookup(resto, n)
}
Env es un alias para una lista de pares: recorrido lineal,
suficiente para un evaluador de juguete. lookup está escrito
en la forma multi-clause del cap. 6: cada case lista un
patrón por argumento separado por coma (aquí env y nombre),
con un when opcional para guardas. Tres casos:
- Lista vacía: la variable no estaba; devuelve un error.
- Cabeza con la clave buscada: éxito.
- Cualquier otra cabeza: sigue en la cola.
Fíjate en que el Err es del tipo ErrorEval, no
ErrorAmbiente, pero NoDefinida(nombre) se construye como
ErrorAmbiente y el upcast implícito lo promueve a
ErrorEval en el sitio del retorno, sin conversión explícita.
eval, a continuación, usa la forma match e { ... }, que se
lee mejor cuando decides sobre un solo argumento de un tipo
suma con muchos constructores. Las dos formas conviven en el
mismo archivo sin tensión.
El evaluador
fn eval(env: Env, e: Expr) : Result[Real, ErrorEval] =
match e {
Lit(n) -> Ok(n)
Var(nombre) -> lookup(env, nombre)
Suma(a, b) -> {
let va = eval(env, a)!
let vb = eval(env, b)!
Ok(va + vb)
}
Mul(a, b) -> {
let va = eval(env, a)!
let vb = eval(env, b)!
Ok(va * vb)
}
Div(a, b) -> {
let va = eval(env, a)!
let vb = eval(env, b)!
if vb == 0.0 { Err(DivCero) } else { Ok(va / vb) }
}
Raiz(x) -> {
let v = eval(env, x)!
if v < 0.0 { Err(RaizNegativa(v)) } else { Ok(real_sqrt(v)) }
}
}
Cada caso de match corresponde a un constructor de Expr.
Los casos recursivos usan ! para evaluar los subárboles y
propagar cualquier error que aparezca; los casos que pueden
fallar localmente (división por cero, raíz negativa)
construyen un Err del tipo apropiado y dejan que el upcast
los promueva.
El ! aparece varias veces; léelo con calma.
eval(env, a)! significa: si eval(env, a) devuelve Ok(v),
amarra va a v; si devuelve Err(e), sale
inmediatamente de la función actual devolviendo ese Err.
Aquí el Err que se propaga ya es Result[_, ErrorEval], el
tipo que eval devuelve, así que el upcast es trivial. La
misma forma funciona cuando los tipos de error de las llamadas
internas son componentes distintos de la unión.
Imprimiendo el resultado
fn describir(e: ErrorEval) : String =
match e {
DivCero -> "división por cero"
RaizNegativa(v) -> "raíz de número negativo (#{v})"
NoDefinida(nombre) -> "variable no definida: " ++ nombre
}
fn imprimir(r: Result[Real, ErrorEval]) : Unit =
match r {
Ok(v) -> println("ok: #{v}")
Err(e) -> println("error: " ++ describir(e))
}
describir consume un ErrorEval enumerando sus tres
constructores. El match enumera porque cada constructor pide
su propio mensaje; si la lógica difiriera por categoría,
convendría narrowing.
El programa principal arma el ambiente, evalúa varias expresiones y las imprime:
fn main() {
let env: Env = [("x", 9.0), ("y", 4.0)]
imprimir(eval(env, Suma(Var("x"), Var("y")))) # ok: 13
imprimir(eval(env, Raiz(Var("x")))) # ok: 3
imprimir(eval(env, Div(Lit(10.0), Lit(0.0)))) # error: división por cero
imprimir(eval(env, Raiz(Lit(-1.0)))) # error: raíz de número negativo (-1)
imprimir(eval(env, Var("z"))) # error: variable no definida: z
}
Lo bonito de este caso: cada error está documentado en el
tipo del evaluador. Si más adelante quieres agregar un
ErrorTipo (por ejemplo, intentar sumar dos cosas que no
son números), declaras la categoría nueva, la incluyes en
ErrorEval, y el compilador te lleva a todos los lugares que
necesitan adaptarse.
Ejercicios
5.1. Define type Maybe[a] = Just(a) | Nothing (que es
otra forma de Option) y escribe fn or_else[a](m: Maybe[a], default: a) : a que devuelva el valor si es Just, o el
default si es Nothing. Nota: el nombre Nothing también es
un tipo primitivo de kaikai (el bottom type del cap. 3); usa
Vacio u otro nombre si te molesta el shadowing.
5.2. Extiende el evaluador del §5.7 para que admita
restas: agrega Resta(Expr, Expr) al AST, agrégalo al match
de eval, y verifica que 2 - 3 evalúe a -1. ¿Cuántas
líneas tuviste que cambiar? ¿Cuántos lugares tocó el
compilador?
5.3. Define un árbol de decisión binario:
type Decision[a]
= Hoja(a)
| Pregunta(String, Decision[a], Decision[a])
donde una Pregunta tiene un texto, una rama sí y una rama
no. Escribe fn aplicar[a](d: Decision[a], respuestas: [(String, Bool)]) : Option[a]
que recorra el árbol siguiendo las respuestas a cada
pregunta, y devuelva Some con la decisión final si llega a
una hoja, o None si en alguna pregunta no hay respuesta.
5.4. Toma el ejemplo de uniones con ErrorIdentidad y
ErrorAuth. Agrega un tercer componente ErrorRedAdministrativa
con dos constructores. Reescribe el match con narrowing
para que el código quede igual de corto. Luego agrega un
constructor nuevo a ErrorAuth (digamos Bloqueado): ¿qué
pasa con tu código? ¿En cuántos lugares interviene el
compilador?
5.5. Crea un sum type para representar un mensaje de
chat: type Mensaje = Texto(String) | Imagen(String, Int, Int) | Audio(String, Int) (ruta, dimensiones o duración). Escribe
una función fn descripcion(m: Mensaje) : String que
devuelva una descripción humana, y una función
fn pesado(m: Mensaje) : Bool que devuelva true si el
mensaje es de imagen o audio. La segunda función debería
usar narrowing en el match, no enumerar.
5.6. En un papel, dibuja el árbol de evaluación que
construye el evaluador del §5.7 cuando procesa la expresión
Mul(Suma(Var("x"), Lit(2.0)), Var("y")) con el ambiente
[("x", 9.0), ("y", 4.0)]. ¿Cuántas llamadas a eval hace?
¿Cuántas a lookup? ¿En qué orden? Esto te ayuda a entender
por qué el operador ! corta la ejecución apenas algo falla:
tres llamadas en cascada, una sola línea por nivel.