Llamar a Java desde Kotlin
La dirección contraria del puente: cómo funciona realmente la conversión SAM y por qué la identidad de una lambda no está garantizada, cómo se traducen los genéricos con comodines en ambos sentidos, y qué idiomas heredados de Java conviene envolver en lugar de tolerar dispersos por el código.
Las cuatro lecciones anteriores miraban hacia fuera: qué ve Java de tu Kotlin y cómo dirigir esa proyección. Esta invierte la dirección y es, con diferencia, la que más vas a usar, porque el ecosistema de la JVM sigue siendo mayoritariamente Java y cualquier proyecto real consume decenas de bibliotecas escritas en un lenguaje con otras convenciones, otras ideas sobre los errores y treinta años de idiomas acumulados. Kotlin hace un trabajo notable para que ese consumo se sienta natural, hasta el punto de que la mayor parte del tiempo olvidas que estás llamando a Java. El problema es que ese olvido tiene tres puntos ciegos concretos, y los tres producen fallos que no se parecen en nada a su causa. Conocerlos de antemano ahorra tardes enteras.
- Explicar el mecanismo real de la conversión SAM y sus consecuencias sobre la identidad de los objetos.
- Resolver las ambigüedades que aparecen cuando varias sobrecargas aceptan interfaces funcionales distintas.
- Traducir en ambas direcciones entre los comodines de Java y la varianza declarada de Kotlin.
- Diseñar capas de envoltura para los idiomas Java que conviven mal con el estilo del lenguaje.
La conversión SAM y su precio oculto
Cuando un método Java espera una interfaz con un único método abstracto, Kotlin acepta una lambda en su lugar y se encarga de la traducción. Es la comodidad más visible de la interoperabilidad y funciona sin que tengas que decir nada.
val hilo = Thread { println("trabajando") }
boton.addActionListener { evento -> manejar(evento) }
ejecutor.submit(Runnable { limpiar() }) // constructor SAM explicito
La forma explícita de la tercera línea se llama constructor SAM y no es decorativa: es la única salida cuando el destino es ambiguo. Si una clase Java ofrece dos sobrecargas del mismo método, una que acepta una interfaz de ejecución y otra que acepta una interfaz que devuelve valor, una lambda desnuda no permite decidir cuál querías y el compilador se detiene. Nombrar la interfaz resuelve la ambigüedad sin renunciar a la sintaxis breve.
El mecanismo por debajo cambió y conviene conocer el actual. Durante mucho tiempo cada conversión producía una clase anónima; hoy el compilador emite una instrucción de invocación dinámica y delega en la factoría de lambdas de la plataforma, que fabrica la implementación en tiempo de ejecución. El resultado es menos código en el jar y mejor comportamiento del compilador de tiempo real, pero trae consigo una consecuencia que muerde.
La factoría de la plataforma no garantiza que dos conversiones del mismo código produzcan la misma instancia. En la práctica eso significa que registrar un oyente con una lambda y después intentar darlo de baja pasando una lambda idéntica no elimina nada: son objetos distintos y el método de baja compara por identidad. La solución es siempre la misma, guardar la instancia en una variable y usar esa misma referencia para registrar y para dar de baja.
// Correcto: una sola instancia, registrada y retirada
private val oyente = ActionListener { manejar(it) }
fun conectar() = boton.addActionListener(oyente)
fun desconectar() = boton.removeActionListener(oyente)
La conversión también funciona hacia interfaces Kotlin, pero solo si las declaras con la palabra reservada que las marca como funcionales.
fun interface Validador {
fun valida(entrada: String): Boolean
}
fun registrar(v: Validador) { }
registrar { it.isNotBlank() } // solo compila por la marca fun
El compilador no aplica la conversión a cualquier interfaz de un solo método escrita en Kotlin; el compilador no aplica la conversión a cualquier interfaz de un solo método escrita en Kotlin, porque en este lenguaje la elección entre un tipo función y una interfaz nominal es deliberada y tiene consecuencias de diseño.
Genéricos, comodines y proyecciones
El segundo punto ciego es el sistema de genéricos, donde los dos lenguajes resuelven el mismo problema con herramientas opuestas. Java no tiene varianza en la declaración, así que la expresa en cada uso con comodines; Kotlin la declara una vez en el tipo y deriva el resto. Traducir entre ambos mundos es mecánico pero no evidente.
flowchart LR A[Comodin de extension en Java] --> B[Proyeccion de salida en Kotlin] C[Comodin de superclase en Java] --> D[Proyeccion de entrada en Kotlin] E[Comodin sin limite en Java] --> F[Proyeccion estrella en Kotlin] G[Tipo crudo sin parametros] --> F H[Varianza declarada en Kotlin] --> I[Comodines generados en la firma JVM] I --> J[JvmSuppressWildcards para desactivarlos] I --> K[JvmWildcard para forzarlos] style J fill:#f9e2af,color:#11111b
En la dirección de lectura, un parámetro Java declarado con comodín de extensión llega a Kotlin como una proyección de salida, y uno con comodín de superclase como una proyección de entrada. Los tipos crudos, que son la herencia de la era anterior a los genéricos, se presentan como proyecciones estrella: puedes leer de ellos con el tipo raíz y no puedes escribir nada que el compilador no pueda verificar.
En la dirección de escritura ocurre algo que sorprende a mucha gente. Cuando Kotlin genera la firma JVM de una función que recibe un tipo con varianza declarada, añade comodines automáticamente, porque de lo contrario un consumidor Java no podría pasarle una lista de un subtipo. Ese automatismo suele ser lo que quieres, pero a veces produce firmas más laxas de lo deseable y existen dos anotaciones para dirigirlo, una que suprime los comodines generados y otra que los fuerza donde no aparecerían solos. Se aplican a un tipo concreto o a una declaración entera, y son de esas herramientas que no necesitas nunca hasta el día en que una firma generada te bloquea.
// Sin la anotacion, la firma JVM usaria un comodin y Java tendria
// que trabajar con una lista de comodin en lugar de una lista concreta
fun consumir(items: List<@JvmSuppressWildcards Numero>) { }
Hay un tercer matiz que aparece al leer código Java antiguo. Los tipos crudos no solo pierden la comprobación de argumentos: arrastran consigo el borrado del resto de la firma, de modo que un método de una clase cruda devuelve el tipo raíz aunque su declaración genérica prometiera otra cosa. Cuando te encuentres uno, la salida limpia es acotarlo en la envoltura y no dejar que la proyección estrella se propague.
// Java: metodo declarado con List sin parametros de tipo
val crudo: List<*> = api.obtenerLegado()
val seguro: List<String> = crudo.filterIsInstance<String>()
Queda un detalle que conviene tener presente: el borrado de tipos es el mismo en ambos lenguajes, con las mismas consecuencias. Ninguna información de argumento de tipo sobrevive a la ejecución salvo la que alguien haya guardado a mano, y las funciones que en Kotlin parecen conocer su parámetro de tipo lo consiguen porque el compilador las expande en el punto de llamada, no porque la JVM haya aprendido nada nuevo.
Los idiomas que conviene envolver
El tercer punto ciego no es técnico sino de estilo, y es el que más deteriora una base de código a largo plazo. Java tiene idiomas propios, perfectamente razonables en su contexto, que al esparcirse por código Kotlin producen un híbrido incómodo que no es bueno en ninguno de los dos lenguajes. La estrategia correcta es siempre la misma: una capa fina de envoltura donde el idioma ajeno entra y de la que sale algo idiomático.
Excepciones comprobadas
Kotlin no las tiene, así que una llamada Java que las declara no obliga a nada. Envolver traduciendo el fallo a un tipo de resultado explícito recupera la información que el compilador ya no exige.
Valores opcionales
La clase opcional de Java resuelve un problema que Kotlin resuelve en el sistema de tipos. Convertir a un tipo nulable en la frontera evita tener dos vocabularios de ausencia conviviendo.
Constructores encadenados
El patrón de construcción por pasos existe porque Java carece de argumentos con nombre. Envolverlo en una función con parámetros nombrados y valores por defecto elimina el andamiaje entero.
Palabras reservadas
Un método Java llamado como una palabra reservada de Kotlin se invoca delimitando su nombre con acentos graves. Funciona, pero conviene esconderlo tras una función con nombre normal.
// La capa de envoltura: el unico sitio que ve el idioma ajeno
fun leerConfig(ruta: String): Result<Config> = runCatching {
LectorJava().parse(ruta) // declara excepcion comprobada
}
fun buscarUsuario(id: Long): Usuario? =
repositorioJava.findById(id).orElse(null) // opcional a nulable
fun crearPeticion(url: String, timeoutMs: Long = 5_000): Peticion =
Peticion.Builder().url(url).timeout(timeoutMs).build()
Tres funciones de una línea eliminan tres idiomas de todo el resto del proyecto, y esa concentración tiene un valor que no es solo estético: cuando la biblioteca cambie de versión, cuando decidas sustituirla, o cuando aparezca una alternativa Kotlin nativa, el número de archivos que hay que tocar es uno. A eso se le suman los accesos habituales al mundo Java que Kotlin ofrece de serie: la propiedad que devuelve la clase de un objeto, los puentes entre las representaciones de clase de ambos lenguajes, la función de bloque sincronizado y la marca que declara excepciones hacia fuera. Todo eso está disponible, y la disciplina consiste en usarlo en la frontera y no en el interior.
Conviene aplicar el criterio con cabeza, porque el Java moderno se parece bastante más a Kotlin que el de hace una década. Los registros llegan como clases con propiedades, las jerarquías selladas se leen con exhaustividad, y las bibliotecas recientes suelen devolver colecciones inmutables de verdad y evitar los nulos. Envolver una API bien diseñada solo añade una capa que mantener. La envoltura se justifica cuando hay un idioma que contamina, no cuando el origen simplemente está escrito en otro lenguaje.
Merece una nota final el objetivo de compilación, porque condiciona qué parte de todo esto tienes disponible. Kotlin 2.4 amplió el rango soportado hasta Java 26, de modo que una base de código Kotlin puede consumir hoy lo último de la plataforma sin esperar a que el lenguaje incorpore un equivalente propio. El criterio para elegir el objetivo ya no es lo que el compilador aguanta, sino lo que tu despliegue garantiza.
Merece la pena cerrar el nivel entendiendo por qué Kotlin invirtió tanto esfuerzo en todo esto, porque la respuesta explica algo mucho más general sobre cómo sobreviven las tecnologías. Casi todos los lenguajes nuevos mueren, y no mueren por ser peores: mueren porque exigen empezar de cero. Un lenguaje que no puede consumir el trabajo acumulado de su plataforma le pide a cada equipo que reescriba treinta años de bibliotecas antes de poder resolver el problema por el que le pagan, y ese precio no lo paga nadie. Kotlin hizo la apuesta contraria y la hizo con una radicalidad que se nota en cada decisión que has estudiado en este nivel: reutilizó los tipos de la plataforma en lugar de envolverlos, aceptó tipos de plataforma en la frontera de nulabilidad en lugar de exigir anotaciones, tradujo lambdas a interfaces funcionales sin ceremonia, y llegó a inventar anotaciones cuyo único propósito es que su sombra en Java resulte cómoda. Cada una de esas decisiones costó pureza conceptual, y todas juntas son la razón de que exista un ecosistema en el que puedes migrar un archivo cada vez, sin ninguna reescritura, sin un día de parada, y con las dos versiones conviviendo indefinidamente en el mismo módulo. La lección que llevarte no es sobre lenguajes sino sobre cualquier cosa que construyas para sustituir a algo que ya funciona, y verás el mismo patrón en marcos de trabajo, en protocolos, en formatos de datos y en cada migración de arquitectura que te toque dirigir: la adopción no se gana siendo mejor, se gana haciendo que el coste de dar el primer paso sea casi cero y que sea posible retroceder. Un camino de migración incremental, con convivencia real y sin decisiones irreversibles, vale más que cualquier lista de ventajas técnicas, y esa es exactamente la razón por la que estás escribiendo Kotlin sobre una máquina que fue diseñada para otro lenguaje y sigue ejecutando el suyo sin enterarse.
- Registra un oyente con una lambda y trata de retirarlo con otra lambda idéntica; comprueba que no funciona y corrígelo guardando la referencia.
- Provoca una ambigüedad llamando a un método Java sobrecargado con dos interfaces funcionales y resuélvela con el constructor SAM correspondiente.
- Llama a un método Java que reciba un parámetro con comodín de extensión y observa qué proyección aparece en el editor.
- Escribe una función Kotlin que reciba una lista con varianza declarada, inspecciona la firma generada y después suprime los comodines con la anotación adecuada.
- Elige una biblioteca Java que uses de verdad y escribe su capa de envoltura completa, traduciendo excepciones comprobadas, opcionales y constructores encadenados.