Capítulo 3 · Tipos básicos y expresiones
Este capítulo describe los 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
ahora vas a fijar bien: if y los bloques son expresiones, no
sentencias.
Si leíste el capítulo 2, aquí encuentras el contenido concreto de sus advertencias. Si no lo leíste y vienes de un mundo imperativo, revísalo: 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. Sirve 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.
Int y Real no son los únicos números del lenguaje: la
sección 3.4 presenta enteros de ancho fijo y tipos de precisión
arbitraria. Pero estos siete 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}.")
Cuando el valor tiene que calzar en un formato, el hueco acepta
una especificación después de dos puntos. #{x:.2} muestra un
Real con dos decimales, #{n:5} rellena hasta un ancho de
cinco alineando a la derecha, #{s:<8} alinea a la izquierda
(^ centra) y #{n:06} rellena con ceros. Ancho y decimales se
combinan: #{precio:9.2}. Con eso una tabla se arma sin
funciones auxiliares:
# ejemplos/cap03/07_formato.kai
fn fila(producto: String, precio: Real, unidades: Int) : String =
"#{producto:<8}#{precio:9.2}#{unidades:5}"
fn main() {
println("#{"producto":<8}#{"precio":>9}#{"uds":>5}")
println(fila("pan", 1290.0, 3))
println(fila("café", 1090.5, 12))
println(fila("queso", 4590.75, 1))
println("boleta #{427:06}")
}
$ kai run ejemplos/cap03/07_formato.kai
producto precio uds
pan 1290.00 3
café 1090.50 12
queso 4590.75 1
boleta 000427
La especificación se revisa al compilar. .2 sobre un Int es
un error de tipos que apunta al hueco, y una especificación mal
escrita, como #{n:5x}, ni siquiera llega a ejecutarse.
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 funcionan
igual que en los strings normales.
El juego de escapes es corto y cerrado: \n, \t, \r,
\0, \\, \", \', más \xHH (exactamente dos dígitos
hexadecimales, un byte) y \u{H..H} (un codepoint, que kaikai
codifica en UTF-8). Cualquier otra cosa es error de
compilación, no un backslash que se evapora en silencio:
error: unknown escape sequence '\q'; write '\\q' for a literal backslash
--> bad.kai:2:9
|
2 | print("ruta: C:\qtemp")
| ^
Que esa lista sea corta y explícita es deliberado. La
decodificación vive en un solo lugar del compilador, con las
mismas reglas para strings, Char, triple comilla e
interpolación.
Los Char se escriben con comilla simple: 'a', '\n',
'\u{2603}'. Un Char y un String de longitud uno son tipos
distintos, lo que evita una familia de errores propios de los
lenguajes que confunden ambos.
Los tres mecanismos juntos, en un programa que corre:
# ejemplos/cap03/02_strings.kai
fn main() {
let nombre = "kaikai"
let edad = 1
# Interpolación con #{...}
println("Hola, #{nombre}. El lenguaje tiene #{edad} año.")
# Concatenación con ++
let saludo = "Hola, " ++ nombre
println(saludo)
# Cualquier expresión cabe dentro de #{...}
let a = 7
let b = 2
println("La suma de #{a} y #{b} es #{a + b}.")
# Triple-quote para multi-línea
let mensaje = """
Esto es un mensaje
de varias líneas, que mantiene
la indentación relativa al cierre.
"""
println(mensaje)
}
$ kai run ejemplos/cap03/02_strings.kai
Hola, kaikai. El lenguaje tiene 1 año.
Hola, kaikai
La suma de 7 y 2 es 9.
Esto es un mensaje
de varias líneas, que mantiene
la indentación relativa al cierre.
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, y
Int / Int es Int.
Y con negativos hay un segundo cambio de hábito, más
traicionero porque no da error: / trunca hacia cero, y el
resto de % lleva el signo del dividendo.
println("#{-17 / 5}") # -3, no -4
println("#{-17 % 5}") # -2, no 3
println("#{17 % -5}") # 2: el signo lo pone el 17
Python decide al revés: usa división hacia abajo, así que
-17 // 5 es -4 y -17 % 5 es 3. Si vienes de C, Go,
Java o Rust, en cambio, kaikai hace lo que ya esperabas. La
identidad que se mantiene en todos los casos es
(a / b) * b + a % b == a.
La consecuencia práctica: si usas % para saber si algo es
par, para rotar un índice en un buffer circular o para
repartir en cubetas, y el valor puede ser negativo, vas a
recibir un resto negativo. x % 2 == 1 es falso para todo x
negativo impar; lo que quieres preguntar es x % 2 != 0.
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 aprendes en el capítulo 9; por ahora, todos los tipos
primitivos los implementan.
Un último hábito, y este es de los Real. Un Real es un
flotante IEEE 754 de doble precisión, así que hay decimales que
no tiene cómo representar: 0.1 no es exactamente un décimo,
es el binario más cercano. kaikai no te lo esconde al imprimir.
La regla es que un Real se escribe con los dígitos que
necesita para que leerlo de vuelta dé el mismo número, y ni uno
más:
println("#{1.5}") # 1.5
println("#{7.0 / 2.0}") # 3.5
println("#{0.1 + 0.2}") # 0.30000000000000004
El tercero no es un error del lenguaje ni de tu máquina: es la
suma que de verdad ocurrió. Un lenguaje que imprimiera 0.3
estaría redondeando para que la cuenta se vea bien, y la
próxima comparación te lo cobraría igual. Si lo que llevas es
dinero, o cualquier cantidad donde eso no se tolera, el tipo
que quieres no es Real, y está en la sección que sigue.
La aritmética de esta sección en un programa, con la división entera y la real una al lado de la otra:
# ejemplos/cap03/01_aritmetica.kai
fn main() {
let a: Int = 7
let b: Int = 2
println("a + b = #{a + b}")
println("a * b = #{a * b}")
println("a / b = #{a / b}") # 3 (sobre Int, / trunca)
println("a % b = #{a % b}") # 1
println("7.0 / 2.0 = #{7.0 / 2.0}") # 3.5
println("9.0 / 2.0 = #{9.0 / 2.0}") # 4.5
}
$ kai run ejemplos/cap03/01_aritmetica.kai
a + b = 9
a * b = 14
a / b = 3
a % b = 1
7.0 / 2.0 = 3.5
9.0 / 2.0 = 4.5
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
let mascara = 0xFFi32 # el sufijo vale en
let bits = 0b1010u8 # cualquier base
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
Se usan 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 y 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 de cualquier tamaño:99n, y también18446744073709551616n, que ya no cabe enInt. Cuando los dígitos llegan en runtime y no en el fuente,bigint.from_literal("…")los toma desde un string, con_permitido como separador.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: la aridad tiene que
calzar con la función marcada, así que 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 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 lo retomas en el
capítulo 12, con los 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 }
let n: Int = r # error: expected Int, found Unit
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.
let con y sin anotación, y un if/else encadenado que
devuelve el valor de la función:
# ejemplos/cap03/03_let_if.kai
fn signo(n: Int) : String =
if n < 0 {
"negativo"
} else if n == 0 {
"cero"
} else {
"positivo"
}
fn main() {
let x = 42 # tipo inferido: Int
let y: Real = 3.14 # con anotación explícita
let mensaje = "x es #{x}, y es #{y}"
println(mensaje)
println("signo(-5) = #{signo(-5)}")
println("signo(0) = #{signo(0)}")
println("signo(7) = #{signo(7)}")
}
$ kai run ejemplos/cap03/03_let_if.kai
x es 42, y es 3.14
signo(-5) = negativo
signo(0) = cero
signo(7) = positivo
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.
# ejemplos/cap03/04_bloques.kai
fn cuadrado_mas_uno(x: Int) : Int {
let cuadrado = x * x
cuadrado + 1 # valor del bloque
}
fn main() {
println("f(3) = #{cuadrado_mas_uno(3)}") # 10
println("f(5) = #{cuadrado_mas_uno(5)}") # 26
}
$ kai run ejemplos/cap03/04_bloques.kai
f(3) = 10
f(5) = 26
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.
El salto de línea es lo que separa una expresión de la
siguiente. No necesitas punto y coma, pero existe: cuando
quieres dos expresiones en la misma línea, ; va entre ellas.
let cuadrado = x * x; cuadrado + 1
Si las pegas sin separador, el compilador para en seco y te dice qué falta:
error: a second expression on the same line needs a separator; put `;` or a newline before it
Un ; al final de una línea es inofensivo: el lenguaje lo
acepta y no cambia nada, ni descarta el valor de la última
expresión. Pero no lo escribas por costumbre. Una línea por
expresión es la forma normal; el ; es para cuando dos pasos
cortos leen mejor juntos.
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. Las dos primeras ya las usaste en los capítulos 1 y 2; aquí las fijas junto con la tercera, y 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.