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

Comparadores, Iterable y el ranking

Al terminar: Ordenar con comparadores, hacer una clase recorrible con for y escribir el ranking del torneo.

Marcador tiene hoy dos rankings escritos a mano: la tabla de posiciones y la de goleadores. Al terminar esta lección los dos usarán una sola clase, Ranking<T>, que sirve igual para jugadores que para filas de la tabla. Para llegar ahí hacen falta tres herramientas: los comparadores (para decidir quién va antes), Iterable (para recorrer una clase con for) y los genéricos de la lección anterior.

Temas: comparadores, hacer una clase recorrible, una clase genérica completa (la mochila), typealias, y el paso que crea Ranking<T> en Marcador.

Un comparador: cómo se ordenan dos cosas

Un ranking ordena elementos de cualquier tipo, y para ordenar hay que decidir qué va antes. Un comparador (Comparator) es el objeto que sabe hacerlo: recibe dos elementos, a y b, y devuelve un número. Negativo significa «a va antes que b», cero significa «empate» y positivo significa «a va después». sortedWith(comparador) lo consulta una y otra vez hasta dejar la lista en orden. Casi nunca se escribe a mano:

  • compareBy { it.nombre } crea un comparador que ordena de menor a mayor por lo que devuelva la lambda (aquí, el nombre). compare(a, b) es la función del comparador que hace la comparación de dos elementos y devuelve ese número.
  • compareByDescending { … } hace lo mismo de mayor a menor.
  • primero.then(segundo) (y thenBy, thenByDescending) encadena dos comparadores: si primero dice «empate», decide segundo.
ejemplo mínimo: comparadores (con comentarios)
class Libro(val titulo: String, val paginas: Int)
 
fun main() {
    val libros = listOf(Libro("Rayuela", 600), Libro("Niebla", 250), Libro("Cien años", 600))
    // Aquí el tipo se escribe entre ángulos (`<Libro>`): la lambda sola no dice de qué tipo es `it`
    val porPaginas = compareBy<Libro> { it.paginas }
    println(porPaginas.compare(libros[1], libros[0]))   // negativo: Niebla (250) va antes que Rayuela (600)
    println(porPaginas.compare(libros[0], libros[2]))   // 0: las dos tienen 600 páginas, empate
    // then: si empatan en páginas, decide el título. Aquí no hace falta `<Libro>`: `then` ya
    // espera un comparador de Libro, y de ahí Kotlin deduce que `it` es un Libro
    println(libros.sortedWith(porPaginas.then(compareBy { it.titulo })).map { it.titulo })
    // de mayor a menor por páginas y, a igualdad, por título
    println(libros.sortedWith(compareByDescending<Libro> { it.paginas }.thenBy { it.titulo }).map { it.titulo })
}
-1
0
[Niebla, Cien años, Rayuela]
[Cien años, Rayuela, Niebla]

Cuándo hace falta escribir el tipo. Kotlin deduce el tipo de it cuando hay algo al lado de donde sacarlo: un tipo esperado (como en then(compareBy { it.titulo }) o en un parámetro que ya dice que es un Comparator<Libro>). Cuando no lo hay, como en la primera línea (val porPaginas = compareBy { … }), la lambda sola no lo dice y se escribe <Libro>.

Comparator<T> es el nombre de ese tipo de objeto, y se puede crear escribiendo su nombre seguido de una lambda con los dos elementos. Los guiones bajos _ marcan parámetros que la lambda recibe y no usa:

ejemplo mínimo: crear un Comparator con una lambda (con comentarios)
fun main() {
    // «Todos iguales»: devuelve siempre cero y no usa ni a ni b (por eso los dos son _)
    val siempreCero = Comparator<String> { _, _ -> 0 }
    println(siempreCero.compare("a", "b"))
    // Uno que sí mira: la diferencia de longitudes es positiva si a es más largo que b
    val porLongitud = Comparator<String> { a, b -> a.length - b.length }
    println(porLongitud.compare("hola", "ab") > 0)
}
0
true

El primero, «todos iguales», es el desempate por defecto de las clases de más abajo: no desempata nada.

Recorrer una clase con for

