Saltar al contenido
K
Kotlin: el lenguajePaso a paso35 min Intermedio

Inmutabilidad y referencias

Al terminar: Entender por qué una val con una lista mutable dentro no es inmutable, y arreglarlo con copias defensivas.

Al terminar esta lección Marcador tendrá su primer objeto que cambia con el tiempo, Torneo, y ese objeto no dejará que nadie de fuera le vacíe la lista de partidos. Además Equipo se defenderá de quien le pase una lista y luego la siga modificando.

Es uno de los errores más silenciosos del curso: el código compila, los tipos dicen «solo lectura» y aun así los datos cambian por debajo. Temas: val con una lista mutable dentro, referencias compartidas y copias defensivas.

val protege la variable, no lo que hay dentro

Un objeto es un valor concreto que vive en la memoria del programa: un Partido, una lista, un Jugador. Como viste en data class e igualdad, una variable no contiene el objeto: guarda la dirección de dónde está, y eso se llama referencia. val significa «esta variable no puede pasar a apuntar a otro objeto». No dice nada sobre si el objeto se puede modificar.

Dos variables pueden apuntar al mismo objeto, y entonces un cambio hecho por una se ve por la otra. Con un número pasa otra cosa: al copiarlo se copia el valor. El contraste, con un objeto propio y con un Int:

ejemplo mínimo: referencia frente a copia (con comentarios)
// Una clase con una propiedad que se puede cambiar (var)
class Marcador(var goles: Int)
 
fun main() {
    // Dos variables que apuntan al MISMO objeto
    val local = Marcador(0)
    val copiaDeLocal = local      // no crea otro Marcador: es el mismo
    copiaDeLocal.goles = 2        // se cambia por una variable…
    println(local.goles)          // …y se ve por la otra
 
    // Con un número pasa otra cosa: se copia el valor
    var a = 5
    val b = a                     // b recibe un 5 propio
    a = 9
    println(b)                    // sigue siendo 5
    println(a)
}

Al ejecutarlo, imprime:

2
5
9

Un objeto es inmutable si no puede cambiar después de crearse: todas sus propiedades son val y no guarda dentro nada que se pueda modificar. Jugador es inmutable: su nombre y su dorsal son val y no se pueden cambiar; para «cambiarlo» creas otro. Marcador del ejemplo es mutable, porque su goles es una var.

ejemplo mínimo: val no congela el objeto (con comentarios)
fun main() {
    val lista = mutableListOf(1, 2)
    lista.add(3)                       // permitido: la variable no cambia, la lista sí
    println(lista)                     // [1, 2, 3]
    // lista = mutableListOf(9)        // error: 'val' cannot be reassigned
 
    // Dos nombres, un solo objeto.
    val original = mutableListOf("Ana")
    val alias = original               // NO copia: apunta al mismo objeto
    alias.add("Luis")
    println(original)                  // [Ana, Luis]
 
    // La copia defensiva sí crea otra lista.
    val copia = original.toList()
    original.add("Marta")
    println(copia)                     // [Ana, Luis]
    println(original)                  // [Ana, Luis, Marta]
}

alias no es una copia de original: son dos nombres del mismo objeto. toList() sí crea uno nuevo, y por eso copia no se entera de que Marta llegó. A eso se le llama copia defensiva: te quedas con tu propia copia para que nadie de fuera pueda tocar la tuya.

Error típico: filtrar la lista interna

Hay dos formas de filtrarla. La primera es evidente:

con el bug: la lista mutable, expuesta
class TorneoConFuga {
    val partidos = mutableListOf<Partido>()   // cualquiera puede llamar a partidos.clear()
}

La segunda es la peligrosa, porque parece correcta. El tipo declarado es List, pero el objeto que se entrega es la lista interna:

