Saltar al contenido
K
Kotlin: el lenguajeConcepto35 min de lectura · 5 min de máquina Intermedio

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.

ejemplo mínimo: null no es cero ni vacío (con comentarios)
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
null

La ú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.

HerramientaQué haceSi el valor es null
dorsal: Int?El tipo admite null—
dorsal?.let { … }Ejecuta el bloque con el valorNo ejecuta nada y da null
dorsal ?: "-" (Elvis)Valor de reservaUsa el de la derecha
dorsal!!Afirma «no es null»Lanza NullPointerException
smart castTras 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).

ejemplo mínimo: ?., ?:, let y smart cast (con comentarios)
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
3

El 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:

así no vale: smart cast sobre un var de fuera de la función
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:

con el bug: !! sobre un valor que puede faltar
fun main() {
    val dorsalDeLuis: Int? = null          // Luis no tiene dorsal
    val dorsal: Int = dorsalDeLuis!!       // compila, y falla al ejecutar
    println("Dorsal: $dorsal")
}
corregido: decidir qué pasa si falta
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:

  1. La primera línea dice qué pasó: java.lang.NullPointerException.
  2. 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.
  3. La línea que importa es la primera que apunta a tu código: ahí está el fallo. Las que empiezan por java.base o kotlin son de las librerías.

Este programa tiene justo esas tres funciones (en un fichero Pila.kt):

ejemplo mínimo: una pila de tres llamadas (con comentarios)
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:

ejemplo mínimo: funciones con datos que pueden faltar (con comentarios)
// 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
null

Reto · 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
solución del ejercicio
// `?.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. 1.Con val j: String? = null, ¿qué imprime println(j?.length ?: -1)?

  2. 2.¿Qué hace dorsal!! cuando dorsal vale null?

  3. 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. 4.Ves una pila de llamadas con líneas at java.base/... y una at com.sebastiannarvaez.marcador.torneo.DorsalesKt.dorsalForzado(Dorsales.kt:9). ¿Dónde empiezas a buscar el fallo?

  5. 5.En una entrevista te piden distinguir ?., ?: y !!. ¿Cuál es la respuesta correcta?

  6. 6.Con val dorsal: Int? = null, ¿qué diferencia hay entre escribir dorsal + 1 y dorsal!! + 1?

Faltan 6 de 6