Saltar al contenido
K
Kotlin: el lenguajePaso a paso37 min de lectura · 5 min de máquina Intermedio

Propiedades y visibilidad

Al terminar: Controlar cómo se lee y se cambia un dato de una clase con setters, private set y propiedades calculadas, y decidir quién puede usarla con la visibilidad y open o final.

Al terminar, Equipo tendrá un capitán que cualquiera puede leer pero que solo el propio equipo puede cambiar, y a través de una función que comprueba que el elegido juega en el equipo. Además sabrá cuántos jugadores tiene sin que nadie se lo cuente.

Importa porque una clase no solo se protege al nacer, como hiciste con init y require: también tiene que protegerse mientras vive. Decidir quién puede leer un dato y quién puede cambiarlo evita que otra parte del programa lo deje en un estado sin sentido.

Temas: visibilidad, setters, field, private set, propiedades calculadas, open y final.

Visibilidad: quién puede ver qué

La visibilidad decide qué partes de una clase puede usar el resto del programa. Esconder lo que no hace falta enseñar evita que alguien lo use mal. Una función escrita dentro de una clase se llama método; y un módulo es cada subproyecto de Gradle, como :torneo o :app (Los ficheros de Gradle de tu proyecto).

ModificadorQuién lo ve
public (el de por defecto)todo el mundo
internalel mismo módulo (por ejemplo, todo :torneo, pero no :app)
protectedla clase y las clases que heredan de ella (se explica al final de esta lección, en «open y final»)
privatesolo dentro de la clase
ejemplo mínimo: private (con comentarios)
class Cuenta(val titular: String, saldoInicial: Int) {
 
    var saldo: Int = saldoInicial
 
    // private: solo se puede llamar desde dentro de la clase
    private fun resumen() = "$titular: $saldo euros"
 
    fun mostrar() = println(resumen())   // un método público puede usar uno privado
}
 
fun main() {
    val cuenta = Cuenta("Ana", 10)
    cuenta.mostrar()           // mostrar es público: se puede llamar desde fuera
    // cuenta.resumen()        // error: resumen es privada
}
Ana: 10 euros

Si quitas los // de la última línea de main, el compilador la rechaza con Cannot access 'fun resumen(): String': it is private in 'Cuenta'.

Propiedades con reglas propias

Una propiedad no tiene por qué ser un simple dato guardado. Puede tener un setter (el código que se ejecuta cuando alguien le asigna un valor), un getter (el que se ejecuta cuando alguien la lee) y una visibilidad distinta para escribir que para leer.

Dentro de un setter, field es el valor guardado de la propiedad (el «campo»: el sitio donde el objeto guarda el dato). Hace falta porque escribir grados = valor dentro del setter de grados volvería a ejecutar el setter, que volvería a ejecutarlo, una y otra vez sin parar. Con field = valor se guarda el dato sin pasar otra vez por el setter.

Un setter puede, por ejemplo, rechazar valores, con el require que ya conoces. Fíjate en el patrón: el dato entra como parámetro (gradosIniciales) y dentro del cuerpo se crea la var con su setter.

ejemplo mínimo: un setter que rechaza valores (con comentarios)
class Termostato(gradosIniciales: Int) {
 
    // la propiedad se crea en el cuerpo, con el valor que llegó como parámetro
    var grados: Int = gradosIniciales
        set(valor) {
            // se ejecuta cada vez que alguien escribe termostato.grados = algo
            require(valor in 5..30) { "Entre 5 y 30 grados, no $valor" }
            field = valor          // si la condición se cumplió, se guarda
        }
}
 
fun main() {
    val sala = Termostato(20)
    sala.grados = 22                                  // pasa por el setter: vale
    println(sala.grados)
    val intento = runCatching { sala.grados = 50 }    // el setter lanza: no se guarda
    println(intento.exceptionOrNull()?.message)
    println(sala.grados)                              // sigue valiendo 22
}
22
Entre 5 y 30 grados, no 50
22

