Saltar al contenido
K
Kotlin: el lenguajePaso a paso40 min de lectura · 15 min de máquina Intermedio

El partido hecho de eventos

Al terminar: Reconstruir Partido a partir de sus eventos y calcular el resultado con un when exhaustivo.

Al terminar esta lección Marcador sabrá de qué está hecho un partido. Hasta ahora un Partido guardaba su marcador en dos números. Desde ahora guarda lo que pasó —goles, tarjetas, cambios— y el marcador se calcula a partir de eso. Un marcador que se deduce no puede contradecir los eventos; uno guardado sí. Temas: listas que «crecen» sin cambiarse, funciones de extensión, count y forEach (como adelanto de las lambdas), y una crónica del partido en Main.kt.

Guardar el marcador o deducirlo

Si Partido guarda golesLocal = 2 y a la vez una lista de goles con tres goles locales, ¿cuál dice la verdad? Con dos fuentes de datos, pueden contradecirse. La solución es guardar una sola: la lista de lo que pasó. El marcador deja de ser un dato y pasa a ser una cuenta: «cuántos de estos eventos son goles del Rayo». Para escribir eso hacen falta cinco piezas que todavía no has visto; las cinco, con datos ajenos a Marcador.

Una lista que no se cambia, solo se amplía

Una lista es un valor que guarda muchos valores del mismo tipo, en orden. List<String> es una lista de textos. Las listas que usas hasta ahora (listOf(...)) no se pueden modificar: no tienen «añadir». Para tener una con un elemento más, se crea otra, y el operador + lo hace: lista + elemento es una lista nueva con el elemento al final, y la original queda como estaba. emptyList() es la lista vacía, el punto de partida. Tres detalles más: size dice cuántos elementos hay, [1] pide el elemento de una posición, que se cuenta desde 0 (la posición 0 es el primero), y first() pide directamente el primer elemento (en un texto, first() da su primera letra).

ejemplo mínimo: listas que se amplían (con comentarios)
fun main() {
    val vacia: List<String> = emptyList()       // una lista de textos sin elementos
    val compra = vacia + "pan"                  // + crea una lista NUEVA con "pan" al final
    val mas = compra + "leche" + "queso"        // se puede encadenar
    println(vacia)                              // la original no ha cambiado
    println(compra)
    println(mas)
    println(mas.size)                           // size: cuántos elementos hay
    println(mas[1])                             // [1]: el elemento en la posición 1 (se cuenta desde 0)
    println(mas.first())                        // first(): el primer elemento de la lista
}
[]
[pan]
[pan, leche, queso]
3
leche
pan

Es la misma idea que copy de data class e igualdad: en vez de modificar, se crea otro valor con el cambio. Las listas se ven a fondo en Colecciones.

Función de extensión: añadirle una función a un tipo

Una función de extensión es una función que escribes fuera de un tipo, pero que se llama como si fuera suya: "hola".gritar(). Sirve para dar funciones a tipos que ya existen, sea String o uno tuyo, sin tocar su código. Se escribe con el tipo delante del nombre, y dentro de ella this es el valor sobre el que se llama. Dentro de la función, this. se puede omitir: uppercase() a secas significa this.uppercase(), y si el tipo tiene una propiedad nombre, escribir nombre es lo mismo que this.nombre.

ejemplo mínimo: funciones de extensión (con comentarios)
// Una función de extensión: se escribe fuera de String, pero se llama como si fuera suya.
// Dentro, this es el texto sobre el que se llama.
fun String.gritar(): String = this.uppercase() + "!"
 
// Con this implícito: this. se puede omitir. uppercase() sola es this.uppercase().
fun String.susurrar(): String = lowercase() + "..."
 
enum class Luz { ROJA, VERDE }
 
// También sirve con tus propios tipos. this es la Luz sobre la que se llama.
fun Luz.texto(): String = when (this) {
    Luz.ROJA -> "Para"
    Luz.VERDE -> "Pasa"
}
 
fun main() {
    println("hola".gritar())          // se lee como si String tuviera esa función
    println("gol de Ana".gritar())
    println("SILENCIO".susurrar())         // dentro, lowercase() es this.lowercase()
    println(Luz.VERDE.texto())
}
HOLA!
GOL DE ANA!
silencio...
Pasa

