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

Delegación: by lazy

Al terminar: Usar by lazy para calcular un valor solo la primera vez que se lee.

En esta lección la tabla de posiciones de una «foto» del torneo se calculará una sola vez, la primera vez que alguien la lea, gracias a by lazy. Es tu primera propiedad delegada: una propiedad cuyo valor no lo guarda ella, sino otro objeto.

Al terminar, main mostrará el contador de cálculos de la foto: 0 antes de leer la tabla y 1 después de leerla dos veces. En las tres lecciones siguientes, JSON e instalar kotlinx.serialization, Guardar y leer JSON con Json y Guardado.kt en Marcador, instalarás la serialización y guardarás el torneo en un fichero.

Delegación: quien lee la propiedad no es quien la calcula

Una propiedad delegada no guarda su valor: cuando alguien la lee (get) o la escribe (set), lo hace otro objeto, el delegado. Se escribe con by: val tabla by lazy { … }.

El problema que resuelve: hay valores que cuestan calcular (una tabla de posiciones recorre todos los partidos) y que casi siempre se leen más de una vez. Calcularlos en cada lectura es trabajo repetido, y calcularlos al crear el objeto es trabajo perdido si nadie los lee. by lazy los calcula la primera vez que se leen y guarda el resultado.

ejemplo mínimo: by lazy (con comentarios)
class Foto(val goles: List<Int>) {
    val total: Int by lazy {          // by lazy: el bloque corre en la primera lectura de total
        println("calculando")
        goles.sum()                   // la última línea es el valor que se guarda
    }
}
 
fun main() {
    val foto = Foto(listOf(1, 2, 3))
    println("antes")
    println(foto.total)   // imprime «calculando» y luego 6
    println(foto.total)   // solo 6: no vuelve a calcular
}

Al ejecutarlo, imprime:

antes
calculando
6
6

Fíjate en las dos condiciones de by lazy: la propiedad es una val (el valor no cambia después) y el bloque se ejecuta en la primera lectura, no al crear el objeto.

Por qué el by lazy vive en InstantaneaDelTorneo y no en Torneo

Torneo es mutable: registrar añade partidos. Una tabla guardada dentro de Torneo con by lazy se calcularía una vez y no se enteraría del partido siguiente: mostraría una tabla obsoleta. Por eso el lazy va en una clase distinta, InstantaneaDelTorneo: una foto con la lista de partidos ya copiada. Como sus partidos ya no cambian, guardar la tabla es seguro.

Reto · Ejercicio: calcula una sola vez

En el Kotlin Playground, fuera de tu proyecto:

  1. Parte de la clase Foto del ejemplo mínimo. Haz una versión en la que total se calcule en cada lectura, como una propiedad calculada de Propiedades y visibilidad, y lee total tres veces.
  2. Haz lo mismo con la versión by lazy. ¿Cuántas veces sale «calculando» en cada caso?

Pista: un get() de varias líneas se escribe con llaves, get() { … }, y devuelve el valor con return en su última línea. Para ver cuándo se calcula, haz que el cálculo imprima «calculando» antes de devolver goles.sum(). Con la lista 1, 2 y 3, y leyendo e imprimiendo total tres veces con cada versión, la salida esperada es:

calculando
6
calculando
6
calculando
6
calculando
6
6
6
Ver solución
solución del reto
class FotoConGet(val goles: List<Int>) {
    val total: Int
        get() {
            println("calculando")
            return goles.sum()
        }
}
 
class FotoLazy(val goles: List<Int>) {
    val total: Int by lazy {
        println("calculando")
        goles.sum()
    }
}
 
fun main() {
    val a = FotoConGet(listOf(1, 2, 3))
    println(a.total)   // «calculando» y 6
    println(a.total)   // «calculando» otra vez
    println(a.total)   // «calculando» otra vez: 3 veces en total
    val b = FotoLazy(listOf(1, 2, 3))
    println(b.total)   // «calculando» y 6
    println(b.total)   // solo 6: valor guardado
    println(b.total)   // solo 6: valor guardado
}

Un get() calculado se ejecuta en cada lectura. Con by lazy se ejecuta en la primera y las demás devuelven el valor guardado. Por eso lazy compensa cuando el cálculo es caro y el resultado no cambia.

Paso · by lazy en InstantaneaDelTorneo

Objetivo. Añadir InstantaneaDelTorneo, una foto del torneo cuya tabla se calcula una sola vez, y usarla desde main. Al ejecutar ./gradlew :torneo:run aparecerán tres líneas nuevas al final, sobre la tabla calculada.

torneo/
kotlin+java → com.sebastiannarvaez.marcador.torneo
Instantanea.ktse crea
Main.ktse modifica
Un fichero nuevo y uno que cambia. Los nodos son los de la vista Android.

Cómo crear Instantanea.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 Instantanea 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: by lazy con un contador que cuenta los cálculos (calculosDeLaTabla++, que suma uno), una propiedad internal var con private set (internal significa «visible dentro de este módulo»; tu módulo :torneo es la carpeta torneo del proyecto, donde viven todas las clases del torneo) y una extensión que devuelve la foto, como las de Extensiones. Todas en un ejemplo con un menú de recetas, explicado línea a línea:

ejemplo mínimo: by lazy con un contador (con comentarios)
class Receta(val nombre: String, val ingredientes: List<String>)
 
class Menu(private val recetas: List<Receta>) {
    // internal: visible en todo el módulo; private set: solo esta clase la cambia
    internal var preparaciones = 0
        private set
 