con el bug: el tipo dice List, el objeto es mutable
class TorneoConFugaDisfrazada {
    private val interna = mutableListOf<Partido>()
    val partidos: List<Partido> get() = interna   // entrega la lista interna tal cual
    fun agregar(partido: Partido) { interna.add(partido) }
}

Quien reciba partidos solo ve una List, pero por dentro sigue siendo una MutableList. Una conversión (cast) le dice al compilador «trata este objeto como otro tipo»: con as MutableList<Partido>, la lista pasa a poder modificarse. Después, clear() (vaciarla) la deja sin partidos. La causa: List es un permiso de lectura, no una garantía sobre el objeto.

La solución es entregar una copia:

corregido: se entrega una copia
class Torneo {
    private val partidosJugados = mutableListOf<Partido>()   // private: solo la clase toca la lista mutable
 
    val partidos: List<Partido>              // lo que ven los de fuera: una List de solo lectura…
        get() = partidosJugados.toList()     // …que en cada lectura es una copia nueva: una foto que no cambia cuando llegan más partidos
}

Lo mismo pasa al recibir una lista. Con la versión de Equipo de Clases que se validan, val plantilla: List<Jugador> = plantilla guarda la lista de quien llamó. Si esa persona conserva su MutableList y añade un jugador con dorsal repetido, se salta el require del init, que ya había terminado de ejecutarse:

la fuga al recibir: la lista de fuera sigue viva (fragmento)
val dorsales = mutableListOf(Jugador("Ana", 9))
val equipo = Equipo("Rayo", dorsales)     // guarda la lista de quien llama, sin copiarla
dorsales.add(Jugador("Otra", 9))          // se cuela un dorsal repetido y nadie se entera

Con una versión de Equipo sin toList(), la plantilla queda con [Jugador(nombre=Ana, dorsal=9), Jugador(nombre=Otra, dorsal=9)], dos dorsales 9 y ningún error.

En el módulo de Swift verás cómo trata Swift esta misma idea, con let y var.

Reto · Ejercicio: el carrito que se vacía solo

En el Kotlin Playground, fuera de tu proyecto.

  1. Un carrito de la compra que guarda productos en una lista que se puede modificar desde fuera. Añade "pan" desde fuera y vacíalo desde fuera, sin pasar por ninguna función del carrito; imprime su contenido.
  2. Arréglalo para que solo el carrito pueda añadir productos y lo que entregue sea una foto.
  3. Con el carrito ya arreglado, añade "pan", guarda una foto, añade "leche" e imprime la foto y el contenido actual.

La salida de los tres pasos, en este orden (la primera línea es la de la versión con fuga):

[]
[pan]
[pan, leche]
Ver solución

Paso 1, la versión con fuga:

solución, paso 1: el carrito con fuga
class CarritoConFuga {
    val items = mutableListOf<String>()   // pública y mutable: cualquiera la cambia
}
 
fun main() {
    val carrito = CarritoConFuga()
    carrito.items.add("pan")    // sin pasar por ninguna función
    carrito.items.clear()       // y la vacía desde fuera
    println(carrito.items)      // []
}

Pasos 2 y 3, con copia defensiva:

solución, pasos 2 y 3: carrito con copia defensiva
class Carrito {
    private val interno = mutableListOf<String>()
    val items: List<String> get() = interno.toList()
    fun agregar(producto: String) { interno.add(producto) }
}
 
fun main() {
    val carrito = Carrito()
    carrito.agregar("pan")
    val foto = carrito.items
    carrito.agregar("leche")
    println(foto)            // [pan]
    println(carrito.items)   // [pan, leche]
}

Con la versión con fuga, carrito.items.clear() vacía el carrito desde fuera e imprime []. Con la corregida, items ya no es la lista interna sino una copia nueva en cada lectura, así que foto se queda con lo que había en ese momento ([pan]) y carrito.items refleja lo actual ([pan, leche]).

Si hace falta que la copia se pueda modificar sin afectar al original, usa toMutableList().

Paso · Un torneo que cuida sus listas

