La declaración object: un singleton garantizado por el lenguaje
Qué genera exactamente el compilador cuando escribes object, en qué instante nace la instancia, de dónde sale la seguridad frente a hilos sin escribir sincronización, y qué significa el campo INSTANCE para quien te llame desde Java.
Casi todos los lenguajes de la familia de la JVM te obligan a construir el patrón singleton a mano: constructor privado, campo estático, comprobación de nulo y, a partir de ahí, una discusión interminable sobre doble comprobación con volatile, clases interiores que hacen de soporte y enumerados de un solo valor. Kotlin borra la discusión con una palabra clave. Una declaración object no es una clase de la que después sacas una instancia: es a la vez la declaración del tipo y la declaración de su única instancia, y la unicidad deja de ser un asunto de tu disciplina para convertirse en una propiedad que verifica la máquina virtual. Merece la pena mirar el código generado, porque de él se deducen las tres preguntas que casi nadie sabe responder: cuándo nace ese objeto, quién garantiza que nazca una sola vez, y qué se ve desde el otro lado de la frontera con Java.
- Leer la traducción exacta de una declaración
objecta una clase final con campoINSTANCE. - Situar el instante preciso de la inicialización y entender por qué es perezosa.
- Explicar de dónde sale la seguridad frente a hilos sin una sola línea de sincronización.
- Reconocer los límites del
object: sin constructor, sin parámetros de tipo y con riesgo de ciclos.
Una palabra que declara tipo e instancia a la vez
Cuando escribes una declaración object, el nombre que eliges cumple dos papeles simultáneos: es el nombre de un tipo, utilizable en firmas y en comprobaciones is, y es también una expresión que designa la única instancia de ese tipo.
object Registro {
private val destinos = mutableListOf<Destino>()
var nivel: Nivel = Nivel.INFO
init {
destinos += ConsolaDestino()
}
fun log(mensaje: String) {
if (nivel <= Nivel.INFO) destinos.forEach { it.escribe(mensaje) }
}
}
En el sitio de uso, Registro.log("hola") no construye nada: navega hasta una instancia que ya existe. La equivalencia en Java es literal y conviene tenerla en la cabeza, porque explica todo lo demás:
public final class Registro {
public static final Registro INSTANCE;
private final List<Destino> destinos;
private Nivel nivel;
private Registro() {
this.destinos = new ArrayList<Destino>();
this.nivel = Nivel.INFO;
this.destinos.add(new ConsolaDestino());
}
static { INSTANCE = new Registro(); }
public final void log(String mensaje) { /* ... */ }
}
Tres detalles cargan con casi todo el peso conceptual. La clase es final, así que nadie puede heredar de tu singleton para colarte una segunda encarnación. El constructor es privado y no hay ninguna forma legítima de invocarlo, de modo que la unicidad no depende de que nadie se despiste. Y el estado no es estático: destinos y nivel son campos de instancia normales, alojados en el único objeto al que apunta INSTANCE. La única excepción son los const val, que sí se compilan como constantes estáticas y, además, se insertan literalmente en cada sitio de llamada.
Una declaración object puede heredar de una clase abierta e implementar tantas interfaces como quieras, y puede anidarse dentro de otra clase, en cuyo caso se traduce a una clase anidada estática. Esa doble capacidad es la que la distingue de un simple espacio de nombres de funciones: un singleton de Kotlin es un valor de pleno derecho, asignable a una variable del tipo de la interfaz que implementa y pasable como argumento.
object OrdenNatural : Comparator<String> {
override fun compare(a: String, b: String) = a.compareTo(b)
}
class Indice {
object Vacio // clase anidada estatica
}
val comparadores: List<Comparator<String>> = listOf(OrdenNatural)
Lo que no puede una declaración object es lo que veremos al final: recibir argumentos.
El instante exacto en que nace, y por qué es seguro
La inicialización de un object en la JVM es perezosa, pero no por obra de Kotlin: es la semántica de inicialización de clases de la propia máquina virtual. El inicializador estático de una clase, el <clinit>, se ejecuta en el primer uso activo de esa clase, y leer el campo estático INSTANCE es exactamente uno de esos usos. Antes de la primera mención, tu singleton no ocupa ni un byte de heap.
flowchart TD A[Primer acceso a Registro en cualquier hilo] --> B[La JVM resuelve y carga la clase] B --> C[Se toma el cerrojo de inicializacion propio de esa clase] C --> D[Se ejecuta clinit con new Registro y los bloques init] D --> E[INSTANCE queda asignado y la clase pasa al estado inicializada] E --> F[Se libera el cerrojo y se despiertan los hilos en espera] F --> G[Todo hilo posterior ve la instancia completamente construida] style D fill:#f9e2af,color:#11111b style G fill:#a6e3a1,color:#11111b
La seguridad frente a hilos sale de ahí y solo de ahí. La especificación de la JVM obliga a serializar la inicialización de cada clase con un cerrojo propio: el primer hilo que llega la inicializa, el resto bloquea hasta que termina, y la salida de ese cerrojo establece una relación de precedencia que garantiza que todos vean los campos ya escritos. Es, palabra por palabra, el idioma clásico de la clase de soporte que se inicializa bajo demanda, salvo que aquí lo escribe el compilador y no puedes equivocarte al copiarlo.
El cerrojo de inicialización protege el nacimiento del objeto, no su vida posterior. Un var público dentro de un object es una carrera de datos en cuanto dos hilos lo tocan, exactamente igual que cualquier otro campo mutable compartido. La palabra clave te regala la unicidad; la disciplina de @Volatile, atómicos o inmutabilidad sigue siendo tuya.
Hay un corolario incómodo. Como el cerrojo es reentrante para el mismo hilo, un ciclo de inicialización no explota: se cuela. Si el init de Registro toca Config y el init de Config toca Registro, el segundo acceso encuentra la clase marcada como “en curso de inicialización por este hilo”, devuelve el INSTANCE a medio construir y tú lees campos con su valor por defecto, es decir, nulos en referencias declaradas como no nulas. Si el ciclo cruza dos hilos, ya no es un nulo sorpresa sino un interbloqueo real entre dos cerrojos de clase.
Hay además una asimetría fácil de pasar por alto y que se puede comprobar en cinco minutos: leer un const val declarado dentro del objeto no inicializa nada. Como es una constante de tiempo de compilación, su valor se inserta literalmente en el sitio de llamada y la clase ni siquiera necesita cargarse. Es una diferencia observable entre dos declaraciones que en el código fuente parecen iguales.
object Ajustes {
const val VERSION = 7 // leerlo no carga ni inicializa la clase
val cargado = calcularCaro() // leerlo dispara la inicializacion entera
}
Fuera de la JVM el fondo se mantiene y cambia el mecanismo. En Kotlin/Native, con el modelo de memoria moderno, las declaraciones object son singletons globales compartidos entre hilos e inicializados de forma perezosa y segura en el primer acceso, sin nada parecido al viejo congelado de objetos; si de verdad quieres una instancia por hilo, lo pides explícitamente con @ThreadLocal. En Kotlin/JS el entorno es de un solo hilo y la pereza es lo único que queda del contrato. Conviene no dar por supuesto el momento exacto de la inicialización en código multiplataforma: lo que sí puedes dar por supuesto es que ocurrirá una vez y antes del primer uso.
Lo que un object no puede ser
Un object no tiene constructor, y esa ausencia no es un olvido: si pudiera recibir argumentos, alguien tendría que decidir cuáles, y esa decisión implicaría poder construirlo, lo que contradice la unicidad. La consecuencia práctica es dura y hay que asumirla: un singleton solo puede configurarse leyendo del ambiente, es decir, acoplándose a él.
Tampoco admite parámetros de tipo. Una declaración genérica exigiría una instancia por argumento de tipo, y solo hay una. El truco canónico para sortearlo está en la propia biblioteca estándar y merece estudiarse: como Nothing es subtipo de todo, un objeto que implementa List<Nothing> es asignable a List<T> para cualquier T, y por eso emptyList puede devolver siempre la misma instancia sin mentir al sistema de tipos.
private object ListaVacia : List<Nothing> {
override val size: Int get() = 0
override fun isEmpty(): Boolean = true
override fun iterator(): Iterator<Nothing> = IteradorVacio
}
fun <T> vacia(): List<T> = ListaVacia // seguro: Nothing es subtipo de T
Y hay una última restricción, más social que técnica: un object no admite herencia hacia abajo. Puede heredar, pero nadie puede heredar de él, porque la clase generada es final. Si alguna vez necesitas dos variantes del comportamiento, no hay refactorización local que te salve: hay que convertir el singleton en una clase normal y repartir sus usos.
INSTANCE visto desde el otro lado de la frontera
Todo lo anterior se vuelve tangible cuando alguien consume tu código desde Java, porque ahí el campo INSTANCE deja de ser un detalle del bytecode y se convierte en la firma que se escribe a mano.
Registro.INSTANCE.log("sin anotaciones queda asi");
Registro.log("con JvmStatic en el miembro queda asi");
object Registro {
@JvmStatic
fun log(mensaje: String) { /* ... */ }
@JvmField
val nombre: String = "raiz"
private fun readResolve(): Any = Registro // defensa ante deserializacion
}
La trampa de interoperabilidad que se cobra víctimas cada año es la última línea. La serialización de la JVM ignora los constructores privados y fabrica una instancia nueva al deserializar, con lo que tu singleton deja de serlo en silencio y las comparaciones por identidad empiezan a fallar en sitios remotos. Declarar un readResolve privado que devuelva la instancia canónica cierra el agujero. Es exactamente la razón histórica por la que se recomendaba implementar singletons como enumerados de un solo valor en Java: el enumerado tiene esa defensa incorporada en la plataforma.
Comparten la unicidad, pero no el resto. Un enumerado aporta nombre, orden, valor de posición, análisis exhaustivo en un when y una defensa nativa ante la serialización; a cambio no puede heredar de una clase. Una declaración object sí puede heredar y encaja en jerarquías selladas como caso sin datos. La elección se decide por si el valor pertenece a un conjunto cerrado enumerable o es un elemento singular dentro de una jerarquía.
La distancia entre el singleton de un manual de patrones y la declaración object de Kotlin es la distancia entre una convención y un teorema. En el manual, la unicidad es una promesa que se sostiene sobre la vigilancia de todo el equipo: nadie debe añadir un constructor público, nadie debe olvidar el volatile en la doble comprobación, nadie debe deserializar sin readResolve, nadie debe heredar. Cada una de esas obligaciones es un sitio donde el patrón puede romperse y, en bases de código grandes, se rompe. En Kotlin la unicidad no se vigila: se deduce. El constructor privado hace imposible la segunda instancia, la clase final hace imposible la subclase que la evadiría, y la creación ocurre dentro del inicializador estático de la clase, un tramo de código que la especificación de la máquina virtual obliga a ejecutarse una vez y solo una, bajo un cerrojo por clase, con una barrera de publicación al salir. Eso significa que la corrección de tu singleton no depende de tu revisión de código sino del intérprete de bytecode, y esa es una categoría de garantía completamente distinta. Pero la lectura profunda no es que hayas ganado seguridad gratis, sino que has movido una decisión de diseño desde tu programa hasta el cargador de clases, y ahí ya no puedes negociarla. Ya no eliges cuándo nace el objeto: nace en el primer uso activo, y ese instante es una propiedad emergente de qué código se ejecutó primero, no de tu arquitectura. Ya no eliges cuándo muere: vive mientras viva el cargador de clases, que en una aplicación normal significa para siempre. Y ya no puedes parametrizarlo, porque parametrizar es construir y construir es justo lo que has prohibido. Entender un object es entender ese intercambio: cambias control por certeza, y la pregunta de ingeniería nunca es si el mecanismo funciona, sino si estabas dispuesto a pagar ese precio en ese sitio concreto.
- Escribe un
objectcon una propiedadvar, unconst valy un bloqueinitque imprima una traza. Compílalo y ejecutajavap -p -csobre la clase: localiza el campoINSTANCE, el constructor privado y el inicializador estático. - En un
main, imprime una traza antes de tocar el objeto y comprueba en qué línea aparece la delinit. Después, lee solo elconst valy verifica que no dispara la inicialización: explica por qué. - Provoca un ciclo entre dos declaraciones
objectcuyosinitse lean mutuamente. Observa el nulo imposible en una propiedad declarada no nula y razona qué garantía de la JVM lo permite. - Serializa y deserializa tu
objecty compara identidades con===. Añade unreadResolveprivado y repite la comprobación. - Consúmelo desde una clase Java sin anotaciones y luego con
@JvmStaticy@JvmField. Anota qué cambia en el sitio de llamada y qué métodos puente aparecen en el bytecode.