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).
| Modificador | Quién lo ve |
|---|---|
public (el de por defecto) | todo el mundo |
internal | el mismo módulo (por ejemplo, todo :torneo, pero no :app) |
protected | la clase y las clases que heredan de ella (se explica al final de esta lección, en «open y final») |
private | solo dentro de la clase |
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 eurosSi 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.
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
22Ojo: 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.
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
trueEl 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.
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
falseLa ú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.
// 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
}TobySi 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 negativoVer solución
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 negativofield 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.
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(unJugador?), 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
privatellamadadescripcion()que da «Rayo FC (3 jugadores)»;toStringla 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.
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
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()
} …
// 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:runtermina conRayo FC (3 jugadores), capitán: AnayToros: Dorsales repetidos en Toros: [7, 7]../gradlew :torneo:testtermina enBUILD SUCCESSFUL(abre el informe conopen 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()enmain, el compilador lo rechaza porque el setter es privado. Bórralo. Equipono llevaopen: si otra clase intenta heredar de ella (class Veterano : Equipo("V", listOf());listOf()sin elementos es una lista vacía), el compilador diceThis type is final, so it cannot be extended.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.
var capitan: Jugador? = nullconprivate set. ¿Qué pasa si otra clase escribeequipo.capitan = jugador?2.¿Qué modificador hace visible un miembro para todo
:torneopero no para:app?3.Escribes
class Veterano : Equipo("V", listOf()). ¿Qué ocurre?4.¿Para qué sirve
fielddentro del setter de una propiedad?5.
val hayGoles: Boolean get() = goles > 0. Sigolespasa de 0 a 1, ¿qué ocurre conhayGoles?