Ojo: el valor inicial que llega por el constructor no pasa por el setter, así que Termostato(50) sí se crearía.

Otras dos piezas. private set significa «cualquiera lo lee, solo la propia clase lo cambia»: así se encapsula un dato para que nadie lo ponga en un estado sin sentido (por ejemplo, para que nadie nombre capitán a alguien que no está en la plantilla, y todos pasen por un método que lo comprueba). Y una propiedad calculada no guarda nada: se escribe con get() y se calcula cada vez que la lees.

ejemplo mínimo: private set y propiedad calculada (con comentarios)
class ContadorDeGoles(val equipo: String) {
 
    // `private set`: cualquiera lo lee, solo la clase lo cambia.
    var goles: Int = 0
        private set
 
    // Propiedad calculada: no guarda nada, se evalúa en cada lectura.
    val hayGoles: Boolean get() = goles > 0
 
    fun marcar() {
        goles += 1
    }
}
 
fun main() {
    val contador = ContadorDeGoles("Rayo")
    println(contador.goles)            // 0: empieza en 0
    println(contador.hayGoles)         // false: se calcula con goles > 0
    contador.marcar()                  // la propia clase sí puede cambiar goles
    println(contador.goles)            // 1
    println(contador.hayGoles)         // true: ahora la misma cuenta da otro resultado
}
0
false
1
true

El get() se escribe en la propia propiedad, con un = y la cuenta a continuación, como en hayGoles; si necesitas varias líneas, lleva llaves y un return.

Preguntar si algo está en una lista

Equipo necesita comprobar que el capitán juega en el equipo. Para eso sirve in sobre una lista: x in lista es true si la lista contiene a x. Y lista.first() es el primer elemento de la lista.

ejemplo mínimo: in y first sobre una lista (con comentarios)
class Jugador(val nombre: String)
 
fun main() {
    val lista = listOf(Jugador("Ana"), Jugador("Luis"))
    val ana = lista.first()                 // el primer elemento de la lista
    println(ana.nombre)                     // Ana
    println(ana in lista)                   // true: ese mismo objeto está en la lista
    println(Jugador("Ana") in lista)        // false: es otro objeto, aunque se llame igual
}
Ana
true
false

La última línea sorprende, y es a propósito: en una clase normal, dos objetos son iguales solo si son el mismo objeto. data class e igualdad cambia eso.

open y final: heredar de una clase

Heredar es crear una clase nueva que parte de otra y se escribe con dos puntos: class Perro(nombre: String) : Animal(nombre) significa «un Perro es un Animal» y recibe todo lo que tiene Animal. A la clase nueva se le llama subclase, y es a ella a quien se refiere protected en la tabla de arriba: lo protected lo ve la clase y sus subclases. Una clase final no admite subclases, y en Kotlin todas lo son salvo que lleven open.

ejemplo mínimo: open permite heredar (con comentarios)
// open: se puede heredar de esta clase
open class Animal(val nombre: String)
 
// los dos puntos: Perro es un Animal, y pasa su nombre al constructor de Animal
class Perro(nombre: String) : Animal(nombre)
 
fun main() {
    val toby = Perro("Toby")
    println(toby.nombre)   // Toby: la propiedad la heredó de Animal
}
Toby

Si Animal no llevara open, el compilador de Kotlin 2.4.20 rechazaría la línea de Perro con This type is final, so it cannot be extended. La herencia y las alternativas las verás en Herencia, interfaces y composición.

Reto · Ejercicio: una entrada que no acepta precios negativos

En el Kotlin Playground: una entrada de un partido es una clase Entrada con un precio. La entrada se crea recibiendo precioInicial por el constructor, y dentro de la clase crea la var precio con ese valor. El precio se puede cambiar después, pero nunca a un valor negativo: si alguien lo intenta, se rechaza con el mensaje «El precio no puede ser negativo». La entrada también sabe decir si es gratis (esGratis), y eso está siempre al día. En main, crea una entrada de 5, cámbiala a 8 e imprime el precio y si es gratis; cámbiala a 0 e imprime si es gratis; por último intenta ponerla a −1 sin que el programa se caiga e imprime el motivo del rechazo. La consola debe mostrar:

8
false
true
El precio no puede ser negativo
Ver solución
solución del ejercicio
class Entrada(precioInicial: Int) {
    var precio: Int = precioInicial
        set(valor) {
            require(valor >= 0) { "El precio no puede ser negativo" }
            field = valor
        }
 
    val esGratis: Boolean get() = precio == 0
}
 
fun main() {
    val entrada = Entrada(5)
    entrada.precio = 8
    println(entrada.precio)   // 8
    println(entrada.esGratis) // false
    entrada.precio = 0
    println(entrada.esGratis) // true
    val fallo = runCatching { entrada.precio = -1 }
    println(fallo.exceptionOrNull()?.message)
}
8
false
true
El precio no puede ser negativo

field es el valor guardado: dentro del setter no puedes escribir precio = valor, porque volvería a ejecutar el setter sin parar. esGratis no guarda nada: se calcula cada vez que se lee.

Paso · El capitán de Equipo

Objetivo: Equipo gana cantidadDeJugadores, un capitan que solo se cambia a través de nombrarCapitan y una función private. Al terminar, main imprime Rayo FC (3 jugadores), capitán: Ana y el rechazo del equipo Toros; y EquipoTest.kt suma 7 tests.

torneo/
kotlin+java/
com.sebastiannarvaez.marcador.torneo
Equipo.ktse modifica
Main.ktse modifica
com.sebastiannarvaez.marcador.torneo (test)
EquipoTest.ktse crea y se rellena con «Copiar fichero»
Lo que tocas, en la vista Android de :torneo.

Intenta escribirlo tú, en Equipo.kt. Además de lo que ya hace, Equipo tiene ahora (la ? de Jugador? es de Nulabilidad: «un jugador o null»):

  • cantidadDeJugadores, que dice cuántos jugadores tiene la plantilla, siempre al día.
  • capitan (un Jugador?), que empieza vacío; cualquiera puede leerlo, pero solo el propio equipo lo cambia.
  • nombrarCapitan(jugador), que lo nombra, y rechaza a quien no esté en la plantilla («Luis no juega en Rayo FC»).
  • Una función private llamada descripcion() que da «Rayo FC (3 jugadores)»; toString la usa.

En Main.kt, sustituye todo lo que escribiste en la lección anterior, desde el comentario // Clases: hasta la última } de main (incluida), por el bloque de abajo. Nombra capitana a la primera de la lista jugadores, imprime el equipo con su capitán, y repite el intento de crear los Toros sin que el programa se caiga. Tras las líneas de los jugadores (Sin jugador), debe verse:

Rayo FC (3 jugadores), capitán: Ana
Toros: Dorsales repetidos en Toros: [7, 7]

EquipoTest.kt no lo escribes: son siete tests que repiten el tipo de comprobación que ya has escrito en PuntosTest y JugadorTest, y lo que enseñan es el código de Equipo, que sí escribes tú. Se copian con el botón. En torneo → kotlin+java, clic derecho sobre el paquete com.sebastiannarvaez.marcador.torneo (test) → New → Kotlin Class/File → File → EquipoTest. Después, igual que con JugadorTest en Clases y objetos: pulsa el botón de abajo, selecciona todo dentro del fichero y pega.

EquipoTest.kt

Los bloques de abajo llevan como título la ruta del fichero en tu proyecto. El de Equipo.kt es el fichero entero: selecciona todo y pégalo encima del que tienes. El de Main.kt es solo un fragmento: el … de arriba son las líneas que ya tienes, así que no selecciones todo; pega solo lo indicado arriba.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
torneo/src/main/java/com/sebastiannarvaez/marcador/torneo/Equipo.kt
package com.sebastiannarvaez.marcador.torneo
 