Una colección (Collection) es el tipo común de las listas y los conjuntos: algo con tamaño que se puede recorrer. Un Iterable es algo que sabe entregar sus elementos de uno en uno, y quien los entrega es un iterador (Iterator), un objeto con «dame el siguiente». Una clase se hace Iterable escribiendo iterator(), y a cambio funciona for (x in objeto):

ejemplo mínimo: una clase que se puede recorrer con for (con comentarios)
// : Iterable<String> promete «me puedes recorrer»; el cumplimiento es la función iterator()
class Estantes(private val libros: List<String>) : Iterable<String> {
    // override: esta función cumple lo que promete Iterable. Se presta el iterador de la lista.
    override fun iterator(): Iterator<String> = libros.iterator()
}
 
fun main() {
    val estantes = Estantes(listOf("Rayuela", "Niebla"))
    for (libro in estantes) println(libro)    // for funciona porque Estantes es Iterable
}
Rayuela
Niebla

Una clase genérica completa: la mochila

Ahora, todo junto en una clase genérica: la mochila ordena cosas de cualquier tipo de más ligero a más pesado, y se recorre con for. Fíjate en el primer parámetro del constructor, cosas: no lleva val ni var, así que solo existe mientras se crea la mochila y no es una propiedad (no podrías escribir mochila.cosas); en cambio private val peso sí se guarda como propiedad, privada. Por eso ordenadas se calcula con cosas en el momento de crear la mochila.

ejemplo mínimo: una clase genérica que se puede recorrer (con comentarios)
// Una mochila de cualquier tipo T, ordenada de más ligero a más pesado.
// peso: una función que dice cuánto pesa un T. : Iterable<T> → se puede recorrer con for
class Mochila<T>(
    cosas: Collection<T>,                                  // Collection: una lista o un conjunto. Sin `val`: solo existe al crear la mochila
    private val peso: (T) -> Int,
    empate: Comparator<T> = Comparator { _, _ -> 0 },      // por defecto: «todos iguales»; _ = parámetro sin usar
) : Iterable<T> {
 
    // compareBy<T> { … }: de menor a mayor peso; .then(empate) decide si pesan lo mismo
    val ordenadas: List<T> = cosas.sortedWith(compareBy<T> { peso(it) }.then(empate))
 
    fun pesoDe(cosa: T): Int = peso(cosa)
 
    // indexOfFirst: posición (desde 0) del primero que cumple la condición; -1 si ninguno
    fun posicion(cosa: T): Int = ordenadas.indexOfFirst { it == cosa } + 1
 
    fun lasMasLigeras(n: Int): List<T> = ordenadas.take(n)  // take(n): los n primeros
 
    // Lo que exige Iterable<T>: entregar un iterador. Se presta el de la lista ordenada
    override fun iterator(): Iterator<T> = ordenadas.iterator()
}
 
fun main() {
    val gramos = mapOf("linterna" to 300, "mapa" to 50, "agua" to 1000, "brújula" to 50)
    // getValue(clave): como gramos[clave], pero devuelve Int (no Int?); si la clave no estuviera, el programa se pararía con un error
    val mochila = Mochila(gramos.keys, peso = { gramos.getValue(it) }, empate = compareBy { it })
 
    for (cosa in mochila) println("$cosa: ${mochila.pesoDe(cosa)} g")   // for funciona por ser Iterable
    println(mochila.posicion("agua"))                 // 4: la más pesada va la última
    println(mochila.lasMasLigeras(2))
    println("tienda" !in mochila.ordenadas)           // !in: true si NO está en la lista
}

Al ejecutarlo, imprime:

brújula: 50 g
mapa: 50 g
linterna: 300 g
agua: 1000 g
4
[brújula, mapa]
true

Recuadro · typealias: un nombre nuevo, no un tipo nuevo

Ranking.kt abre con typealias Desempate<T> = Comparator<T>. Es un nombre corto para un tipo que ya existe.

ejemplo mínimo: un typealias es el mismo tipo (con comentarios)
typealias Minuto = Int            // «Minuto» es otro nombre para Int, nada más
 
fun main() {
    val m: Minuto = 90
    val n: Int = m                // vale: Minuto e Int son exactamente el mismo tipo
    println(n + 1)
}
91