Verás las extensiones a fondo en Extensiones; por ahora, léelas como una función normal que recibe el valor y se escribe con un punto.

when sin argumento y propiedades con get()

Hasta ahora el when miraba un valor (when (luz) { … }). Sin nada entre paréntesis, cada rama es una condición y gana la primera que da true; el else final recoge todo lo demás. Y una propiedad calculada se escribe con get() (la «lectura»): es la función que se ejecuta cada vez que alguien lee la propiedad, y lo que va después del = es lo que devuelve. Si necesita varias líneas, lleva llaves y un return.

ejemplo mínimo: when sin argumento y get() (con comentarios)
// when sin argumento: cada rama es una condición; gana la primera que se cumple
fun tarifa(edad: Int): String = when {
    edad < 12 -> "Infantil"
    edad < 65 -> "General"
    else -> "Jubilado"          // si ninguna condición se cumplió
}
 
class Cuenta(val saldo: Int) {
    // get() = …: se calcula en cada lectura, no se guarda
    val enRojo: Boolean get() = saldo < 0
 
    // get() con llaves: varias líneas y un return
    val resumen: String
        get() {
            return if (enRojo) "Debes $saldo" else "Tienes $saldo"
        }
}
 
fun main() {
    println(tarifa(8))
    println(tarifa(30))
    println(tarifa(70))
    println(Cuenta(-5).enRojo)
    println(Cuenta(20).resumen)
}
Infantil
General
Jubilado
true
Tienes 20

count y forEach: un bloque que se ejecuta por cada elemento

Las listas traen funciones que reciben un bloque de código entre llaves y lo ejecutan para cada elemento. Es una lambda, y se ve a fondo en Lambdas y funciones de orden superior. Lo único que necesitas hoy:

  • count { … } cuenta los elementos para los que el bloque da true («sí»).
  • forEach { … } ejecuta el bloque una vez por cada elemento.
  • Dentro del bloque, it es el elemento de esa vuelta.
  • && significa «y»: la condición es verdadera solo si las dos lo son (lo viste en Condiciones: if y when). Y después de it is Cafe, Kotlin ya trata it como un Cafe (el smart cast de la lección anterior) y deja leer sus propiedades.

Todo junto, con una cafetería y una cuenta que se amplía pedido a pedido:

ejemplo mínimo: una cuenta de cafetería (con comentarios)
// Las líneas de la cuenta: una interfaz sellada, como el EventoDePartido que ya conoces
sealed interface Linea { val precio: Int }
data class Cafe(override val precio: Int, val grande: Boolean) : Linea
data class Tostada(override val precio: Int) : Linea
 
data class Cuenta(
    val mesa: Int,
    val lineas: List<Linea> = emptyList(),   // List<Linea>: lista de líneas; vacía por defecto
) {
    // Propiedad calculada: no se guarda, se calcula cada vez que se lee.
    // count { … } cuenta las líneas que son un Cafe Y además grande.
    val grandes: Int get() = lineas.count { it is Cafe && it.grande }
 
    // lineas + linea crea una lista NUEVA con la línea al final; la original no cambia.
    // copy(lineas = …) devuelve una Cuenta nueva con esa lista.
    fun pedir(linea: Linea): Cuenta = copy(lineas = lineas + linea)
}
 
fun main() {
    val vacia = Cuenta(mesa = 3)
    val llena = vacia                           // la cadena se puede partir antes de cada punto:
        .pedir(Cafe(2, grande = true))          // cada .pedir(…) actúa sobre la Cuenta que devolvió la anterior
        .pedir(Tostada(3))
        .pedir(Cafe(1, grande = false))
    llena.lineas.forEach { println(it) }        // forEach: el bloque se ejecuta una vez por elemento
    println("Antes ${vacia.lineas.size}, después ${llena.lineas.size}; grandes: ${llena.grandes}")
}

Al ejecutarlo, imprime:

Cafe(precio=2, grande=true)
Tostada(precio=3)
Cafe(precio=1, grande=false)
Antes 0, después 3; grandes: 1

Fíjate en que vacia sigue vacía: cada .pedir(...) devuelve una cuenta nueva y la original no se toca.

Reto · Ejercicio: el partido de baloncesto

