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

La tabla de posiciones

Al terminar: Construir la tabla de posiciones del torneo con flatMap, groupBy, comparadores y sortedWith.

Al terminar esta lección Marcador calculará su tabla de posiciones —partidos jugados, ganados, goles y puntos, ordenada como en una liga de verdad— a partir de una lista de partidos, la imprimirá con las columnas alineadas y, debajo, mostrará su tabla de goleadores. Es el primer cálculo real de la app.

Temas: flatMap, groupBy con dos lambdas, keys, orEmpty y los comparadores con sortedWith; y, en el paso, asSequence, groupingBy y format de la lección anterior.

Las piezas de la tabla de posiciones

La tabla de posiciones de una liga tiene una fila por equipo con sus partidos jugados, ganados, empatados y perdidos, sus goles y sus puntos. Se calcula con piezas que ya casi conoces y tres nuevas:

  • flatMap { … }: como map, pero la lambda devuelve una lista por cada elemento y flatMap las une todas en una sola lista. Sirve cuando cada partido cuenta dos veces, una por equipo.
  • groupBy con dos lambdas: la primera da la clave del grupo y la segunda, lo que se guarda en él.
  • Un comparador (un objeto que dice cuál de dos elementos va antes) con compareByDescending, thenByDescending y thenBy, que se pasa a sortedWith. Hace falta cuando se ordena por varios criterios a la vez, porque sortedBy admite solo uno.

Además, mapa.keys son las claves del mapa, mapa[clave].orEmpty() cambia un null por una lista vacía (listOf()) y sum() suma los números de una lista. Todo junto, en un ejemplo con notas de examen en vez de partidos, explicado línea a línea:

ejemplo mínimo: una clasificación de notas (con comentarios)
data class Examen(val alumnoA: String, val alumnoB: String, val notaA: Int, val notaB: Int)
data class Fila(val alumno: String, val total: Int, val examenes: Int)
 
fun main() {
    val examenes = listOf(Examen("Eva", "Leo", 7, 9), Examen("Leo", "Sara", 6, 6))
 
    // flatMap: la lambda devuelve una LISTA por examen (aquí, dos pares) y flatMap las une en una sola
    val notas = examenes.flatMap { listOf(it.alumnoA to it.notaA, it.alumnoB to it.notaB) }
    println(notas)                       // [(Eva, 7), (Leo, 9), (Leo, 6), (Sara, 6)]
 
    // La lambda de flatMap puede llevar dentro un map: cada lista se transforma y luego se unen todas
    println(listOf(listOf(1, 2), listOf(3)).flatMap { lista -> lista.map { it * 2 } })   // [2, 4, 6]
 
    // groupBy con DOS lambdas: la 1.ª da la clave del grupo; la 2.ª, lo que se guarda en él
    val porAlumno = notas.groupBy({ it.first }, { it.second })
    println(porAlumno)                   // {Eva=[7], Leo=[9, 6], Sara=[6]}
 
    // keys: las claves del mapa. + une dos colecciones en una nueva; toSet() quita los repetidos
    val todos = (listOf("Eva", "Iker") + porAlumno.keys).toSet()
    println(todos)                       // [Eva, Iker, Leo, Sara]
 
    val filas = todos.map { alumno ->
        // porAlumno[alumno] es null si no hay clave (Iker); orEmpty() lo cambia por una lista vacía
        val suyas = porAlumno[alumno].orEmpty()
        Fila(alumno, total = suyas.sum(), examenes = suyas.size)   // sum(): suma los números
    }
 
    // Un comparador dice cuál de dos elementos va antes:
    val orden = compareByDescending<Fila> { it.total }   // 1.º: más total primero (<Fila>: el tipo de los elementos)
        .thenByDescending { it.examenes }                 // desempate: más exámenes primero
        .thenBy { it.alumno }                             // desempate final: nombre de la A a la Z
    filas.sortedWith(orden).forEach { println(it) }       // sortedWith ordena con el comparador
}

Al ejecutarlo, imprime:

[(Eva, 7), (Leo, 9), (Leo, 6), (Sara, 6)]
[2, 4, 6]
{Eva=[7], Leo=[9, 6], Sara=[6]}
[Eva, Iker, Leo, Sara]
Fila(alumno=Leo, total=15, examenes=2)
Fila(alumno=Eva, total=7, examenes=1)
Fila(alumno=Sara, total=6, examenes=1)
Fila(alumno=Iker, total=0, examenes=0)

Reto · Ejercicio: lo que gasta cada cliente

En el Kotlin Playground, fuera de tu proyecto.

Una tienda guarda cada pedido como Pedido(cliente, precios), con la lista de precios de lo que compró. Con estos datos:

datos del ejercicio
data class Pedido(val cliente: String, val precios: List<Int>)
 
val pedidos = listOf(
    Pedido("Ana", listOf(10, 5)),
    Pedido("Leo", listOf(8)),
    Pedido("Ana", listOf(20)),
)
val registrados = listOf("Ana", "Leo", "Zoe")   // Zoe aún no ha comprado nada

Calcula cuánto ha gastado cada cliente registrado (Zoe, 0) y escribe una línea por cliente, del que más gastó al que menos y, a igualdad, por nombre. Usa flatMap para convertir cada pedido en una pareja cliente to precio por cada precio, groupBy con dos lambdas para agrupar por cliente, orEmpty() para el cliente sin compras y sortedWith con un comparador (que lleva el tipo de los elementos entre <>: aquí, compareByDescending<Pair<String, Int>> { … }).

La salida tiene que ser exactamente esta:

Ana: 35
Leo: 8
Zoe: 0
Ver solución
solución: lo que gasta cada cliente
data class Pedido(val cliente: String, val precios: List<Int>)
 
fun main() {
    val pedidos = listOf(
        Pedido("Ana", listOf(10, 5)),
        Pedido("Leo", listOf(8)),
        Pedido("Ana", listOf(20)),
    )
    val registrados = listOf("Ana", "Leo", "Zoe")
 
    // flatMap: por cada pedido, una pareja (cliente, precio) por cada precio; todas en una sola lista
    val compras = pedidos.flatMap { pedido -> pedido.precios.map { precio -> pedido.cliente to precio } }
 
    // groupBy con dos lambdas: clave = cliente, valor = el precio
    val preciosPorCliente = compras.groupBy({ it.first }, { it.second })
 
    // Registrados y los que ya compraron, sin repetir; orEmpty() cambia el null de Zoe por una lista vacía
    val clientes = (registrados + preciosPorCliente.keys).toSet()
    val gastos = clientes.map { cliente -> cliente to preciosPorCliente[cliente].orEmpty().sum() }
 
    // Comparador: más gasto primero; a igualdad, por nombre
    gastos
        .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first })
        .forEach { (cliente, total) -> println("$cliente: $total") }
}

Paso · La tabla de posiciones y los goleadores

Objetivo. Un fichero nuevo, Tabla.kt, con FilaDePosicion, la función tablaDePosiciones(partidos, equipos) y la función tablaDeGoleadores(partidos); después, hacer que Main imprima la tabla con tres equipos y dos goleadores y copiar TablaTest.kt.

torneo → kotlin+java
com.sebastiannarvaez.marcador.torneo
Tabla.ktse crea
Main.ktse modifica
com.sebastiannarvaez.marcador.torneo (test)
TablaTest.ktse crea (se copia)
Uno nuevo, uno modificado y el test, en la vista Android.

En la vista Android, despliega torneo → kotlin+java. Allí hay dos paquetes con el mismo nombre: el que lleva (test) guarda los tests y el que no lo lleva guarda el código del programa. Tabla.kt va en el segundo: clic derecho sobre el paquete com.sebastiannarvaez.marcador.torneo (sin (test)) → New → Kotlin Class/File → elige File, escribe Tabla y pulsa Intro. Main.kt ya existe: ábrelo con doble clic.

Las columnas de la tabla usan las abreviaturas habituales en las ligas: PJ partidos jugados, G ganados, E empatados, P perdidos, GF goles a favor, GC goles en contra, DG diferencia de goles (GF menos GC) y Pts puntos.

El plan de tablaDePosiciones, en cinco pasos:

  1. flatMap: cada partido cuenta dos veces, una desde el lado local y otra desde el visitante. flatMap convierte cada partido en dos entradas y las une en una sola lista. Cada entrada es un Triple: como un Pair, pero con tres partes (first, second y third): el equipo, sus goles a favor y sus goles en contra.
  2. groupBy con dos lambdas: agrupa por equipo y guarda solo su marcador (a favor, en contra).
  3. toSet: une los equipos que ya jugaron con los que se pasen aparte, sin repetir.
  4. map con count y sumOf: cada equipo se convierte en una fila con sus columnas.
  5. sortedWith: ordena por puntos, diferencia de goles, goles a favor y nombre. Hay cuatro criterios encadenados y sortedBy admite uno solo, así que se usa un comparador, como en el ejemplo de las notas.

tablaDeGoleadores te la damos escrita, más abajo: no hay que inventarla, solo pegarla al final de Tabla.kt y leer sus comentarios. Usa la secuencia de la lección anterior.

Una cosa de formato: en FilaDePosicion verás una coma después del último parámetro (val puntos: Int,) y después del último argumento de una llamada. Kotlin la permite y es opcional; sirve para añadir una línea sin tocar la anterior.

