Saltar al contenido
K
Kotlin: el lenguajePaso a paso40 min de lectura · 3 min de máquina Básico

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.

ejemplo mínimo: una extensión de String (con comentarios)
// 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:

ReglaQué significa
Se resuelven al compilarQué función se llama depende del tipo declarado del receptor, no del objeto real que haya en memoria
El miembro ganaSi la clase ya tiene una función con ese nombre y esos parámetros, se llama la de la clase
No ven lo privateUna extensión es una función de fuera: solo llega a lo público
Las propiedades no guardan nadaUna 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».

ejemplo mínimo: la extensión que se elige al compilar (con comentarios)
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
portero

Extensiones 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.

ejemplo mínimo: extensiones sobre una lista y una propiedad (con comentarios)
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
false

Lambda 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 %: %-8s es un texto alineado a la izquierda en 8 posiciones, %4d un entero en 4 posiciones, %d un entero sin ancho fijo y %02d un entero en 2 posiciones, rellenando con ceros por la izquierda ("%02d".format(5) da 05; "%02d".format(35) da 35).
  • / 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 / 100 es 1 y 120 % 100 es 20, justo lo que hace falta para pasar 120 céntimos a «1.20».
ejemplo mínimo: buildString y formato de texto (con comentarios)
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  | 250

Reto · Ejercicio: precios en euros

En el Kotlin Playground, fuera de tu proyecto:

  1. Un producto tiene un nombre y un precio en céntimos.
  2. 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.
  3. 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
solución: precios en euros
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.

torneo/
kotlin+java → com.sebastiannarvaez.marcador.torneo
Formato.ktse crea
Tabla.ktse modifica
Torneo.ktse modifica
Main.ktse modifica
kotlin+java → com.sebastiannarvaez.marcador.torneo (test)
TablaTest.ktse sustituye
Un fichero nuevo, tres que cambian y un test que se sustituye. Los nodos son los de la vista Android.

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):

PiezaDebe devolver
Jugador.etiqueta()Ana (#9) con dorsal 9; Luis (-) si no tiene dorsal (usa dorsalOGuion(), que ya existe)
Equipo.capitanONingunoel 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án

Qué se sustituye y dónde (obligatorio: vale para lo que escribas tú y para lo que copies de los bloques):

  1. Tabla.kt: tres cambios, cada uno con su bloque «antes / después» más abajo. La firma de tablaDePosiciones, la línea val apariciones = … (pierde partidos.) y la función tablaDeGoleadores entera, que se sustituye por goleadores(): no es solo un cambio de tipo, la función se renombra, y el cuerpo sigue igual salvo que partidos.asSequence() pasa a asSequence().
  2. Torneo.kt: sustituye las dos funciones tablaDePosiciones() y goleadores(), que ahora llaman a las extensiones.
  3. Main.kt: sustituye desde el comentario // Colecciones: una jornada completa en un torneo, la tabla y los goleadores. hasta la línea torneo.goleadores().forEach { … } (incluida) por el bloque de más abajo. La línea println("Partidos en el torneo: …") y todo lo que va después se queda como estaba.
  4. Tests: TablaTest.kt se 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:

torneo/src/main/java/com/sebastiannarvaez/marcador/torneo/Formato.kt
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.kt, antes (esto es lo que tienes)
// 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):

torneo/src/main/java/com/sebastiannarvaez/marcador/torneo/Tabla.kt
// 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:

Tabla.kt, antes (esto es lo que tienes)
    val apariciones = partidos.flatMap { partido ->

Después (sin partidos.):

torneo/src/main/java/com/sebastiannarvaez/marcador/torneo/Tabla.kt
    val apariciones = flatMap { partido ->

Tabla.kt, tercer cambio, al final del fichero. Antes, tablaDeGoleadores entera, con su comentario:

Tabla.kt, antes (esto es lo que tienes)
// 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):

torneo/src/main/java/com/sebastiannarvaez/marcador/torneo/Tabla.kt
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:

torneo/src/main/java/com/sebastiannarvaez/marcador/torneo/Torneo.kt
    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():

torneo/src/main/java/com/sebastiannarvaez/marcador/torneo/Main.kt
    // 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:

  1. En la vista Android, ábrelo con doble clic dentro del paquete com.sebastiannarvaez.marcador.torneo (test).
  2. Pulsa Copiar fichero en el bloque de TablaTest.kt.
  3. Selecciona todo y pega encima: Cmd+A / Cmd+V. El texto copiado trae su propio package.
TablaTest.kt

Qué hace cada pieza nueva, en una línea:

PiezaQué demuestra
Jugador.etiqueta()Extensión sobre una clase tuya, que llama a un método público del receptor
Equipo.capitanONingunoPropiedad 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:run termina en BUILD SUCCESSFUL. Si un test falla, Gradle lo dice con FAILED y no llega a BUILD SUCCESSFUL. En total pasan 54 tests: los mismos de antes, con TablaTest ya 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): 3 y Ivá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.torneo tiene ahora Formato.kt.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.En fun String.gritar() = uppercase() + "!", ¿qué es String y a qué se refiere uppercase()?

  2. 2.Una clase tiene un método descripcion() y tú declaras una extensión descripcion() con los mismos parámetros. ¿Cuál se ejecuta al llamar objeto.descripcion()?

  3. 3.¿Puede una extensión leer una propiedad private de la clase que amplía?

  4. 4.Declaras val x: Deportista = Portero() y existen fun Deportista.rol() y fun Portero.rol(). ¿Cuál se ejecuta con x.rol()?

  5. 5.Escribes val Libro.esLargo: Boolean get() = paginas > 300. ¿Dónde guarda Libro ese valor?

Faltan 5 de 5