wandres.dev
KOTLIN/JVM · la vista desde Java

Cómo se compila tu Kotlin

El compilador no traduce tu código a un Java equivalente: fabrica clases que nunca escribiste, renombra lo que hace falta y añade métodos sintéticos para sostener funciones que la JVM no tiene. Nombres binarios generados, clases de fichero, la familia de métodos que aparece sola, y cómo abrir un .class para verlo con tus propios ojos.

⏱ 21 min

Llevas veintiocho niveles escribiendo Kotlin y razonando dentro de Kotlin, y eso ha sido correcto porque el lenguaje está diseñado para que su modelo mental se sostenga solo. Pero debajo hay una máquina que no conoce las funciones de nivel superior, ni las propiedades, ni los argumentos por defecto, ni los objetos singleton, y a la que hay que explicárselo todo con un vocabulario pobre de clases, campos y métodos. Esa traducción no es un detalle de implementación que puedas ignorar para siempre: determina los nombres que verá un consumidor Java, define la superficie de tu API binaria, decide qué cambios rompen la compatibilidad al publicar una biblioteca y explica buena parte de los mensajes de error más desconcertantes que vas a leer en tu carrera. Este nivel entero consiste en mirar tu propio código desde el otro lado del cristal.

🎯 Al terminar esta lección sabrás
  • Predecir el nombre binario de cualquier declaración Kotlin antes de compilarla.
  • Explicar por qué existen las clases de fichero y qué consecuencias tienen sobre la API pública.
  • Identificar las familias de métodos sintéticos y saber qué funcionalidad del lenguaje sostiene cada una.
  • Inspeccionar un .class con las herramientas del JDK y leer los metadatos que Kotlin deja dentro.

Los nombres que no escribiste

La JVM solo sabe cargar clases. Todo lo que declares tiene que acabar dentro de una, y cuando tu código no proporciona ninguna, el compilador la inventa. La regla básica es mecánica: un archivo Utiles.kt con declaraciones de nivel superior produce una clase llamada UtilesKt, y cada función y propiedad de ese archivo se convierte en un miembro public static final de esa clase.

// Utiles.kt
package app.texto

fun normalizar(s: String): String = s.trim().lowercase()
val VERSION = "3.1"

Visto desde Java, eso no es un paquete con funciones sueltas, porque tal cosa no existe: es app.texto.UtilesKt.normalizar(...) y app.texto.UtilesKt.getVERSION(). El nombre UtilesKt es ahora parte de tu API pública, con todo lo que eso implica: si renombras el archivo, rompes la compatibilidad binaria de cualquier consumidor Java sin tocar una sola línea de lógica.

Las clases anidadas siguen la convención de la propia JVM, con el signo del dólar como separador entre el contenedor y lo contenido, mientras que las construcciones anónimas y locales reciben un contador. Conviene tener el diccionario entero a la vista.

📄

Nivel superior

Utiles.kt produce la clase UtilesKt, con miembros estáticos. Cambiar el nombre del archivo cambia la API binaria.

📦

Anidadas e internas

Exterior.Interior tiene nombre binario Exterior$Interior. Una clase inner guarda además una referencia sintética al contenedor.

👻

Anónimas y locales

Un objeto anónimo se numera como Exterior$1; una clase local dentro de una función lleva el nombre de esta, como Exterior$procesar$1.

Objetos y acompañantes

Un object es una clase con un campo estático INSTANCE. Un companion object es una clase anidada Companion más un campo estático homónimo.

El caso del acompañante merece una parada, porque es la fuente número uno de sorpresa para quien llega desde Java. Los miembros de un companion object no son estáticos: viven en la clase anidada Companion, de modo que desde Java se invocan como Configuracion.Companion.crear(). Eso no es un accidente sino la consecuencia directa de que en Kotlin el acompañante es un objeto de verdad, con identidad, capaz de implementar interfaces y de recibir extensiones. La forma estática de toda la vida existe, pero hay que pedirla, y esa petición es el tema de la lección siguiente.

💡
Las lambdas ya casi nunca son clases

Durante años cada lambda producía una clase anónima en disco y el consejo era vigilar la explosión de archivos generados. Desde Kotlin 2.0 la estrategia por defecto es invokedynamic con la factoría estándar de la plataforma: la clase se materializa en tiempo de ejecución como clase oculta y no aparece en el jar. Si buscas un Ejemplo$1 para tu lambda y no lo encuentras, no está perdido, es que ya no se genera.

