Ver lo que genera: bytecode, descompilación y verdad
La forma más rápida de dejar de creer cosas sobre Kotlin es mirar lo que produce. Esta lección enseña a inspeccionar el bytecode desde el IDE y con javap, a descompilar a Java sabiendo exactamente en qué mentira estás incurriendo al hacerlo, a leer los metadatos que Kotlin deja en cada clase, y a usar todo eso como método para desmontar una característica del lenguaje hasta entender su coste real en lugar de repetir lo que dice la documentación.
Hay una diferencia enorme entre saber que una data class genera equals, hashCode, toString, copy y los componentN, y haber visto esos métodos con tus ojos en la salida del compilador, con sus firmas exactas, su visibilidad y su tamaño. La primera es una frase memorizada que te falla en cuanto aparece un caso raro; la segunda es una capacidad que se aplica a cualquier característica futura del lenguaje, incluidas las que todavía no existen. Inspeccionar la salida no es una técnica de depuración avanzada reservada a quien escribe compiladores: es el modo más barato de convertir creencias en conocimiento, y está a dos atajos de teclado.
- Inspeccionar el bytecode generado desde el IDE y con herramientas de línea de órdenes.
- Descompilar a Java entendiendo qué parte de lo que ves es real y cuál es una reconstrucción.
- Leer los metadatos que Kotlin deja en cada clase y saber para qué sirven.
- Usar la inspección como método sistemático para entender una característica del lenguaje.
Tres lentes sobre la misma salida
La primera lente es el IDE, y es la que deberías usar el noventa por ciento de las veces por pura fricción: en IntelliJ, con el fichero abierto, la acción de mostrar el bytecode de Kotlin abre un panel que se actualiza mientras editas. Ver el bytecode cambiar en tiempo real al añadir un ? a un tipo o al marcar una función como inline es, con diferencia, la forma más eficiente de aprender qué hace cada palabra clave. Ese mismo panel ofrece un botón para descompilar el resultado a Java, que es la segunda lente.
La tercera es la línea de órdenes, y es la que no depende de ningún entorno. La herramienta estándar de la JVM basta para casi todo.
kotlinc Ejemplo.kt -d out
# Firmas publicas y privadas, sin cuerpos.
javap -p out/EjemploKt.class
# Con el bytecode de cada metodo.
javap -c -p out/EjemploKt.class
# Todo: constantes, atributos, anotaciones, tabla de excepciones.
javap -v -p out/EjemploKt.class
Merece la pena fijarse en un detalle que sorprende la primera vez: un fichero llamado Ejemplo.kt con funciones de nivel superior produce una clase llamada EjemploKt. No hay funciones sueltas en la JVM, así que el compilador fabrica una clase contenedora con métodos estáticos y le añade el sufijo. Ese único hecho explica de golpe por qué existe la anotación para renombrar el fichero de clase, por qué las funciones de nivel superior no se pueden sobrescribir y cómo se ven desde Java.
flowchart LR A[Fuente Kotlin] --> B[Compilador] B --> C[Bytecode y metadatos] C --> D[Panel del IDE] C --> E[javap] C --> F[Descompilador a Java] D --> G[Comprension real] E --> G F --> G
Para los destinos que no son la JVM el planteamiento es el mismo aunque las herramientas cambien: en nativo se inspecciona el binario resultante con las utilidades habituales del sistema y se puede pedir al compilador que vuelque representaciones intermedias; en JavaScript basta con abrir la salida generada, que es legible. El método —mirar en vez de suponer— no depende de la plataforma.
Panel del IDE para explorar, porque el ciclo de prueba es de segundos y ves el efecto de cada tecla. javap para confirmar, porque no depende del entorno, se puede guardar en un fichero y se puede comparar con diff entre dos versiones del mismo código. Descompilar a Java solo cuando el bytecode ya no te cabe en la cabeza y necesitas una vista estructurada.
Ese uso de diff es más potente de lo que parece y casi nadie lo emplea. Guardar la salida de javap antes y después de un cambio —o antes y después de subir la versión del compilador— convierte una pregunta difusa como «esto habrá cambiado algo» en una respuesta binaria y exacta, incluidos los casos en los que la respuesta es que no cambió nada y te ahorras la migración que ibas a emprender.
Descompilar sin dejarse engañar
La descompilación a Java es la lente más cómoda y la más peligrosa, porque produce algo que parece código fuente y no lo es. Lo que ves es una reconstrucción: un descompilador lee bytecode y propone un programa Java que, compilado, produciría algo parecido. En esa reconstrucción hay tres clases de mentira que conviene tener presentes.
Reconstrucción, no fuente
Nombres de variables locales inventados, bucles reescritos, condicionales reordenados. La estructura es plausible, no literal.
Java no sabe decir Kotlin
Cosas que el bytecode expresa bien no tienen sintaxis en Java: nombres desfigurados de value class, propiedades, receptores de extensión. El descompilador improvisa.
Optimizaciones ausentes
Lo que ves es la salida del compilador, no lo que ejecuta la máquina. La JVM aún tiene que insertar, especular y compilar en caliente.
Metadatos invisibles
La anotación de metadatos que Kotlin adjunta a cada clase no aparece descompilada de forma legible, y es donde vive la mitad de la información.
De esas cuatro, la que más equivocaciones causa es la tercera. Descompilar sirve para responder preguntas sobre qué genera el compilador: cuántos objetos se crean, qué firma tiene un método, dónde aparece una comprobación de nulidad, si una clase envolvente sobrevive. No sirve para responder preguntas sobre rendimiento real, porque entre el bytecode y el procesador hay un compilador de tiempo de ejecución que insertará llamadas, eliminará asignaciones y reordenará todo lo que pueda. Un bucle que en el bytecode parece caro puede ser gratis en producción, y ninguna descompilación te lo va a decir.
La cuarta merece explicación aparte. Cada clase producida por Kotlin lleva una anotación de metadatos con información que el bytecode no puede expresar: nulabilidad de cada tipo, si algo es una propiedad o un método, los parámetros de tipo reales, los valores por defecto, la visibilidad internal. Ahí es donde miran la reflexión de Kotlin, el propio compilador cuando consume una biblioteca ya compilada y las herramientas que generan código. Cuando alguien dice que una biblioteca «se ve mal desde Kotlin», casi siempre está diciendo que esos metadatos faltan o están desactualizados.
Todo lo que sea estructura —clases, firmas, campos, número de objetos creados, dónde aparece una comprobación— es fiable. Todo lo que sea forma —nombres locales, estilo de los bucles, orden de las sentencias— es invención del descompilador. Si tu conclusión depende de la forma, vuelve a javap y mira el bytecode.
Una última advertencia sobre lo que no vas a ver. Los metadatos y las firmas cuentan lo que el compilador de Kotlin produjo, no lo que llega al dispositivo. Entre medias suele haber un reductor y ofuscador que elimina lo no usado, renombra clases y funde métodos, de modo que la clase que aquí ves generada puede no existir en la versión publicada. Si tu pregunta es sobre tamaño final, la herramienta correcta no es javap sobre tus clases sino un análisis del artefacto ya procesado.
Desmontar una característica de principio a fin
El valor de estas herramientas se ve cuando dejas de usarlas para curiosear y empiezas a usarlas como método. El procedimiento es siempre el mismo: escribe el caso mínimo, escribe también su variante de control, compila los dos y compara. La comparación es lo que enseña; una sola inspección aislada casi nunca dice nada.
// Caso y control para entender que hace exactamente inline.
fun normal(f: () -> Int): Int = f()
inline fun insertada(f: () -> Int): Int = f()
fun usar(): Int = normal { 1 } + insertada { 2 }
Al inspeccionar el resultado aparecen dos historias distintas. La versión no insertada genera una clase adicional que implementa la interfaz de función y una llamada virtual a su método de invocación. La versión insertada no genera clase alguna: el cuerpo de la lambda aparece copiado dentro de usar, y el método insertada sigue existiendo solo para quien la llame desde Java. Con eso a la vista, tres cosas que antes eran reglas memorizadas se vuelven consecuencias evidentes: por qué inline evita asignaciones, por qué el return no local es posible únicamente en la versión insertada, y por qué marcar como inline una función con un cuerpo grande y sin lambdas es contraproducente.
El mismo método aplicado a la nulabilidad revela las comprobaciones que el compilador inserta en los parámetros públicos, y explica por qué la frontera con Java es donde aparecen los fallos de nulidad y no en el interior del código Kotlin. Aplicado a una función suspend, muestra el parámetro extra de continuación y el when gigante sobre una etiqueta de estado que es, literalmente, la máquina de estados de la que hablaba la lección tercera.
El caso de la value class es el más didáctico de todos porque su resultado depende del contexto, y esa dependencia es imposible de recordar como regla y trivial de comprobar como observación.
@JvmInline
value class Id(val v: Long)
fun directo(id: Id): Long = id.v // parametro: se desempaqueta
fun nulable(id: Id?): Long? = id?.v // nulable: reaparece el boxing
fun enLista(ids: List<Id>): Int = ids.size // generico: tambien boxea
Al inspeccionar la clase generada aparecen los nombres desfigurados: el primer método recibe un long y lleva un sufijo con un resumen de la firma original, mientras que los otros dos reciben el tipo envolvente. Ese sufijo no es decorativo, existe porque dos funciones que en Kotlin tienen firmas distintas podrían colisionar en la JVM al desempaquetarse, y es también la razón de que llamar a esas funciones desde Java sea incómodo.
# Confirmar el desempaquetado y el nombre desfigurado.
javap -p out/EjemploKt.class | grep -i id
# El patron completo, reproducible en cualquier caso.
kotlinc Caso.kt -d out
javap -c -p out/CasoKt.class | head -60
Aplicado a una data class, el método revela algo que las reglas memorizadas no cuentan: solo las propiedades declaradas en el constructor primario entran en equals, hashCode y componentN. Una propiedad añadida en el cuerpo de la clase existe, se serializa si alguien la serializa, y no participa en la igualdad. Ver esa asimetría en la lista de métodos generados vacuna contra una clase entera de errores sutiles en colecciones y en cachés.
Hay dos hábitos que multiplican el rendimiento de todo esto. El primero es guardar los hallazgos: un fichero con casos mínimos y sus salidas es una referencia personal que envejece mucho mejor que cualquier resumen. El segundo es repetir la inspección al cambiar de versión del compilador, porque las bajadas evolucionan y lo que era cierto en una versión puede dejar de serlo; ese hábito es además la manera más directa de enterarte de una optimización nueva antes de que nadie escriba sobre ella.
Toda persona que programa opera con un modelo mental de lo que hace su lenguaje, y ese modelo se construye de una de dos maneras. La primera es por acumulación de reglas recibidas: inline evita asignaciones, las data class generan métodos, suspend no bloquea hilos. Es un modelo que funciona en el caso central y falla en cuanto aparece un caso raro, y falla además de la peor manera posible, en silencio, porque no incluye ningún mecanismo para detectar que es incorrecto. La persona que aprende así no puede distinguir entre una regla que sabe y una regla que cree recordar, y cuando dos reglas entran en conflicto no tiene forma de dirimirlas salvo buscar a alguien con más autoridad. La segunda manera es por deducción a partir de lo observado, y la inspección de la salida es lo que la hace posible, porque introduce en el proceso algo que ningún tutorial puede darte: falsabilidad. En el momento en que puedes escribir dos programas de cuatro líneas, compilarlos y comparar lo que produjeron, tus creencias sobre el lenguaje dejan de ser opiniones y pasan a ser hipótesis con un experimento asociado que cuesta treinta segundos. Y las hipótesis se corrigen solas: descubres que la regla que te habían contado tenía una condición que nadie mencionó, que la optimización que dabas por hecha depende de dónde aparezca el tipo, que la característica que creías azúcar sintáctico genera una clase por cada uso. Ese cambio de régimen tiene una consecuencia que va mucho más allá de Kotlin. Un modelo falsable no envejece, porque cuando el lenguaje cambia el modelo se revisa con el mismo experimento en vez de quedarse obsoleto; se transfiere, porque el método de escribir caso y control y comparar salidas funciona igual en cualquier lenguaje compilado que te encuentres después; y sobre todo se defiende, porque cuando alguien afirma en una revisión de código que algo es caro o barato, tú puedes contestar con una medida en vez de con una cita. Por eso esta lección cierra el nivel y no lo abre. Todo lo anterior —el porqué de la reescritura, las fases del frontend, el IR común, el demonio y el incremental— es la teoría que te permite interpretar lo que ves aquí. Pero mirar es lo que convierte esa teoría en algo tuyo, y es el único de los cinco hábitos de este nivel que seguirás usando dentro de veinte años, con este lenguaje o con el que venga después.
- Compila un fichero con una función de nivel superior y localiza la clase contenedora generada. Comprueba con
javapla firma exacta del método. - Escribe el par caso y control de
inlinede esta lección, inspecciona ambos y cuenta las clases generadas en cada uno. - Declara una
data classcon tres propiedades y enumera todos los métodos generados con su visibilidad. Añade después una propiedad en el cuerpo y comprueba qué cambia enequals. - Escribe una función
suspendcon dos puntos de suspensión, descompílala y localiza el parámetro de continuación y la etiqueta de estado. Contrástalo con el dibujo que hiciste en la lección tercera. - Coge una
value classy sigue su rastro en tres firmas distintas. Marca en cuál sobrevive el desempaquetado y en cuál reaparece el boxing, y explica por qué a partir del nombre desfigurado del método.