Las anotaciones de interoperabilidad
Cinco anotaciones que no cambian nada en Kotlin y lo cambian todo en Java. Qué problema concreto resuelve cada una, qué código genera, qué restricciones impone y por qué la decisión de anotar o no es en realidad una decisión de diseño sobre quién es el cliente de tu API.
La lección anterior terminó con un incordio: el código Kotlin idiomático, al proyectarse sobre la JVM, produce una API que un programador Java encuentra torpe. Los miembros del acompañante exigen un intermediario, los argumentos por defecto desaparecen, las propiedades se convierten en pares de métodos y la clase que contiene tus funciones de nivel superior se llama como el archivo más un sufijo. Nada de eso es un error: es la consecuencia inevitable de tener abstracciones que la plataforma no tiene. Pero Kotlin no se conforma con explicar la traducción, sino que te da un mando para dirigirla, y ese mando es un puñado de anotaciones cuya característica común es que no alteran ni una coma del significado de tu programa en Kotlin. Solo cambian la forma de la sombra que proyecta.
- Asociar cada anotación de interoperabilidad al problema de traducción concreto que resuelve.
- Anticipar el código generado y las restricciones que cada anotación impone sobre la declaración.
- Distinguir cuándo anotar y cuándo es preferible una fachada explícita escrita a mano.
- Razonar el impacto de estas decisiones sobre la compatibilidad binaria de una biblioteca publicada.
Las cinco herramientas
Cada anotación existe porque hay un desajuste concreto entre los dos modelos, y la forma más rápida de recordarlas es memorizar el desajuste, no la anotación.
`@JvmStatic`
Los miembros de un companion object o de un object viven en el objeto, no en la clase. Esta anotación emite además un método verdaderamente estático en la clase contenedora.
`@JvmName`
Cambia el nombre binario de una función, de una clase de fichero o de un accesor. Resuelve colisiones de firma tras el borrado y da nombres naturales a la vista Java.
`@JvmOverloads`
Los argumentos por defecto no existen en la JVM. Genera la escalera completa de sobrecargas, de la que solo lleva los obligatorios a la que los lleva todos.
`@JvmField`
Suprime los accesores y expone el campo directamente, con la visibilidad de la propiedad. Elimina el ruido de getX y setX en la vista Java.
La quinta es @file:JvmName, que se aplica al archivo entero y debe escribirse antes de la declaración de paquete. Su función es liberarte de la convención automática: en lugar de heredar el nombre del archivo con el sufijo, tú decides cómo se llamará la clase que aloja tus funciones de nivel superior.
@file:JvmName("Textos")
@file:JvmMultifileClass
package app.texto
fun normalizar(s: String): String = s.trim().lowercase()
Desde Java eso ya no es UtilesKt.normalizar sino Textos.normalizar, que es exactamente lo que un consumidor esperaría de una clase de utilidades. La segunda anotación es su complemento natural y permite que varios archivos distintos contribuyan a la misma clase fachada, de modo que puedes organizar el código Kotlin en los archivos que tengan sentido para ti sin que esa organización se filtre a la API pública.
Sin @file:JvmName, renombrar Utiles.kt a Texto.kt es un cambio incompatible a nivel binario para todo consumidor Java, aunque no toques ni una línea de código. En una biblioteca publicada, fijar el nombre de la clase fachada de forma explícita no es un lujo estético: es desacoplar tu organización interna de tu contrato externo.
De la anotación al bytecode
Conviene ver el efecto exacto, porque cada una tiene matices que solo se aprenden mirando lo que sale.
class Cliente private constructor(val host: String) {
companion object {
@JvmStatic
fun local(): Cliente = Cliente("localhost")
const val PUERTO_POR_DEFECTO = 8080
}
@JvmOverloads
fun peticion(ruta: String, metodo: String = "GET", reintentos: Int = 3) { }
}
Con @JvmStatic, el compilador no sustituye nada: emite el método estático en Cliente y conserva el método de instancia en Cliente$Companion, porque el código Kotlin ya compilado contra la forma antigua tiene que seguir funcionando. Añadirla es compatible hacia atrás; quitarla no lo es. Con @JvmOverloads obtienes tres métodos peticion en lugar de uno, cada uno delegando en el siguiente, lo que le da al consumidor Java la ilusión completa de los valores por defecto a costa de multiplicar la superficie binaria.
La lógica de @JvmName es distinta y más sutil, porque su motivo principal no es la estética sino una imposibilidad del sistema de tipos de la JVM.
@JvmName("filtrarTextos")
fun List<String>.filtrar(): List<String> = filter { it.isNotBlank() }
@JvmName("filtrarNumeros")
fun List<Int>.filtrar(): List<Int> = filter { it != 0 }
Sin las anotaciones eso ni siquiera compila. Tras el borrado de tipos ambas funciones tienen exactamente la misma firma JVM, el compilador detecta el choque de declaraciones de plataforma y se niega a continuar. La anotación es aquí la única salida disponible, y el mismo mecanismo se aplica con los prefijos de accesor mediante las formas dirigidas a objetivo, que permiten forzar el nombre del getter o del setter cuando la convención automática produce algo incómodo.
flowchart LR P[Problema de traduccion] --> S1[Miembro del acompanante no es estatico] P --> S2[Firmas identicas tras el borrado] P --> S3[Argumentos por defecto invisibles] P --> S4[Accesores donde bastaba un campo] P --> S5[Nombre de clase derivado del archivo] S1 --> A1[JvmStatic] S2 --> A2[JvmName] S3 --> A3[JvmOverloads] S4 --> A4[JvmField] S5 --> A5[file JvmName] style A2 fill:#f9e2af,color:#11111b
Hay una variante dirigida al objetivo que se usa poco y resuelve mucho. Cuando el nombre automático del accesor choca con otro miembro, o cuando la convención de la plataforma exige un prefijo distinto del que Kotlin deduciría, se puede renombrar el getter o el setter por separado sin tocar el nombre de la propiedad en Kotlin.
class Documento(contenido: String) {
@get:JvmName("getRawContent")
val contenido: String = contenido
}
@JvmField es la más restrictiva de todas y sus condiciones se derivan directamente de lo que significa exponer un campo. La propiedad no puede ser privada, ni open, ni override, ni tener accesores personalizados, ni estar delegada, porque en todos esos casos existe lógica que ejecutar al leer o escribir y un campo desnudo la eliminaría. Su pariente cercano es const, que va más allá: solo admite tipos primitivos y cadenas conocidas en compilación, genera un campo static final y además propaga el valor a los puntos de uso, lo que significa que cambiar una constante obliga a recompilar a todos sus consumidores.
@Throws merece una mención aparte porque resuelve el mismo tipo de desajuste desde otro ángulo. Kotlin no tiene excepciones comprobadas, así que una función que lanza una excepción de entrada y salida no lo declara, y un consumidor Java no puede capturarla con la sintaxis habitual sin que el compilador proteste. Anotar la firma restaura la cláusula en el bytecode. Es imprescindible en cualquier API pensada para ser consumida desde Java.
Anotar o construir una fachada
Llegados aquí aparece la pregunta de diseño real, que no es cuál anotación usar sino si usar anotaciones en absoluto. Salpicar una base de código con marcas de interoperabilidad tiene un coste que no es solo visual: cada anotación amplía la superficie binaria comprometida, y en el caso de la escalera de sobrecargas la multiplica.
El criterio que funciona en la práctica es preguntarse quién es el cliente. Si la biblioteca se consume mayoritariamente desde Kotlin y solo ocasionalmente desde Java, anotar el núcleo entero es pagar un impuesto permanente por un uso marginal; conviene entonces escribir un módulo fachada, pequeño y explícito, donde la ergonomía Java se diseña a mano en lugar de derivarse por accidente.
// Modulo de fachada, escrito para ser leido desde Java
@file:JvmName("ClienteJava")
package app.http.java
object ClienteFactory {
@JvmStatic
@JvmOverloads
fun crear(host: String, timeoutMs: Long = 5_000): Cliente = Cliente(host, timeoutMs)
}
Ese archivo puede evolucionar bajo sus propias reglas de compatibilidad mientras el núcleo Kotlin cambia con libertad, y tiene la virtud de convertir una decisión difusa en un artefacto revisable. Si en cambio la biblioteca es un componente de infraestructura que consumirán equipos Java como primer ciudadano, la conclusión se invierte y las anotaciones dejan de ser un adorno para convertirse en parte del contrato, con la misma disciplina de revisión que cualquier firma pública.
Detrás de estas cinco anotaciones hay una idea que va a reaparecer en cada sistema que construyas, independientemente del lenguaje. Cuando publicas código, dejas de tener una sola representación de tu diseño y pasas a tener dos: la que tú escribes y la que otros ven. Casi todos los programadores viven años sin darse cuenta de esa duplicidad, porque en un proyecto cerrado ambas coinciden, y descubren que existían separadas el día en que un cambio inofensivo en la organización interna rompe a un consumidor. Kotlin hace algo insólitamente honesto al respecto: en lugar de esconder la proyección, la expone y te entrega el control sobre ella, y al hacerlo te obliga a admitir que estás tomando decisiones de contrato aunque creas estar tomando decisiones de estilo. El nombre de un archivo se convierte en un símbolo enlazable. Un valor por defecto se convierte en una escalera de métodos que no puedes retirar. Una constante se copia dentro del binario de quien la usa. Esta es la razón de fondo por la que el diseño de bibliotecas es una disciplina distinta del diseño de aplicaciones, y por la que se aprende tarde y a golpes: en una aplicación puedes refactorizar el mundo entero porque el mundo entero está en tu repositorio, mientras que en una biblioteca cada símbolo público es una decisión que otro ya tomó por ti al escribir código contra ella. El buen diseño de API consiste precisamente en decidir de forma deliberada y minimalista qué parte de tu estructura interna se convierte en promesa, y en tener la disciplina de que todo lo demás siga siendo tuyo para cambiarlo mañana.
- Crea una clase con
companion objecty una función de factoría, e invócala desde Java sin@JvmStatic; después añádela y compara ambas llamadas. - Escribe dos extensiones con el mismo nombre sobre listas de tipos distintos, observa el error de choque de declaraciones y resuélvelo con
@JvmName. - Aplica
@JvmOverloadsa una función con tres argumentos por defecto y cuenta conjavapcuántos métodos aparecen en el.class. - Intenta anotar con
@JvmFielduna propiedad con getter personalizado y explica con precisión por qué el compilador lo rechaza. - Escribe un módulo fachada para una API Kotlin existente y argumenta en tres frases por qué esa opción es preferible a anotar el núcleo.