Extensiones
Al terminar: Añadir funciones a tipos que no son tuyos con funciones y propiedades de extensión.
Al terminar esta lección la tabla de posiciones se pedirá como si fuera un método de la lista de partidos, partidos.tablaDePosiciones(), y la consola de Marcador imprimirá la tabla ya formateada, con una fila por equipo. Todo el formato de texto vivirá en un fichero nuevo, Formato.kt, sin tocar las clases de Marcador (Equipo, Jugador, Partido…).
La herramienta se llama extensión: una función que se añade a un tipo desde fuera. Temas: funciones de extensión, propiedades de extensión y la lambda con receptor que usa buildString. En la lección siguiente llegan las funciones de alcance (let, run, with, apply y also).
Extensiones: una función que se llama como un método
A veces necesitas una función que «parece» de un tipo que no es tuyo: String, List, Int. No puedes abrir esos tipos y escribirles una función dentro, porque vienen con Kotlin. Una función de extensión resuelve eso: la escribes fuera, pero la llamas con un punto, como si el tipo la hubiera traído de serie ("gol".gritar()). Sirve para ordenar el código por lo que se lee, no por dónde vive.
Se declara con el tipo al que se pega delante del nombre: fun String.gritar(). Ese tipo se llama receptor (receiver). Dentro, this es el objeto sobre el que se llama (como en Resultado.titular() de El partido hecho de eventos), y se puede omitir.
// El receptor es String: dentro, `this` es el texto sobre el que se llama.
// uppercase() pasa el texto a mayúsculas; sin escribir `this.`, se aplica al receptor.
fun String.gritar(): String = uppercase() + "!"
fun main() {
println("gol".gritar()) // se llama como si fuera una función de String
}Al ejecutarlo, imprime:
GOL!Cuatro reglas que conviene tener a mano:
| Regla | Qué significa |
|---|---|
| Se resuelven al compilar | Qué función se llama depende del tipo declarado del receptor, no del objeto real que haya en memoria |
| El miembro gana | Si la clase ya tiene una función con ese nombre y esos parámetros, se llama la de la clase |
No ven lo private | Una extensión es una función de fuera: solo llega a lo público |
| Las propiedades no guardan nada | Una propiedad de extensión no tiene campo (el sitio donde un objeto guarda el valor de una propiedad); solo puede calcular con get() |
Sobre la primera regla: la documentación oficial dice que las extensiones se despachan de forma estática, es decir, que el compilador elige la función mirando el tipo con que declaraste la variable, no el objeto que hay dentro. Con un ejemplo:
Para este ejemplo hace falta la herencia que ya viste en Propiedades y visibilidad: open class Deportista permite que otras clases hereden de ella, y class Portero : Deportista() significa «un Portero es también un Deportista».
open class Deportista
class Portero : Deportista()
// Dos extensiones con el mismo nombre, cada una con su receptor
fun Deportista.rol() = "deportista"
fun Portero.rol() = "portero"
fun main() {
val x: Deportista = Portero() // declarado como Deportista, pero el objeto es un Portero
println(x.rol()) // manda el tipo declarado
println(Portero().rol()) // aquí el tipo declarado es Portero
}deportista
porteroExtensiones sobre una lista y propiedades de extensión
El receptor puede ser cualquier tipo, también una lista: List<Libro> quiere decir «una lista de libros». Y además de funciones se pueden añadir propiedades, pero sin campo donde guardar nada: se calculan con get() cada vez que se leen.
class Libro(val titulo: String, val paginas: Int)
// Extensión sobre una lista: el receptor es List<Libro>, y dentro this es la lista
fun List<Libro>.totalDePaginas(): Int = sumOf { it.paginas } // sumOf sin escribir «lista.» delante
// Propiedad de extensión: no guarda nada, se calcula con get() en cada lectura
val Libro.esLargo: Boolean
get() = paginas > 300
fun main() {
val libros = listOf(Libro("Rayuela", 600), Libro("Niebla", 250))
println(libros.totalDePaginas()) // se llama como si fuera de List
println(libros[0].esLargo) // se lee como una propiedad más
println(libros[1].esLargo)
}850
true
falseLambda con receptor: buildString
Hasta ahora una lambda recibía sus datos como parámetros ({ it.paginas }). Hay otra forma: una lambda con receptor, donde dentro de las llaves this ya es un objeto que la propia función crea. Por eso dentro llamas a sus funciones sin escribir su nombre delante. buildString { … } es el ejemplo más útil: te da un StringBuilder (un objeto para ir montando un texto poco a poco) y devuelve el texto final.
Tres piezas pequeñas que también salen en el ejemplo:
forEachIndexed(de Secuencias y formato de tablas) da el número de vuelta además del elemento."…".format(…)construye texto con huecos que empiezan por%:%-8ses un texto alineado a la izquierda en 8 posiciones,%4dun entero en 4 posiciones,%dun entero sin ancho fijo y%02dun entero en 2 posiciones, rellenando con ceros por la izquierda ("%02d".format(5)da05;"%02d".format(35)da35)./y%entre enteros son operaciones:/divide sin decimales y%da el resto. Ojo, no es el mismo%de los huecos de formato (el de"%4d", que va dentro de un texto).120 / 100es 1 y120 % 100es 20, justo lo que hace falta para pasar 120 céntimos a «1.20».
class Libro(val titulo: String, val paginas: Int)
fun main() {
val libros = listOf(Libro("Rayuela", 600), Libro("Niebla", 250))
// buildString { … }: dentro de las llaves, this es un StringBuilder que crea la función,
// y appendLine añade una línea (con salto) sin escribir el nombre del builder
val lista = buildString {
// forEachIndexed da el número de vuelta (desde 0) y el elemento
libros.forEachIndexed { i, libro -> appendLine("${i + 1}. ${libro.titulo}") }
}.trimEnd() // trimEnd(): quita el salto de línea sobrante del final
println(lista)
// "%-8s" = texto alineado a la izquierda en 8 posiciones; "%4d" = entero en 4 posiciones
println("%-8s|%4d".format("Niebla", 250))
}1. Rayuela
2. Niebla
Niebla | 250Reto · Ejercicio: precios en euros
En el Kotlin Playground, fuera de tu proyecto:
- Un producto tiene un nombre y un precio en céntimos.
- Haz que cualquier número entero de céntimos se pueda leer como precio en euros (120 se lee
1.20 €), y que cualquier lista de productos sepa su total en céntimos. - Con un pan (120) y una leche (95), imprime una línea por producto y una última con el total.
La salida tiene que ser:
Pan 1.20 €
Leche 0.95 €
Total 2.15 €Ver solución
class Producto(val nombre: String, val centimos: Int)
// Propiedad de extensión sobre Int: se calcula al leerla
val Int.enEuros: String
get() = "%d.%02d €".format(this / 100, this % 100) // %02d: entero en 2 cifras, con cero delante
// Extensión sobre una lista de productos
fun List<Producto>.total(): Int = sumOf { it.centimos }
fun main() {
val compra = listOf(Producto("Pan", 120), Producto("Leche", 95))
for (producto in compra) {
println("${producto.nombre} ${producto.centimos.enEuros}")
}
println("Total ${compra.total().enEuros}")
}El receptor puede ser un tipo básico como Int o una lista; en los dos casos, dentro this es el valor sobre el que se llama. Para el precio sirve una propiedad, porque no necesita argumentos; para el total, una función.
Paso · La tabla como extensión de la lista de partidos
Objetivo. Convertir tablaDePosiciones en extensión de List<Partido>, convertir tablaDeGoleadores en la extensión goleadores() (cambia también de nombre) y crear Formato.kt con las funciones de texto. Al ejecutar ./gradlew :torneo:run verás, entre otras cosas, la tabla con cabecera y una fila por equipo. En un ajuste de formato, el equipo se alinea ahora a 8 caracteres (%-8s) en vez de a 7, para dar más aire a la columna, y por eso la cabecera lleva un espacio más.
Cómo crear Formato.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 Formato y pulsa Intro. Android Studio crea Formato.kt con la línea package ya escrita y lo abre.
Los bloques de código de esta lección 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.
Las piezas nuevas de este paso son las que acabas de ver: una extensión sobre List<…>, una propiedad de extensión con get(), buildString con appendLine, forEachIndexed y .trimEnd().
Intenta escribirlo tú. Empieza por Tabla.kt: la tabla de posiciones pasa a ser una extensión de la lista de partidos: se le pide a la propia lista en vez de recibirla como primer parámetro, y la tabla de goleadores también, con el nombre nuevo goleadores(). Android Studio marca en rojo las llamadas que se rompen: están en Torneo.kt (que sustituyes más abajo) y en el test (que también se sustituye), así que basta con seguir los pasos de «Qué se sustituye y dónde». Cuando compile, crea Formato.kt con estas cuatro piezas, que se llaman como si fueran de su tipo y devuelven estos textos exactos (el punto de control los compara tal cual):
| Pieza | Debe devolver |
|---|---|
Jugador.etiqueta() | Ana (#9) con dorsal 9; Luis (-) si no tiene dorsal (usa dorsalOGuion(), que ya existe) |
Equipo.capitanONinguno | el nombre del capitán, por ejemplo Ana; sin capitán si no hay |
FilaDePosicion.aTexto(puesto) | una fila con el mismo formato de columnas de la lección La tabla de posiciones: 1 Rayo FC 2 2 0 0 3 1 2 6 (el equipo alineado a 8 caracteres) |
Torneo.tablaComoTexto() | la cabecera Pos Equipo PJ G E P GF GC DG Pts y una fila por equipo, cada una en su línea, sin salto de línea final |
Después, en main, imprime la tabla como texto, los goleadores con su etiqueta y el capitán de los Toros. Donde antes salían la tabla y los goleadores, ahora 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 (#9): 3
Iván (#7): 2
Capitán de Toros: sin capitánQué se sustituye y dónde (obligatorio: vale para lo que escribas tú y para lo que copies de los bloques):
Tabla.kt: tres cambios, cada uno con su bloque «antes / después» más abajo. La firma detablaDePosiciones, la líneaval apariciones = …(pierdepartidos.) y la funcióntablaDeGoleadoresentera, que se sustituye porgoleadores(): no es solo un cambio de tipo, la función se renombra, y el cuerpo sigue igual salvo quepartidos.asSequence()pasa aasSequence().Torneo.kt: sustituye las dos funcionestablaDePosiciones()ygoleadores(), que ahora llaman a las extensiones.Main.kt: sustituye desde el comentario// Colecciones: una jornada completa en un torneo, la tabla y los goleadores.hasta la líneatorneo.goleadores().forEach { … }(incluida) por el bloque de más abajo. La líneaprintln("Partidos en el torneo: …")y todo lo que va después se queda como estaba.- Tests:
TablaTest.ktse sustituye con el botón Copiar fichero, como se explicó en Clases y objetos (más abajo, con los pasos).
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
Formato.kt, fichero nuevo entero:
package com.sebastiannarvaez.marcador.torneo
// Extensiones sobre tipos propios: leen como métodos, pero viven aparte.
// Un miembro de la clase siempre gana a una extensión del mismo nombre, y la
// extensión NO puede ver lo `private` de la clase.
fun Jugador.etiqueta(): String = "$nombre (${dorsalOGuion()})"
// Extensión con propiedad: sin campo, solo calculada.
val Equipo.capitanONinguno: String
get() = capitan?.nombre ?: "sin capitán"
fun FilaDePosicion.aTexto(puesto: Int): String =
"%2d %-8s %2d %2d %2d %2d %3d %2d %3d %3d".format(
puesto, equipo.nombre, jugados, ganados, empatados, perdidos,
golesAFavor, golesEnContra, diferencia, puntos,
)
// `buildString` da un StringBuilder como `this` (lambda con receptor): dentro
// se llama a `appendLine` sin escribir el nombre del builder.
fun Torneo.tablaComoTexto(): String = buildString {
appendLine("Pos Equipo PJ G E P GF GC DG Pts")
tablaDePosiciones().forEachIndexed { indice, fila ->
appendLine(fila.aTexto(indice + 1))
}
}.trimEnd()Tabla.kt, primer cambio, la firma. Antes (son estas siete líneas, desde el comentario hasta la {):
// 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(),
reglamento: Reglamento = ReglamentoLiga,
): List<FilaDePosicion> {Después (sustituyen a esas siete líneas):
// Función de extensión: añade un método a `List<Partido>` sin tocar la clase List.
// Dentro, `this` es la lista de partidos. Se llama como si fuera de la lista:
// partidos.tablaDePosiciones()
// `equipos` permite que salgan también los que aún no han jugado.
fun List<Partido>.tablaDePosiciones(
equipos: List<Equipo> = emptyList(),
reglamento: Reglamento = ReglamentoLiga,
): List<FilaDePosicion> {Tabla.kt, segundo cambio, la línea de apariciones. Antes:
val apariciones = partidos.flatMap { partido ->Después (sin partidos.):
val apariciones = flatMap { partido ->Tabla.kt, tercer cambio, al final del fichero. Antes, tablaDeGoleadores entera, con su comentario:
// 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 })Después, goleadores() entera (sustituye a todo lo anterior; el comentario se puede conservar encima):
fun List<Partido>.goleadores(): List<Pair<Jugador, Int>> =
asSequence()
.flatMap { it.eventos.asSequence() }
.filterIsInstance<Gol>()
.groupingBy { it.jugador }
.eachCount()
.toList()
.sortedWith(compareByDescending<Pair<Jugador, Int>> { it.second }.thenBy { it.first.nombre })Torneo.kt, las dos funciones que llaman a las extensiones:
fun tablaDePosiciones(): List<FilaDePosicion> = partidosJugados.tablaDePosiciones(equipos, reglamento)
fun goleadores(): List<Pair<Jugador, Int>> = partidosJugados.goleadores()Main.kt, el bloque de la jornada, que ahora usa tablaComoTexto():
// Colecciones y extensiones: una jornada completa en un torneo.
val lobos = Equipo("Lobos", listOf(Jugador("Pedro", 4)))
val torneo = Torneo(nombreDelTorneo, listOf(rayo, toros, lobos))
torneo.agregar(alFinal)
torneo.agregar(Partido(toros, lobos).registrar(Gol(20, ivan, toros)))
torneo.agregar(Partido(lobos, rayo).registrar(Gol(40, ana, rayo)))
println(torneo.tablaComoTexto())
torneo.goleadores().forEach { (jugador, goles) -> println("${jugador.etiqueta()}: $goles") }
println("Capitán de Toros: ${toros.capitanONinguno}")Cuando el código compile, entrega el test. El TablaTest.kt de abajo sustituye al tuyo: trae las llamadas ya cambiadas a la forma de extensión (partidos.tablaDePosiciones(), partidos.goleadores()), y sin él el test antiguo deja de compilar. No hace falta escribirlo: comprueba lo que acabas de escribir y no enseña nada nuevo, así que se copia con el botón.
Para TablaTest.kt, que ya existe y se sustituye:
- En la vista Android, ábrelo con doble clic dentro del paquete
com.sebastiannarvaez.marcador.torneo (test). - Pulsa Copiar fichero en el bloque de
TablaTest.kt. - Selecciona todo y pega encima: Cmd+A / Cmd+V. El texto copiado trae su propio
package.
Qué hace cada pieza nueva, en una línea:
| Pieza | Qué demuestra |
|---|---|
Jugador.etiqueta() | Extensión sobre una clase tuya, que llama a un método público del receptor |
Equipo.capitanONinguno | Propiedad de extensión: sin campo, solo get() |
Torneo.tablaComoTexto() | Lambda con receptor: buildString |
List<Partido>.tablaDePosiciones() | Extensión sobre una lista, con el cuerpo de la función de antes |
Fíjate en que Torneo.tablaDePosiciones() (método del torneo) y List<Partido>.tablaDePosiciones() (extensión de la lista) tienen el mismo nombre y no chocan: cada una tiene un receptor distinto.
Punto de control
- En la Terminal de Android Studio (View → Tool Windows → Terminal, que se abre en la raíz del proyecto),
./gradlew :torneo:test :torneo:runtermina enBUILD SUCCESSFUL. Si un test falla, Gradle lo dice conFAILEDy no llega aBUILD SUCCESSFUL. En total pasan 54 tests: los mismos de antes, conTablaTestya en la forma de extensión. - La salida contiene la tabla con cabecera y tres filas, la primera
1 Rayo FC 2 2 0 0 3 1 2 6. - Después de la tabla salen
Ana (#9): 3yIván (#7): 2. - La última línea de esa parte es
Capitán de Toros: sin capitán. - En la vista Android,
torneo → kotlin+java → com.sebastiannarvaez.marcador.torneotiene ahoraFormato.kt.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.En
fun String.gritar() = uppercase() + "!", ¿qué esStringy a qué se refiereuppercase()?2.Una clase tiene un método
descripcion()y tú declaras una extensióndescripcion()con los mismos parámetros. ¿Cuál se ejecuta al llamarobjeto.descripcion()?3.¿Puede una extensión leer una propiedad
privatede la clase que amplía?4.Declaras
val x: Deportista = Portero()y existenfun Deportista.rol()yfun Portero.rol(). ¿Cuál se ejecuta conx.rol()?5.Escribes
val Libro.esLargo: Boolean get() = paginas > 300. ¿Dónde guardaLibroese valor?