wandres.dev
EXTENSIONES · añadir sin tocar

Ámbito, doble receptor e importación

Una extensión declarada dentro de una clase tiene dos receptores: uno estático y otro virtual. Qué significa eso para la sobrescritura, cómo la visibilidad y las importaciones deciden qué extensiones existen en cada archivo, y cómo organizarlas sin acabar con un fichero cajón de sastre.

⏱ 21 min

Una extensión declarada en el nivel superior de un archivo pertenece a un paquete y se importa como cualquier otra función. Una extensión declarada dentro de una clase no pertenece a un paquete: pertenece al cuerpo de esa clase, y solo existe mientras haya una instancia de ella disponible como receptor. Esa segunda forma es la que sostiene todos los constructores de DSL que has usado en Kotlin, y también la que produce la única situación del lenguaje donde una misma llamada se resuelve a la vez de forma estática y de forma virtual, cada mitad por un receptor distinto. Entender la mecánica del doble receptor y la de la importación es entender por qué las extensiones no ensucian el espacio global: no existe ningún espacio global de extensiones, solo ámbitos donde unas están y otras no.

🎯 Al terminar esta lección sabrás
  • Distinguir receptor de despacho y receptor de extensión en una extensión declarada dentro de una clase.
  • Predecir el resultado cuando ambos receptores participan en una jerarquía con sobrescritura.
  • Usar this cualificado para desambiguar entre receptores anidados.
  • Organizar extensiones en archivos y paquetes decidiendo visibilidad e importación de forma deliberada.

Dos receptores en la misma función

Cuando una extensión se declara dentro de una clase, hay dos objetos implicados en cada llamada. El receptor de despacho es la instancia de la clase donde la extensión está declarada. El receptor de extensión es la instancia del tipo que precede al nombre. Dentro del cuerpo, ambos están accesibles; this sin cualificar es el de extensión, que es el más cercano, y al de despacho se llega con this cualificado por el nombre de la clase.

class Conexion(val host: String) {

    fun String.enviar(): Boolean {
        // this        -> el String, receptor de extension
        // this@Conexion -> la Conexion, receptor de despacho
        println("enviando ${this.length} bytes a ${this@Conexion.host}")
        return true
    }

    fun ejecutar() {
        "PING".enviar()      // legal: hay receptor de despacho
    }
}

fun main() {
    "PING".enviar()          // error: enviar no existe en este ambito
}

Esa restricción es el mecanismo entero de los DSL con receptor. La extensión enviar no contamina el proyecto: no se puede importar, no aparece en el autocompletado general y solo cobra existencia dentro de un cuerpo donde haya una Conexion disponible. Es la misma técnica con la que buildString te da un StringBuilder, con la que los constructores de HTML habilitan las etiquetas solo dentro de su bloque y con la que Compose limita ciertos modificadores a un contenedor concreto. with, apply y run sirven para introducir ese receptor de despacho desde fuera.

fun main() {
    with(Conexion("ejemplo.org")) {
        "PING".enviar()      // legal: with aporta el receptor de despacho
    }
}

Cuando los bloques se anidan hay varios receptores de despacho activos a la vez, y ahí es donde @DslMarker gana su sueldo: una anotación marcada con ella impide que un receptor externo se cuele implícitamente dentro de un bloque interno, obligando a cualificar con this si de verdad se quiere salir un nivel.

flowchart TB
call[llamada texto punto enviar dentro de Conexion] --> d[receptor de despacho igual a la Conexion]
call --> e[receptor de extension igual al String]
d --> vir[se resuelve de forma virtual por la clase real]
e --> est[se resuelve de forma estatica por el tipo declarado]

El receptor de despacho sí es virtual

Aquí está la asimetría que conviene tener grabada. Una extensión declarada dentro de una clase puede ser open y puede sobrescribirse, porque en ese caso lo que se sobrescribe es un miembro de la clase contenedora. Lo que sigue siendo estático es la elección por el tipo del receptor de extensión.

open class Base
class Derivada : Base()

open class Emisor {
    open fun Base.marcar() = "Emisor sobre Base"
    open fun Derivada.marcar() = "Emisor sobre Derivada"
    fun disparar(b: Base) = b.marcar()
}