Lo que sale en Ranking<T>

Ranking es la mochila con otro nombre y otra regla: en vez de ordenar de menos a más peso, ordena de más a menos puntos. Los trozos son los mismos que ya viste:

  • class Ranking<T>(elementos: Collection<T>, …): sirve para cualquier tipo T, y le pasas una colección de lo que sea.
  • private val puntos: (T) -> Int: la función que puntúa un T (goles, puntos de la liga, longitud de una palabra).
  • desempate: Desempate<T>: un comparador para separar a los empatados. Desempate<T> es el typealias de arriba: un Desempate<Jugador> es exactamente un Comparator<Jugador>.
  • compareByDescending<T> { puntos(it) }.then(desempate): de mayor a menor por los puntos y, si empatan, decide el desempate. Aquí se escribe <T> porque no hay de dónde deducirlo.
  • : Iterable<T>: se recorre con for, como la mochila.

Reto · Ejercicio: ordenar películas

En el Kotlin Playground, fuera de tu proyecto:

  1. Una clase Pelicula con un título y una nota (un entero).
  2. Una lista con tres: ("Alma", 8), ("Brisa", 9) y ("Cima", 8).
  3. Ordénala de mayor a menor nota y, a igualdad de nota, por título de la A a la Z. Imprime solo los títulos, en una lista.

Salida esperada:

[Brisa, Alma, Cima]
Ver solución
solución del reto
class Pelicula(val titulo: String, val nota: Int)
 
fun main() {
    val peliculas = listOf(Pelicula("Alma", 8), Pelicula("Brisa", 9), Pelicula("Cima", 8))
    // De mayor a menor nota; si empatan, decide el título
    val orden = compareByDescending<Pelicula> { it.nota }.thenBy { it.titulo }
    println(peliculas.sortedWith(orden).map { it.titulo })
}

Alma y Cima empatan con 8, y por eso decide el título. Se escribe <Pelicula> porque la lambda sola no dice de qué tipo es it.

Paso · Un solo Ranking<T> para la tabla y los goleadores