Fuera de tu proyecto, en el Kotlin Playground (https://play.kotlinlang.org).

Una jugada de baloncesto es una canasta (de un equipo, con sus puntos) o una falta (de un equipo). Nombres exactos: una sealed interface Jugada con la propiedad equipo (un texto), y dos data class, Canasta (con equipo y puntos) y Falta (con equipo). Un data class Partido guarda la lista jugadas, vacía al empezar, y no cambia nunca: registrar(jugada) devuelve otro partido con ella al final, y faltasDe(equipo) cuenta las faltas de ese equipo. Una función describir(jugada) da una frase por jugada. Registra una canasta de 2 de Lobos, una falta de Osos, una canasta de 3 de Osos y otra falta de Osos; describe cada jugada y muestra cuántas jugadas había antes y después de registrarlas, y las faltas de Osos. La consola debe mostrar:

Canasta de 2 para Lobos
Falta de Osos
Canasta de 3 para Osos
Falta de Osos
Jugadas: antes 0, después 4
Faltas de Osos: 2
Ver solución
solución: el partido de baloncesto
sealed interface Jugada {
    val equipo: String
}
data class Canasta(override val equipo: String, val puntos: Int) : Jugada
data class Falta(override val equipo: String) : Jugada
 
fun describir(jugada: Jugada): String = when (jugada) {
    is Canasta -> "Canasta de ${jugada.puntos} para ${jugada.equipo}"
    is Falta -> "Falta de ${jugada.equipo}"
}
 
data class Partido(val jugadas: List<Jugada> = emptyList()) {
    fun registrar(jugada: Jugada): Partido = copy(jugadas = jugadas + jugada)
 
    fun faltasDe(equipo: String): Int = jugadas.count { it is Falta && it.equipo == equipo }
}
 
fun main() {
    val inicio = Partido()
    val alFinal = inicio
        .registrar(Canasta("Lobos", 2))
        .registrar(Falta("Osos"))
        .registrar(Canasta("Osos", 3))
        .registrar(Falta("Osos"))
    alFinal.jugadas.forEach { println(describir(it)) }
    println("Jugadas: antes ${inicio.jugadas.size}, después ${alFinal.jugadas.size}")
    println("Faltas de Osos: ${alFinal.faltasDe("Osos")}")
}

Al ejecutarlo imprime las seis líneas de arriba. Fíjate en que inicio sigue teniendo cero jugadas: registrar no modifica el partido, devuelve otro.

Paso · El partido hecho de eventos

Objetivo. Hacer que Partido deduzca el marcador de sus eventos, añadir titular() a Resultado.kt y escribir en Main.kt la crónica de un partido. Al terminar, ./gradlew :torneo:run imprime la crónica y el resultado, y ./gradlew :torneo:test pasa con 30 tests.

torneo/
kotlin+java/
com.sebastiannarvaez.marcador.torneo
Resultado.ktse modifica
Partido.ktse modifica
Main.ktse modifica
com.sebastiannarvaez.marcador.torneo (test)
EventoTest.ktse crea (se copia)
PartidoTest.ktse modifica (se copia: sustituye al tuyo)
Lo que tocas, en la vista Android: tres ficheros que cambian y dos de tests.

Lo que se borra: el marcador guardado

En data class e igualdad Partido guardaba golesLocal y golesVisitante y tenía conGolLocal() y conGolVisitante(). Todo eso se borra: ahora el marcador sale de los eventos. También se borra la desestructuración de cuatro campos de Main.kt, porque Partido pasa a tener tres (local, visitante, eventos). Los tests de Partido que ya tenías usaban esas funciones, así que, hasta que los sustituyas al final de este paso, ./gradlew :torneo:test no compilará: es lo esperado.

En Main.kt, borra todo lo que hay desde la línea del comentario // data class: copy, == frente a ===, desestructuración. (incluida) hasta la última } de main (también incluida): son las líneas de la lección de data class y las tres líneas provisionales de la lección anterior. En su lugar pegarás el bloque nuevo, que ya trae esa } al final. El resto del fichero no cambia.

Piezas nuevas de este paso, todas vistas arriba: List<…> con emptyList(), lista + elemento, count { … } con &&, forEach { … }, una propiedad calculada con get(), un when sin argumento (el de resultado()), una función de extensión con when (this) (y this. omitido), lista[1] y first(), y una cadena de llamadas partida en varias líneas.

Intenta escribirlo tú, en este orden:

  1. El resultado. En Resultado.kt, una función de extensión titular() sobre Resultado que devuelva una frase: «Gana Rayo FC» (con el nombre del ganador) o «Empate».
  2. El nuevo Partido. Ya no guarda los goles. Nombres exactos, porque los tests copiados los usan: guarda eventos (la lista de EventoDePartido, vacía por defecto); una función golesDe(equipo) que cuenta los goles de ese equipo; las propiedades calculadas golesLocal y golesVisitante, que usan golesDe; registrar(evento), que devuelve un partido nuevo con el evento al final; y resultado(), que devuelve un Resultado (Victoria(ganador, perdedor) o Empate).
  3. La crónica en main. Juega un Rayo FC-Toros con estos cinco eventos: gol de Ana (Rayo FC) en el 12, tarjeta amarilla a Iván en el 30, gol de Iván (Toros) en el 55, gol de Ana en el 80 y cambio en el 85 (sale Ana, entra Luis). Cuenta los eventos antes y después, describe cada uno y termina con el marcador y el titular.

La línea Antes: 0-0, después: 2-1 de la lección de data class e igualdad desaparece (el Partido ya no cuenta goles así). Tras Con otro dorsal: …, la salida debe acabar así:

Antes: 0 eventos, después: 5
12' Gol de Ana (Rayo FC)
30' Tarjeta amarilla para Iván
55' Gol de Iván (Toros)
80' Gol de Ana (Rayo FC)
85' Cambio: sale Ana, entra Luis
Marcador: 2-1 -> Gana Rayo FC

Cuando termines, abre el bloque y compara con tu código. En titular(), ganador es this.ganador: tras is Resultado.Victoria, Kotlin ya sabe que this es una Victoria, y el this. se omite. Los bloques con ruta llevan la del fichero en tu proyecto.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
torneo/src/main/java/com/sebastiannarvaez/marcador/torneo/Resultado.kt
package com.sebastiannarvaez.marcador.torneo
 
// Un partido acaba en victoria (de alguien) o en empate.
sealed interface Resultado {
    // Lleva datos: quién ganó y quién perdió.
    data class Victoria(val ganador: Equipo, val perdedor: Equipo) : Resultado
 
    // No lleva datos: basta un único objeto. `data object` da un toString legible.
    data object Empate : Resultado
}
 
fun Resultado.titular(): String = when (this) {
    is Resultado.Victoria -> "Gana ${ganador.nombre}"
    Resultado.Empate -> "Empate"
}
torneo/src/main/java/com/sebastiannarvaez/marcador/torneo/Partido.kt
package com.sebastiannarvaez.marcador.torneo
 
// Sigue siendo inmutable. El marcador ya no se guarda: se DEDUCE de los
// eventos, así que no puede contradecirlos.
data class Partido(
    val local: Equipo,
    val visitante: Equipo,
    val eventos: List<EventoDePartido> = emptyList(),
) {
    fun golesDe(equipo: Equipo): Int = eventos.count { it is Gol && it.equipo == equipo }
 
    val golesLocal: Int get() = golesDe(local)
    val golesVisitante: Int get() = golesDe(visitante)
 
    // Registrar un evento devuelve un partido nuevo con el evento al final.
    fun registrar(evento: EventoDePartido): Partido = copy(eventos = eventos + evento)
 
    fun resultado(): Resultado = when {
        golesLocal > golesVisitante -> Resultado.Victoria(ganador = local, perdedor = visitante)
        golesLocal < golesVisitante -> Resultado.Victoria(ganador = visitante, perdedor = local)
        else -> Resultado.Empate
    }
}

Este es el bloque final de Main.kt: pégalo en lugar de lo que borraste, hasta la última } de main (ya la incluye):