Objetivo. Crear Torneo, que acumula partidos y solo los enseña como copia; arreglar Equipo para que copie su plantilla; y ver el bug en un test. Al terminar, Main calcula la tabla desde un Torneo y el test de la fuga pasa.

torneo → kotlin+java
com.sebastiannarvaez.marcador.torneo
Torneo.ktse crea
Equipo.ktse modifica
Main.ktse modifica
com.sebastiannarvaez.marcador.torneo (test)
InmutabilidadTest.ktse crea (se copia)
Dos ficheros nuevos y dos modificados, en la vista Android.

En la vista Android, torneo → kotlin+java tiene dos paquetes con el mismo nombre: com.sebastiannarvaez.marcador.torneo guarda el código y com.sebastiannarvaez.marcador.torneo (test) guarda los tests. Torneo.kt se crea con clic derecho sobre el primero → New → Kotlin Class/File → File, escribe Torneo y pulsa Intro. Equipo.kt y Main.kt ya existen: ábrelos con doble clic.

Dónde va cada cosa:

  • Torneo.kt es un fichero nuevo entero.
  • Equipo.kt: cambia una sola propiedad, plantilla (con un comentario nuevo encima); el resto del fichero no cambia.
  • Main.kt: borra desde el comentario // Colecciones: una jornada completa, la tabla y los goleadores. hasta el final de main (incluida su última }) y pega en su lugar el bloque de abajo, que ya trae esa llave. Ese comentario es la primera línea que se borra porque el bloque nuevo vuelve a declarar val lobos: si empiezas más abajo, quedarían dos val lobos y el compilador se queja. Lo que hay antes del comentario no cambia.

Los bloques de abajo llevan como título la ruta del fichero en tu proyecto.

Dos piezas que verás en el bloque:

  • partido.local in equipos pregunta por pertenencia: es verdadero si el equipo está en la lista.
  • Una sobrecarga. Un método es una función que vive dentro de una clase. Dentro de Torneo, tablaDePosiciones(partidosJugados, equipos) llama a la función suelta de Tabla.kt, no al método del mismo nombre de Torneo. Las dos comparten nombre pero reciben parámetros distintos, y Kotlin elige la que encaja: eso es una sobrecarga.

Las dos, en un ejemplo con otros datos:

ejemplo mínimo: sobrecarga e in (con comentarios)
// Función suelta, fuera de cualquier clase: recibe un nombre
fun saludo(nombre: String): String = "Hola, $nombre"
 
class Recepcion(private val invitados: List<String>) {
    // Método con el MISMO nombre, pero sin parámetros: es otra función (sobrecarga)
    fun saludo(): String = saludo(invitados.first())   // first(): el primero de la lista. Se le pasa un String: Kotlin elige la suelta
 
    // in pregunta por pertenencia: true si el nombre está en la lista
    fun estaInvitado(nombre: String): Boolean = nombre in invitados
}
 
fun main() {
    val recepcion = Recepcion(listOf("Eva", "Leo"))
    println(recepcion.saludo())               // el método, que llama a la función suelta
    println(recepcion.estaInvitado("Leo"))
    println(recepcion.estaInvitado("Sara"))
}

Al ejecutarlo, imprime:

Hola, Eva
true
false

Intenta escribirlo tú, en este orden:

  1. Torneo. Tiene un nombre y una lista de equipos inscritos, y va acumulando partidos. Nadie de fuera puede cambiar sus listas: partidos entrega una foto que no cambia aunque lleguen más. agregar(partido) rechaza un partido en el que algún equipo no esté inscrito («Los dos equipos deben estar inscritos en Copa Barrio»). tablaDePosiciones() y goleadores() devuelven la tabla y los goleadores de sus partidos, usando las funciones de Tabla.kt.
  2. Equipo. Que guarde su propia copia de la plantilla que recibe, para que quien se la pasó no pueda cambiarla después.
  3. main. La jornada de la lección anterior pasa a jugarse dentro de un torneo con Rayo FC, Toros y Lobos: se imprimen la misma tabla y los mismos goleadores, y al final cuántos partidos tiene el torneo.