Objetivo. Crear Ranking.kt y hacer que tablaDePosiciones y goleadores lo usen. Torneo.goleadores() devolverá un Ranking<Jugador> en vez de una lista de pares. Al ejecutar verás los goleadores con su puesto: 1. Ana (#9): 3 y 2. Iván (#7): 2.

torneo/
kotlin+java → com.sebastiannarvaez.marcador.torneo
Ranking.ktse crea
Tabla.ktse modifica
Torneo.ktse modifica
Main.ktse modifica
kotlin+java → com.sebastiannarvaez.marcador.torneo (test)
RankingTest.ktse copia
TablaTest.kt · FormatoTest.kt · InmutabilidadTest.ktse sustituyen
Un fichero nuevo, tres que cambian y cuatro tests. Los nodos son los de la vista Android.

Cómo crear Ranking.kt. En la vista Android, despliega torneo → kotlin+java, haz clic derecho sobre el paquete com.sebastiannarvaez.marcador.torneo (el que no lleva (test) detrás) → New → Kotlin Class/File. En la ventanita elige File, escribe Ranking y pulsa Intro.

Los bloques de código llevan como título la ruta del fichero en tu proyecto. En la vista Android, esos ficheros están en torneo → kotlin+java → com.sebastiannarvaez.marcador.torneo.

Piezas nuevas de este paso: una clase genérica que recibe una función (T) -> Int y un comparador por defecto, sortedWith con .then(…), indexOfFirst, take, !in, mapa.getValue(clave) (como mapa[clave], pero devuelve Int en vez de Int?; si la clave no estuviera, el programa se pararía con una excepción, un error que verás a fondo en la lección siguiente; en goleadores() las claves salen del propio mapa, así que siempre existen) y override fun iterator(), lo que exige Iterable<T>. Todas aparecen en la mochila de arriba.

Intenta escribirlo tú. Ranking sirve para clasificar elementos de cualquier tipo: recibe los elementos, una forma de puntuar cada uno y, opcionalmente, un desempate (si no se da, no desempata). Se puede recorrer con un bucle, en orden, y ofrece cuatro cosas: ordenados (de más a menos puntos y, a igualdad, según el desempate), puntosDe(elemento), puesto(elemento) (el primero es el 1; los empatados a puntos comparten puesto, por ejemplo 1, 1, 3: busca la posición del primero que tenga sus mismos puntos; y si el elemento no está en el ranking devuelve nada, null) y top(cantidad) (los primeros de la lista). Donde antes salían los goleadores, main debe imprimir ahora cada uno con su puesto:

1. Ana (#9): 3
2. Iván (#7): 2

El resto de la salida no cambia. Los cambios en los otros ficheros no hay que inventarlos: van escritos después, y los pegas.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código

Ranking.kt, fichero nuevo entero:

torneo/src/main/java/com/sebastiannarvaez/marcador/torneo/Ranking.kt
package com.sebastiannarvaez.marcador.torneo
 
// typealias: un nombre nuevo para un tipo que ya existe. No crea un tipo distinto.
typealias Desempate<T> = Comparator<T>
 
// `<T>` es un parámetro de tipo: el Ranking sirve igual para Jugador (goleadores)
// que para FilaDePosicion (tabla), y el compilador sabe en cada caso qué es T.
// Al usarlo se escribe Ranking<Jugador>: el tipo real sustituye a T.
class Ranking<T>(
    elementos: Collection<T>,
    // Cómo se puntúa un elemento: cada uso lo decide (goles, puntos de la liga…).
    private val puntos: (T) -> Int,
    desempate: Desempate<T> = Comparator { _, _ -> 0 },
) : Iterable<T> {
 
    // De más a menos puntos y, a igualdad, según el desempate.
    val ordenados: List<T> = elementos.sortedWith(
        compareByDescending<T> { puntos(it) }.then(desempate),
    )
 
    fun puntosDe(elemento: T): Int = puntos(elemento)
 
    // Puesto con empates: dos con los mismos puntos comparten puesto (1, 1, 3…).
    // Devuelve null si el elemento no está en el ranking.
    fun puesto(elemento: T): Int? {
        if (elemento !in ordenados) return null
        val suyos = puntos(elemento)
        return ordenados.indexOfFirst { puntos(it) == suyos } + 1
    }
 
    fun top(cantidad: Int): List<T> = ordenados.take(cantidad)
 
    override fun iterator(): Iterator<T> = ordenados.iterator()
}

Los cambios en Tabla.kt, Torneo.kt y Main.kt. Son los que no cuesta pensar y sí cuesta equivocarse, así que van escritos para pegar. Primero, en Tabla.kt, la función tablaDePosiciones cambia desde return todos hasta su llave de cierre }. Antes tienes esto (como lo dejó Extensiones y alcance):

antes: tablaDePosiciones, de return a la llave final
    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) -> reglamento.puntosPor(aFavor, enContra) },
            )
        }
        .sortedWith(
            compareByDescending<FilaDePosicion> { it.puntos }
                .thenByDescending { it.diferencia }
                .thenByDescending { it.golesAFavor }
                .thenBy { it.equipo.nombre },
        )
}

Después: selecciona esas líneas (desde return todos hasta la } final, incluida) y pega esto. El map se guarda en filas, y el .sortedWith(…) desaparece porque ahora ordena el Ranking: los puntos son su criterio principal y el resto, el desempate.

después: tablaDePosiciones, de val filas a la llave final
    val filas = 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) -> reglamento.puntosPor(aFavor, enContra) },
        )
    }
 
    // La tabla es un Ranking<FilaDePosicion> puntuado por los puntos de la liga.
    return Ranking(
        elementos = filas,
        puntos = { it.puntos },
        desempate = compareByDescending<FilaDePosicion> { it.diferencia }
            .thenByDescending { it.golesAFavor }
            .thenBy { it.equipo.nombre },
    ).ordenados
}

Los otros tres cambios son más pequeños:

Tabla.kt, la función goleadores() entera (sustituye a la anterior, incluidos sus comentarios):

