Control de flujo y colecciones en Kotlin

Lección 3 de 8. Hasta ahora los programas iban en línea recta. Aquí aprendes a decidir con if y when, que en Kotlin además devuelven valores, a repetir con rangos y bucles, y a guardar muchos datos en listas, mapas y conjuntos. Verás también la idea que distingue a las colecciones de Kotlin: las de solo lectura y las mutables son tipos distintos, y el compilador no deja confundirlas.

if también devuelve un valor

El if de Kotlin se escribe como en Java, C o JavaScript: una condición entre paréntesis y un bloque entre llaves. La condición tiene que ser un Boolean; un número o un texto no cuentan como «verdadero».

fun main() {
    val temperatura = 31

    if (temperatura >= 30) {
        println("Hace calor")
    } else if (temperatura >= 15) {
        println("Se está bien")
    } else {
        println("Hace frío")
    }
}
salidaHace calor

La novedad es que en Kotlin if es una expresión: devuelve un valor, el de la rama que se ejecutó. Por eso Kotlin no tiene el operador ternario condición ? a : b de Java: no le hace falta.

fun main() {
    val temperatura = 12
    val ropa = if (temperatura >= 20) "playera" else "suéter"
    println("Hoy toca $ropa")

    val mensaje = if (temperatura < 0) {
        println("(alerta de helada)")
        "Quédate en casa"
    } else {
        "Puedes salir"
    }
    println(mensaje)
}
salidaHoy toca suéter
Puedes salir

Si una rama es un bloque, el valor es la última expresión del bloque. Cuando se usa como expresión, el else es obligatorio: sin él, no habría valor que dar en el otro caso.

when: el switch que devuelve valores

when compara un valor con varias opciones y ejecuta la primera que coincida. Cada rama puede tener varios valores separados por comas, un rango con in o una comprobación de tipo con is:

fun describir(x: Any): String = when (x) {
    0 -> "cero"
    1, 2, 3 -> "poquito"
    in 4..9 -> "un dígito"
    is String -> "un texto de ${x.length} letras"
    else -> "otra cosa"
}

fun main() {
    println(describir(0))
    println(describir(2))
    println(describir(7))
    println(describir("Mazatlán"))
    println(describir(3.5))
}
salidacero
poquito
un dígito
un texto de 8 letras
otra cosa

Any es el tipo del que descienden todos, así que describir acepta cualquier valor. Fíjate en la rama is String: dentro de ella, Kotlin ya sabe que x es un texto y deja usar x.length sin conversiones. Es un smart cast, y lo verás mucho en la lección 4.

when también funciona sin valor entre paréntesis: entonces cada rama es una condición, y es la forma clara de escribir una cadena de if/else if:

fun tarifa(edad: Int, estudiante: Boolean): Int = when {
    edad < 3 -> 0
    edad >= 60 -> 40
    estudiante -> 50
    else -> 90
}

fun main() {
    println(tarifa(2, false))
    println(tarifa(65, false))
    println(tarifa(20, true))
    println(tarifa(35, false))
}
salida0
40
50
90

El error que te ahorra: un when al que le falta un caso

fun nombreDelDia(n: Int): String = when (n) {
    1 -> "lunes"
    2 -> "martes"
    3 -> "miércoles"
}

