Funciones de extensión: qué son de verdad
Una extensión no añade nada a la clase: el compilador genera una función estática que recibe el receptor como primer parámetro. Cómo se ve desde Java, por qué no ve lo privado y las cuatro cosas que no puede hacer jamás.
La primera vez que escribes fun String.esPalindromo(): Boolean da la sensación de haber abierto una clase sellada de la biblioteca estándar de Java para meterle un método dentro. No ha ocurrido nada de eso. La clase java.lang.String sigue siendo el mismo archivo .class que era antes de que tu proyecto existiera: los mismos miembros, la misma tabla de métodos virtuales, el mismo tamaño de instancia. Lo único que ha pasado es que el compilador de Kotlin ha generado una función estática en otra clase distinta y ha aprendido a traducir la sintaxis de punto para llamarla. Toda la potencia de las extensiones y absolutamente todas sus limitaciones salen de ese hecho único, y entenderlo con precisión es lo que separa usarlas con criterio de sorprenderse cada vez que no hacen lo que parecían prometer.
- Describir la traducción exacta de una función de extensión a una función estática con receptor explícito.
- Escribir y leer la llamada equivalente desde Java, incluido el nombre de la clase contenedora que genera el compilador.
- Justificar por qué una extensión no puede acceder a los miembros privados del receptor.
- Enumerar lo que una extensión no puede hacer y reconducir todos esos límites a una única causa.
Lo que escribes y lo que se genera
Una función de extensión declara un tipo receptor delante del nombre. Dentro del cuerpo, this es ese receptor y puede omitirse igual que dentro de un miembro.
// archivo: Texto.kt
package dominio.texto
fun String.esPalindromo(): Boolean {
val limpio = lowercase().filter { it.isLetterOrDigit() }
return limpio == limpio.reversed()
}
fun main() {
println("Anita lava la tina".esPalindromo()) // true
}
El lowercase() sin cualificar del cuerpo es this.lowercase(). La ilusión es completa: se escribe como un método y se autocompleta como un método. Pero el compilador emite algo mucho más humilde, cuya traducción aproximada a Kotlin sería esta:
object TextoKt {
@JvmStatic
fun esPalindromo(receptor: String): Boolean {
val limpio = receptor.lowercase().filter { it.isLetterOrDigit() }
return limpio == limpio.reversed()
}
}
Tres consecuencias inmediatas, y las tres importan. La primera: el receptor es un parámetro más, el parámetro cero. La segunda: la función vive en una clase sintética llamada como el archivo con el sufijo Kt, no en String. La tercera: la llamada "texto".esPalindromo() no es una invocación virtual sino una invocación estática que el compilador resuelve mirando el tipo escrito en el código, no el objeto que haya en ejecución.
flowchart LR src[fun String.esPalindromo] --> comp[compilador de Kotlin] comp --> cls[clase sintetica TextoKt] cls --> met[metodo estatico esPalindromo] met --> p0[parametro 0 igual al receptor] met --> p1[resto de parametros declarados]
Vista desde Java: aquí se cae el disfraz
Java no conoce las extensiones, así que ve exactamente lo que hay. Y lo que hay es una llamada estática de manual.
import dominio.texto.TextoKt;
public class Main {
public static void main(String[] args) {
boolean r = TextoKt.esPalindromo("Anita lava la tina");
}
}
Ese TextoKt es un nombre generado y, por tanto, parte de tu superficie pública si el proyecto es mixto. Se controla con una anotación de archivo, que debe ir antes de la declaración de paquete:
@file:JvmName("Textos")
package dominio.texto
fun String.esPalindromo(): Boolean = TODO()
Con eso la llamada desde Java pasa a ser Textos.esPalindromo(...). Si varios archivos aportan extensiones al mismo tipo, @JvmMultifileClass permite fundirlos en una sola clase de fachada, que es justo lo que hace la biblioteca estándar para que todo kotlin.text aparezca desde Java bajo StringsKt.
@file:JvmName("Textos")
@file:JvmMultifileClass
package dominio.texto
Merece la pena detenerse en la implicación. Ese nombre generado forma parte de tu compatibilidad binaria desde el primer día en que alguien compile contra él. Cambiar el archivo de sitio, renombrarlo o partirlo en dos altera la clase contenedora y rompe a los clientes de Java aunque ninguna firma de Kotlin haya cambiado. En una librería, por tanto, la organización de los archivos deja de ser una cuestión de gusto y pasa a ser parte del contrato.
El compilador marca el parámetro receptor con @NotNull o @Nullable según el tipo declarado. Java no lo obliga a nada en ejecución, pero además Kotlin inserta una comprobación de nulidad al principio del método para receptores no nulables. Por eso una extensión sobre String invocada desde Java con null no falla con un NullPointerException opaco a mitad del cuerpo, sino de inmediato y con el nombre del parámetro en el mensaje.
Lo que una extensión no puede hacer
Todos los límites siguientes son el mismo límite: una función estática externa no tiene privilegios sobre la clase.
No ve lo privado
Una extensión solo alcanza la API pública, y la internal si comparte módulo. No puede tocar campos private ni miembros protected, porque no es miembro de nada. Se declara fuera y se compila fuera.
No añade estado
No hay campo de respaldo posible: la instancia ya está construida y su tamaño es fijo. Una propiedad de extensión solo puede ser un accesor calculado, nunca almacenamiento nuevo.
No entra en la tabla virtual
No se puede declarar open, ni marcar override, ni ser sobrescrita por una subclase. No participa en el polimorfismo del receptor porque nunca llegó a formar parte de su tipo.
No satisface un contrato
Una extensión no hace que un tipo ajeno conforme a tu interfaz. Ni la reflexión de la JVM la ve como miembro: String::class.java.methods no la lista, porque objetivamente no está ahí.
La primera limitación es la que más sorprende y la más sana. Aunque el archivo esté en el mismo paquete y hasta en el mismo módulo que la clase, la extensión queda sujeta a las reglas de visibilidad como cualquier código cliente. Escribir una extensión no es una vía trasera para saltarse la encapsulación de nadie: es una forma de dar nombre a una composición de operaciones que ya podías hacer desde fuera.
class Cuenta(private val saldoCentimos: Long) {
val saldo: Long get() = saldoCentimos
}
// Error: cannot access saldoCentimos, it is private in Cuenta
fun Cuenta.enEuros(): Double = saldoCentimos / 100.0
// Correcto: solo la API publica
fun Cuenta.enEurosOk(): Double = saldo / 100.0
La segunda limitación tiene una consecuencia que sorprende menos y cuesta más: las extensiones son invisibles para todo lo que trabaje por reflexión. Un serializador, un inyector de dependencias o un motor de plantillas que recorra los métodos declarados de la clase no encontrará ni rastro de ellas, porque objetivamente no forman parte del tipo. La reflexión de Kotlin sí puede alcanzarlas, pero solo a través de la clase contenedora del archivo y como funciones con un parámetro extra, que es lo que realmente son.
Antes de escribir una extensión, pregúntate si podrías escribir la misma lógica como una función normal fun esPalindromo(texto: String). Si la respuesta es no, la extensión tampoco va a funcionar: no te da ni un permiso más. Si la respuesta es sí, la extensión solo está cambiando el orden de las palabras en el punto de llamada, que resulta ser un cambio con enorme valor de legibilidad y ninguno de potencia.
Por qué esta forma y no otra
Hay dos maneras radicalmente distintas de permitir que un programador añada operaciones a un tipo que no controla, y Kotlin eligió la barata a propósito. La primera es la del monkey patching de Ruby, Python o JavaScript: modificar la clase real en tiempo de ejecución, de modo que el método nuevo pasa a existir para todo el proceso, entra en el despacho dinámico y lo ven todas las librerías cargadas. Esa vía es enormemente expresiva y enormemente destructiva, porque convierte cualquier dependencia en un colaborador no declarado del comportamiento de tu programa: dos librerías que parchean String de forma incompatible producen un fallo que no aparece en ningún fichero, sino en el orden de carga. La segunda es la de las clases de tipos de Haskell o los trait de Rust implementados para tipos ajenos: el compilador guarda un diccionario de implementaciones que se pasa como argumento invisible, y esa sí es una extensión real del sistema de tipos, con despacho basado en la instancia elegida y con la regla del huérfano para que dos módulos no puedan aportar implementaciones contradictorias. Kotlin no hizo ninguna de las dos. Hizo la tercera, que suena decepcionante hasta que se ve lo que compra: una función de extensión no es más que azúcar sobre la sintaxis de llamada, y su resolución ocurre entera en compilación, en el ámbito léxico donde escribiste el código, según lo que hayas importado. Eso significa que ninguna extensión tuya puede alterar el comportamiento del código de otro, ni siquiera del código que manipula los mismos objetos, porque quien no importó tu función simplemente no la ve. Significa que la biblioteca estándar puede regalarte más de mil operaciones sobre List, String y Sequence sin engordar ni una sola clase de Java ni cargar nada al arrancar. Significa que la interoperabilidad es exacta en ambos sentidos: lo que Kotlin ve como método, Java lo ve como estático, y no hace falta ningún adaptador. Y significa, en el otro plato de la balanza, que renuncias al despacho dinámico sobre el receptor, a la implementación retroactiva de interfaces y a cualquier forma de estado nuevo. El precio es real, y las tres lecciones siguientes de este nivel tratan exactamente de las facturas que llegan por él. Pero el intercambio está bien planteado: Kotlin te deja escribir como si el tipo fuera tuyo, sin dejarte nunca creer que lo es.
Una función de extensión se compila como una función estática en una clase sintética, con el receptor convertido en el primer parámetro. No modifica la clase receptora, no ve sus miembros privados, no añade estado, no se sobrescribe y no la ve la reflexión de la JVM. Todo lo que puede hacer una extensión lo podía hacer ya una función libre; lo que aporta es la forma de la llamada.
- Escribe
fun String.esPalindromo(): Booleanen un archivoTexto.kty compila el módulo. - Abre el bytecode con la herramienta de tu IDE o con
javap -psobre la clase generada, y localiza la firma real: nombre de la clase, modificadores y tipo del parámetro cero. - Llama a la función desde un archivo Java del mismo módulo. Anota el nombre exacto que has tenido que escribir.
- Añade
@file:JvmName("Textos")y repite el paso anterior. Explica qué se rompió y qué significa eso para la compatibilidad binaria de una librería. - Declara una clase con un campo
privatee intenta escribir una extensión que lo lea. Copia el error del compilador y reformúlalo con la palabraestáticadentro.