torneo/src/main/java/com/sebastiannarvaez/marcador/torneo/Main.kt
    // data class: copy y == frente a ===.
    val toros = Equipo("Toros", listOf(Jugador("Iván", 7)))
    val ana = jugadores.first()
    val ivan = toros.plantilla.first()
    println("¿Misma ficha? ${ana == Jugador("Ana", 9)} (==), ${ana === Jugador("Ana", 9)} (===)")
    println("Con otro dorsal: ${ana.copy(dorsal = 10)}")
 
    // sealed interface y when exhaustivo: los eventos de un partido.
    val inicio = Partido(rayo, toros)
    val alFinal = inicio
        .registrar(Gol(12, ana, rayo))
        .registrar(Tarjeta(30, ivan, ColorDeTarjeta.AMARILLA))
        .registrar(Gol(55, ivan, toros))
        .registrar(Gol(80, ana, rayo))
        .registrar(Cambio(85, sale = ana, entra = jugadores[1]))
    println("Antes: ${inicio.eventos.size} eventos, después: ${alFinal.eventos.size}")
    alFinal.eventos.forEach { println(describir(it)) }
    println("Marcador: ${alFinal.golesLocal}-${alFinal.golesVisitante} -> ${alFinal.resultado().titular()}")
}

Ejecuta ./gradlew :torneo:run en la Terminal de Android Studio (View → Tool Windows → Terminal, que se abre en la carpeta del proyecto, donde está gradlew). Las últimas líneas de la salida son las del punto de control de abajo.