fun main() {
    println(nombreDelDia(4))
}
kotlincwhen_sin_else.kt:1:36: error: 'when' expression must be exhaustive. Add an 'else' branch.
fun nombreDelDia(n: Int): String = when (n) {
                                   ^^^^

Un when que devuelve un valor tiene que ser exhaustivo: cubrir todos los casos posibles. Con un Int eso solo se logra con un else. Con un enum o una clase sealed (lección 5) basta con nombrar todas las opciones, y si mañana alguien añade una nueva, el compilador te señala cada when que se quedó corto.

Si vienes de Java: when frente a switch

El switch clásico de Java necesita un break en cada caso y, si se te olvida, la ejecución «se cae» al siguiente. when nunca se cae: cada rama es independiente. Las switch expressions de Java 14 en adelante (case 1 -> ...) se parecen mucho a when; lo tienes comparado en las versiones de Java.

Rangos

Un rango es una secuencia de valores entre dos extremos. Se usan en bucles, en when y para preguntar si un valor está dentro con in:

Cuatro rangos de Kotlin. 1..5 da del 1 al 5 con los dos extremos. 1..<5 da del 1 al 4, sin el último, igual que until. 5 downTo 1 recorre del 5 al 1 hacia atrás. 1..9 step 3 da 1, 4 y 7Cuatro formas de recorrer números1..512345con los dos extremos1..<512345sin el último, como until5 downTo 154321hacia atrás1..9 step 3123456789de 3 en 3: 1, 4, 7En azul, los números que da el rango; en gris, los que se salta.

Los rangos de enteros de Kotlin: con los dos extremos, sin el último, hacia atrás y con paso. Esquema: decodigo.com.
fun main() {
    println((1..5).toList())
    println((1..<5).toList())
    println((1 until 5).toList())
    println((5 downTo 1).toList())
    println((1..9 step 3).toList())
    println(('a'..'e').joinToString(""))

    val calificacion = 87
    println(calificacion in 80..89)
    println(calificacion !in 0..100)
}
salida[1, 2, 3, 4, 5]
[1, 2, 3, 4]
[1, 2, 3, 4]
[5, 4, 3, 2, 1]
[1, 4, 7]
abcde
true
false

..< y until son lo mismo; ..< es más reciente y se lee mejor. toList() está ahí solo para poder imprimir los valores: el rango en sí no guarda los números, solo sus extremos y el paso, así que 1..1_000_000 no ocupa memoria.

Bucles: for y while

El for de Kotlin siempre recorre algo: un rango, una lista, un texto, un mapa. No existe el for (int i = 0; i < n; i++) de Java; su equivalente es un rango.

fun main() {
    for (i in 1..3) {
        println("Vuelta $i")
    }

    val frutas = listOf("mango", "guayaba", "tuna")
    for (fruta in frutas) {
        print("$fruta ")
    }
    println()

    for ((i, fruta) in frutas.withIndex()) {
        println("$i: $fruta")
    }

    for (i in frutas.indices.reversed()) {
        print("${frutas[i]} ")
    }
    println()
}
salidaVuelta 1
Vuelta 2
Vuelta 3
mango guayaba tuna 
0: mango
1: guayaba
2: tuna
tuna guayaba mango 

withIndex() da a la vez la posición y el valor, y los paréntesis de (i, fruta) los separan en dos variables: eso se llama desestructurar. indices es el rango de posiciones válidas de la lista, de 0 a size - 1.

while repite mientras se cumpla la condición; do…while la comprueba al final, así que se ejecuta al menos una vez. break sale del bucle y continue salta a la siguiente vuelta:

fun main() {
    var saldo = 1000
    var meses = 0
    while (saldo > 0) {
        saldo -= 300
        meses++
    }
    println("Se acabó en $meses meses; quedó $saldo")

    for (n in 1..10) {
        if (n % 2 == 0) continue
        if (n > 7) break
        print("$n ")
    }
    println()
}
salidaSe acabó en 4 meses; quedó -200
1 3 5 7 

Listas

Kotlin separa las colecciones en dos familias: las de solo lectura, que se crean con listOf, setOf y mapOf, y las mutables, con mutableListOf, mutableSetOf y mutableMapOf. La diferencia está en el tipo: una List no tiene métodos para cambiarla.

Las tres colecciones de Kotlin. List es ordenada y admite repetidos; Set no admite repetidos; Map guarda pares de clave y valor. Cada una se crea de solo lectura con listOf, setOf o mapOf, o modificable con mutableListOf, mutableSetOf o mutableMapOfSolo lectura o mutable: lo decide el tipoListordenada, con repetidossolo lecturalistOf(…)se puede cambiarmutableListOf(…)[pan, pan, leche]Setsin repetidossolo lecturasetOf(…)se puede cambiarmutableSetOf(…)[pan, leche]Mapclave y valorsolo lecturamapOf(a to 1)se puede cambiarmutableMapOf(…){pan=2, leche=1}

List, Set y Map, cada una en su versión de solo lectura y en la mutable. Esquema: decodigo.com.
fun main() {
    val estados = listOf("Oaxaca", "Jalisco", "Yucatán", "Sonora")
    println(estados.size)
    println(estados[0])
    println(estados.first() + " y " + estados.last())
    println("Oaxaca" in estados)
    println(estados.indexOf("Sonora"))
    println(estados.getOrNull(10))
    println(estados.sorted())
    println(estados.joinToString(" / "))
}
salida4
Oaxaca
Oaxaca y Sonora
true
3
null
[Jalisco, Oaxaca, Sonora, Yucatán]
Oaxaca / Jalisco / Yucatán / Sonora

Los corchetes dan el elemento por su posición, desde 0. getOrNull devuelve null si la posición no existe, en vez de fallar. Y sorted() no ordena la lista: devuelve otra lista ordenada, porque la original es de solo lectura. Si intentas añadirle algo, el compilador no encuentra el método:

fun main() {
    val estados = listOf("Jalisco", "Oaxaca")
    estados.add("Sonora")
}
kotlinclista_add.kt:3:13: error: unresolved reference 'add' on receiver of type 'List<String>'.
    estados.add("Sonora")
            ^^^

«Unresolved reference» significa que add no existe para ese tipo. Para una lista que crece, usa mutableListOf; si empieza vacía, hay que decir de qué tipo serán sus elementos:

fun main() {
    val pendientes = mutableListOf<String>()
    pendientes.add("pagar la luz")
    pendientes.add("comprar tortillas")
    pendientes += "llamar a mamá"
    pendientes.removeAt(0)
    pendientes[0] = "comprar tortillas y queso"
    println(pendientes)
    println(pendientes.isEmpty())
}
salida[comprar tortillas y queso, llamar a mamá]
false

Pedir una posición que no existe con los corchetes sí es un error, pero al ejecutar:

fun main() {
    val numeros = listOf(10, 20, 30)
    println(numeros[3])
}
salida con errorException in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3
    at Fuera_de_rangoKt.main(fuera_de_rango.kt:3)

La excepción habla de un array porque la lista que crea listOf se guarda por dentro en uno. Si no sabes si la posición existe, pregunta antes con i in lista.indices o usa getOrNull.

Si vienes de Java: List no es lo mismo en los dos

Por dentro, una List de Kotlin es una java.util.List normal: puedes pasarla a cualquier método de Java. La diferencia está en el compilador de Kotlin, que no te deja llamar a add o remove sobre una List. En Java, List.of(...) sí deja escribir add y la lista se queja al ejecutar con UnsupportedOperationException. Ojo: «solo lectura» no es lo mismo que «inmutable»; lo explicamos en las rarezas de Kotlin.

Mapas

Un mapa guarda pares de clave y valor. Cada par se escribe con to:

fun main() {
    val ladas = mapOf("CDMX" to "55", "Guadalajara" to "33", "Monterrey" to "81")
    println(ladas["Monterrey"])
    println(ladas["Tijuana"])
    println(ladas.getOrDefault("Tijuana", "sin dato"))
    println(ladas.keys)
    println(ladas.size)

    for ((ciudad, lada) in ladas) {
        println("$ciudad: $lada")
    }
}
salida81
null
sin dato
[CDMX, Guadalajara, Monterrey]
3
CDMX: 55
Guadalajara: 33
Monterrey: 81

Buscar con corchetes una clave que no existe no falla: devuelve null. Por eso el tipo de ladas["Monterrey"] es String? y no String, y el compilador te obliga a contemplar que falte. Si estás seguro de que la clave existe, getValue devuelve el valor directamente y lanza una excepción si no está. Un mapa conserva el orden en que se insertaron las claves.

Un uso clásico de un mapa mutable es contar cosas:

fun main() {
    val texto = "el perro y el gato y el loro"
    val cuenta = mutableMapOf<String, Int>()
    for (palabra in texto.split(" ")) {
        cuenta[palabra] = cuenta.getOrDefault(palabra, 0) + 1
    }
    println(cuenta)
    println(cuenta.maxBy { it.value }.key)
}
salida{el=3, perro=1, y=2, gato=1, loro=1}
el

La última línea usa una lambda, el bloque entre llaves, para decir por qué valor se busca el máximo. Las lambdas son el tema de la lección 6; ahí verás que este conteo cabe en una línea con groupingBy.

Conjuntos

Un Set no admite repetidos y responde muy rápido a la pregunta «¿está este elemento?»:

fun main() {
    val vistos = mutableSetOf<String>()
    for (correo in listOf("ana@example.com", "luis@example.com", "ana@example.com")) {
        if (!vistos.add(correo)) {
            println("Repetido: $correo")
        }
    }
    println(vistos)

    val lunes = setOf("Ana", "Luis", "Eva")
    val martes = setOf("Luis", "Eva", "Raúl")
    println(lunes intersect martes)
    println(lunes union martes)
    println(lunes subtract martes)
}
salidaRepetido: ana@example.com
[ana@example.com, luis@example.com]
[Luis, Eva]
[Ana, Luis, Eva, Raúl]
[Ana]

add devuelve false si el elemento ya estaba, lo que permite detectar repetidos sin buscar antes. intersect, union y subtract se pueden escribir entre los dos conjuntos, sin punto ni paréntesis, porque son funciones infix.

Arrays

También existen los arrays de tamaño fijo, arrayOf(...) y, para números, IntArray o intArrayOf(...), que por dentro son el int[] de Java. En el código de Kotlin se usan poco, casi siempre para hablar con bibliotecas de Java o cuando el rendimiento importa. Una advertencia: si imprimes un array directamente, sale algo como [I@1b6d3586 (el tipo y una dirección); para ver su contenido usa contentToString():

fun main() {
    val dias = IntArray(7)
    dias[0] = 5
    dias[6] = 2
    println(dias.contentToString())
    println(dias.sum())

    val colores = arrayOf("rojo", "verde")
    println(colores.contentToString())
}
salida[5, 0, 0, 0, 0, 0, 2]
7
[rojo, verde]

Resumen

  • ParaEscribeRecuerda
  • Elegir un valorval x = if (c) a else bno hay operador ternario
  • Varias opcioneswhen (x) { 1, 2 -> ... ; in 3..9 -> ... ; else -> ... }como expresión, exhaustivo
  • Contar de 1 a nfor (i in 1..n)..< excluye el último
  • Hacia atrás o con paso10 downTo 1 step 2los rangos no ocupan memoria
  • Posición y valorfor ((i, x) in lista.withIndex())desestructurar
  • Una lista fijalistOf(...)sin add; sorted() da otra
  • Una lista que crecemutableListOf<String>()+= añade
  • Clave y valormapOf("a" to 1)m[k] puede ser null
  • Sin repetidossetOf(...)add devuelve si era nuevo

Retos

Reto 1. Escribe el clásico FizzBuzz del 1 al 15 con when: si el número es múltiplo de 3 y de 5, escribe «FizzBuzz»; si solo de 3, «Fizz»; si solo de 5, «Buzz»; si no, el número.

Ver la solución
fun main() {
    for (n in 1..15) {
        val texto = when {
            n % 15 == 0 -> "FizzBuzz"
            n % 3 == 0 -> "Fizz"
            n % 5 == 0 -> "Buzz"
            else -> n.toString()
        }
        print("$texto ")
    }
    println()
}
salida1 2 Fizz 4 Buzz Fizz 7 8 Fizz Buzz 11 Fizz 13 14 FizzBuzz 

El orden de las ramas importa: when se queda con la primera que se cumple, así que el caso de 15 va antes que los de 3 y 5. Y como es una expresión, todas las ramas dan un String: por eso el número se convierte con toString().

Reto 2. Cuenta cuántas veces aparece cada vocal en «murcielago» y escríbelo como un mapa. Las vocales que no aparezcan no deben salir.

Ver la solución
fun main() {
    val vocales = mutableMapOf<Char, Int>()
    for (letra in "murcielago") {
        if (letra in "aeiou") {
            vocales[letra] = vocales.getOrDefault(letra, 0) + 1
        }
    }
    println(vocales)
}
salida{u=1, i=1, e=1, a=1, o=1}

Un String se recorre con for letra a letra, y letra in "aeiou" pregunta si ese carácter está en el texto. Las claves salen en el orden en que aparecieron por primera vez.

Reto 3. Suma los números pares del 1 al 100 sin usar if.

Ver la solución
fun main() {
    var suma = 0
    for (n in 2..100 step 2) {
        suma += n
    }
    println(suma)
    println((2..100 step 2).sum())
}
salida2550
2550

Con un rango que empieza en 2 y avanza de 2 en 2 no hace falta preguntar si cada número es par. La segunda línea hace lo mismo sin bucle: un rango ya sabe sumarse.

Todo el código de esta lección 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.