wandres.dev
CLASES Y PROPIEDADES · estado con contrato

Propiedades, no campos: getter, setter y field

Una propiedad de Kotlin es un par de accesores con un campo opcional detras: el identificador field solo existe dentro de los accesores, las propiedades calculadas no ocupan memoria, y la ausencia de campos publicos es lo que permite cambiar la implementacion sin romper a quien la usa.

⏱ 18 min

La frase que más se repite al enseñar Kotlin es que las propiedades son campos con getter y setter automáticos. Es falsa, y la falsedad tiene consecuencias. Una propiedad no es un campo: es un par de accesores, y el campo detrás es un detalle de implementación que puede existir o no. Cuando entiendes que lo que declaras es una operación de lectura y no una casilla de memoria, tres cosas dejan de sorprenderte: que una propiedad pueda no ocupar ni un byte, que el identificador field aparezca de la nada dentro de un accesor, y que Kotlin haya decidido, deliberadamente, no ofrecerte campos públicos aunque la JVM los tenga.

🎯 Al terminar esta lección sabrás
  • Ver una propiedad como un par de accesores y no como una casilla de memoria.
  • Escribir getters y setters personalizados y usar el identificador field correctamente.
  • Distinguir una propiedad con campo de una calculada y elegir según coste y semántica.
  • Explicar por qué Kotlin no expone campos públicos y qué compatibilidad protege esa decisión.

Una propiedad es un par de accesores

Declarar val nombre: String no declara memoria: declara una operación de lectura llamada getNombre. Declarar var añade además una operación de escritura. El campo aparece solo si algún accesor lo necesita:

class Persona(nombre: String) {

    var nombre: String = nombre           // hay campo: el accesor por defecto lo usa
        get() = field.trim()
        set(valor) {
            require(valor.isNotBlank()) { "nombre vacio" }
            field = valor.trim()
        }
}

La regla exacta que aplica el compilador es sencilla y merece memorizarse: se genera un campo de respaldo si, y solo si, algún accesor usa el identificador field, o si al menos uno de los accesores es el que viene por defecto. Fuera de ese caso, no hay campo, y la propiedad es puro código.

field es un identificador especial que solo existe dentro del cuerpo de un getter o un setter, y su razón de ser es evitar una recursión infinita. Si dentro del getter escribieras nombre en lugar de field, estarías llamando al propio getter, que volvería a llamarse, hasta desbordar la pila. field es la única forma de decir la casilla, distinguiéndola de la operación.

flowchart LR
P[Declaracion de propiedad] --> G[Getter]
P --> S[Setter solo si es var]
G --> F[Campo de respaldo]
S --> F
F --> R[Existe solo si algun accesor usa field o es el accesor por defecto]
style P fill:#89b4fa,color:#11111b
style F fill:#f9e2af,color:#11111b
style R fill:#a6e3a1,color:#11111b

Una consecuencia práctica: el modificador de visibilidad puede aplicarse al setter por separado. private set deja la propiedad legible desde fuera y escribible solo desde dentro, que es la forma canónica de exponer estado mutable sin ceder el control de cuándo cambia.

class Contador {
    var valor: Int = 0
        private set                    // se lee fuera, se escribe solo aqui

    fun incrementar() { valor++ }
}

Esa asimetría solo existe porque la propiedad son dos operaciones y no una casilla: un campo, por definición, se lee y se escribe con la misma visibilidad. Fíjate también en que el setter puede tener visibilidad distinta pero no un tipo distinto: el parámetro del setter es siempre del tipo de la propiedad, precisamente porque ambos accesores describen el mismo concepto visto en dos direcciones.

Hay un tercer estado que merece nombre propio. Una propiedad puede declararse lateinit var para prometer al compilador que la asignarás antes de leerla, saltándote la exigencia de valor inicial sin recurrir a un tipo nulable. Es una concesión pragmática para inyección de dependencias y ciclos de vida ajenos, y su precio es que la comprobación pasa de compilación a ejecución: leerla antes de tiempo lanza una excepción con un mensaje explícito en lugar de un error del compilador.

Propiedades calculadas: cero bytes, cero sincronización

Cuando el getter deriva su resultado de otras propiedades, no hay campo y no hay memoria ocupada:

class Rectangulo(val ancho: Double, val alto: Double) {
    val area: Double get() = ancho * alto            // sin campo
    val esCuadrado: Boolean get() = ancho == alto    // sin campo
}

Compara eso con guardar area en un campo asignado en el constructor. La versión con campo cuesta ocho bytes por instancia y, sobre todo, introduce una segunda fuente de verdad: si algún día ancho pasa a ser mutable, existirá un instante en el que el área guardada ya no corresponde a las medidas. La versión calculada no puede desincronizarse porque no hay nada que sincronizar. Ese es el criterio de elección: si el valor es una función de otros datos, calcúlalo; guarda solo lo que es realmente independiente.

El coste, claro, es que el cálculo se repite en cada lectura. Cuando ese cálculo es caro y el dato no cambia, la respuesta correcta no es volver al campo asignado a mano, sino by lazy, que combina lo mejor de ambos: se calcula una vez, la primera vez que alguien lo pide, y después se sirve desde memoria.

class Documento(val texto: String) {
    val indice: Map<String, Int> by lazy {          // caro, se calcula una sola vez
        texto.split(" ").withIndex().associate { (i, palabra) -> palabra to i }
    }
}
💡
El getter debe ser barato y no debe sorprender

Quien lee objeto.total espera el coste de leer un campo. Si detrás hay una consulta a base de datos, una lectura de disco o un recorrido de una lista larga, la sintaxis está mintiendo sobre el precio. La convención de Kotlin es clara: si la operación es cara, no predecible o puede fallar, no es una propiedad, es una función. Reserva la sintaxis de propiedad para lo que se comporta como un atributo: barato, sin efectos secundarios y con el mismo resultado si lo llamas dos veces seguidas.