class SubEmisor : Emisor() {
    override fun Base.marcar() = "SubEmisor sobre Base"
    override fun Derivada.marcar() = "SubEmisor sobre Derivada"
}

fun main() {
    println(Emisor().disparar(Derivada()))     // Emisor sobre Base
    println(SubEmisor().disparar(Derivada()))  // SubEmisor sobre Base
}

Las dos salidas dicen lo mismo desde ángulos distintos. Cambiar el receptor de despacho de Emisor a SubEmisor sí cambia la implementación elegida: ahí hay despacho virtual de verdad. Cambiar el objeto del receptor de extensión de Base a Derivada no cambia nada, porque dentro de disparar el parámetro está declarado como Base y esa decisión se tomó al compilar. Una misma línea, dos reglas de resolución opuestas.

⚠️
Colisión de nombres entre los dos receptores

Si el tipo receptor de extensión y la clase contenedora tienen miembros con el mismo nombre, gana el receptor de extensión por ser el ámbito más interno, y el de la clase queda tapado. La única forma de recuperarlo es this@NombreDeLaClase.miembro. Merece la pena escribir el this cualificado siempre que haya la menor duda: cuesta doce caracteres y ahorra una lectura equivocada.

Visibilidad, importación y organización

Una extensión de nivel superior obedece a los modificadores habituales, y cada uno traza un radio de alcance distinto. private la limita al archivo donde se declara, que es la herramienta correcta para los ayudantes que solo dan servicio a un módulo pequeño. internal la limita al módulo de compilación y es el valor por defecto razonable en una aplicación. public la ofrece a todo el mundo y, si el proyecto es una librería, la convierte en superficie pública con todo lo que eso implica en compatibilidad.

Pero la visibilidad solo dice quién puede verla; quién la ve lo decide la importación. Una extensión pública que nadie importa es, a efectos de resolución, inexistente en ese archivo.

import dominio.texto.esPalindromo                    // solo esta
import dominio.texto.*                               // todas las del paquete
import otro.paquete.formatear as formatearComoTabla  // alias para desambiguar

Hay una excepción cómoda: las extensiones declaradas en el mismo paquete que el archivo que las usa no necesitan importación. Es práctico dentro de un módulo y engañoso cuando se publica, porque el código de la librería compila sin importaciones y el de los clientes no. Al escribir los ejemplos de una API conviene ponerlos en otro paquete, precisamente para descubrir cuántas importaciones va a necesitar quien la use.

ℹ️
Extensiones de compañero y referencias invocables

Se puede extender el objeto compañero de una clase con fun MiClase.Companion.desdeJson(...), lo que produce la sintaxis de fábrica MiClase.desdeJson(...) sin tocar la clase. Y toda extensión admite referencia invocable con String::esPalindromo, cuyo tipo es una función de un argumento, coherente con lo que realmente hay debajo.

🗂️

Un archivo por tipo receptor

Archivos como StringExt.kt, ListExt.kt o InstantExt.kt hacen que buscar una extensión sea trivial y que las importaciones de asterisco no arrastren cosas ajenas. Evita el Extensions.kt único que acaba con trescientas funciones sin relación.

📦

El paquete es la unidad de intención

Agrupa por para qué sirven, no por sobre qué tipo operan: dominio.formato, dominio.validacion. Así una importación de asterisco significa algo, y quien lee el encabezado del archivo aprende qué vocabulario está en juego.

🔐

Empieza por internal

Salvo que estés publicando una librería, internal es el punto de partida sensato. Subir la visibilidad después es trivial; bajarla cuando ya hay clientes es una ruptura de compatibilidad.

🏷️

Nombra la fachada de Java

En proyectos mixtos, @file:JvmName y @JvmMultifileClass deciden cómo se ven tus extensiones desde Java. Sin ellas heredas nombres generados que después no podrás cambiar sin romper a alguien.

💡
Extensiones locales dentro de una función

También se puede declarar una extensión dentro del cuerpo de otra función. Su ámbito es esa función y desaparece al salir. Es una válvula excelente para algoritmos largos donde una operación auxiliar merece un nombre y sintaxis de punto, pero no merece contaminar ni el archivo ni el módulo.