Tras las líneas de las lecciones anteriores, la salida debe acabar así:

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: 3
Iván: 2
Partidos en el torneo: 3

El test lo copiarás justo después, y sus siete tests deben salir en verde. Cuando termines, abre el bloque y compara con tu código.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
torneo/src/main/java/com/sebastiannarvaez/marcador/torneo/Torneo.kt
package com.sebastiannarvaez.marcador.torneo
 
// El torneo es lo único mutable del dominio: va acumulando partidos.
// Por eso cuida qué enseña hacia fuera.
class Torneo(val nombre: String, equipos: List<Equipo>) {
 
    // Copia defensiva de lo que entra.
    val equipos: List<Equipo> = equipos.toList()
 
    // `val` NO significa inmutable: la referencia no cambia, pero la lista sí
    // puede cambiar por dentro. Es `private` para que nadie de fuera la toque.
    private val partidosJugados = mutableListOf<Partido>()
 
    // Lo que sale es una COPIA de solo lectura. Si devolviéramos
    // `partidosJugados` directamente, el tipo `List` engañaría: por dentro
    // sigue siendo una MutableList y un `as MutableList` la vaciaría.
    val partidos: List<Partido>
        get() = partidosJugados.toList()
 
    fun agregar(partido: Partido) {
        require(partido.local in equipos && partido.visitante in equipos) {
            "Los dos equipos deben estar inscritos en $nombre"
        }
        partidosJugados.add(partido)
    }
 
    fun tablaDePosiciones(): List<FilaDePosicion> = tablaDePosiciones(partidosJugados, equipos)
 
    fun goleadores(): List<Pair<Jugador, Int>> = tablaDeGoleadores(partidosJugados)
}

Este es el cambio de Equipo.kt: la propiedad plantilla, con su comentario:

torneo/src/main/java/com/sebastiannarvaez/marcador/torneo/Equipo.kt
    // Copia defensiva: `toList()` crea una lista nueva. Sin ella, quien nos pasó
    // su lista mutable podría seguir modificándola por fuera y saltarse el
    // `require` de arriba.
    val plantilla: List<Jugador> = plantilla.toList()

En Main.kt la jornada pasa a vivir dentro de un Torneo. Sustituye desde el comentario // Colecciones: una jornada completa, la tabla y los goleadores. hasta la última } de main (incluida; el bloque de abajo ya la trae) por este bloque:

torneo/src/main/java/com/sebastiannarvaez/marcador/torneo/Main.kt
    // Colecciones: una jornada completa en un torneo, la tabla y los goleadores.
    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("Pos Equipo  PJ  G  E  P  GF GC  DG Pts")
    torneo.tablaDePosiciones().forEachIndexed { indice, fila ->
        println(
            "%2d  %-7s %2d %2d %2d %2d %3d %2d %3d %3d".format(
                indice + 1, fila.equipo.nombre, fila.jugados, fila.ganados, fila.empatados,
                fila.perdidos, fila.golesAFavor, fila.golesEnContra, fila.diferencia, fila.puntos,
            ),
        )
    }
    torneo.goleadores().forEach { (jugador, goles) -> println("${jugador.nombre}: $goles") }
 
    // Inmutabilidad: lo que devuelve el torneo es una copia.
    println("Partidos en el torneo: ${torneo.partidos.size}")
}