Dónde va cada cosa: Tabla.kt es un fichero nuevo entero (tú escribes FilaDePosicion y tablaDePosiciones; tablaDeGoleadores se pega al final, como se explica abajo). En Main.kt, pega el bloque nuevo en lugar de la última } de main (el bloque ya trae esa llave al final); el resto del fichero no cambia.

Intenta escribirlo tú, en este orden:

  1. FilaDePosicion: una fila de la tabla. Guarda el equipo, los partidos jugados, ganados, empatados y perdidos, los goles a favor y en contra y los puntos; la diferencia de goles se calcula, no se guarda.
  2. tablaDePosiciones(partidos, equipos): recibe los partidos jugados y, opcionalmente, equipos que deben salir aunque no hayan jugado. Devuelve una fila por equipo con los puntos de puntosPor, ordenada por puntos, después por diferencia de goles, después por goles a favor (todo de mayor a menor) y, si siguen empatados, por nombre. Sigue el plan de cinco pasos de arriba.
  3. main: añade a los Lobos (Pedro, dorsal 4), juega una jornada de tres partidos (el Rayo FC-Toros de antes, un Toros-Lobos que gana Toros con gol de Iván y un Lobos-Rayo FC que gana Rayo con gol de Ana) e imprime la tabla y, debajo, los goleadores. La cabecera es "Pos Equipo PJ G E P GF GC DG Pts" y cada fila usa format con "%2d %-7s %2d %2d %2d %2d %3d %2d %3d %3d" (diez valores: puesto, equipo, PJ, G, E, P, GF, GC, DG y Pts). Cada goleador se imprime como Ana: 3.

Tras las líneas de la lección anterior, debe verse:

Pos Equipo  PJ  G  E  P  GF GC  DG Pts
 1  Rayo FC  2  2  0  0   3  1   2   6
 2  Toros    2  1  0  1   2  2   0   3
 3  Lobos    2  0  0  2   0  2  -2   0
Ana: 3
Iván: 2

Cuando termines, abre el bloque y compara con tu código. 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/Tabla.kt
package com.sebastiannarvaez.marcador.torneo
 
// Una fila de la tabla de posiciones.
data class FilaDePosicion(
    val equipo: Equipo,
    val jugados: Int,
    val ganados: Int,
    val empatados: Int,
    val perdidos: Int,
    val golesAFavor: Int,
    val golesEnContra: Int,
    val puntos: Int,
) {
    val diferencia: Int get() = golesAFavor - golesEnContra
}
 
// Tabla de posiciones a partir de los partidos jugados.
// `equipos` permite que salgan también los que aún no han jugado.
fun tablaDePosiciones(partidos: List<Partido>, equipos: List<Equipo> = emptyList()): List<FilaDePosicion> {
    // Cada partido cuenta dos veces: una desde el lado local y otra desde el visitante.
    // Cada elemento es (equipo, goles a favor, goles en contra).
    val apariciones = partidos.flatMap { partido ->
        listOf(
            Triple(partido.local, partido.golesLocal, partido.golesVisitante),
            Triple(partido.visitante, partido.golesVisitante, partido.golesLocal),
        )
    }
 
    // groupBy con dos lambdas: la clave (equipo) y el valor (el marcador visto por él).
    // El resultado es un Map<Equipo, List<Pair<Int, Int>>>.
    val marcadoresPorEquipo = apariciones.groupBy({ it.first }, { it.second to it.third })
 
    // Un Set no admite repetidos: une los equipos dados con los que ya jugaron.
    val todos: Set<Equipo> = (equipos + marcadoresPorEquipo.keys).toSet()
 
    return todos
        .map { equipo ->
            val marcadores = marcadoresPorEquipo[equipo].orEmpty()
            FilaDePosicion(
                equipo = equipo,
                jugados = marcadores.size,
                ganados = marcadores.count { (aFavor, enContra) -> aFavor > enContra },
                empatados = marcadores.count { (aFavor, enContra) -> aFavor == enContra },
                perdidos = marcadores.count { (aFavor, enContra) -> aFavor < enContra },
                golesAFavor = marcadores.sumOf { it.first },
                golesEnContra = marcadores.sumOf { it.second },
                puntos = marcadores.sumOf { (aFavor, enContra) -> puntosPor(aFavor, enContra) },
            )
        }
        .sortedWith(
            compareByDescending<FilaDePosicion> { it.puntos }
                .thenByDescending { it.diferencia }
                .thenByDescending { it.golesAFavor }
                .thenBy { it.equipo.nombre },
        )
}

Este es el bloque de Main.kt (el … marca el código que ya tienes):