Los tests

Los tests de este paso son de relleno: comprueban describir y resultado() sobre el código que acabas de escribir, con las comprobaciones que ya conoces (@Test y assertEquals). EventoTest.kt es nuevo y PartidoTest.kt ya lo tienes, así que el nuevo lo sustituye entero: es el mismo test adaptado a los eventos.

EventoTest.kt (nuevo). Primero crea el fichero: en torneo → kotlin+java, clic derecho sobre el paquete com.sebastiannarvaez.marcador.torneo (test) (el que lleva (test) detrás) → New → Kotlin Class/File → File → EventoTest → Intro. Después pulsa Copiar fichero, que copia el contenido al portapapeles. En el editor, selecciona todo (Cmd+A) y pega (Cmd+V): el fichero copiado trae su propia línea package, y sustituye a la que escribió Android Studio.

EventoTest.kt

PartidoTest.kt (el de antes, sustituido). Abre PartidoTest.kt en el paquete (test) con doble clic y pulsa Copiar fichero. Después selecciona todo en el editor (Cmd+A) y pega (Cmd+V): el contenido nuevo sustituye al anterior.

PartidoTest.kt

Ejecuta ./gradlew :torneo:test. Ahora sí debe compilar y terminar en BUILD SUCCESSFUL.

Punto de control

  • ./gradlew :torneo:run termina en BUILD SUCCESSFUL y las últimas líneas de la salida son:
Antes: 0 eventos, después: 5
12' Gol de Ana (Rayo FC)
30' Tarjeta amarilla para Iván
55' Gol de Iván (Toros)
80' Gol de Ana (Rayo FC)
85' Cambio: sale Ana, entra Luis
Marcador: 2-1 -> Gana Rayo FC
  • ./gradlew :torneo:test pasa. Abre el informe (open torneo/build/reports/tests/test/index.html, como en Bucles y tu primer test) y comprueba que pone 30 tests en total: 5 de PuntosTest, 6 de JugadorTest, 7 de EquipoTest, 5 de EventoTest y 7 de PartidoTest.
  • En EventoDePartido.kt el when de describir no tiene else. Prueba a quitar la rama is Cambio ->: Android Studio y Gradle dicen 'when' expression must be exhaustive. Devuélvela.
  • Partido ya no tiene conGolLocal ni conGolVisitante, y golesLocal y golesVisitante se calculan (no están en el constructor).

Comprueba lo que entendiste

6 preguntas · necesitas 5 para completar la lección

  1. 1.En Marcador, Partido deja de guardar golesLocal y golesVisitante y los calcula a partir de eventos. ¿Qué se gana?

  2. 2.Tienes val a = Partido(rayo, toros) y haces val b = a.registrar(Gol(10, ana, rayo)). ¿Cuántos eventos tiene a después?

  3. 3.Con val letras = listOf("a", "b", "c"), ¿qué es letras[1]?

  4. 4.fun Resultado.titular(): String = when (this) { … } está escrita fuera de Resultado, y aun así se llama resultado.titular(). ¿Cómo se llama eso y qué es this dentro?

  5. 5.En eventos.count { it is Gol && it.equipo == equipo }, ¿qué cuenta count?

  6. 6.Dentro de fun String.gritar(): String = uppercase() + "!" no aparece this. ¿Qué pasa al llamar "hola".gritar()?

Faltan 6 de 6