Tabla.kt: la función goleadores (se pega)
// Tabla de goleadores: otro Ranking, esta vez de Jugador puntuado por sus goles.
// Usa una Sequence: las operaciones se encadenan y se ejecutan elemento a
// elemento, sin crear una lista intermedia entre paso y paso.
fun List<Partido>.goleadores(): Ranking<Jugador> {
    val goles: Map<Jugador, Int> = asSequence()
        .flatMap { it.eventos.asSequence() }
        .filterIsInstance<Gol>()
        .groupingBy { it.jugador }
        .eachCount()
    return Ranking(
        elementos = goles.keys,
        puntos = { goles.getValue(it) },
        desempate = compareBy { it.nombre },
    )
}

Torneo.kt, solo cambia el tipo de retorno:

Torneo.kt: el tipo de retorno (se pega)
    fun goleadores(): Ranking<Jugador> = partidosJugados.goleadores()

Main.kt, el bucle de goleadores (sustituye a la línea torneo.goleadores().forEach { … }):

Main.kt: el bucle de goleadores (se pega)
    val goleadores = torneo.goleadores()
    for (jugador in goleadores) {
        println("${goleadores.puesto(jugador)}. ${jugador.etiqueta()}: ${goleadores.puntosDe(jugador)}")
    }

Cuando compile, entrega los tests. RankingTest.kt es nuevo (6 tests: String, Int, empates y puestos) y los otros tres sustituyen a los tuyos, con goleadores() ya como Ranking (los que comparaban con pares, como ana to 3, dejan de compilar). InmutabilidadTest.kt también se sustituye aquí: su último test comparaba goleadores() con una lista de pares (ana to 1) y ahora compara con .ordenados. No enseñan nada nuevo: comprueban lo que acabas de escribir, así que se copian con el botón (copia al portapapeles y tú pegas).

Para RankingTest.kt, que es nuevo:

  1. En la vista Android, clic derecho sobre el paquete com.sebastiannarvaez.marcador.torneo (test) → New → Kotlin Class/File → File → escribe RankingTest y Intro.
  2. Pulsa Copiar fichero en el bloque de RankingTest.kt.
  3. En el editor, selecciona todo y pega: Cmd+A / Cmd+V. El texto copiado trae su propio package.
RankingTest.kt

Para TablaTest.kt, FormatoTest.kt e InmutabilidadTest.kt, que ya existen, repite con cada uno: ábrelo con doble clic dentro de ese mismo paquete (test), pulsa su Copiar fichero, selecciona todo y pega encima (mismos atajos).

TablaTest.kt

FormatoTest.kt

InmutabilidadTest.kt

Un límite y un detalle que conviene conocer, porque el código los tiene y los tests no los esconden. El límite:

Y el detalle de forma: puesto() devuelve Int?, porque el elemento puede no estar en el ranking. Los tests comprueban que puesto("corner") es null.

Punto de control

  • En la Terminal de Android Studio (View → Tool Windows → Terminal), ./gradlew :torneo:test :torneo:run termina en BUILD SUCCESSFUL. Si un test falla, Gradle lo dice con FAILED y no llega a BUILD SUCCESSFUL.
  • Abre el informe de tests como en Bucles y tu primer test (open torneo/build/reports/tests/test/index.html) y comprueba que arriba pone 65 tests (59 de antes más los 6 nuevos de RankingTest).
  • La salida trae ahora 1. Ana (#9): 3 y 2. Iván (#7): 2, con el puesto delante.
  • Torneo.goleadores() devuelve un Ranking<Jugador>, y tablaDePosiciones() sigue devolviendo una List<FilaDePosicion>.
  • En el paquete (test) está RankingTest.kt, con un test con String y otro con Int: el mismo Ranking con tipos que no tienen nada que ver.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.Un comparador recibe a y b y devuelve un número negativo. ¿Qué significa?

  2. 2.Una clase declara : Iterable<String> y escribe override fun iterator(). ¿Qué se gana?

  3. 3.Dos equipos empatan a 1 punto en la tabla, pero uno tiene mejor diferencia de goles. ¿Qué devuelve puesto() para cada uno?

  4. 4.En Ranking.kt aparece typealias Desempate<T> = Comparator<T>. ¿Qué es un Desempate<Jugador>?

  5. 5.Tienes libros.sortedWith(porPaginas.then(compareBy { it.titulo })). ¿Por qué ahí no hace falta escribir compareBy<Libro>?

Faltan 5 de 5