El libro

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:

TipoPara qué sirveLiteral de ejemplo
IntEnteros con signo, 64 bits42, -7, 1_000_000
RealReales de doble precisión, IEEE 7543.14, -0.5, 1e10
BoolVerdadero / falsotrue, false
StringCadenas de texto Unicode"hola", "α + β"
CharUn único carácter Unicode'a', '\n', '\u{2603}'
UnitEl tipo con un solo valor()
NothingEl 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 sufijo n construye uno desde un literal de cualquier tamaño: 99n, y también 18446744073709551616n, que ya no cabe en Int. 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 sobre BigInt, pensado para cantidades decimales exactas. add, sub y mul son totales; div pide la escala destino de forma explícita, porque truncar es una decisión, no un accidente. Comparar 1.5 con 1.50 da igualdad: la escala no es parte del valor. Su hermano Decimal (import decimal) usa Int128 como soporte: más liviano, con techo cerca de los 38 dígitos. También tiene sufijo: 12.0d es un Decimal.
  • Rational (import rational): fracción exacta, un par num/den sobre BigInt siempre reducido a términos mínimos. 1/2 + 1/3 es exactamente 5/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.

← Volver al índice