Nulabilidad
Al terminar: Usar ?, ?., ?:, !!, let y smart casts sin miedo, y explicar por qué Kotlin evita el error del billón de dólares.
Esta lección no toca tu proyecto: todo se practica en el Kotlin Playground (el mismo que usaste en Tu primer proyecto en Kotlin), con ejercicios sueltos. Al terminar sabrás tratar un dato que puede faltar, como el dorsal de un jugador del fútbol amateur, que a veces no tiene: un Int? que se muestra como #9 o - sin que el programa se caiga. También sabrás leer el mensaje que Kotlin da cuando un null se cuela donde no debía.
Importa porque el fallo más común de casi cualquier lenguaje es «usé algo que no existía». Kotlin convierte ese fallo de ejecución en un error de compilación, y esta lección explica cómo, con qué operadores y dónde se rompe la protección.
Temas: null, ?, ?., ?:, !!, let y smart casts. Leer la pila de llamadas de un error.
Qué es null
null es la ausencia de valor: no es 0 ni un texto vacío, es «no hay nada». Un jugador sin dorsal no tiene el dorsal 0 (que sería un dorsal como cualquier otro), simplemente no tiene ninguno. Un texto vacío tampoco es null: "" es un texto con cero letras, y null es que no hay texto.
El problema aparece cuando un programa intenta usar algo que no existe, como pedir las letras de un texto que no hay. En muchos lenguajes eso falla mientras el programa se ejecuta. Para entender por qué Kotlin lo hace distinto, hace falta distinguir dos momentos:
- Error de compilación: lo encuentra Kotlin al compilar, es decir, al traducir tu código antes de ejecutarlo. Aparece en rojo en el editor y el programa ni siquiera llega a arrancar. Es el error barato: lo ves tú, en tu mesa.
- Error de ejecución: aparece con el programa ya en marcha y lo para en seco. Es el que ve el usuario, y el caro.
Kotlin convierte el fallo de «usé algo que no existía» de error de ejecución en error de compilación. Cómo lo consigue: en el tipo.
En 1965, Tony Hoare añadió el valor nulo a un lenguaje de programación. Décadas después lo llamó «quizá un error de mil millones de dólares».
(Un «puntero» es la dirección donde está guardado un dato; un puntero nulo es una dirección que no lleva a ninguna parte. Lo vuelves a ver más abajo.)
Un tipo normal (String, Int) no admite null. Le añades ? para permitirlo (String?, Int?), y a partir de ahí el compilador te obliga a decidir qué hacer con la posibilidad de que no haya nada.
El punto: pedirle algo a un valor
Los valores traen datos y funciones propios, que se piden con un punto: "Ana".length es 3 (length es cuántos caracteres tiene el texto). Un dato que lleva el propio valor, como length, se llama propiedad; una función que lleva, como la toLong() de Variables, tipos y operaciones, se llama método. Ya lo viste de pasada; ahora lo tienes explicado, y lo usarás constantemente. Y aquí está el problema: si el valor es null, no hay nada a quien pedírselo.
fun main() {
val ana: String = "Ana" // un String normal: siempre tiene texto
val sinNada: String? = null // String?: puede tener texto o no tener nada (null)
val vacio: String = "" // un texto vacío NO es null: es un texto con cero letras
println(ana.length) // 3: el punto le pide a "Ana" una de sus propiedades
println(vacio.length) // 0
println(sinNada) // null
// println(sinNada.length) // error de compilación: sinNada podría ser null
}3
0
nullLa última línea está comentada a propósito: si la activas, el editor la marca en rojo y Kotlin se niega a compilar, porque sinNada podría ser null. Eso es lo que hace el ?: te obliga a tratar el caso antes de ejecutar nada.
Las herramientas para tratar un null
Una excepción es un error que para el programa; lanzar una excepción es provocarla, y la verás a fondo en Excepciones: try, catch y require. La más famosa es NullPointerException («excepción de puntero nulo»), que salta cuando se usa un null como si fuera un valor, y se abrevia NPE.
| Herramienta | Qué hace | Si el valor es null |
|---|---|---|
dorsal: Int? | El tipo admite null | — |
dorsal?.let { … } | Ejecuta el bloque con el valor | No ejecuta nada y da null |
dorsal ?: "-" (Elvis) | Valor de reserva | Usa el de la derecha |
dorsal!! | Afirma «no es null» | Lanza NullPointerException |
| smart cast | Tras if (x != null), x pasa a tipo no nulable | — |
(En la cita, «el objeto» es simplemente el valor que hay a la izquierda del ?., y NPE es la NullPointerException de arriba.)
Dos palabras nuevas que aparecen ya en el ejemplo siguiente. let es una función que ejecuta un bloque de código entre llaves, y dentro de él el valor se llama it; let devuelve lo que valga la última línea del bloque. Ese bloque es una lambda, un trozo de código que se pasa como un valor: la verás a fondo en Lambdas y funciones de orden superior. Y el smart cast («conversión inteligente») es que, tras comprobar que algo no es null, Kotlin trata el valor como de otro tipo, de String? a String, sin que lo pidas (tratar un valor como de otro tipo se llama cast).
fun main() {
val n: String? = "Ana"
val j: String? = null
println(n?.length) // 3: n tiene valor, así que se pide length
println(j?.length) // null: j es null, ?. no pide nada y da null
println(j?.length ?: -1) // -1: valor de reserva cuando lo de la izquierda es null
n?.let { println("Hola $it") } // Hola Ana: el bloque solo corre si n no es null
if (n != null) println(n.length) // smart cast: dentro del if, n ya es String (3)
}3
null
-1
Hola Ana
3El smart cast tiene condiciones
Tras if (x != null), el compilador trata x como no nulable solo si puede garantizar que nadie lo cambia entre la comprobación y el uso. Con un val o una variable local de la función, sí. Con un var declarado fuera de la función (en el fichero, al nivel de las funciones), otro código podría cambiarlo en medio, y no lo acepta:
var nombreDelCapitan: String? = "Ana" // fuera de la función: cualquiera puede cambiarlo
fun mostrarCapitan() {
if (nombreDelCapitan != null) {
println(nombreDelCapitan.length) // error de compilación
}
}Smart cast to 'String' is impossible, because 'nombreDelCapitan' is a mutable property that could be mutated concurrently. Traducido: «el smart cast a String es imposible, porque nombreDelCapitan es una variable cambiable (una propiedad mutable: un dato con nombre que puede cambiar) y podría cambiar a la vez desde otro sitio». Se arregla copiando el valor a un val local, val actual = nombreDelCapitan, y comprobando actual; o con nombreDelCapitan?.length.
Error típico: !! como atajo
!! compila siempre. El problema llega al ejecutar:
fun main() {
val dorsalDeLuis: Int? = null // Luis no tiene dorsal
val dorsal: Int = dorsalDeLuis!! // compila, y falla al ejecutar
println("Dorsal: $dorsal")
}fun main() {
val dorsalDeLuis: Int? = null
println("Dorsal: ${dorsalDeLuis ?: "-"}") // Dorsal: -
}Falla porque !! no comprueba nada: le quita al compilador la protección y traslada el error del momento de compilar al de ejecutar, justo el problema que el ? resolvía. Úsalo solo donde un null sería un fallo de tu programa y no un dato posible.
Leer la pila de llamadas
Cuando el programa cae, imprime la pila de llamadas: la lista de quién llamó a quién hasta llegar al fallo. Si main llama a presentar, y presentar llama a dorsalForzado, y ahí está el error, la pila los lista de la última llamada a la primera. Se lee de arriba abajo:
- La primera línea dice qué pasó:
java.lang.NullPointerException. - Cada
at …es una llamada, de la más reciente a la más antigua, con el fichero y el número de línea entre paréntesis. - La línea que importa es la primera que apunta a tu código: ahí está el fallo. Las que empiezan por
java.baseokotlinson de las librerías.
Este programa tiene justo esas tres funciones (en un fichero Pila.kt):
fun dorsalForzado(dorsal: Int?): Int = dorsal!! // línea 1: aquí se cae si dorsal es null
fun presentar(dorsal: Int?): String {
return "Dorsal ${dorsalForzado(dorsal)}" // línea 4: presentar llama a dorsalForzado
}
fun main() {
println(presentar(7)) // funciona
println(presentar(null)) // línea 9: main llama a presentar con null
}Dorsal 7
Exception in thread "main" java.lang.NullPointerException
at PilaKt.dorsalForzado(Pila.kt:1)
at PilaKt.presentar(Pila.kt:4)
at PilaKt.main(Pila.kt:9)
at PilaKt.main(Pila.kt)thread "main" («hilo principal») es la línea de ejecución por la que corre tu programa; por ahora ignóralo, solo dice que el fallo ocurrió en el programa principal. La primera línea at es dorsalForzado (Pila.kt:1): ahí estaba el null que !! juró que no existía. Las siguientes son quién la llamó: presentar (línea 4) y main (línea 9), de la más reciente a la más antigua. La última, PilaKt.main(Pila.kt) sin número de línea, es un envoltorio que Kotlin genera por su cuenta y puedes ignorarla. PilaKt es el nombre que Kotlin da a las funciones sueltas del fichero Pila.kt. En un proyecto, antes de ese nombre aparece el paquete (com.sebastiannarvaez…), y en el Playground el mensaje es más corto; en los dos se lee igual: busca la primera línea que apunte a tu código.
En el módulo de Swift verás el equivalente de todo esto con otra sintaxis.
Práctica · Las funciones del dorsal
Antes del ejercicio, tres piezas que vas a necesitar: takeIf, el return temprano (salir de la función en cuanto sabes la respuesta, sin esperar al final) y un tipo de retorno que termina en ?, que avisa de que la función puede devolver null. Aquí están en un ejemplo, explicado línea a línea:
// takeIf { … }: devuelve el propio valor si cumple la condición, y null si no.
// Dentro de las llaves, it es el propio valor (aquí, el texto), igual que con let.
// isNotBlank() es true si el texto tiene algo más que espacios.
// El tipo de retorno Int? avisa de que la función puede devolver null.
fun letrasDelTitulo(titulo: String): Int? = titulo.takeIf { it.isNotBlank() }?.length
// Recibe un título que puede faltar (String?) y unas páginas que pueden faltar (Int?)
fun ficha(titulo: String?, paginas: Int?): String {
if (titulo == null) return "Sin libro" // return temprano: si es null, la función acaba aquí
return "$titulo, ${paginas ?: "?"} págs." // aquí titulo ya es String: smart cast
}
fun main() {
println(ficha("Rayuela", 600)) // con todos los datos
println(ficha("Diario", null)) // sin páginas: el ?: pone "?"
println(ficha(null, null)) // sin libro: sale por el return temprano
println(letrasDelTitulo("Rayuela")) // 7
println(letrasDelTitulo(" ")) // null: el título solo tiene espacios
}Al ejecutarlo, imprime:
Rayuela, 600 págs.
Diario, ? págs.
Sin libro
7
nullReto · Ejercicio: las funciones del dorsal
En el Kotlin Playground, escribe cinco funciones (en una sola ventana, con su main):
dorsalOGuion: recibe un dorsal que puede faltar y devuelve el dorsal con almohadilla delante («#9») o un guion («-») si falta.dorsalForzado: recibe un dorsal que puede faltar y lo devuelve dando por hecho que existe; si falta, el programa se cae.esPortero: recibe un dorsal que puede faltar y dice si es el 1; si falta, no es portero.longitudDelNombre: recibe un nombre y devuelve cuántas letras tiene, o nada (null) si está en blanco.presentar: recibe un nombre que puede faltar y un dorsal que puede faltar, y devuelve «Ana (#9)», o «Sin jugador» si no hay nombre.
En main, presenta a Ana (dorsal 9), a Luis (sin dorsal), a Marta (dorsal 1) y a nadie; después escribe si el 1 es portero, las letras de «Ana» y las de un nombre en blanco. Por último llama a dorsalForzado sin dorsal y mira cómo se cae. La consola debe mostrar:
Ana (#9)
Luis (-)
Marta (#1)
Sin jugador
true
3
null
Exception in thread "main" java.lang.NullPointerException(El mensaje de error del final puede variar un poco en el Playground; lo importante es que aparece NullPointerException.)
Ver solución
// `?.let`: el bloque solo corre si el dorsal no es null.
// `?:` (elvis): el valor de reserva cuando lo de la izquierda es null.
fun dorsalOGuion(dorsal: Int?): String = dorsal?.let { "#$it" } ?: "-"
// `!!` promete «esto no es null». Si mientes, salta NullPointerException.
fun dorsalForzado(dorsal: Int?): Int = dorsal!!
// Smart cast: tras comprobar `dorsal != null`, dentro del `if` ya es Int.
fun esPortero(dorsal: Int?): Boolean {
if (dorsal != null) {
return dorsal == 1
}
return false
}
// `?.` encadena: si algo es null, todo el resultado es null.
fun longitudDelNombre(nombre: String): Int? = nombre.takeIf { it.isNotBlank() }?.length
// Smart cast sobre un parámetro: tras el return temprano, `nombre` ya es String.
fun presentar(nombre: String?, dorsal: Int?): String {
if (nombre == null) return "Sin jugador"
return "$nombre (${dorsalOGuion(dorsal)})"
}
fun main() {
println(presentar("Ana", 9))
println(presentar("Luis", null))
println(presentar("Marta", 1))
println(presentar(null, null))
println(esPortero(1))
println(longitudDelNombre("Ana"))
println(longitudDelNombre(" "))
println(dorsalForzado(null)) // aquí se cae: NullPointerException
}Estas mismas funciones, metidas dentro de una clase Jugador, son las que añadirás a tu proyecto en la lección siguiente.
Comprueba lo que entendiste
6 preguntas · necesitas 5 para completar la lección
1.Con
val j: String? = null, ¿qué imprimeprintln(j?.length ?: -1)?2.¿Qué hace
dorsal!!cuandodorsalvalenull?3.
var nombreDelCapitan: String? = "Ana"está declarado fuera de la función. ¿Por quéif (nombreDelCapitan != null) println(nombreDelCapitan.length)da error dentro de una función?4.Ves una pila de llamadas con líneas
at java.base/...y unaat com.sebastiannarvaez.marcador.torneo.DorsalesKt.dorsalForzado(Dorsales.kt:9). ¿Dónde empiezas a buscar el fallo?5.En una entrevista te piden distinguir
?.,?:y!!. ¿Cuál es la respuesta correcta?6.Con
val dorsal: Int? = null, ¿qué diferencia hay entre escribirdorsal + 1ydorsal!! + 1?