wandres.dev
OBJETOS Y COMPANION · singletons en el lenguaje

companion object: la ausencia de static y sus consecuencias

Por qué Kotlin eliminó los miembros estáticos y qué ganó a cambio, dónde acaban realmente las constantes según las declares con const o sin él, y cómo JvmStatic y JvmField reconstruyen una fachada natural para quien te llame desde Java.

⏱ 17 min

Kotlin no tiene miembros estáticos, y esa ausencia es una de las decisiones de diseño más deliberadas del lenguaje. Un miembro estático de Java es una anomalía dentro de un lenguaje orientado a objetos: no tiene receptor, no participa en la herencia, no puede implementar una interfaz, no puede ser sobrescrito ni pasado como valor, y sin embargo se declara con la misma sintaxis que un miembro normal, invitando a confundirlo con él. Kotlin lo sustituye por algo que sí es un objeto de pleno derecho, ligado al ciclo de vida de la clase que lo contiene: el compañero. La sustitución no es gratis, y su factura se paga en una indirección, en una clase extra cargada y en una interoperabilidad con Java que hay que reconstruir a mano cuando importa.

🎯 Al terminar esta lección sabrás
  • Justificar la eliminación de static desde el sistema de tipos y no desde el gusto sintáctico.
  • Leer la traducción de un companion object a clase anidada más campo estático en la clase externa.
  • Elegir con criterio entre const val, val con @JvmField y propiedad con acceso, sabiendo dónde acaba cada uno.
  • Diseñar una fachada natural para consumidores Java con @JvmStatic sin romper el modelo de Kotlin.

Un objeto de verdad en lugar de un agujero

El compañero se declara dentro de la clase y, si no le pones nombre, recibe uno por defecto: Companion. Todo lo que escribas ahí dentro es un miembro de instancia de un objeto normal y corriente que existe una sola vez por clase.

class Peticion(val url: String) {
    companion object Fabrica : Comparador<Peticion> {
        private var emitidas = 0

        fun de(url: String): Peticion {
            emitidas++
            return Peticion(url)
        }

        override fun compara(a: Peticion, b: Peticion) = a.url.compareTo(b.url)
    }
}

Aquí ocurre algo imposible con static: el compañero implementa una interfaz. Eso significa que Peticion.Fabrica es un valor de tipo Comparador<Peticion> que puedes pasar como argumento, guardar en una lista o inyectar en un algoritmo. Un bloque de métodos estáticos jamás podría hacerlo, porque no hay ningún objeto al que referirse. La misma lógica habilita otras dos cosas que se usan mucho: escribir funciones de extensión sobre el compañero, con la forma fun Peticion.Fabrica.desdeCache(...), y usarlo como receptor genérico en jerarquías donde cada tipo debe aportar su propia fábrica.

La traducción a bytecode es directa. La clase externa gana un campo estático llamado como el compañero, y aparece una clase anidada estática que contiene los miembros:

public final class Peticion {
    public static final Peticion.Fabrica Fabrica;
    static { Fabrica = new Peticion.Fabrica(null); }

    public static final class Fabrica implements Comparador<Peticion> {
        public final Peticion de(String url) { /* ... */ }
    }
}

De ahí salen dos consecuencias que conviene interiorizar. La primera es que el compañero se inicializa cuando se inicializa la clase externa, porque su campo estático vive en ella: no hay pereza independiente entre ambos. La segunda es que una clase con compañero carga dos clases en lugar de una, con su coste de verificación, de memoria de metaespacio y de recuento de métodos en plataformas donde eso importa.

Hay tres reglas de forma que conviene memorizar de una vez. Solo puede haber un compañero por clase, porque su papel es ser el objeto asociado a ese tipo y no un cajón de sastre. Si le das nombre, ese nombre sustituye a Companion en el bytecode y en el acceso desde Java. Y una interfaz también puede tener compañero, lo que la convierte en un sitio natural donde colgar sus propias fábricas.

interface Codec<T> {
    fun codifica(v: T): ByteArray