    // by lazy: el bloque se ejecuta en la PRIMERA lectura y el resultado se guarda
    val compra: List<String> by lazy {
        preparaciones++                               // ++ suma uno: preparaciones = preparaciones + 1
        recetas.flatMap { it.ingredientes }.toSet().toList()   // toSet() quita los repetidos; toList() lo vuelve a lista
    }
}
 
// Extensión que devuelve un objeto nuevo a partir de la receta
fun Receta.enMenu(): Menu = Menu(listOf(this))
 
fun main() {
    val menu = Receta("Tortilla", listOf("huevo", "patata", "huevo")).enMenu()
    println("Antes de leer: ${menu.preparaciones}")   // 0: aún nadie ha leído compra
    println(menu.compra)                              // primera lectura: calcula
    println(menu.compra)                              // segunda: devuelve lo guardado
    println("Después: ${menu.preparaciones}")         // 1: se calculó una sola vez
}

Al ejecutarlo, imprime:

Antes de leer: 0
[huevo, patata]
[huevo, patata]
Después: 1

Intenta escribirlo tú. InstantaneaDelTorneo es una foto de un torneo: sus partidos, sus equipos y su reglamento. Su tabla (la tabla de posiciones de esos partidos) y sus goleadores se calculan la primera vez que alguien los lee, y nunca más. El contador calculosDeLaTabla sube en uno cada vez que la tabla se calcula de verdad; se lee desde todo el módulo, pero solo la foto lo cambia. A un torneo se le pide su foto con instantanea(). Al final de main, pide una foto, muestra el contador antes de leer la tabla, lee el líder dos veces y muestra el contador otra vez. Tras las líneas de antes, debe verse:

Tabla calculada 0 veces antes de leerla
Líder: Rayo FC
Líder otra vez: Rayo FC (calculada 1 vez)

Qué se cambia y dónde (obligatorio): el bloque nuevo de Main.kt va al final de main, después de destino.delete() y antes de la llave de cierre.

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

Instantanea.kt, fichero nuevo entero:

torneo/src/main/java/com/sebastiannarvaez/marcador/torneo/Instantanea.kt
package com.sebastiannarvaez.marcador.torneo
 
// Una «foto» del torneo en un momento dado: sus partidos ya no cambian.
// Por eso aquí SÍ tiene sentido calcular la tabla una sola vez y guardarla.
//
// `by lazy` es una propiedad delegada: en vez de guardar un valor, delega
// leer la propiedad en otro objeto (el Lazy). El bloque corre la PRIMERA vez
// que alguien lee `tabla`; las siguientes lecturas devuelven el resultado
// guardado.
class InstantaneaDelTorneo(
    val partidos: List<Partido>,
    val equipos: List<Equipo>,
    val reglamento: Reglamento,
) {
    // Solo para que los tests vean cuántas veces se calculó de verdad.
    internal var calculosDeLaTabla = 0
        private set
 
    val tabla: List<FilaDePosicion> by lazy {
        calculosDeLaTabla++
        partidos.tablaDePosiciones(equipos, reglamento)
    }
 
    val goleadores: Ranking<Jugador> by lazy { partidos.goleadores() }
}
 
// `partidos` ya devuelve una copia, así que la foto no se ve afectada por lo que
// se registre después.
fun Torneo.instantanea(): InstantaneaDelTorneo = InstantaneaDelTorneo(partidos, equipos, reglamento)

Main.kt, el bloque final (de momento termina aquí; la llave de cierre de main va después):

torneo/src/main/java/com/sebastiannarvaez/marcador/torneo/Main.kt
    // by lazy: la tabla de una foto del torneo se calcula la primera vez que se lee.
    val foto = torneo.instantanea()
    println("Tabla calculada ${foto.calculosDeLaTabla} veces antes de leerla")
    println("Líder: ${foto.tabla.first().equipo.nombre}")
    println("Líder otra vez: ${foto.tabla.first().equipo.nombre} (calculada ${foto.calculosDeLaTabla} vez)")

Abre la Terminal de Android Studio (View → Tool Windows → Terminal, que se abre en la raíz del proyecto) y ejecuta:

./gradlew :torneo:run

Al final de la salida deben aparecer estas tres líneas. Fíjate en el contador: vale 0 antes de leer la tabla y 1 después de leerla dos veces.

Tabla calculada 0 veces antes de leerla
Líder: Rayo FC
Líder otra vez: Rayo FC (calculada 1 vez)

Punto de control

  • En la Terminal de Android Studio, ./gradlew :torneo:test :torneo:run termina en BUILD SUCCESSFUL. Si un test falla, Gradle lo dice con FAILED y no llega a BUILD SUCCESSFUL.
  • La salida termina con Tabla calculada 0 veces antes de leerla, luego Líder: Rayo FC y Líder otra vez: Rayo FC (calculada 1 vez).
  • Abre el informe de tests (open torneo/build/reports/tests/test/index.html, como en Bucles y tu primer test): arriba pone 75 tests, los mismos que antes de esta lección, porque no hay ningún test nuevo.
  • En el paquete com.sebastiannarvaez.marcador.torneo está Instantanea.kt.

Comprueba lo que entendiste

4 preguntas · necesitas 3 para completar la lección

  1. 1.Lees foto.tabla (declarada con by lazy) tres veces. ¿Cuántas veces se ejecuta el bloque de lazy?

  2. 2.¿Por qué la tabla con by lazy está en InstantaneaDelTorneo y no dentro de Torneo?

  3. 3.Una propiedad calculada con get() { … } y otra con by lazy { … } hacen el mismo cálculo. Las lees tres veces. ¿Cuántas veces se calcula cada una?

  4. 4.¿Por qué una propiedad con by lazy se declara con val y no con var?

Faltan 4 de 4