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)(ythenBy,thenByDescending) encadena dos comparadores: siprimerodice «empate», decidesegundo.
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:
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
trueEl 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):
// : 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
NieblaUna 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.
// 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]
trueRecuadro · 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.
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)
}91Lo 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 tipoT, y le pasas una colección de lo que sea.private val puntos: (T) -> Int: la función que puntúa unT(goles, puntos de la liga, longitud de una palabra).desempate: Desempate<T>: un comparador para separar a los empatados.Desempate<T>es eltypealiasde arriba: unDesempate<Jugador>es exactamente unComparator<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 confor, como la mochila.
Reto · Ejercicio: ordenar películas
En el Kotlin Playground, fuera de tu proyecto:
- Una clase
Peliculacon un título y una nota (un entero). - Una lista con tres: ("Alma", 8), ("Brisa", 9) y ("Cima", 8).
- 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
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.
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): 2El 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:
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):
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.
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 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:
fun goleadores(): Ranking<Jugador> = partidosJugados.goleadores()Main.kt, el bucle de goleadores (sustituye a la línea torneo.goleadores().forEach { … }):
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:
- En la vista Android, clic derecho sobre el paquete
com.sebastiannarvaez.marcador.torneo (test)→ New → Kotlin Class/File → File → escribeRankingTesty Intro. - Pulsa Copiar fichero en el bloque de
RankingTest.kt. - En el editor, selecciona todo y pega: Cmd+A / Cmd+V. El texto copiado trae su propio
package.
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).
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:runtermina enBUILD SUCCESSFUL. Si un test falla, Gradle lo dice conFAILEDy no llega aBUILD 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 deRankingTest). - La salida trae ahora
1. Ana (#9): 3y2. Iván (#7): 2, con el puesto delante. Torneo.goleadores()devuelve unRanking<Jugador>, ytablaDePosiciones()sigue devolviendo unaList<FilaDePosicion>.- En el paquete
(test)estáRankingTest.kt, con un test conStringy otro conInt: el mismoRankingcon tipos que no tienen nada que ver.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.Un comparador recibe
ayby devuelve un número negativo. ¿Qué significa?2.Una clase declara
: Iterable<String>y escribeoverride fun iterator(). ¿Qué se gana?3.Dos equipos empatan a 1 punto en la tabla, pero uno tiene mejor diferencia de goles. ¿Qué devuelve
puesto()para cada uno?4.En
Ranking.ktaparecetypealias Desempate<T> = Comparator<T>. ¿Qué es unDesempate<Jugador>?5.Tienes
libros.sortedWith(porPaginas.then(compareBy { it.titulo })). ¿Por qué ahí no hace falta escribircompareBy<Libro>?