El ámbito léxico como mecanismo de gobierno: por qué las extensiones no degeneran en un espacio global

La objeción clásica contra cualquier mecanismo de extensión abierto es el problema de la coherencia, y conviene enunciarla en su forma fuerte porque es la que explica el diseño. Si dos módulos independientes pueden añadir operaciones a un mismo tipo, ¿qué impide que ambos añadan la misma y que el programa deje de tener una respuesta única? En los lenguajes de parcheo dinámico no impide nada: gana el último en cargarse y el resultado depende del orden de importación, con lo que la corrección del programa pasa a ser una propiedad emergente del despliegue. En Haskell lo impide la regla del huérfano, que prohíbe declarar una instancia de una clase de tipos si no controlas ni la clase ni el tipo, garantizando así una implementación canónica por par y un despacho coherente en todo el programa; el precio es rigidez y una danza de envoltorios cuando dos librerías ajenas necesitan hablarse. Rust hace lo propio con su regla de coherencia sobre los trait. Kotlin ni siquiera plantea la pregunta, porque su respuesta está un nivel más abajo: no hay ninguna afirmación global que pueda entrar en conflicto. Una extensión no registra nada, no modifica nada y no participa en ninguna tabla; solo introduce un nombre en un ámbito léxico. Y los ámbitos léxicos son locales por construcción, anidados, ordenados por proximidad y controlados enteramente por quien escribe el archivo mediante sus importaciones. Dos extensiones idénticas en dos paquetes no son una contradicción, son dos nombres que solo colisionan si alguien los mete a la vez en el mismo ámbito, y en ese caso el compilador rechaza la llamada por ambigua y pide un alias. La coherencia deja de ser una propiedad global que el sistema debe garantizar y pasa a ser una propiedad local que cada archivo declara. Esto tiene una consecuencia de diseño que va bastante más allá de las extensiones y que las extensiones dentro de clases llevan al extremo: el ámbito se convierte en un mecanismo de gobierno. Al meter una extensión dentro de una clase estás diciendo que esa operación solo tiene sentido en presencia de cierto contexto, y el compilador lo hace cumplir; fuera del bloque la función sencillamente no existe, y ninguna disciplina de equipo ni revisión de código tiene que vigilarlo. Los DSL seguros de tipos de Kotlin, la anotación @DslMarker que impide que un receptor externo se cuele en un bloque interno, y los context parameters estabilizados en la versión 2.4 son todos desarrollos de la misma idea: el conjunto de operaciones disponibles es una función del lugar del código, y el lugar del código es algo que el sistema de tipos sabe comprobar. La lección para tu propio diseño no es cuántas extensiones escribes sino dónde las pones. Una extensión pública de nivel superior es vocabulario para todo el proyecto y hay que tratarla con el respeto de una API. Una extensión internal es vocabulario del módulo. Una extensión dentro de una clase o de una función es vocabulario de un momento, y es la que casi nunca se lamenta.

📝
Lo esencial

Una extensión declarada en una clase tiene receptor de despacho, que es virtual y admite sobrescritura, y receptor de extensión, que sigue siendo estático. Solo existe dentro del ámbito de esa clase, que es el fundamento de los DSL con receptor. En el nivel superior, la visibilidad decide quién puede verla y la importación decide quién la ve; organiza por tipo receptor y por intención, y empieza siempre por la visibilidad más estrecha.

⚔️ Domina el ámbito
  1. Declara una extensión dentro de una clase, úsala desde un método e intenta usarla desde fuera. Copia el error y explícalo con las palabras receptor de despacho.
  2. Dentro de esa extensión, provoca una colisión de nombres entre el tipo receptor y la clase contenedora y resuélvela con this cualificado.
  3. Reproduce el ejemplo de Emisor y SubEmisor. Cambia solo el tipo declarado del parámetro de disparar y explica por qué cambia la salida sin haber tocado ninguna implementación.
  4. Escribe la misma extensión en dos paquetes, impórtalos ambos con asterisco y observa el error de ambigüedad. Resuélvelo con un alias.
  5. Reorganiza las extensiones de un módulo tuyo en archivos por tipo receptor, baja todo lo posible a internal y anota cuántas dejaron de ser públicas sin romper nada.