wandres.dev
SINTAXIS ESENCIAL · todo es una expresión

Tipos básicos: números, Char, Boolean, String, Any, Unit y Nothing

Por qué Kotlin prohíbe la conversión numérica implícita, qué ocurre de verdad con el boxing y la identidad, cómo funcionan las plantillas y las cadenas sin formato, y qué papel juegan los tres tipos frontera del sistema: Any, Unit y Nothing.

⏱ 16 min

En Kotlin no hay tipos primitivos en el sentido de Java: todo es un objeto y todo tiene métodos, incluido un Int. Esa uniformidad es una ficción cuidadosamente sostenida por el compilador, que en la mayoría de los casos genera el primitivo de la JVM sin caja. Entender dónde se rompe la ficción —la ausencia de conversiones implícitas, el boxing, la identidad— es entender el sistema de tipos de Kotlin por debajo del azúcar.

🎯 Al terminar esta lección sabrás
  • Explicar por qué no existe la conversión numérica implícita y qué la sustituye.
  • Predecir el resultado de comparar valores numéricos con igualdad y con identidad.
  • Manejar Char, Boolean y String con plantillas y cadenas sin formato.
  • Situar Any, Unit y Nothing en la jerarquía de tipos.

Números sin conversiones implícitas

Los tipos numéricos son Byte, Short, Int, Long, Float y Double, más los sin signo UByte, UShort, UInt y ULong, implementados como clases de valor sobre sus equivalentes con signo. Un literal entero es Int salvo que no quepa, en cuyo caso pasa a Long; un literal decimal es Double salvo sufijo f.

La regla que rompe todos los hábitos importados de Java es que no hay ensanchamiento automático:

val i: Int = 1
// val l: Long = i          // error: Int no es subtipo de Long
val l: Long = i.toLong()    // conversión explícita, siempre

val b: Byte = 1             // legal: el literal se ajusta al tipo esperado
// val b2: Byte = i         // error: aquí ya es un Int en tránsito

La justificación no es purismo. Si Int fuese asignable a Long de forma implícita, entonces un Int metido en una caja sería sustituible por un Long en caja, y la igualdad estructural de las cajas dejaría de ser coherente: un mismo número compararía distinto según el recorrido que hubiera hecho por el programa. Kotlin prefiere pagar unos cuantos toLong() a cambio de que == signifique siempre lo mismo.

La aritmética mixta sí funciona, porque está definida como sobrecargas de operador, no como conversión de tipos: 1 + 1L es Long porque Int.plus(Long) existe y devuelve Long. Y la división entera sigue siendo entera, con el redondeo hacia cero de siempre.

La escritura de literales aporta el resto de la precisión que necesitas, incluidos los separadores de legibilidad y los sufijos de los tipos sin signo:

val hex = 0xFF_FF                // hexadecimal con separador
val bin = 0b0110_1001            // binario
val millon = 1_000_000           // el guion bajo es puramente visual
val sinSigno = 3_000_000_000u    // UInt: cabe donde Int desbordaría
val mascara: ULong = 0xFFFF_FFFF_FFFF_FFFFu

Los tipos sin signo no son primitivos de la JVM: son clases de valor sobre los tipos con signo, así que en tiempo de ejecución un UInt es un int con otra interpretación de sus bits y otra tabla de operaciones. Eso los hace gratuitos en aritmética y caros en cuanto entran en una colección genérica, donde vuelve a aparecer la caja.

Igualdad, identidad y cajas

Kotlin separa dos preguntas que Java mezcla. == es igualdad estructural y delega en equals; === es identidad referencial. Sobre números sin caja === no tiene sentido y el compilador lo impide; sobre números en caja lo tiene, y el resultado incomoda:

val a: Int = 10_000
val caja1: Int? = a          // se genera una caja
val caja2: Int? = a          // se genera otra caja distinta

println(caja1 == caja2)      // true: igualdad estructural
println(caja1 === caja2)     // false: son objetos diferentes

Con valores pequeños el mismo código puede imprimir true en la segunda línea, porque la JVM cachea las cajas del rango habitual. Es decir: el resultado de === sobre números depende de una decisión de implementación de la plataforma. La conclusión práctica es tajante: sobre datos, compara siempre con ==; reserva === para preguntas sobre objetos, no sobre valores.

⚠️
La nulabilidad es lo que crea la caja

Un Int se compila a int; un Int? no puede, porque int no admite nulo. Cada vez que introduces nulabilidad en un numérico, introduces una asignación en el montón. En bucles calientes y colecciones grandes esa diferencia se nota, y es la razón de que existan IntArray o LongArray frente a Array<Int>.

Char, Boolean y String

Char representa una unidad de código UTF-16 y, a diferencia de Java, no es un número: no se asigna a Int ni participa en aritmética general. Para cruzar se usa code y digitToInt, y la suma de un entero a un carácter sí está definida como desplazamiento:

val c = 'K'
val punto: Int = c.code          // 75
val siguiente: Char = c + 1      // 'L'
val digito = '7'.digitToInt()    // 7, no 55

Boolean tiene los operadores perezosos && y ||, que cortocircuitan, y los ansiosos and, or y xor, que evalúan ambos lados. La diferencia deja de ser académica en cuanto uno de los lados tiene efectos.

String es inmutable y iterable, y su verdadera potencia está en las plantillas y en las cadenas sin formato. Una plantilla simple usa $identificador; una compleja, ${expresión}. Las cadenas sin formato van entre tres comillas, no interpretan escapes y conservan los saltos de línea:

val nombre = "Ada"
val n = 3
println("Hola $nombre, tienes $n mensajes y ${n * 2} avisos")

val consulta = """
    SELECT *
      FROM usuarios
     WHERE nombre = '$nombre'
""".trimIndent()

trimIndent calcula el margen común mínimo y lo elimina; trimMargin hace lo mismo tomando un prefijo explícito. Ambas se ejecutan en tiempo de ejecución, así que en una constante caliente conviene medir. Y dentro de una cadena sin formato el símbolo del dólar literal se escribe interpolando el propio carácter, ya que no existen escapes.

Any, Unit y Nothing

El sistema de tipos tiene tres piezas frontera que conviene no confundir.

Any es la raíz de los tipos no nulables y expone únicamente equals, hashCode y toString. La verdadera cima es Any?, porque solo ella incluye al nulo. En la JVM Any se proyecta sobre java.lang.Object, pero no hereda sus métodos de sincronización: wait y notify no están disponibles, decisión deliberada para desalentar el modelo de monitores.

Unit es un tipo con exactamente un valor, el objeto Unit. Una función que no devuelve nada útil devuelve ese valor único en lugar de no devolver nada, lo cual permite que las funciones sean uniformes: un lambda de tipo () -> Unit es un valor de primera clase igual que uno de tipo () -> Int, y los genéricos no necesitan un caso especial para el vacío.

Nothing es el extremo opuesto: un tipo sin ningún valor, subtipo de todos los demás. Nada puede tener tipo Nothing, y por eso una expresión con ese tipo señala que el control no continúa por ahí.

✍️

Any

Raíz de lo no nulable. Útil como techo genérico, inútil como tipo de dato: si acabas devolviendo Any, has perdido información en el camino.

Unit

Un tipo, un valor, un objeto. Convierte la ausencia de resultado en un resultado corriente y por eso puede aparecer como argumento genérico.

🕳️

Nothing

Cero valores y subtipo universal. Es el modo que tiene el compilador de escribir en el sistema de tipos que una rama no vuelve.

La diferencia entre Unit y Nothing se ve mejor con dos funciones que aparentemente hacen lo mismo. La primera termina y no dice nada; la segunda no termina en absoluto, y el compilador aprovecha esa promesa para marcar como inalcanzable todo lo que venga después:

fun registrar(msg: String): Unit { println(msg) }      // vuelve
fun abortar(msg: String): Nothing = error(msg)         // no vuelve
flowchart TD
T[Any nullable es la cima] --> A[Any]
A --> I[Int]
A --> S[String]
A --> U[Unit]
I --> N[Nothing es el fondo]
S --> N
U --> N
style T fill:#89b4fa,color:#11111b
style N fill:#f38ba8,color:#11111b
Tres tipos que no guardan datos y sostienen todo el sistema

Any, Unit y Nothing no existen para almacenar información: existen para que la retícula de tipos esté completa. Un sistema de tipos es un orden parcial, y un orden parcial sin cima ni fondo obliga a llenar de casos especiales cualquier operación que combine ramas. Con Any? arriba, el compilador siempre puede calcular el supertipo común de dos ramas de un when. Con Nothing abajo, siempre puede calcular el subtipo común y, sobre todo, puede tratar throw, return y break como expresiones normales cuyo tipo encaja en cualquier hueco. Con Unit en medio, la ausencia de resultado deja de ser un agujero en el lenguaje —el void de Java, que no es un tipo y por eso no puede aparecer como argumento genérico— y pasa a ser un valor corriente. La consecuencia es que en Kotlin no existe la categoría de las funciones que no devuelven nada, ni la de los constructos que no son expresiones. Todo devuelve algo, y esa uniformidad es la que hace posible que if, when y try sean expresiones sin añadir ni una regla extra al compilador.

⚔️ Palpa las fronteras
  1. Intenta asignar un Int a un Long sin conversión y lee el mensaje completo del compilador.
  2. Reproduce el experimento de caja1 === caja2 con el valor 100 y con el valor 10000, y explica la diferencia.
  3. Escribe una consulta SQL multilínea con cadena sin formato, trimIndent y una plantilla interpolada.
  4. Comprueba con c.code y c + 1 que Char no es un número pero admite desplazamiento.
  5. Investiga: declara una variable de tipo Nothing e intenta darle un valor. Razona por qué es imposible.