Por qué Kotlin no tiene campos públicos

Aquí está la decisión de diseño de fondo, y no es estética. En Java, punto.x compila a un acceso directo al campo y punto.getX() compila a una llamada a método. Son instrucciones distintas del bytecode, así que el día que necesites validar la escritura o derivar el valor, cambiar el campo público por accesores rompe la compatibilidad binaria: todo el código ya compilado que dependía de ti deja de enlazar y hay que recompilarlo. Esa amenaza es la que obliga a la industria de Java a escribir getters triviales desde el primer día, por si acaso.

Kotlin corta el nudo por el otro lado: no te deja exponer campos, así que en el sitio de llamada siempre hay una invocación de accesor, aunque tú hayas escrito una asignación con aspecto de campo. La sintaxis se parece a la de un campo y la semántica es la de un método.

// Version 1 de la libreria: propiedad con campo
class Config(val ruta: String)

// Version 2: se vuelve calculada. El bytecode del llamante NO cambia
class Config(private val base: String) {
    val ruta: String get() = base.removeSuffix("/")
}

Esto es el principio de acceso uniforme de Bertrand Meyer llevado al nivel del lenguaje: quien consume un valor no debería saber si está almacenado o computado, porque esa elección es del implementador y debe poder cambiar. Kotlin lo hace cumplir por construcción en lugar de dejarlo a la disciplina.

🏛️

Propiedad con campo

El accesor por defecto o el uso de field generan una casilla. Ocupa memoria, sirve para datos independientes y es la representación de referencia.

🧮

Propiedad calculada

Solo getter, sin campo ni bytes. Nunca se desincroniza porque deriva su valor cada vez que la preguntan.

Delegada con lazy

Se calcula la primera vez y se recuerda. Une el coste único del campo con la garantía de derivación de la calculada.

🔓

Campo real con JvmField

Elimina los accesores y expone la casilla. Solo para interoperabilidad, y renunciando a poder cambiar la representación después.

La misma uniformidad explica un detalle de la herencia que sorprende al venir de Java: una subclase puede sobrescribir una propiedad con campo por una calculada, o al revés, porque lo que hereda es el accesor y no la casilla. La representación nunca formó parte del contrato, así que cambiarla en un descendiente no rompe nada.

Las dos escapatorias existen y son explícitas, precisamente para que se vean. @JvmField elimina los accesores y expone el campo, útil solo cuando interoperas con código Java o con una librería que reflexiona sobre campos. Y const val, para constantes de tipos primitivos y String conocidas en compilación, que se insertan literalmente en el sitio de llamada.

class Punto(@JvmField val x: Int, @JvmField val y: Int)   // campo publico real

object Limites {
    const val MAXIMO = 100        // se inserta en el bytecode del llamante
}
⚠️
const val se copia, no se lee

Un const val se inserta literalmente en el bytecode de quien lo usa. Si publicas una librería, cambiar su valor no afecta a los binarios ya compilados contra la versión anterior: siguen llevando el número viejo dentro. Para constantes públicas de una API que pueda evolucionar, un val normal dentro de un object es más seguro, porque se lee en tiempo de ejecución a través de un accesor.

Declarar una propiedad es publicar una operacion, no ceder una casilla

El cambio mental que hay que hacer aquí es abandonar la idea de que una clase es una estructura de datos con métodos alrededor. Cuando escribes val saldo: Long no estás diciendo dónde vive un número, estás publicando una operación de consulta cuyo contrato es devolver el saldo, y ese contrato es lo único que ves desde fuera. La casilla es tuya, privada, negociable, y puedes cambiarla cuando quieras: convertirla en un cálculo sobre una lista de movimientos, en una lectura de caché, en una consulta a otro objeto, en un valor perezoso. Nada de eso se ve desde el sitio de llamada porque desde el sitio de llamada nunca hubo una casilla, solo una llamada. Ahí está la razón profunda por la que Kotlin borró los campos públicos del lenguaje: no para ahorrarte teclear getters, sino para que la frontera de tu tipo esté hecha exclusivamente de comportamiento, sin ni un solo punto donde un consumidor pueda acoplarse a tu representación. Un campo público es una decisión de implementación filtrada al contrato, y una vez filtrada ya no es reversible sin romper a alguien. Un accesor es una promesa sobre qué se devuelve, y las promesas se pueden cumplir de mil maneras distintas a lo largo de la vida de un proyecto. Cuando interiorizas esto, el diseño de clases cambia de forma: dejas de preguntarte qué datos guarda este objeto y empiezas a preguntarte qué se le puede preguntar y qué se le puede pedir, y solo al final, como una nota privada, decides cuáles de esas respuestas conviene almacenar. El estado deja de ser el punto de partida y pasa a ser una optimización del contrato, que es exactamente el orden correcto de las prioridades.

⚔️ Convierte estado en contrato
  1. Escribe una propiedad con setter personalizado que normalice su valor y comprueba qué ocurre si dentro del getter usas el nombre de la propiedad en vez de field.
  2. Declara una propiedad calculada y una equivalente con campo asignado en el constructor. Haz mutable un dato del que dependen y observa cuál se desincroniza.
  3. Sustituye una propiedad calculada cara por by lazy y razona en qué casos ese cambio sería incorrecto.
  4. Expón una propiedad con private set y explica qué invariante protege esa asimetría frente a un var público.
  5. Toma una propiedad pública de tu código, conviértela en calculada sin tocar el sitio de llamada y argumenta por qué eso sería imposible con un campo público de Java.