wandres.dev
OBJETOS Y COMPANION · singletons en el lenguaje

Expresiones de objeto: clases anónimas con memoria

El equivalente moderno de la clase anónima de Java: instancias nuevas en cada evaluación, tipos anónimos que solo se filtran en ámbitos privados, captura y mutación del entorno con sus fugas asociadas, y la frontera exacta donde una lambda deja de servir.

⏱ 17 min

La declaración object produce una instancia única y con nombre. Su hermana, la expresión de objeto, hace lo contrario: fabrica una instancia nueva cada vez que se evalúa y, con frecuencia, ni siquiera le da un tipo con nombre. Es el sustituto directo de la clase anónima de Java, pero con dos diferencias que cambian el cálculo de diseño: puede implementar varias interfaces a la vez y puede capturar variables mutables del ámbito que la rodea. De esas dos capacidades salen tanto los usos legítimos que ninguna lambda cubre como la familia de fugas de memoria más común en aplicaciones con ciclo de vida. Aprender a decidir entre una lambda y una expresión de objeto no es una cuestión de estilo: es una pregunta sobre cuántos métodos tiene el contrato y cuánto tiempo va a vivir la referencia.

🎯 Al terminar esta lección sabrás
  • Distinguir declaración y expresión de objeto por identidad, momento de creación y tipo resultante.
  • Entender cuándo el tipo anónimo se filtra al llamador y cuándo se ensancha al supertipo.
  • Analizar la captura del ámbito, la mutación de variables y las fugas que provoca la referencia implícita al contenedor.
  • Decidir con criterio entre lambda y expresión de objeto según la aridad del contrato y el coste de asignación.

Una expresión, no una declaración

La sintaxis se parece, pero el significado es opuesto. Donde la declaración crea una instancia global y perezosa, la expresión crea una instancia local y ansiosa: su cuerpo se ejecuta en el momento en que la expresión se evalúa, y cada evaluación produce un objeto distinto con su propia identidad.

fun observador(prefijo: String): Oyente = object : Oyente, Cerrable {
    private var recibidos = 0

    override fun alRecibir(evento: Evento) {
        recibidos++
        println("$prefijo $recibidos ${evento.nombre}")
    }

    override fun cerrar() = println("$prefijo cerrado tras $recibidos")
}

Ese objeto implementa dos interfaces a la vez. Ninguna lambda puede hacerlo, y esa es ya una primera frontera dura. También puede heredar de una clase abierta y llamar a su constructor con argumentos, lo que la convierte en la única forma de sobrescribir puntualmente el comportamiento de una clase sin declarar un tipo nuevo con nombre.

El detalle que casi nadie conoce es cómo se comporta el tipo resultante cuando no declaras supertipos. Un objeto anónimo tiene un tipo anónimo real, con todos sus miembros, pero ese tipo solo es visible si la declaración que lo devuelve es local o privada. En cuanto la firma es pública o interna, el compilador ensancha el tipo al supertipo declarado y, si no hay ninguno, a Any.

private fun interno() = object { val marca = 1 }
fun publico() = object { val marca = 1 }

fun uso() {
    interno().marca   // compila: el tipo anonimo se filtra en ambito privado
    publico().marca   // error: el tipo visto es Any
}

La regla tiene una razón sólida. Un tipo anónimo no tiene nombre que escribir en un fichero de cabecera ni en la firma de una biblioteca compilada, de modo que exponerlo públicamente crearía una dependencia que nadie puede nombrar. Al restringir su filtración a lo local y lo privado, Kotlin te deja usarlo como una tupla ad hoc dentro de una función sin comprometer la superficie pública.

Conviene además fijar la diferencia de identidad, porque es la fuente de errores más silenciosa. Una declaración object evaluada dos veces devuelve el mismo objeto; una expresión evaluada dos veces devuelve dos objetos distintos que no son iguales entre sí salvo que hayas redefinido la igualdad.

object Unico
val a = Unico; val b = Unico
println(a === b)                      // true, siempre

fun nuevo() = object : Runnable { override fun run() = Unit }
println(nuevo() === nuevo())          // false, dos instancias distintas