    companion object {
        fun <T> identidad(): Codec<T> = TODO()
    }
}

fun <T> ordena(xs: List<T>, c: Comparador<T>): List<T> = TODO()

val ref: Comparador<Peticion> = Peticion.Fabrica     // el companion es un valor
val ordenado = ordena(listOf(Peticion("b"), Peticion("a")), Peticion.Fabrica)

Ese último detalle merece subrayarse porque es la prueba de que el compañero no es azúcar: Peticion.Fabrica aparece en una posición de valor, se pasa a una función que espera un comparador y participa en el despacho dinámico. Ninguna de las tres cosas es expresable con miembros estáticos.

Dónde acaban realmente las constantes

Esta es la parte que más gente escribe por costumbre y por eso mismo se equivoca. Dentro de un compañero hay tres formas de declarar un valor y las tres se compilan a sitios distintos.

🧊

const val

Solo para primitivos y cadenas. Genera un campo estático final en la clase externa y, sobre todo, el valor se inserta literalmente en cada sitio de llamada. Leerlo no dispara la inicialización de la clase.

📦

val normal

Campo privado estático en la clase externa más un método de acceso en el compañero. Desde Kotlin es transparente; desde Java hay que pasar por Clase.Companion.getX().

🔓

val con JvmField

Expone un campo público estático en la clase externa, sin método de acceso. Es lo que necesitas para que Java lea Clase.X sin ceremonia, y lo único válido cuando el valor no es una constante de compilación.

📄

Constante de nivel superior

Un const val fuera de toda clase acaba como campo estático de la clase sintética del fichero. Es la opción correcta cuando la constante pertenece al módulo y no a un tipo concreto.

La inserción literal de const val tiene una consecuencia de compatibilidad binaria que se olvida siempre: si publicas una biblioteca y cambias el valor de una constante insertada, los consumidores ya compilados seguirán usando el valor viejo hasta que se recompilen. Para un número de versión o un límite que puede cambiar, un val con @JvmField es más seguro que un const val, aunque cueste un acceso indirecto. La regla operativa: const val para valores que forman parte del contrato y no cambiarán jamás, val para todo lo demás.

flowchart TD
K[Codigo Kotlin dentro del companion] --> A[const val]
K --> B[val sin anotacion]
K --> C[val con JvmField]
A --> A2[Campo estatico final en la clase externa mas inline en cada llamada]
B --> B2[Campo privado estatico mas getter en la clase Companion]
C --> C2[Campo publico estatico directo en la clase externa]
style A2 fill:#f9e2af,color:#11111b
style C2 fill:#a6e3a1,color:#11111b

Reconstruir la fachada para Java

Sin anotaciones, un consumidor Java ve el compañero tal cual está en el bytecode y escribe Peticion.Companion.de(url), que es correcto pero delata la implementación. @JvmStatic arregla exactamente eso: genera en la clase externa un método estático puente que delega en el miembro del compañero, sin eliminar el original.

class Peticion private constructor(val url: String) {
    companion object {
        const val ESQUEMA = "https"

        @JvmField
        val VACIA = Peticion("")

        @JvmStatic
        fun de(url: String): Peticion = Peticion(url)
    }
}
// Desde Java, con las anotaciones puestas:
Peticion p = Peticion.de("https://ejemplo.org");
String esquema = Peticion.ESQUEMA;   // constante insertada en el sitio de uso
Peticion vacia = Peticion.VACIA;     // campo estatico directo

Merece la pena entender que @JvmStatic no convierte nada en estático de verdad: añade una fachada. El método del compañero sigue existiendo y sigue siendo el que se invoca desde Kotlin, y el puente estático se limita a llamar a Companion.de(url). Por eso puedes anotar un miembro que implementa una interfaz sin romper el polimorfismo, y por eso la anotación funciona también dentro de declaraciones object normales.

