Kotlin atrapa muchos errores antes de ejecutar, empezando por los null, pero no todos: a veces compila limpio un código que falla, y a veces hace algo distinto de lo que parece. Estas son quince rarezas de Kotlin, compiladas y ejecutadas con Kotlin 2.4.21 sobre Java 25, con la explicación de por qué pasan y la costumbre que las evita.
Kotlin tiene fama de lenguaje seguro: el compilador distingue lo que puede ser null de lo que no, obliga a cubrir todos los casos de un when y no hace conversiones de tipos a escondidas. Esa fama es merecida, pero no es absoluta. Hay rincones donde el código compila limpio y falla al ejecutar, y otros donde hace algo distinto de lo que parece. Muchos vienen de que Kotlin corre sobre la JVM y comparte sus reglas con Java. Cada fragmento de esta página se compiló con Kotlin 2.4.21 y se ejecutó de verdad en Java 25.
- 127 === 127 es true, pero 128 === 128 es false
- Una List de solo lectura que cambia
- Dos arrays iguales que == dice que no lo son
- Dos data classes iguales con datos distintos
- copy() no copia las listas de dentro
- Un return dentro de forEach sale de la función
- «$nombre.length» no imprime la longitud
- -5.absoluteValue da -5
- Un Int que se desborda sin avisar
- -0.0 == 0.0 depende del tipo de la variable
- Un NullPointerException en código sin un solo !!
- lateinit: el compilador confía en ti
- Un val que es null aunque su tipo no lo permite
- Un val que cambia cada vez que lo lees
- La desestructuración no mira los nombres
127 === 127 es true, pero 128 === 128 es false
fun main() {
val a: Int? = 127
val b: Int? = 127
println(a === b)
val c: Int? = 128
val d: Int? = 128
println(c === d)
println(c == d)
}kotlincidentidad.kt:4:13: warning: identity equality for arguments of types 'Int?' and 'Int?' is prohibited.
println(a === b)
^^^^^^^
identidad.kt:8:13: warning: identity equality for arguments of types 'Int?' and 'Int?' is prohibited.
println(c === d)
^^^^^^^salidatrue false true
Por qué: en Kotlin, == compara valores y === compara identidad: si las dos variables apuntan al mismo objeto. Un Int normal se guarda como un número primitivo de la JVM, pero un Int? tiene que poder ser null, así que se guarda en un objeto java.lang.Integer. La JVM tiene una caché con los Integer de -128 a 127 y los reutiliza: por eso dos 127 son el mismo objeto y dos 128 no. Es la misma rareza que en Java con Integer, y el compilador avisa: el mensaje dice «prohibited», pero es solo un warning, así que el programa compila y se ejecuta.
== para comparar números, siempre. === solo tiene sentido con objetos de tus clases, cuando de verdad quieres saber si son el mismo. Si vienes de Java, en las rarezas de Java está la versión original de este problema.Una List de solo lectura que cambia
fun main() {
val compras = mutableListOf("pan", "leche")
val vista: List<String> = compras
compras.add("huevos")
println(vista)
val fija = listOf("a", "b")
(fija as MutableList<String>)[0] = "z"
println(fija)
fija.add("c")
}salida con error[pan, leche, huevos]
[z, b]
Exception in thread "main" java.lang.UnsupportedOperationException
at Solo_lecturaKt.main(solo_lectura.kt:11)Por qué: List en Kotlin significa de solo lectura, no inmutable. La interfaz no tiene add, pero el objeto que hay detrás puede cambiar: vista es la misma lista que compras vista por una ventana sin botones de escribir. Y listOf devuelve, en la JVM, una lista de Java (Arrays.asList) que deja cambiar elementos con un cast pero no añadirlos: por eso el [0] = "z" funciona y el add lanza UnsupportedOperationException.
List a MutableList, y si recibes una lista que tienes que guardar, haz tu propia copia con toList(). Para listas inmutables de verdad existe la biblioteca kotlinx.collections.immutable. Las colecciones están en la lección 3.Dos arrays iguales que == dice que no lo son
data class Lectura(val sensor: String, val valores: IntArray)
fun main() {
println(intArrayOf(1, 2) == intArrayOf(1, 2))
println(intArrayOf(1, 2).contentEquals(intArrayOf(1, 2)))
println(listOf(1, 2) == listOf(1, 2))
println(Lectura("t1", intArrayOf(20, 21)) == Lectura("t1", intArrayOf(20, 21)))
}kotlincarrays_igual.kt:4:30: warning: '==' on arrays compares only references. Replace '==' with 'contentEquals' to compare the arrays' contents or use '===' to remove the warning.
println(intArrayOf(1, 2) == intArrayOf(1, 2))
^^salidafalse true true false
Por qué: los arrays de Kotlin son arrays de la JVM, y su equals compara referencias. Las listas, en cambio, comparan elemento por elemento. Lo peor es la última línea: una data class genera su equals llamando al equals de cada propiedad, así que dos lecturas con los mismos datos salen distintas. El compilador avisa del == directo entre arrays, pero no de la data class.
data class, usa List en lugar de arrays; para comparar arrays sueltos, contentEquals.Dos data classes iguales con datos distintos
data class Producto(val nombre: String, val precio: Double) {
var existencias: Int = 0
}
fun main() {
val a = Producto("Café", 189.0).apply { existencias = 3 }
val b = Producto("Café", 189.0).apply { existencias = 50 }
println(a == b)
println(a)
}salidatrue Producto(nombre=Café, precio=189.0)
Por qué: todo lo que genera una data class (equals, hashCode, toString, copy y los componentN) usa solo las propiedades del constructor primario. existencias está en el cuerpo, así que no cuenta para comparar ni sale al imprimir. A veces se hace a propósito, pero casi siempre es un descuido.
data class están en la lección 5.copy() no copia las listas de dentro
data class Carrito(val cliente: String, val articulos: MutableList<String>)
fun main() {
val original = Carrito("Ana", mutableListOf("pan"))
val copia = original.copy(cliente = "Eva")
copia.articulos.add("leche")
println(original)
println(copia)
}salidaCarrito(cliente=Ana, articulos=[pan, leche]) Carrito(cliente=Eva, articulos=[pan, leche])
Por qué: copy crea un objeto nuevo, pero copia las referencias de cada propiedad, no su contenido. Original y copia comparten la misma MutableList, y la leche aparece en el carrito de Ana.
data class, propiedades val con tipos de solo lectura (List, no MutableList). Para «añadir», crea otra lista: c.copy(articulos = c.articulos + "leche").Un return dentro de forEach sale de la función
fun buscar(nombres: List<String>) {
nombres.forEach {
if (it == "Luis") return
println("Reviso a $it")
}
println("Terminé de buscar")
}
fun main() {
buscar(listOf("Ana", "Luis", "Eva"))
}salidaReviso a Ana
Por qué: forEach es una función inline, y dentro de un lambda que se pasa a una función inline, return sale de la función que lo contiene (buscar), no del lambda. Ni Eva se revisa ni se imprime «Terminé de buscar». Para saltar solo ese elemento, como haría un continue, se usa un return con etiqueta:
fun buscar(nombres: List<String>) {
nombres.forEach {
if (it == "Luis") return@forEach
println("Reviso a $it")
}
println("Terminé de buscar")
}
fun main() {
buscar(listOf("Ana", "Luis", "Eva"))
}salidaReviso a Ana Reviso a Eva Terminé de buscar
break o continue, usa un for normal; en un forEach, return@forEach. Los lambdas e inline están en la lección 6.«$nombre.length» no imprime la longitud
fun main() {
val nombre = "Ana"
val usuario = mapOf("nombre" to "Eva")
println("Letras: $nombre.length")
println("Letras: ${nombre.length}")
println("Hola, $usuario[nombre]")
}salidaLetras: Ana.length
Letras: 3
Hola, {nombre=Eva}[nombre]Por qué: en una plantilla, $ toma solo un nombre: $nombre. Lo que sigue (.length, [nombre]) es texto normal. Para una expresión hacen falta las llaves: ${nombre.length}. Compila sin avisos porque es perfectamente válido.
${...} en cuanto haya un punto, unos corchetes o una operación. Las plantillas están en la lección 1.-5.absoluteValue da -5
import kotlin.math.absoluteValue
fun main() {
println(-5.absoluteValue)
println((-5).absoluteValue)
println(-7 % 3)
println((-7).mod(3))
println(-7 / 2)
}salida-5 5 -1 2 -3
Por qué: el - delante de un número no forma parte del literal: es un operador, y la llamada con punto tiene más prioridad. -5.absoluteValue se lee -(5.absoluteValue). Además, % es el resto y conserva el signo del dividendo (-1), como en Java; el módulo matemático, siempre positivo para un divisor positivo, es mod. Y la división entera corta hacia cero: -3, no -4.
mod en vez de % para cosas circulares (días de la semana, índices que dan la vuelta).Un Int que se desborda sin avisar
fun main() {
val segundosEnUnSiglo = 60 * 60 * 24 * 365 * 100
println(segundosEnUnSiglo)
val bien = 60L * 60 * 24 * 365 * 100
println(bien)
println(Int.MAX_VALUE + 1)
}salida-1141367296 3153600000 -2147483648
Por qué: todos los literales son Int (32 bits, hasta 2,147,483,647), así que la multiplicación se hace en Int y se desborda en silencio: da la vuelta a los negativos. Que el resultado se guarde luego en algo más grande no ayuda, porque el daño ya está hecho. Llama la atención que kotlinc 2.4.21 compiló esto sin ningún aviso, aunque todas las cifras son constantes.
Long (60L). Para detectar el desbordamiento, Math.multiplyExact lanza una excepción en lugar de dar la vuelta.-0.0 == 0.0 depende del tipo de la variable
fun main() {
println(0.1 + 0.2 == 0.3)
val a: Double = -0.0
val b: Any = -0.0
println(a == 0.0)
println(b == 0.0)
println(Double.NaN == Double.NaN)
val n: Any = Double.NaN
println(n == Double.NaN)
}salidafalse true false false true
Por qué: la primera es la de siempre: 0.1 y 0.2 no tienen representación exacta en binario. Lo raro viene después. Cuando el compilador sabe que la variable es un Double, == sigue la norma IEEE 754: -0.0 es igual a 0.0 y NaN no es igual a nada, ni a sí mismo. Cuando la variable es Any (o Double?, o está en una colección), == llama a equals, que distingue -0.0 de 0.0 y considera NaN igual a NaN. El mismo valor, la misma comparación, dos respuestas según el tipo declarado.
Double con ==; comprueba que la diferencia sea pequeña (abs(a - b) < 1e-9) y usa isNaN(). Para dinero, BigDecimal, como explicamos en BigDecimal en Java, que funciona igual desde Kotlin.Un NullPointerException en código sin un solo !!
Una clase de Java, como las que usarás a montones en Android o con Spring:
public class Clientes {
public static String correo(int id) {
return id == 1 ? "ana@example.com" : null;
}
}fun main() {
val a = Clientes.correo(1)
println(a.uppercase())
val b: String = Clientes.correo(2)
println("Nunca llego aquí: $b")
}salida con errorANA@EXAMPLE.COM
Exception in thread "main" java.lang.NullPointerException: correo(...) must not be null
at PlataformaKt.main(plataforma.kt:5)Por qué: Java no dice si un String puede ser null, así que Kotlin no puede saberlo. Lo que devuelve un método de Java es un tipo de plataforma (String! en los mensajes del IDE): Kotlin te deja tratarlo como quieras, sin avisos, y la responsabilidad es tuya. Al guardarlo en una variable String, Kotlin añade una comprobación y, si llega null, lanza ahí mismo el NullPointerException con el nombre del método. Es lo mejor que puede hacer, pero el error llega al ejecutar.
String? todo lo que venga de Java si no sabes si puede ser null, y trátalo con ?. y ?:. Si escribes la parte de Java, ponle anotaciones @Nullable/@NonNull (JSpecify, JetBrains o las de Android): Kotlin las lee y convierte el tipo de plataforma en uno normal. Está en la lección 4.lateinit: el compilador confía en ti
class Pedido {
lateinit var cliente: String
}
fun main() {
val p = Pedido()
println("Pedido creado")
println(p.cliente)
}salida con errorPedido creado
Exception in thread "main" kotlin.UninitializedPropertyAccessException: lateinit property cliente has not been initialized
at Pedido.getCliente(lateinit_.kt:2)Por qué: lateinit es una promesa: «esta propiedad no es null, pero le daré valor más tarde». Existe para los frameworks que inyectan los valores después de crear el objeto (Android, Spring, las pruebas). El compilador se fía y no comprueba nada; si lees antes de asignar, la excepción llega al ejecutar. Al menos el mensaje dice exactamente qué propiedad fue.
lateinit, comprueba con ::cliente.isInitialized donde no estés seguro, o usa by lazy si el valor se puede calcular la primera vez que se pide.Un val que es null aunque su tipo no lo permite
open class Animal {
open val nombre: String = "animal"
init {
println("Creando: ${nombre.length} letras")
}
}
class Perro : Animal() {
override val nombre: String = "Firulais"
}
fun main() {
Perro()
}salida con errorException in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because the return value of "Animal.getNombre()" is null
at Animal.<init>(init_orden.kt:5)
at Perro.<init>(init_orden.kt:9)Por qué: al crear un Perro, primero se ejecuta el constructor de Animal, con su init. Ese init lee nombre, pero como es open y Perro lo sobrescribe, la lectura va al getNombre() de Perro, cuyo campo todavía no se ha inicializado: en la JVM vale null. Así, una propiedad String, que según el tipo no puede ser null, lo es. kotlinc 2.4.21 lo compiló sin ningún aviso; IntelliJ IDEA sí lo marca («Accessing non-final property in constructor»).
open en un init o en el inicializador de una propiedad de la clase base. La herencia y open están en la lección 5.Un val que cambia cada vez que lo lees
class Contador {
private var veces = 0
val siguiente: Int
get() = ++veces
}
fun main() {
val c = Contador()
println(c.siguiente)
println(c.siguiente)
println(c.siguiente)
}salida1 2 3
Por qué: val significa «no tiene setter»: nadie puede asignarle un valor desde fuera. No significa que su valor sea constante. Con un getter propio, cada lectura ejecuta código y puede dar un resultado distinto.
fun siguiente(): Int. Quien lee c.siguiente espera algo barato y sin sorpresas. Para constantes de verdad está const val.La desestructuración no mira los nombres
data class Contacto(val nombre: String, val correo: String)
fun main() {
val c = Contacto("Ana", "ana@example.com")
val (correo, nombre) = c
println("Escribir a $correo")
}salidaEscribir a Ana
Por qué: val (a, b) = c se traduce a c.component1() y c.component2(): va por posición, en el orden del constructor, y los nombres de la izquierda dan igual. Como las dos propiedades son String, no hay error de tipos que lo delate, y kotlinc 2.4.21 tampoco avisó.
Pair, una coordenada), con los mismos nombres que las propiedades, o lee las propiedades por su nombre: c.correo.Resumen: la regla detrás de cada rareza
- RarezaLa causaLa costumbre que la evita
===conInt?Caché deIntegerde -128 a 127Comparar números con==Listque cambiaSolo lectura no es inmutableCopiar contoList(), sin casts- Arrays con
==equalsde la JVM, por referenciacontentEqualsoList - Propiedades en el cuerpo
data classsolo usa el constructor primarioTodo el valor en el constructor copy()Copia referencias, no contenidoPropiedades de solo lecturareturnenforEachSale de la función que contiene el lambdareturn@forEacho unfor$nombre.length$toma solo un nombre${...}para expresiones-5.absoluteValueEl punto tiene más prioridad que el signoParéntesis;moden vez de%- DesbordamientoLos literales son
IntEmpezar conLong -0.0y NaNIEEE 754 oequals, según el tipoComparar con margen eisNaN()- Tipos de plataformaJava no dice si algo puede ser null
String?y anotaciones en Java lateinitEl compilador no comprueba la promesaConstructor,isInitializedolazyopenen uninitLa subclase aún no se ha inicializadoNadaopenen la construcciónvalque cambiavales «sin setter», no constanteFunciones para lo que tiene efectos- DesestructuraciónVa por posición, no por nombreLeer por nombre
Varias de estas rarezas vienen directamente de la JVM, así que si programas en Java te sonarán: compáralas con las rarezas de Java. ¿Quieres comprobar si ya piensas como el compilador? Prueba el test de Kotlin, con 24 preguntas sobre si un código compila y qué escribe. Y si alguna te tomó por sorpresa, el curso de Kotlin explica la base de cada una.
Todo el código de esta página se compiló con Kotlin 2.4.21 (kotlinc) y se ejecutó en la JVM de Temurin 25.0.5 en un contenedor limpio y sin red antes de publicarla; los mensajes del compilador y las salidas están copiados de esa ejecución. ¿Algo no te compila? Revisa tu versión con kotlinc -version.
