wandres.dev
CLASES Y PROPIEDADES · estado con contrato

Clases anidadas e internas: nested frente a inner

Una clase anidada en Kotlin no guarda referencia a la externa salvo que la marques inner, el acceso al exterior se escribe con this arroba Externa, y ese valor por defecto invertido respecto a Java elimina por construccion toda una familia de fugas de memoria.

⏱ 17 min

Anidar una clase dentro de otra parece un gesto puramente organizativo, y en Java no lo es: allí, escribir una clase dentro de otra crea silenciosamente un enlace permanente hacia la instancia externa, un campo oculto que ningún código muestra y que mantiene viva a la clase contenedora mientras exista un solo objeto contenido. Kotlin invirtió ese valor por defecto. Aquí anidar es solo agrupar, y si de verdad quieres el enlace tienes que pedirlo con inner. La diferencia se escribe con una palabra y se paga, o se ahorra, en megabytes de memoria retenida.

🎯 Al terminar esta lección sabrás
  • Distinguir una clase anidada de una inner y saber qué genera cada una.
  • Acceder al receptor externo con this cualificado y entender por qué hace falta cualificarlo.
  • Explicar la fuga de memoria clásica que el valor por defecto de Kotlin evita.
  • Reconocer que objetos anónimos y lambdas presentan la misma superficie de captura.

Anidar es agrupar, no enlazar

Una clase declarada dentro de otra, sin más modificadores, no tiene ninguna relación con instancias de la externa. Es el equivalente a una clase static anidada de Java, y se construye sin necesidad de un objeto contenedor:

class Peticion(val url: String) {

    class Cabecera(val clave: String, val valor: String)   // anidada, independiente

    val cabeceras = mutableListOf<Cabecera>()
}

val c = Peticion.Cabecera("Accept", "application/json")     // sin instancia de Peticion

Lo único que aporta la anidación aquí es espacio de nombres y visibilidad: Cabecera se lee como parte de Peticion, no contamina el paquete y puede marcarse private para que sea un detalle interno. Nada más. La clase anidada tampoco puede leer las propiedades de instancia de la externa, porque no hay ninguna instancia a la que preguntar.

Sí puede, en cambio, acceder a los miembros privados de la clase externa cuando trabaja con una instancia que le pasen, algo que el compilador resuelve generando accesores sintéticos. La dirección contraria no funciona: en Kotlin, la clase externa no ve los miembros privados de una clase anidada, al contrario de lo que ocurre en Java.

inner: pedir la referencia explícitamente

Cuando la clase contenida necesita de verdad el estado de la contenedora —un iterador que recorre la colección que lo creó, un constructor fluido que acumula sobre el objeto padre— se marca inner. Entonces sí hay un enlace, y entonces sí hace falta una instancia externa para construirla:

class Coleccion(private val datos: List<String>) {

    inner class Cursor {
        private var posicion = 0
        fun siguiente(): String? =
            if (posicion < datos.size) datos[posicion++] else null   // usa el estado externo

        fun total() = this@Coleccion.datos.size    // this cualificado
    }

    fun cursor() = Cursor()
}

val cursor = Coleccion(listOf("a", "b")).Cursor()   // construccion desde una instancia

Dentro de una inner, this a secas designa la instancia interna. Para hablar de la externa hay que cualificar con this@Coleccion. Esa cualificación no es burocracia: es la marca visible de que existen dos receptores en juego, y hace explícito en el punto de uso lo que en Java estaba escondido en un campo generado. En el bytecode, esa referencia es un campo sintético que suele aparecer con el nombre this$0.

flowchart LR
subgraph Anidada
  A1[Instancia externa] -.no hay enlace.-> A2[Instancia anidada]
end
subgraph Inner
  B1[Instancia externa] -->|referencia fuerte| B2[Instancia inner]
  B2 -->|this arroba Externa| B1
end
style A2 fill:#a6e3a1,color:#11111b
style B2 fill:#f38ba8,color:#11111b

La fuga que evita el valor por defecto

Ahora la parte que importa en producción. Una referencia fuerte impide la recolección de basura, y el enlace de una inner es fuerte. Si un objeto interno sobrevive a su contenedor —porque lo registraste en un manejador estático, en un temporizador, en un ejecutor de larga vida o en un flujo que nadie cancela— ese objeto mantiene vivo al contenedor entero, con todo lo que el contenedor a su vez retiene.

class Pantalla {
    private val datosPesados = ByteArray(20 * 1024 * 1024)

    // inner: cada tarea encolada retiene la Pantalla completa
    inner class TareaFragil : Runnable {
        override fun run() { /* ... */ }
    }

    // anidada: no retiene nada; recibe solo lo que necesita
    class TareaSegura(private val idPantalla: String) : Runnable {
        override fun run() { /* ... */ }
    }
}

Este es exactamente el patrón que durante años arruinó aplicaciones Android: un Handler, un AsyncTask o un Runnable declarado como clase interna dentro de una Activity, encolado con un retardo de treinta segundos, mantenía viva la Activity destruida junto a su jerarquía completa de vistas. La receta que se enseñaba era declarar la clase static y guardar una WeakReference. En Kotlin, la mitad de esa receta ya viene puesta: la clase es estática salvo que escribas inner, así que la fuga deja de ser el resultado de no saber y pasa a ser el resultado de haberlo pedido.