La contrapartida es que el puente duplica la firma en el artefacto: hay dos métodos con el mismo nombre, uno estático en la clase externa y otro de instancia en el compañero. En bibliotecas con reflexión, generación de código o comprobadores de compatibilidad binaria, esa duplicación se ve y a veces sorprende. Y si anotas un miembro que también existe como propiedad, aparecerán además los accesores estáticos correspondientes.

📝
Extensiones sobre el compañero frente a extensiones sobre el tipo

Una extensión declarada sobre Clase.Companion se invoca con el nombre de la clase como receptor y se lee como una fábrica; una extensión declarada sobre Clase necesita una instancia previa. Confundirlas produce el error clásico de intentar llamar a una supuesta fábrica desde una instancia. La regla es tan simple como mirar quién debe existir antes de la llamada: si el objeto todavía no existe, la extensión tiene que ir sobre el compañero.

💡
Un compañero vacío también sirve

Aunque no tengas nada que meter dentro, declarar un companion object vacío te deja un punto de extensión: cualquier módulo posterior puede colgar de él funciones de extensión, que aparecerán como si fueran fábricas del tipo. Es un truco muy usado en bibliotecas para permitir que integraciones externas añadan constructores alternativos sin tocar el original.

Kotlin no quitó static por elegancia: lo quitó porque static es un espacio de nombres disfrazado de miembro

Para ver la magnitud del cambio hay que aceptar primero una incomodidad: un método estático de Java no es un método. Un método es una operación con un receptor, seleccionable en tiempo de ejecución según el tipo dinámico de ese receptor, y por tanto sustituible, sobrescribible y capaz de cumplir un contrato declarado en una interfaz. Un miembro estático no tiene nada de eso. Se resuelve en compilación, no participa en el despacho virtual, no puede aparecer en una interfaz como obligación, no puede pasarse como valor sin envolverse, y su única relación con la clase que lo contiene es que comparte su nombre a modo de prefijo. Es, literalmente, una función libre alojada dentro de un espacio de nombres que resulta ser una clase. La ambigüedad de usar la misma sintaxis para dos cosas tan distintas ha costado décadas de confusión: código que no se puede probar porque una dependencia se resolvió estáticamente, jerarquías donde la fábrica no se puede sobrescribir en la subclase, patrones enteros como la plantilla estática que solo existen para rodear esa limitación. Kotlin corta el nudo eliminando la categoría y ofreciendo dos sustitutos, cada uno para una de las dos cosas que static mezclaba. Para la función libre que solo quería un prefijo de nombre, están las declaraciones de nivel superior, que se compilan como estáticas de verdad y sin clase que las justifique. Para el miembro que sí pertenece conceptualmente al tipo, está el compañero, que es un objeto real, con identidad, capaz de implementar interfaces, de recibir extensiones, de ser el receptor de un genérico y de sustituirse en una prueba. La lección de diseño trasciende a Kotlin: cuando un lenguaje ofrece una construcción que parece un miembro pero no obedece las reglas de los miembros, esa construcción acabará usándose para esconder acoplamientos que nadie puede ver en las firmas. El precio de la solución de Kotlin es honesto y hay que conocerlo: una clase extra, una indirección y un puente que hay que pedir con una anotación para que el vecino de Java no note la costura.

⚔️ Sigue la pista de cada declaración
  1. Declara en un compañero un const val, un val sin anotación y un val con @JvmField. Compila y usa javap -p para localizar en qué clase acabó cada uno y con qué modificadores.
  2. Comprueba experimentalmente la inserción literal: compila un consumidor contra tu biblioteca, cambia el valor del const val, recompila solo la biblioteca y ejecuta. Explica el resultado.
  3. Haz que tu compañero implemente una interfaz y pásalo como argumento a una función que la espera. Escribe en una frase por qué esto es imposible con miembros estáticos.
  4. Escribe una función de extensión sobre el compañero de una clase ajena y llámala como si fuera una fábrica del tipo. Razona qué visibilidad necesita el compañero para que funcione.
  5. Consume tu clase desde Java sin anotaciones y luego con @JvmStatic y @JvmField. Compara los sitios de llamada y localiza en el bytecode el método puente que genera la anotación.