Los métodos que aparecen solos

Un buen número de funcionalidades del lenguaje no tienen contrapartida en la JVM y se sostienen sobre métodos que tú no escribiste. Reconocerlos convierte un volcado de bytecode ilegible en un texto perfectamente narrativo.

Las propiedades son el caso más frecuente y el más invisible: un val nombre: String es un campo privado más un método getNombre, y un var añade setNombre. Los argumentos por defecto son el segundo, y su traducción es ingeniosa. La JVM no tiene valores por defecto, así que el compilador emite el método real y a su lado un puente sintético con un parámetro adicional de máscara de bits que indica qué argumentos venían y cuáles hay que rellenar.

fun conectar(host: String, puerto: Int = 443, tls: Boolean = true) { }
// Lo que existe realmente en el .class
public static final void conectar(String host, int puerto, boolean tls) { }
public static void conectar$default(String host, int puerto, boolean tls, int mascara, Object marca) { }

Ese conectar$default es sintético y no forma parte de tu API pública: un consumidor Java está obligado a pasar los tres argumentos, y de ahí nace la anotación que estudiaremos después. La misma familia de trucos aparece en varios frentes más.

flowchart TD
K[Codigo Kotlin] --> A[Propiedad]
K --> B[Argumentos por defecto]
K --> C[Miembro internal]
K --> D[When sobre enum]
K --> E[Interfaz con cuerpo]
A --> A1[Campo privado mas getter y setter]
B --> B1[Metodo dolar default con mascara de bits]
C --> C1[Publico con nombre alterado por modulo]
D --> D1[Clase sintetica WhenMappings con tabla de enteros]
E --> E1[Metodo por defecto mas clase DefaultImpls]
style B1 fill:#f9e2af,color:#11111b
style C1 fill:#f38ba8,color:#11111b

El caso de internal es especialmente traicionero. La JVM no tiene un concepto de módulo compatible con el de Kotlin, así que un miembro internal se compila como público y su nombre se altera añadiendo el del módulo, algo como procesar$mi_modulo. La consecuencia práctica es doble: desde Java ese miembro es perfectamente invocable, con lo cual internal no es una barrera de seguridad sino una convención verificada por el compilador Kotlin; y como el nombre del módulo forma parte del nombre binario, renombrar el módulo rompe la compatibilidad de cualquier código ya compilado contra él.

Quedan los métodos puente, que la JVM impone a cualquier lenguaje con genéricos y retornos covariantes. Su origen es el borrado de tipos: si una clase concreta especializa un parámetro de tipo, la firma que hereda y la que implementa no coinciden en el bytecode, y hace falta un intermediario que reciba la primera, convierta y delegue en la segunda.

interface Caja<T> {
    fun leer(): T
}

class CajaDeTexto : Caja<String> {
    override fun leer(): String = "hola"
}
// Dos metodos donde tu escribiste uno
public String leer() { return "hola"; }
public synthetic bridge Object leer() { return this.leer(); }

A esa familia se suman las clases sintéticas de apoyo: la tabla WhenMappings, que traduce constantes de enumeración a enteros consecutivos para permitir un salto de tabla en lugar de una cadena de comparaciones, y la clase DefaultImpls, que aloja los cuerpos de los métodos de interfaz.

📝
La historia de los métodos de interfaz por defecto

Cuando Kotlin nació, la JVM que tenía como objetivo no permitía cuerpos en las interfaces, así que el compilador emitía la interfaz abstracta y una clase auxiliar con las implementaciones, a la que cada clase que la implementara delegaba. Cuando la plataforma incorporó los métodos por defecto, cambiar de estrategia sin más habría roto todo lo ya compilado. La solución fue una opción del compilador que hoy genera ambas formas de manera predeterminada, y existe un modo sin compatibilidad para bibliotecas que puedan permitirse renunciar a los consumidores antiguos.

ℹ️
La anotación que lo explica todo