⚠️
Inner mas ciclo de vida corto es la combinacion que quema

La regla práctica cabe en una línea: usa inner cuando la vida del objeto interno esté contenida en la del externo, y nunca cuando pueda escapar de ella. Un iterador que se consume en el acto, un nodo de un árbol que muere con el árbol, un paso de un constructor fluido: todos ellos son legítimos porque no sobreviven a nadie. Un callback, una suscripción, una tarea programada o cualquier cosa que se entregue a un registro externo debe ser una clase anidada normal que reciba por parámetro lo poco que necesite.

Objetos anónimos y lambdas: la misma captura

La cuestión no termina en la palabra inner, porque hay otras dos formas de crear la misma referencia sin escribirla. Un objeto anónimo declarado dentro de una clase captura la instancia externa en cuanto usa cualquiera de sus miembros, y una lambda hace exactamente lo mismo:

class Servicio(private val nombre: String) {

    fun registrarFragil(registro: MutableList<Runnable>) {
        registro.add(Runnable { println(nombre) })     // captura this: retiene el Servicio
    }

    fun registrarSeguro(registro: MutableList<Runnable>) {
        val copia = nombre                              // se captura el valor, no el objeto
        registro.add(Runnable { println(copia) })
    }
}

La diferencia entre ambas versiones es sutil y decisiva. En la primera, nombre se resuelve como this.nombre, así que la lambda guarda una referencia al Servicio completo. En la segunda, la variable local ya contiene el dato, y lo que se captura es la cadena. Como detalle de implementación, una lambda que no captura nada puede compilarse a una única instancia reutilizada, mientras que una que captura obliga a crear un objeto por llamada.

🏛️

Clase anidada

Sin enlace al exterior. Agrupa por nombre y por visibilidad, se construye sola y no retiene nada. Es el valor por defecto y casi siempre el correcto.

🔗

Clase inner

Guarda una referencia fuerte a la instancia externa en un campo sintético. Necesaria cuando el objeto interno opera sobre el estado del externo.

👤

Objeto anonimo

Captura el receptor en cuanto usa un miembro de la clase que lo rodea. La misma retención que una inner, pero sin ninguna palabra que lo anuncie.

Lambda

Captura variables por valor y receptores por referencia. Si no captura nada, el compilador puede reutilizar una sola instancia.

Merece cerrar con el matiz que unifica los cuatro casos: lo que retiene memoria no es la sintaxis sino la referencia, y la sintaxis solo decide si esa referencia se ve. Una inner la declara con una palabra clave, un objeto anónimo la crea sin avisar y una lambda la crea o no según qué nombres uses dentro. Por eso la pregunta que hay que hacerse al escribir cualquier cosa anidada es siempre la misma: quién guardará esto, cuánto vivirá quien lo guarde y qué se está llevando consigo.

El valor por defecto correcto es aquel cuyo olvido no cuesta nada

Lo que se juega en la diferencia entre nested e inner es mucho más grande que dos palabras clave, y es la tesis de diseño que recorre todo Kotlin: cuando una decisión tiene una opción barata y una cara, el idioma debe hacer que la barata sea la que ocurre cuando nadie piensa. Java eligió lo contrario sin querer. Anidar una clase daba por defecto el enlace al exterior porque en el momento del diseño parecía la opción cómoda, y el resultado fue que durante veinte años miles de programadores crearon referencias fuertes que jamás pidieron, en un campo que nunca vieron, retenido durante un tiempo que nunca calcularon. La solución existía y era escribir una palabra, static, pero exigía saber que el problema existía, y un valor por defecto que solo es seguro para quien ya conoce la trampa no es un valor por defecto, es un examen. Kotlin invirtió esa polaridad aquí igual que la invirtió en final frente a open y en la nulabilidad frente a la permisividad, y en los tres casos con el mismo razonamiento: el coste de escribir una palabra de más cuando de verdad quieres la opción peligrosa es trivial, mientras que el coste de sufrir esa opción sin haberla pedido es enorme y, sobre todo, invisible hasta que un perfilador de memoria lo revela meses después. Ahí está la lección que trasciende al lenguaje y que puedes aplicar a tus propias APIs desde hoy: cada valor por defecto que publicas es una decisión que estás tomando en nombre de todos los que usarán tu código sin leerlo entero, que son casi todos. Elígelo de modo que el descuido salga gratis y que solo lo deliberado pueda hacer daño, porque la mayor parte del código del mundo lo escriben personas con prisa que confían en que lo obvio sea lo correcto. Diseñar bien consiste, en buena medida, en hacer que esa confianza esté justificada.

⚔️ Mide lo que retiene cada anidacion
  1. Declara una clase anidada normal e intenta leer una propiedad de instancia de la externa. Explica por qué el compilador no te deja.
  2. Convierte esa clase en inner, accede al exterior con this cualificado y observa cómo cambia la forma de construirla.
  3. Crea una inner que contenga un array grande en la externa, guárdala en una lista estática y confirma con un perfilador que la externa no se recolecta.
  4. Rehaz el caso anterior con una clase anidada que reciba por parámetro solo el dato que necesita, y compara la memoria retenida.
  5. Escribe dos lambdas dentro de una clase, una que use una propiedad y otra que use una copia local, y razona cuál de las dos retiene la instancia contenedora.