torneo/src/main/java/com/sebastiannarvaez/marcador/torneo/Main.kt
    …
 
    // Colecciones: una jornada completa, la tabla y los goleadores.
    val lobos = Equipo("Lobos", listOf(Jugador("Pedro", 4)))
    val jornada = listOf(
        alFinal,
        Partido(toros, lobos).registrar(Gol(20, ivan, toros)),
        Partido(lobos, rayo).registrar(Gol(40, ana, rayo)),
    )
    println("Pos Equipo  PJ  G  E  P  GF GC  DG Pts")
    tablaDePosiciones(jornada).forEachIndexed { indice, fila ->
        println(
            "%2d  %-7s %2d %2d %2d %2d %3d %2d %3d %3d".format(
                indice + 1, fila.equipo.nombre, fila.jugados, fila.ganados, fila.empatados,
                fila.perdidos, fila.golesAFavor, fila.golesEnContra, fila.diferencia, fila.puntos,
            ),
        )
    }
    tablaDeGoleadores(jornada).forEach { (jugador, goles) -> println("${jugador.nombre}: $goles") }
}

Pega la tabla de goleadores. Al final de Tabla.kt, después de tablaDePosiciones, pega esta función (ya está en el bloque de Main.kt, que la llama):

tablaDeGoleadores: pégala al final de Tabla.kt
// Tabla de goleadores: cada jugador con sus goles, de más a menos.
// Usa una Sequence: las operaciones se encadenan y se ejecutan elemento a
// elemento, sin crear una lista intermedia entre paso y paso.
fun tablaDeGoleadores(partidos: List<Partido>): List<Pair<Jugador, Int>> =
    partidos.asSequence()
        .flatMap { it.eventos.asSequence() }
        .filterIsInstance<Gol>()
        .groupingBy { it.jugador }
        .eachCount()
        .toList()
        .sortedWith(compareByDescending<Pair<Jugador, Int>> { it.second }.thenBy { it.first.nombre })

Ahora, el test. TablaTest.kt prueba el orden de la tabla, cada columna, el desempate, los equipos que aún no han jugado y la tabla vacía. Comprueba lo que acabas de escribir y no enseña nada nuevo de kotlin.test, así que no hace falta que lo escribas: lo copias. Las lecciones Extensiones y Comparadores, Iterable y el ranking lo modifican y suponen que ya lo tienes.

  1. En torneo → kotlin+java, clic derecho sobre el paquete com.sebastiannarvaez.marcador.torneo (test) → New → Kotlin Class/File → File, escribe TablaTest y pulsa Intro. Se abre un fichero con solo la línea package.
  2. Pulsa el botón de abajo: copia el contenido al portapapeles (no escribe nada en el proyecto).
  3. Con el cursor en el fichero vacío, selecciona todo (Cmd+A) y pega (Cmd+V). Lo pegado trae su propia línea package, así que sustituye a la tuya.
TablaTest.kt

Punto de control

  • Ejecuta el programa desde la Terminal de Android Studio (View → Tool Windows → Terminal): ./gradlew :torneo:run termina en BUILD SUCCESSFUL y las últimas líneas son:
Pos Equipo  PJ  G  E  P  GF GC  DG Pts
 1  Rayo FC  2  2  0  0   3  1   2   6
 2  Toros    2  1  0  1   2  2   0   3
 3  Lobos    2  0  0  2   0  2  -2   0
Ana: 3
Iván: 2
  • Los puntos siguen la regla de puntosPor: 3 por victoria, 1 por empate.
  • ./gradlew :torneo:test pasa, con TablaTest.kt copiado. Abre el informe como en Bucles y tu primer test (open torneo/build/reports/tests/test/index.html) y comprueba que arriba pone 41 tests: los 36 de antes más los 5 de TablaTest.
  • Cambia el orden de los partidos en jornada y la tabla sale igual: no depende del orden de entrada. Deshaz el cambio antes de seguir.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.Cada partido cuenta dos veces en la tabla: una por el local y otra por el visitante. ¿Qué hace partidos.flatMap { listOf(a, b) }?

  2. 2.¿Qué hace notas.groupBy({ it.first }, { it.second }) sobre una lista de parejas (alumno, nota)?

  3. 3.Para ordenar la tabla por puntos y, a igualdad, por diferencia de goles y por nombre, ¿por qué se usa sortedWith con un comparador y no sortedBy?

  4. 4.Un Map de notas no tiene la clave "Iker". ¿Qué devuelve notas["Iker"].orEmpty().sum()?

  5. 5.Con compareByDescending<Fila> { it.total }.thenBy { it.alumno }, dos alumnos con el mismo total, ¿cómo quedan?

Faltan 5 de 5