Ahora, el test. InmutabilidadTest.kt comprueba lo que acabas de escribir y tiene siete tests. Lo que enseña son las dos clases con fuga, TorneoConFuga y TorneoConFugaDisfrazada, que viven solo dentro de ese fichero (son private); por eso, y porque no trae nada nuevo de kotlin.test, no lo escribes: lo copias. Las lecciones de Comparadores, Iterable y el ranking (que lo sustituye por una versión nueva) y de Result: errores como valores (que lo modifica) suponen que ya lo tienes.

  1. En torneo → kotlin+java, clic derecho sobre com.sebastiannarvaez.marcador.torneo (test) → New → Kotlin Class/File → File, escribe InmutabilidadTest y pulsa Intro.
  2. Pulsa el botón de abajo: copia el fichero al portapapeles.
  3. En el fichero vacío, selecciona todo (Cmd+A) y pega (Cmd+V): lo pegado trae su propia línea package.
InmutabilidadTest.kt

Como en las lecciones anteriores, el botón solo copia al portapapeles: el fichero lo creas tú.

Tres de sus tests, sin la clase que los rodea. El primero documenta el error típico del principio: TorneoConFuga expone la lista mutable tal cual.

tres tests de InmutabilidadTest, resumidos
@Test
fun valNoImpideCambiarLaListaPorDentro() {
    val torneo = TorneoConFuga()
    torneo.partidos.add(Partido(rayo, toros))   // la referencia no cambia, la lista sí
    assertEquals(1, torneo.partidos.size)
}
 
@Test
fun elTipoListNoBastaSiElObjetoEsMutable() {
    val torneo = TorneoConFugaDisfrazada()
    torneo.agregar(Partido(rayo, toros))
    @Suppress("UNCHECKED_CAST")
    (torneo.partidos as MutableList<Partido>).clear()   // ¡se cuela!
    assertEquals(0, torneo.partidos.size)
}
 
@Test
fun torneoDevuelveUnaFotoQueNoCambiaDespues() {
    val torneo = Torneo("Copa", listOf(rayo, toros))
    torneo.agregar(Partido(rayo, toros))
    val foto = torneo.partidos
    torneo.agregar(Partido(toros, rayo))
    assertEquals(1, foto.size)              // la copia no ve lo que llega después
    assertEquals(2, torneo.partidos.size)
}

El primer test pasa, y eso es lo que documenta: alguien de fuera ha añadido un partido sin pasar por ningún método del torneo, así que la fuga existe. En el segundo pasa lo mismo: el clear() desde fuera deja la lista en 0. @Suppress("UNCHECKED_CAST") le pide al compilador que no avise de que Kotlin no puede comprobar por dentro de qué tipo es la lista; aquí forzamos la conversión a propósito para provocar la fuga. En el tercero, val foto = torneo.partidos es la forma de comprobar una copia defensiva: la foto no cambia cuando llegan más partidos.

Punto de control

  • En la Terminal de Android Studio, ./gradlew :torneo:run termina en BUILD SUCCESSFUL y la última línea es Partidos en el torneo: 3. La tabla sigue siendo la de la lección anterior: Rayo FC con 6 puntos, Toros con 3 y Lobos con 0.
  • ./gradlew :torneo:test pasa, con InmutabilidadTest.kt copiado: son 48 tests en total, y InmutabilidadTest aporta 7.
  • Torneo.partidos devuelve una copia (toList()), y Equipo.plantilla también.
  • Quita el .toList() de Equipo y vuelve a ejecutar los tests: equipoHaceCopiaDefensivaDeLaPlantilla debe fallar. Devuélvelo antes de seguir.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.Tienes val partidos = mutableListOf<Partido>(). ¿Qué puedes hacer con partidos.add(otro)?

  2. 2.val partidos: List<Partido> get() = interna (con interna una MutableList). ¿Qué problema tiene?

  3. 3.val alias = original con original una MutableList. Después haces alias.add("Luis"). ¿Qué contiene original?

  4. 4.¿Por qué Equipo hace plantilla.toList() en el constructor si ya valida los dorsales en su init?

  5. 5.¿Cómo compruebas de forma fiable que torneo.partidos entrega una copia y no la lista interna?

Faltan 5 de 5