Capítulo 3 · Tipos básicos y expresiones
Vamos a las primitivas. Este capítulo describe los siete tipos
básicos de kaikai, la forma de los literales, los operadores que
los manipulan, y cómo se atan a nombres con let. También
introduce algo que ya viste de pasada y vamos a fijar bien:
if y los bloques son expresiones, no sentencias.
Si leíste el capítulo 2, lo que viene es el contenido concreto de aquellas advertencias. Si no lo leíste y vienes de un mundo imperativo, vuelve. Te ahorra fricción.
3.1 Los siete tipos primitivos
El día a día de kaikai se apoya en siete tipos básicos:
| Tipo | Para qué sirve | Literal de ejemplo |
|---|---|---|
Int | Enteros con signo, 64 bits | 42, -7, 1_000_000 |
Real | Reales de doble precisión, IEEE 754 | 3.14, -0.5, 1e10 |
Bool | Verdadero / falso | true, false |
String | Cadenas de texto Unicode | "hola", "α + β" |
Char | Un único carácter Unicode | 'a', '\n', '\u{2603}' |
Unit | El tipo con un solo valor | () |
Nothing | El tipo vacío, sin habitantes | (no tiene literal) |
Los primeros cinco son lo que esperarías de cualquier lenguaje. Los últimos dos merecen una explicación.
Unit es el tipo del valor (). Tiene un solo habitante. Es lo
que devuelve una función que “no devuelve nada útil”: el
equivalente al void de C, pero es un tipo de verdad, con un
valor de verdad, que se puede pasar como argumento o guardar en
una variable. Un println(...) devuelve Unit. Un bloque que
termina sin un valor explícito devuelve Unit.
Nothing es el opuesto: cero habitantes. Ninguna expresión
produce un Nothing que el programa pueda usar. ¿Para qué
sirve, entonces? Para describir el tipo de retorno de cosas que
nunca terminan normalmente: un panic(...) que aborta el
proceso, un loop infinito, una función forever cuyo cuerpo no
puede salir. Una función fn loop_eterno() : Nothing le dice al
sistema de tipos que cualquier código después de llamarla es
inalcanzable, y por eso una expresión de tipo Nothing calza en
cualquier contexto donde se espere otro tipo. Te vas a topar con
Nothing rara vez, pero conviene tener el nombre.
Estos siete no son los únicos números del lenguaje — hay enteros de ancho fijo y tipos de precisión arbitraria, que veremos en la sección 3.4 —, pero sí son los que vas a teclear el 95% del tiempo.
3.2 Literales e interpolación de strings
Los literales numéricos admiten guion bajo como separador visual:
let poblacion = 19_000_000
let pi = 3.141_592
let escala = 1e6
Los String se escriben con comillas dobles. La sintaxis más
útil del día a día es la interpolación:
let nombre = "kaikai"
let edad = 1
println("Hola, #{nombre}. El lenguaje tiene #{edad} año.")
Salida:
Hola, kaikai. El lenguaje tiene 1 año.
Cualquier expresión cabe dentro de #{...}, no solo nombres.
Su valor se convierte a String automáticamente:
let a = 7
let b = 2
println("La suma de #{a} y #{b} es #{a + b}.")
Para concatenar strings sin interpolación se usa ++:
let saludo = "Hola, " ++ nombre
El operador es ++, no +, para no confundirlo con suma. Es
un detalle pequeño que evita un bug habitual.
Para mensajes que ocupan varias líneas, kaikai tiene strings con triple comilla:
let mensaje = """
Esto es un mensaje
de varias líneas, que mantiene
la indentación relativa al cierre.
"""
La indentación de cada línea se mide respecto al cierre """,
así que puedes formatear el string visualmente sin que aparezcan
espacios extra en la salida. Las secuencias de escape (\n,
\t, \u{HHHH}, etc.) funcionan igual que en los strings
normales.
Los Char se escriben con comilla simple: 'a', '\n',
'\u{2603}'. Un Char no es un String de longitud uno, son
tipos distintos. Eso evita una familia de errores propios de
los lenguajes que confunden ambos.
3.3 Operadores aritméticos, lógicos y de comparación
Los operadores aritméticos:
+ suma (Int o Real)
- resta / negación (Int o Real)
* producto (Int o Real)
/ división (Int o Real)
% módulo (Int o Real)
Los cinco están sobrecargados por tipo: funcionan tanto
con Int como con Real, y el resultado tiene el tipo de
los operandos. Lo que no existe es coerción implícita
entre Int y Real: no puedes mezclarlos en una misma
expresión. Si lo necesitas, conviertes explícitamente con
int_to_real(...) o real_to_int(...).
El detalle que conviene fijar: / con dos Int ya trunca el
resto. Para obtener un cociente con parte fraccionaria, los
dos operandos tienen que ser Real.
let a : Int = 7
let b : Int = 2
println("a / b = #{a / b}") # 3: sobre Int, / trunca
let x : Real = 7.0
let y : Real = 2.0
println("x / y = #{x / y}") # 3.5: sobre Real, hay parte
# fraccionaria
Si vienes de Python 3, hay un cambio de hábito. Allá /
siempre devuelve flotante; en kaikai el tipo manda: Int / Int es Int, y si quieres parte fraccionaria conviertes
explícitamente o trabajas con Real desde el principio.
Los operadores lógicos son palabras, no símbolos:
and or not
if x > 0 and x < 10 { ... }
if not vacio { ... }
A la mayoría de los lenguajes que conoces les da por usar &&,
|| y !. kaikai eligió palabras porque son más legibles, y
porque los símbolos están reservados para otras cosas (|| es
flat-map en pipes, capítulo 6).
Los operadores de comparación son los habituales:
== != < > <= >=
Devuelven Bool. Funcionan sobre cualquier tipo que implemente
los protocolos Eq (para ==/!=) y Ord (para los demás).
Eso lo veremos en el capítulo 9; por ahora, todos los tipos
primitivos los implementan.
3.4 Más números: anchos fijos y precisión arbitraria
Int y Real cubren casi todo el código que vas a escribir. El
resto tiene requisitos duros: hablar con C usando el ancho exacto
que la ABI espera, contar más allá de 2⁶³, o llevar cantidades
donde un redondeo binario es inaceptable. Para esos casos kaikai
trae dos familias más. No las vas a necesitar pronto, pero
conviene saber que existen y dónde terminan las garantías de
cada una.
Enteros de ancho fijo
Cuatro tipos con ancho exacto: Int32, UInt32, UInt64 e
Int128. El literal los nombra con un sufijo pegado a los
dígitos:
let w = 42i32 + 7i32
let grande : Int128 = 9223372036854775808i128 # 2⁶³: fuera del
# alcance de Int
Dos reglas los gobiernan. La primera: no se mezclan con
Int. Un Int32 no unifica con un Int; sumarlos es error de
tipos, igual que mezclar Int con Real. Se convierte con
nombre y apellido — int_to_int32(...), int32_to_int(...) y
los análogos para u32, u64 e i128. Nada de coerciones
silenciosas, tampoco aquí.
La segunda: la aritmética envuelve. Sumar 1i32 al máximo
de Int32 no promueve ni lanza excepción: da la vuelta en
complemento a dos, como en C.
let tope = 2147483647i32 + 1i32 # -2147483648
El ejemplo ejemplos/cap03/05_ancho_fijo.kai recorre las dos
reglas:
$ kai run ejemplos/cap03/05_ancho_fijo.kai
w = 49
tope = -2147483648
grande = 9223372036854775808
n32 + 1 = 11
de vuelta = 11
¿Cuándo los usas? Sobre todo en la frontera con C: en una firma
extern "C", un Int32 cruza como int32_t, un UInt64 como
uint64_t — el ancho que declaras es el ancho que viaja
(capítulo 16). Int128 además sirve solo: alcanza ~38 dígitos
donde Int se queda en ~19, con la misma aritmética de siempre.
Precisión arbitraria: BigInt, DecimalBig, Rational
Cuando ningún ancho fijo basta, el stdlib ofrece tres tipos que crecen lo que haga falta. Son opt-in — se importan, no vienen cargados por defecto — porque su costo es real: valores en heap, aritmética por software. kaikai te lo cobra solo cuando lo pides.
BigInt(import math.bigint): entero de precisión arbitraria, nunca desborda. El sufijonconstruye uno desde un literal:99n. El sufijo solo alcanza para literales que caben enInt; para valores más grandes se parte de un string conbigint.from_string("…").DecimalBig(import decimal_big): punto fijo sobreBigInt, pensado para cantidades decimales exactas.add,subymulson totales;divpide la escala destino de forma explícita, porque truncar es una decisión, no un accidente. Comparar1.5con1.50da igualdad: la escala no es parte del valor. Su hermanoDecimal(import decimal) usaInt128como soporte — más liviano, con techo cerca de los 38 dígitos. También tiene sufijo:12.0des unDecimal.Rational(import rational): fracción exacta, un parnum/densobreBigIntsiempre reducido a términos mínimos.1/2 + 1/3es exactamente5/6, sin redondeo en ninguna parte.
Estos tipos son records con invariantes — un Rational siempre
está reducido, un Decimal lleva su escala — así que no se
construyen con el literal de record crudo, que se saltaría esa
lógica. La forma corta es el constructor posicional: un tipo
marcado con el atributo #[constructor] deja que Tipo(args)
llame a la función que lo construye de verdad. Rational(1, 2)
es rational.make(1, 2), con la reducción incluida; Complex(1.5, 2.0) es complex.mk(...). Es azúcar, no magia: la aridad tiene
que calzar con la función marcada — Rational(5), con un solo
argumento, no existe (para eso está rational.from_int(5)).
El ejemplo ejemplos/cap03/06_numeros_grandes.kai muestra los
tres en acción:
$ kai run ejemplos/cap03/06_numeros_grandes.kai
a² = 1000000014000000049
d × 2 = 246913578024691357802469135780.246913578
1/2 + 1/3 = 5/6
Si vienes de Python, BigInt es lo que allá llaman simplemente
int: la diferencia es que en kaikai el entero de 64 bits es el
caso común y rápido, y la precisión arbitraria es la excepción
que se pide por su nombre. Y si tu problema es dinero, la
respuesta completa combina estos tipos con las unidades de
medida del capítulo 10 — los decimales exactos ponen la
aritmética; las unidades, la disciplina de no sumar pesos con
dólares.
3.5 let y la propagación local de tipos
let ata un nombre a un valor. El tipo se infiere del lado
derecho:
let x = 42 # x : Int
let y = 3.14 # y : Real
let nombre = "kaikai" # nombre : String
Si quieres dejar el tipo explícito, lo anotas con ::
let x : Int = 42
let y : Real = 3.14
La anotación no es solo decoración. Sirve para dos cosas:
documentar la intención cuando el tipo no es obvio, y guiar al
inferidor en los pocos casos en que la inferencia local no
alcanza. La regla práctica es anotar los argumentos y el
retorno de las funciones públicas, dejar los let locales sin
anotación. Así el tipo viaja por las firmas y el cuerpo se
queda limpio.
Una vez que un nombre se ata, no se puede volver a atar al mismo nombre en el mismo bloque. La línea siguiente daría un error:
let x = 42
let x = 7 # error: nombre ya definido en este ámbito
Lo que sí puedes hacer es atar el mismo nombre en un ámbito interno, lo que produce un shadowing: el nombre local oculta al externo:
let x = 42
{
let x = 7 # OK: shadowing dentro del bloque
println("#{x}") # 7
}
println("#{x}") # 42: el de afuera no cambió
Esto es ortogonal a la mutación. No estás “cambiando x”:
estás introduciendo un x distinto en un ámbito anidado, que
deja de existir cuando el ámbito se cierra. El de afuera nunca
fue tocado.
Para los casos donde necesitas una celda mutable de verdad,
kaikai te da var: nombre := v declara y escribe, y un
nombre desnudo lee. Lo viste en §2.2 y volveremos a ello en el
capítulo 12 cuando hablemos de efectos.
3.6 if como expresión
Un if en kaikai produce un valor:
let s = if x > 0 { "positivo" } else { "no positivo" }
Las dos ramas tienen que producir valores del mismo tipo, y ese
tipo es el del if. La sintaxis no usa then, no usa
paréntesis alrededor de la condición, y cada rama es un bloque.
Si la condición tiene varias ramas, se encadenan con else if:
fn signo(n: Int) : String =
if n < 0 { "negativo" }
else if n == 0 { "cero" }
else { "positivo" }
Cada llave abre un bloque cuyo valor es el resultado de la rama.
La función entera es una sola expresión, atada a la firma con
=. No hay return.
Una variante que conviene fijar: un if sin else siempre
tiene tipo Unit. El compilador no completa la rama faltante
con un valor sintetizado; toma la decisión más simple posible y
dice “el tipo del if es Unit, y si la condición es falsa, el
valor es ()”.
Por eso la forma usual de un if sin else es un cuerpo que
se ejecuta por sus efectos:
if i <= n {
println(label(classify(i)))
loop(i + 1, n)
}
Es la forma habitual de “haz algo o sigue”. Y si la rama del
then produce un valor de otro tipo, ese valor se descarta
en silencio: el if sigue siendo Unit:
if x > 0 {
x + 1 # Int, pero se tira a la basura
}
Esto compila, no produce advertencia, y rara vez es lo que
querías. El error suele aparecer un poco más allá, cuando
intentas usar el resultado del if:
let r = if x > 0 { 42 }
println(int_to_string(r)) # error: r es Unit, no Int
La regla práctica es simple: si te interesa el valor, escribe
un if/else exhaustivo; si te interesa el efecto, escribe
un if solo, sin atarlo a nada.
3.7 Bloques y el valor de un bloque
Un bloque { ... } es una expresión cuyo valor es el de la
última expresión adentro. Las líneas anteriores se ejecutan en
orden y sus valores se descartan, salvo cuando los atas con
let.
fn cuadrado_mas_uno(x: Int) : Int = {
let cuadrado = x * x
cuadrado + 1
}
cuadrado es un binding local. La última línea, cuadrado + 1,
es la expresión de retorno. No hay return; el valor del bloque
es el valor de la función.
Esto se compone con todo lo demás: una rama de if puede ser un
bloque, una rama de match también, el cuerpo de una lambda
también. Todo el lenguaje se reduce a expresiones que devuelven
valores.
3.8 Tres formas de cuerpo de función
El cuerpo de una función puede tomar tres formas. Lo viste en el capítulo 1 y lo fijamos aquí; el cap. 6 vuelve sobre ellas con más profundidad.
Forma corta, con = y una sola expresión:
fn doble(x: Int) : Int = x * 2
fn signo(n: Int) : String =
if n < 0 { "negativo" }
else if n == 0 { "cero" }
else { "positivo" }
Forma larga, con { ... } y bindings intermedios:
fn cuadrado_mas_uno(x: Int) : Int = {
let cuadrado = x * x
cuadrado + 1
}
Forma multi-clause, con case arms cuando la función decide
por la forma de sus argumentos:
fn signo(n: Int) : String {
case 0 -> "cero"
case k when k > 0 -> "positivo"
case _ -> "negativo"
}
El compilador acepta las tres. La diferencia es para quien lee:
- Forma corta cuando la función es una expresión directa, sin pasos intermedios. Lee como una definición matemática.
- Forma larga cuando hay bindings intermedios o varios pasos que ayuda separar visualmente.
- Multi-clause cuando la función dispatcha por patrones sobre sus argumentos. Es lo natural para muchas funciones recursivas y para deciders sobre tipos suma.
No hay forma “preferida”: elige la que comunica mejor la intención de esa función en particular.
Ejercicios
3.1. Escribe una función fn fahrenheit_a_celsius(f: Real) : Real usando solo lo de este capítulo. Verifica que
fahrenheit_a_celsius(32.0) da 0.0 y que
fahrenheit_a_celsius(212.0) da 100.0. Hazlo con = y una
sola expresión.
3.2. Escribe fn es_par(n: Int) : Bool y
fn paridad(n: Int) : String, donde paridad devuelve "par"
o "impar" según el caso. La segunda función debería usar la
primera, no repetir la lógica.
3.3. Dada la función:
fn precio_total(unidades: Int, precio_unitario: Int) : Int = {
let subtotal = unidades * precio_unitario
let iva = subtotal * 19 / 100
subtotal + iva
}
¿Qué imprime precio_total(3, 100)? Modifica la función para
que el IVA se calcule correctamente como un porcentaje real
(con parte fraccionaria), no entero. ¿Qué tipos cambias?
3.4. Escribe fn maximo(a: Int, b: Int, c: Int) : Int que
devuelve el mayor de los tres argumentos, usando if como
expresión y sin match ni funciones del stdlib. ¿Cuántos
else if necesitas?
3.5. Lee la documentación del operador ++ y averigua qué
pasa si intentas escribir "hola, " ++ 42. ¿Compila? Si no,
¿cómo lo arreglas? Razona la respuesta antes de probar.