Resolución estática: el tipo escrito manda
Una extensión se elige en compilación por el tipo declarado, no por el objeto real. El caso clásico de la extensión sobre el supertipo, la regla de que el miembro siempre gana y por qué eso convierte cualquier extensión en un compromiso frágil frente a una librería que evoluciona.
Si una función de extensión se compila como una función estática cuyo primer parámetro es el receptor, entonces la pregunta interesante deja de ser qué hace y pasa a ser cuál se llama. En un método normal la respuesta la da el objeto: la máquina virtual consulta la tabla de métodos de la clase real y ejecuta la implementación que corresponde, sin importar cómo hayas anotado la variable. En una extensión la respuesta la da el compilador antes de que exista ningún objeto, mirando únicamente el tipo que escribiste. Son dos mundos con reglas incompatibles conviviendo bajo la misma sintaxis de punto, y el precio de confundirlos es un programa que ejecuta código correcto sobre el objeto equivocado sin emitir ni un aviso.
- Explicar por qué la resolución de una extensión ocurre en compilación y qué información usa exactamente.
- Predecir el resultado del caso clásico de dos extensiones sobre un supertipo y un subtipo.
- Aplicar la regla de precedencia entre un miembro y una extensión homónima, incluida la sobrecarga.
- Evaluar el riesgo que una extensión asume frente a una librería que puede añadir miembros nuevos.
El tipo declarado decide, y decide antes de ejecutar
El caso canónico cabe en diez líneas y conviene sabérselo de memoria, porque es el que aparece disfrazado en producción.
open class Forma
class Circulo : Forma()
fun Forma.nombre() = "forma"
fun Circulo.nombre() = "circulo"
fun main() {
val f: Forma = Circulo()
println(f.nombre()) // forma
}
El objeto es un Circulo. La salida es forma. No hay ningún misterio en cuanto se recuerda la traducción del nivel anterior: la llamada se compiló como nombre(f), y en ese punto lo único que el compilador sabe de f es que está declarada como Forma. La sobrecarga se resolvió por el tipo estático del argumento, exactamente igual que se resolvería la elección entre dos funciones libres nombre(f: Forma) y nombre(c: Circulo) de Java. La máquina virtual nunca llega a participar en la decisión: cuando el programa arranca, la elección ya está grabada en el bytecode.
flowchart TB
llamada[expresion punto nombre] --> q{es un miembro del tipo}
q -->|si| miembro[despacho virtual por la clase real en ejecucion]
q -->|no| ext[busqueda de extensiones importadas en el ambito]
ext --> sel[se elige por el tipo estatico del receptor]
sel --> fija[la eleccion queda fijada en compilacion]Nada de esto es un fallo del lenguaje: es lo mismo que ocurre con la sobrecarga de funciones en cualquier lenguaje estático. El problema es que la sintaxis de punto sugiere despacho dinámico, y la intuición de quien lee el código viene entrenada por los métodos.
La contraprueba lo deja claro. Basta con quitar la anotación de tipo para que la salida cambie sin haber tocado ni el objeto ni las extensiones:
val a: Forma = Circulo()
val b = Circulo() // tipo inferido: Circulo
println(a.nombre()) // forma
println(b.nombre()) // circulo
Dos variables que apuntan a instancias equivalentes producen resultados distintos porque el compilador dedujo tipos estáticos distintos. Es exactamente el mismo objeto para la máquina virtual y dos llamadas diferentes en el bytecode.
El fallo casi nunca aparece con una variable local anotada a mano. Aparece cuando el valor viaja: dentro de un List<Forma>, como parámetro de una función que acepta el supertipo, como retorno de una fábrica declarada con la interfaz, o al recorrer una jerarquía sealed en un when cuyo sujeto se declaró con el tipo base. En todos esos sitios el tipo estático es el general y la extensión especializada nunca se ejecuta, aunque el objeto sea exactamente el que la motivó.
El miembro siempre gana
La segunda regla es más simple de enunciar y más peligrosa a largo plazo: si el tipo tiene un miembro con la misma firma, el miembro se llama y la extensión queda muerta.
class Caja {
fun imprimir() = println("miembro")
}
fun Caja.imprimir() = println("extension")
fun main() {
Caja().imprimir() // miembro
}
Kotlin avisa aquí con la advertencia de que la extensión está siendo ocultada por un miembro, pero es una advertencia y no un error, y sobre todo solo se emite cuando ambos son visibles en la misma compilación. La precedencia no admite excepciones ni se puede invertir: no existe forma de forzar la llamada a la extensión con la sintaxis de punto, y para invocarla hay que escribirla como lo que realmente es, una función con un argumento.
import dominio.caja.imprimir as imprimirExt
fun main() {
imprimirExt(Caja()) // extension, llamada como funcion
}
Ahora bien, la regla habla de firmas, no de nombres. Una extensión con parámetros distintos no queda oculta: entra en el juego normal de la sobrecarga junto a los miembros, con la particularidad de que los miembros se consideran primero y solo si ninguno es aplicable se buscan las extensiones.
class Caja {
fun imprimir() = println("miembro sin argumentos")
}
fun Caja.imprimir(prefijo: String) = println("$prefijo extension")
fun main() {
Caja().imprimir() // miembro sin argumentos
Caja().imprimir("hola") // hola extension
}
Si dos paquetes distintos declaran la misma extensión y ambos entran en el ámbito con importaciones de asterisco, la llamada se vuelve ambigua y el compilador la rechaza. La salida no es renombrar el código ajeno sino importar explícitamente la que quieres, o usar un alias con import paquete.formatear as formatearComoTabla. Esto también es una consecuencia de la resolución léxica: las extensiones no compiten a nivel global, compiten dentro del archivo que las importa.
La factura la paga el futuro
Que el miembro gane significa que cualquier extensión que escribas sobre un tipo ajeno es un contrato que la otra parte puede romper sin saberlo. El día en que la librería publique una versión con un método de la misma firma, tu extensión dejará de ejecutarse en todas las llamadas del proyecto. Nada dejará de compilar: el código sigue siendo válido, la sintaxis es idéntica y el comportamiento cambia por debajo.
El escenario completo, en orden:
Hoy escribes la extensión
El tipo ajeno no tiene el método. Tu extensión se llama en cien sitios y funciona. La compilación es limpia y los tests pasan.
Mañana llega la versión nueva
La librería añade un miembro con esa firma, quizá con una semántica parecida pero no idéntica. La nota de la versión lo describe como una mejora compatible, y desde su punto de vista lo es.
Tu código se apaga sin ruido
Todas las llamadas pasan al miembro. No hay error, no hay aviso salvo el de ocultación si la extensión sigue visible, y el comportamiento del programa cambia en cien sitios a la vez.
Solo los tests lo ven
La única defensa práctica es tener pruebas sobre el comportamiento que la extensión aportaba, no sobre la extensión en sí. Si las pruebas la llaman con sintaxis de punto, también se apagarán con ella.
Lo que ocurre aquí es que Kotlin ha decidido dar la misma notación a dos mecanismos que pertenecen a familias semánticas distintas, y la incomodidad que produce esta lección es el eco de esa decisión. El despacho de un miembro es una operación del sistema de tipos en tiempo de ejecución: hay un objeto, ese objeto conoce su clase, su clase conoce sus métodos y la respuesta correcta depende de qué es la cosa. Es polimorfismo de subtipos en el sentido de Cardelli, y su virtud es precisamente que el llamador no necesita saber nada. La resolución de una extensión es una operación léxica en tiempo de compilación: hay un ámbito, ese ámbito contiene un conjunto de nombres importados, y la respuesta correcta depende de qué sabe el llamador y de cómo ha decidido nombrar al valor. Es polimorfismo ad hoc, la sobrecarga de toda la vida, con la resolución del argumento cero escrita a la izquierda del punto en lugar de dentro del paréntesis. Que ambas cosas se escriban x.f() no es un descuido: es lo que permite que la biblioteca estándar te ofrezca map, filter y first con la misma fluidez que un método real, y lo que hace que el código de Kotlin se lea como se lee. Pero el coste es una violación del principio de sustitución de Liskov de la variedad más traicionera, la silenciosa: el mismo objeto, exactamente el mismo, produce resultados distintos según la anotación de tipo por la que haya viajado, que es justo lo que una abstracción debía volver irrelevante. Y hay una segunda víctima, menos comentada: el principio abierto-cerrado también queda a medias. Una extensión te deja añadir operaciones sin tocar el tipo, que es la mitad buena, pero no te deja añadir comportamiento que las jerarquías puedan especializar, que era la mitad interesante. La conclusión práctica no es prohibirse las extensiones sino usarlas donde su semántica sea la verdadera. Una extensión es correcta cuando su resultado depende solo del contrato público del tipo declarado y sería idéntico para cualquier implementación concreta: cuando escribes un algoritmo, no una decisión. En cuanto sientas el impulso de escribir dos extensiones para dos subtipos, o de mirar la clase real con un is, lo que estás pidiendo es un método polimórfico, y el sitio de un método polimórfico es la interfaz. Si la interfaz es ajena y no puedes tocarla, la respuesta honesta tampoco es la extensión: es un envoltorio, un sealed propio o un patrón visitante, algo que ponga la decisión donde el sistema de tipos pueda comprobarla. Programar bien con extensiones es, en buena medida, aprender a reconocer cuándo lo que quieres no es una extensión.
Las extensiones se resuelven en compilación por el tipo estático del receptor y por lo que esté importado en el archivo. Una extensión sobre un supertipo se ejecutará aunque el objeto sea del subtipo si la variable está declarada con el supertipo. Un miembro con la misma firma siempre gana, hoy y en cualquier versión futura de la librería. Reserva las extensiones para lógica que no dependa de qué implementación concreta hay detrás.
- Reproduce el caso de
FormayCirculoy comprueba la salida. Después cambia la declaración aval f = Circulo()y explica por qué cambia sin haber tocado el objeto. - Guarda tres círculos en un
List<Forma>, recorre la lista llamando anombre()y anota qué extensión se ejecuta. - Escribe una extensión homónima de un miembro existente, localiza la advertencia del compilador y después invoca la extensión sin usar la sintaxis de punto.
- Añade una sobrecarga de extensión con un parámetro extra y verifica que conviven miembro y extensión.
- Simula la evolución de una librería: define una clase sin cierto método, escribe la extensión, añade luego el miembro a la clase y comprueba cuántas líneas de tu programa cambian de comportamiento sin cambiar de texto.