// Una clase es `final` por defecto: nadie puede heredar de Equipo salvo que
// se escriba `open class`. Aquí no hace falta.
//
// `nombre` lleva `val` en los paréntesis de la clase: se convierte en propiedad.
// `plantilla` es solo un parámetro (sin val): existe durante la construcción.
class Equipo(val nombre: String, plantilla: List<Jugador>) {
 
    // El bloque init corre al construir. `require` lanza IllegalArgumentException
    // con el mensaje si la condición es falsa.
    init {
        require(nombre.isNotBlank()) { "El equipo necesita un nombre" }
        val dorsales = plantilla.mapNotNull { it.dorsal }
        require(dorsales.size == dorsales.toSet().size) {
            "Dorsales repetidos en $nombre: $dorsales"
        }
    }
 
    val plantilla: List<Jugador> = plantilla
 
    // Propiedad calculada: sin campo, se evalúa en cada lectura.
    val cantidadDeJugadores: Int
        get() = plantilla.size
 
    // `private set`: cualquiera lee el capitán, pero solo la clase lo cambia,
    // a través de nombrarCapitan (que valida).
    var capitan: Jugador? = null
        private set
 
    fun nombrarCapitan(jugador: Jugador) {
        require(jugador in plantilla) { "${jugador.nombre} no juega en $nombre" }
        capitan = jugador
    }
 
    // `private`: solo visible dentro de la clase. `internal` sería visible en
    // todo el módulo.
    private fun descripcion() = "$nombre ($cantidadDeJugadores jugadores)"
 
    override fun toString(): String = descripcion()
}
torneo/src/main/java/com/sebastiannarvaez/marcador/torneo/Main.kt
    …
    // Clases: un equipo valida su plantilla al construirse.
    val rayo = Equipo("Rayo FC", jugadores)
    rayo.nombrarCapitan(jugadores.first())
    println("$rayo, capitán: ${rayo.capitan?.nombre}")
    val error = runCatching { Equipo("Toros", listOf(Jugador("A", 7), Jugador("B", 7))) }
    println("Toros: ${error.exceptionOrNull()?.message}")
}
Rayo FC (3 jugadores), capitán: Ana
Toros: Dorsales repetidos en Toros: [7, 7]

Dos detalles que se quedan a propósito. jugador in plantilla compara por identidad (como viste arriba, dos objetos son iguales solo si son el mismo objeto), porque Jugador aún es una clase normal; cambiará en data class e igualdad. Y Equipo guarda la lista tal cual la recibe, sin copiarla: es una deuda que se paga en Inmutabilidad y referencias.

En los tests copiados, assertFailsWith<IllegalArgumentException> { … } (lo viste en Clases y objetos) comprueba que el bloque lanza esa excepción; require lanza justo esa.

Punto de control

  • ./gradlew :torneo:run termina con Rayo FC (3 jugadores), capitán: Ana y Toros: Dorsales repetidos en Toros: [7, 7].
  • ./gradlew :torneo:test termina en BUILD SUCCESSFUL (abre el informe con open torneo/build/reports/tests/test/index.html, como en Bucles y tu primer test, y comprueba que arriba pone 18 tests: los 11 anteriores y los 7 que copiaste).
  • Si escribes rayo.capitan = jugadores.first() en main, el compilador lo rechaza porque el setter es privado. Bórralo.
  • Equipo no lleva open: si otra clase intenta heredar de ella (class Veterano : Equipo("V", listOf()); listOf() sin elementos es una lista vacía), el compilador dice This type is final, so it cannot be extended.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.var capitan: Jugador? = null con private set. ¿Qué pasa si otra clase escribe equipo.capitan = jugador?

  2. 2.¿Qué modificador hace visible un miembro para todo :torneo pero no para :app?

  3. 3.Escribes class Veterano : Equipo("V", listOf()). ¿Qué ocurre?

  4. 4.¿Para qué sirve field dentro del setter de una propiedad?

  5. 5.val hayGoles: Boolean get() = goles > 0. Si goles pasa de 0 a 1, ¿qué ocurre con hayGoles?

Faltan 5 de 5