Esto importa mucho al registrar y dar de baja oyentes: dar de baja pasando una expresión de objeto recién creada nunca elimina nada, porque el registro compara con la instancia que guardó y no con la que acabas de fabricar. Hay que conservar la referencia original en una variable.

Lo que el objeto se lleva dentro

Una expresión de objeto captura el entorno léxico donde aparece, y aquí Kotlin se separa de Java de forma sustancial: no exige que las variables capturadas sean efectivamente finales, así que puedes leerlas y también escribirlas.

fun contador(): () -> Int {
    var n = 0                       // capturada y mutada desde dentro
    return object : () -> Int {
        override fun invoke(): Int = ++n
    }
}

El mecanismo no es magia: el compilador detecta que n se captura y se muta, y la promueve a un objeto contenedor de la biblioteca interna, del estilo de Ref.IntRef para primitivos o Ref.ObjectRef para referencias. La variable deja de vivir en la pila y pasa al montículo, dentro de una celda compartida por la función y por el objeto anónimo. Eso explica dos cosas que se observan en un perfilador: la asignación extra que aparece al capturar variables mutables, y el hecho de que la mutación sea visible en ambos lados, con todas las consecuencias que eso tiene si varios hilos tocan la celda.

flowchart LR
F[Marco de la funcion contador] --> R[Celda Ref.IntRef en el monticulo]
O[Objeto anonimo creado por la expresion] --> R
O --> T[Referencia implicita al this exterior si lo hay]
T --> C[El contenedor completo queda retenido]
style R fill:#f9e2af,color:#11111b
style C fill:#f38ba8,color:#11111b

La flecha de la derecha es la que causa problemas reales. Si la expresión de objeto aparece dentro de un miembro de una clase, el objeto anónimo guarda una referencia implícita a la instancia que lo creó, porque necesita poder resolver this para llamar a sus miembros. Un oyente anónimo registrado en un bus de eventos global mantiene vivo, por esa referencia, todo el objeto que lo declaró y, transitivamente, cuanto cuelgue de él. Es el mecanismo exacto de la fuga clásica en interfaces con ciclo de vida: la pantalla se destruye, el bus sigue vivo, y el bus sostiene la pantalla entera.

class Pantalla(private val datos: ByteArray) {          // varios megabytes
    fun arranca() {
        BusGlobal.registra(object : Oyente {            // captura this de Pantalla
            override fun alRecibir(e: Evento) = pinta(e)
        })
    }

    private fun pinta(e: Evento) { /* usa datos */ }
}

Dentro de alRecibir se llama a pinta, que es un miembro de Pantalla, así que el objeto anónimo necesita el contenedor y lo retiene. Basta con que la expresión no toque nada del exterior para que el compilador no genere esa referencia; el diagnóstico útil, por tanto, es preguntarse qué del ámbito exterior se está usando de verdad dentro del cuerpo. Si la respuesta es “un par de valores”, extraerlos a variables locales antes de crear el objeto rompe el enlace con el contenedor y convierte la fuga en una retención acotada.

⚠️
El anónimo que no captura nada sigue costando una clase

Cada expresión de objeto genera una clase distinta en el artefacto compilado, con nombres del estilo del contenedor seguido de un dígito. No importa si captura o no: la clase existe, se carga y ocupa espacio. En entornos sensibles al número de clases y al tiempo de carga, sustituir expresiones de objeto por lambdas o por tipos con nombre reutilizados es una optimización medible.

Dónde una lambda deja de servir

La conversión SAM permite escribir una lambda allí donde se espera una interfaz con un único método abstracto: automáticamente para interfaces de Java, y para interfaces de Kotlin solo si están declaradas como fun interface. Fuera de ese caso, la lambda no es una opción y la expresión de objeto es la respuesta.

fun interface Filtro { fun acepta(s: String): Boolean }

interface Ciclo {                      // dos metodos: no hay conversion posible
    fun alAbrir()
    fun alCerrar()
}

val f = Filtro { it.isNotEmpty() }     // lambda: un solo metodo abstracto

val c = object : Ciclo {               // obligatorio: hay que nombrar cada uno
    override fun alAbrir() = println("abierto")
    override fun alCerrar() = println("cerrado")
}
🎯

Contratos con más de un método