Cada clase generada lleva una anotación @Metadata cuyo contenido es un mensaje binario serializado con la firma Kotlin auténtica: nulabilidad, varianza, tipos de retorno reales, nombres de parámetros, cuál es una propiedad y cuál un método. Es lo que permite que el compilador, al leer un jar de terceros, recupere el Kotlin original y no la proyección empobrecida que ve Java. Existe una biblioteca oficial de lectura de esos metadatos, y es la base sobre la que funcionan los serializadores conscientes de Kotlin.

Abrir el fichero y mirarlo

Todo lo anterior es verificable en menos de un minuto, y esa verificación debería ser un reflejo antes que una curiosidad. La herramienta mínima viene con el JDK.

kotlinc Utiles.kt -d salida/
javap -p -c salida/app/texto/UtilesKt.class
javap -p -v salida/app/texto/UtilesKt.class | head -40

La bandera de miembros privados es imprescindible, porque casi todo lo interesante es sintético o privado y sin ella no aparece. El modo detallado muestra además el conjunto de constantes, las tablas de excepciones y las anotaciones de nulabilidad que Kotlin escribe en las firmas para que las herramientas de Java las aprovechen. Dentro del IDE, la ruta equivalente es mostrar el bytecode de Kotlin y pulsar el botón de decompilar, que produce el Java aproximadamente equivalente: es menos fiel que el bytecode, pero infinitamente más rápido de leer y suficiente para el noventa por ciento de las preguntas.

# El nivel de bytecode y la version de Java se eligen explicitamente
kotlinc -jvm-target 21 Utiles.kt -d salida/

Esa elección es independiente del JDK con el que compiles y del que usarás en producción, y su efecto es real: determina si el compilador puede emitir métodos por defecto en interfaces, qué familia de concatenación de cadenas se usa y qué constructos modernos de la plataforma están disponibles. Kotlin 2.4 amplió el rango soportado hasta Java 26, de modo que hoy el compilador cubre desde objetivos muy conservadores hasta lo último publicado, y el criterio para elegir ya no es lo que el compilador aguanta sino lo que tu despliegue garantiza.

Un lenguaje no es su sintaxis, es lo que sobrevive a la compilación

Aquí es donde conviene detenerse, porque lo que acabas de ver es mucho más grande que un truco de interoperabilidad. Un lenguaje de alto nivel es, en el fondo, un contrato sobre qué abstracciones vas a poder pensar y un compromiso sobre cómo se van a materializar en una máquina que no las entiende. Kotlin decidió que las funciones de nivel superior, las propiedades y los argumentos por defecto merecían existir aunque la JVM no supiera qué son, y pagó ese precio en clases inventadas, métodos sintéticos y nombres alterados. Ese precio no es gratuito ni invisible: se convierte en tu API binaria, en la superficie que otro equipo consumirá, en lo que puedes cambiar sin romper a nadie. Y aquí está la lección que trasciende a Kotlin por completo. Toda abstracción que uses en cualquier lenguaje, sobre cualquier plataforma, tiene una representación concreta debajo, y hay un nivel de dominio profesional que empieza exactamente cuando dejas de tratar esa representación como magia y empiezas a poder predecirla. No para programar contra ella, que sería renunciar a la abstracción que tanto costó construir, sino para saber cuándo la abstracción te está cobrando algo, para poder leer un error que aflora dos capas por debajo del código que escribiste y para tomar decisiones de diseño de biblioteca sabiendo qué queda grabado en piedra. Programar con un modelo mental de una sola capa funciona hasta el día en que el sistema falla en la capa que ignorabas. Tener dos capas simultáneas en la cabeza, la que escribes y la que se ejecuta, y poder bajar de una a otra a voluntad, es lo que separa a quien usa una plataforma de quien la domina.

⚔️ Levanta la tapa
  1. Escribe un archivo con dos funciones de nivel superior y una propiedad, compílalo y localiza con javap la clase generada y los tres miembros estáticos que contiene.
  2. Añade un argumento por defecto a una de las funciones, recompila y encuentra el método sintético con la máscara de bits; deduce qué bit corresponde a qué parámetro.
  3. Declara una función internal y observa el nombre alterado; después renombra el módulo y comprueba que el nombre binario cambia.
  4. Escribe un when exhaustivo sobre una enumeración y busca la clase sintética de la tabla de correspondencias en el directorio de salida.
  5. Decompila desde el IDE una clase con companion object y explica por qué el acceso desde Java necesita el intermediario Companion.