Un oyente con métodos de apertura, dato y cierre no admite lambda: no hay forma de saber cuál de los tres estás definiendo. La expresión de objeto nombra cada sobrescritura de forma explícita.

🧠

Estado propio entre llamadas

Si la implementación necesita recordar algo entre invocaciones, un campo dentro del objeto anónimo es más honesto que capturar una variable mutable del ámbito exterior, que quedaría también accesible desde fuera.

🔁

Necesitas referirte a ti mismo

Para darse de baja, un oyente debe pasar su propia referencia al registro. Dentro de una expresión de objeto, this es esa referencia; dentro de una lambda, this es el this exterior y no existe forma directa de nombrarse.

🏗️

Heredar de una clase, no de una interfaz

Ajustar un solo método de una clase abstracta con constructor y estado propio solo se expresa con una expresión de objeto que invoque ese constructor.

El eje del coste apunta en dirección contraria. Desde Kotlin 2.0, las lambdas destinadas a la JVM se compilan por defecto mediante invokedynamic y la fábrica de la plataforma, lo que permite que una lambda que no captura nada se resuelva a una instancia única y reutilizada, y que el enlace se retrase al primer uso. Una expresión de objeto, en cambio, asigna un objeto nuevo en cada evaluación sin excepción. En un bucle caliente esa diferencia es real y se mide.

La elección no es sintáctica: es una decisión sobre aridad del contrato e identidad de la referencia

Se enseña la expresión de objeto como la versión larga de la lambda, la que escribes cuando la corta no compila, y esa presentación esconde justo lo que hay que entender. Los dos mecanismos no son dos grafías del mismo concepto: son dos respuestas a preguntas distintas. Una lambda es un valor funcional, es decir, una implementación de exactamente un contrato con exactamente un método, cuya identidad no importa y de la que la plataforma puede fabricar tantas o tan pocas copias como quiera, hasta el extremo de devolverte siempre la misma si no captura nada. Una expresión de objeto es una instancia con identidad, con un lugar en el montículo, con estado propio si lo quiere, con la posibilidad de firmar varios contratos a la vez y de mirarse a sí misma con this. Cuando eliges entre ambas estás decidiendo dos cosas a la vez, y conviene separarlas. La primera es la aridad del contrato: un solo método abstracto admite lambda, dos ya no, y esa frontera no es negociable porque sin nombres de método no hay forma de asignar cuerpos a operaciones. La segunda es mucho más sutil y es la que se paga en producción: cuánto va a vivir la referencia y qué se lleva colgando. Una lambda que solo se usa dentro de la función que la crea desaparece con ella; una expresión de objeto registrada en una estructura de vida larga se convierte en un ancla que retiene la celda de cada variable capturada y, si nació dentro de un miembro de clase, la instancia entera que la contenía. La mayoría de las fugas que se atribuyen a “los oyentes anónimos” son en realidad esto: alguien eligió una construcción con identidad y con enlace al contenedor, y después la entregó a algo que vive más que él. La regla que sobrevive a cualquier lenguaje es esa: pregúntate primero cuántos métodos tiene el contrato, y después quién va a sostener la referencia y durante cuánto tiempo. La sintaxis se deduce sola de esas dos respuestas.

⚔️ Mide, filtra y rompe
  1. Escribe una función que devuelva un objeto anónimo con dos propiedades sin declarar supertipos. Haz la función privada y accede a las propiedades; hazla pública y observa el error. Explica qué tipo ve el llamador en cada caso.
  2. Captura una variable var desde una expresión de objeto y examina el bytecode compilado: localiza la clase contenedora de referencia y razona qué pasó con el marco de pila.
  3. Crea un registro global de oyentes y regístrale una expresión de objeto declarada dentro de un miembro de una clase con un campo grande. Vuelca el montículo y sigue la cadena de referencias hasta el contenedor retenido.
  4. Reescribe ese oyente para que sea capaz de darse de baja a sí mismo. Intenta hacer lo mismo con una lambda y describe con precisión por qué no sale.
  5. Compara en un microbanco de pruebas un bucle de un millón de iteraciones que crea una lambda sin captura frente a otro que crea una expresión de objeto equivalente. Cuenta asignaciones, no solo tiempo.