Referencias a función y a propiedad
Una lambda que se limita a llamar a algo que ya existe es ruido. Esta lección estudia el operador de dos puntos dobles como forma de obtener un valor a partir de una declaración: referencias a funciones de nivel superior y a miembros, referencias no vinculadas donde el receptor se convierte en el primer parámetro, referencias vinculadas a una instancia concreta, referencias a constructores como fábricas gratuitas, referencias a propiedades y a propiedades mutables, y el tipo real de todas ellas dentro de la jerarquía de reflexión de Kotlin.
Escribir lista.map { it.length } es correcto, pero contiene una pequeña redundancia que conviene aprender a ver: se está fabricando una función nueva cuyo único trabajo es delegar en algo que ya existe con ese nombre y esa forma. Kotlin ofrece una alternativa que no crea ninguna función intermedia sino que toma la declaración existente y la convierte en un valor, y lo hace con dos puntos dobles. La operación parece un atajo de escritura y en su superficie lo es, pero debajo hay un mecanismo con consecuencias reales: el receptor de un miembro puede convertirse en un parámetro o quedar fijado a una instancia, un constructor puede pasar a comportarse como una fábrica sin escribir la fábrica, y una propiedad puede viajar como valor conservando la capacidad de leer y de escribir. Estudiar todo eso es aprender a manipular los nombres del programa como si fueran datos, que es la puerta de entrada a la reflexión y a buena parte del diseño genérico de Kotlin.
- Construir referencias a funciones de nivel superior, a miembros y a funciones de extensión con la sintaxis correcta.
- Distinguir la referencia no vinculada de la vinculada y deducir el tipo función resultante de cada una.
- Emplear referencias a constructores y a propiedades, incluidas las mutables, en lugar de lambdas triviales.
- Situar los tipos
KFunction,KPropertyyKMutablePropertyrespecto de los tipos función ordinarios.
Dos puntos dobles: de la declaración al valor
El operador de dos puntos dobles toma el nombre de una declaración y produce un valor cuyo tipo es un tipo función compatible con esa declaración. Para una función de nivel superior basta con anteponerlo al nombre; para un miembro hay que decir a qué tipo pertenece, escribiendo el nombre del tipo, el operador y el nombre del miembro. Ahí aparece la primera decisión de diseño que hay que entender, porque un miembro tiene un receptor y ese receptor tiene que ir a algún sitio.
fun esPar(n: Int): Boolean = n % 2 == 0
val referencia: (Int) -> Boolean = ::esPar
val pares = listOf(1, 2, 3, 4).filter(::esPar)
// Referencia no vinculada: el receptor se vuelve el primer parametro
val longitud: (String) -> Int = String::length
val mayusculas: (String) -> String = String::uppercase
Cuando la referencia se construye sobre el nombre del tipo, el receptor no está fijado y el compilador lo convierte en un parámetro adicional que se coloca el primero. Por eso String::length, que sobre una instancia sería una lectura sin argumentos, tiene el tipo (String) -> Int, y por eso encaja exactamente donde map sobre una lista de cadenas espera una función de cadena a algo. Esta transformación tiene nombre propio en la teoría de la programación funcional y aquí es simplemente lo que hace el compilador sin pedir permiso.
Las funciones de extensión se comportan igual, con el receptor de extensión ocupando también la primera posición, y las funciones con parámetros por defecto pierden esos valores por defecto al convertirse en referencia: el tipo función resultante declara todos los parámetros y quien la invoque tendrá que suministrarlos. Es un detalle pequeño que sorprende a quien espera que la referencia arrastre toda la comodidad de la declaración original.
fun String.recortadoA(maximo: Int = 20): String = take(maximo)
val conExtension: (String, Int) -> String = String::recortadoA
conExtension("una cadena larga", 5) // el maximo ya no tiene valor por defecto
// conExtension("una cadena larga") // error: falta un argumento
La razón de esa pérdida vuelve a ser el tipo. Los valores por defecto no forman parte del tipo función sino de la declaración de la función, y viven en el punto de llamada, donde el compilador rellena lo que falta antes de emitir la invocación. Una referencia produce un valor cuyo tipo es un tipo función ordinario, y en ese tipo no hay ningún lugar donde guardar esa información. La misma lógica explica que los parámetros con nombre no funcionen al invocar a través de una referencia.
Una referencia a un nombre con varias sobrecargas es ambigua por sí sola, porque el operador no lleva lista de argumentos que permita elegir. La resolución la aporta el contexto: si la referencia se asigna a una variable con tipo función declarado o se pasa a un parámetro cuya firma es conocida, el compilador selecciona la sobrecarga que encaja. Si no hay ninguna de las dos cosas, hay que declarar el tipo explícitamente. Cuando el mensaje de error hable de ambigüedad en una referencia, la corrección casi siempre es añadir el tipo a la izquierda, no cambiar el nombre de la función.
Vinculada o no vinculada: dónde queda el receptor
Si en lugar del nombre del tipo se escribe una expresión que produce una instancia, la referencia queda vinculada a esa instancia concreta y el receptor deja de aparecer entre los parámetros. La diferencia de tipo es inmediata y es el corazón de esta lección.
class Documento(val titulo: String) {
fun contiene(termino: String): Boolean = titulo.contains(termino)
}
val informe = Documento("Informe anual")
val noVinculada: (Documento, String) -> Boolean = Documento::contiene
val vinculada: (String) -> Boolean = informe::contiene
noVinculada(informe, "anual") // hay que pasar el receptor
vinculada("anual") // el receptor ya esta dentro
flowchart TD
A[Miembro contiene con receptor Documento] --> B{Que se escribe a la izquierda}
B -- Nombre del tipo --> C[No vinculada]
B -- Expresion instancia --> D[Vinculada]
C --> E[Tipo con Documento como primer parametro]
D --> F[Tipo sin el receptor: solo el termino]
F --> G[La instancia queda capturada dentro del valor]La última caja del diagrama es la que hay que retener. Una referencia vinculada captura el receptor exactamente igual que lo capturaría una lambda escrita a mano, con todas las consecuencias de la lección anterior: hay una instancia por evaluación y hay una referencia viva al objeto receptor mientras la referencia siga viva. Registrar un this::alRecibirDato en un emisor de eventos de larga vida mantiene vivo el objeto entero, y ese es un origen clásico de fugas de memoria en aplicaciones con ciclo de vida, tan clásico como el de la lambda que captura la vista.
La forma vinculada admite cualquier expresión a la izquierda, incluido this, lo que produce el idioma más común de todos al escribir código orientado a objetos que colabora con APIs funcionales. Y admite también dejar el receptor implícito dentro de una clase, escribiendo el operador seguido del nombre del miembro sin nada delante, que es azúcar de la forma con this.
class Presentador(private val vista: Vista) {
fun alRecibir(dato: Dato) { vista.pintar(dato) }
fun conectar(fuente: Fuente) {
fuente.suscribir(this::alRecibir) // vinculada explicita
fuente.suscribir(::alRecibir) // vinculada implicita, lo mismo
}
}
Conviene subrayar que la expresión de la izquierda se evalúa una sola vez, en el momento de construir la referencia, y no en cada invocación. Una referencia escrita como el resultado de una llamada fija el objeto que esa llamada devolvió entonces, de modo que si la fuente del receptor cambia después, la referencia sigue apuntando al anterior. Es una diferencia real respecto de una lambda que resolviera el receptor dentro del cuerpo, y explica algunos comportamientos aparentemente fantasmales en código que reasigna dependencias en caliente.
Constructores y propiedades como valores
Una referencia a constructor se escribe anteponiendo el operador al nombre del tipo, y produce una función cuyos parámetros son los del constructor y cuyo resultado es una instancia. Elimina de un plumazo la lambda que solo envuelve una construcción, y encaja de forma natural en cualquier lugar donde se necesite una fábrica.
data class Usuario(val nombre: String, val edad: Int)
val fabrica: (String, Int) -> Usuario = ::Usuario
val usuarios = nombres.map { n -> Usuario(n, 0) } // lambda envolvente
val desdePares = pares.map { (n, e) -> Usuario(n, e) } // desestructurando
// Referencia a propiedad: se lee como funcion de la instancia al valor
val nombres2: List<String> = usuarios.map(Usuario::nombre)
val edades: List<Int> = usuarios.map(Usuario::edad)
val porEdad = usuarios.sortedBy(Usuario::edad)
Las referencias a propiedad son el caso donde más se nota la ganancia de legibilidad, porque expresan una intención de proyección sin introducir ningún nombre nuevo. Y tienen una capacidad extra que las lambdas no pueden imitar: además de leer, saben escribir cuando la propiedad es mutable. Una referencia a una propiedad declarada con var expone acceso de lectura y de escritura, lo que permite construir utilidades que copian, sincronizan o vinculan campos sin conocerlos de antemano.
Referencia a constructor
Tipo función con la firma del constructor y el tipo construido como resultado. Es la forma canónica de pasar una fábrica sin declarar ninguna interfaz de fábrica.
Referencia a propiedad de solo lectura
Se comporta como una función de la instancia al valor y además ofrece la operación de lectura explícita, útil cuando se necesita el nombre de la propiedad además de su valor.
Referencia a propiedad mutable
Añade la operación de escritura sobre el mismo valor de referencia, algo imposible de expresar con una lambda porque una lambda solo sabe producir resultados.
El tipo real y qué se puede hacer con él
Aunque una referencia se pueda asignar a un tipo función ordinario, su tipo real es más específico y vive en la jerarquía de reflexión de Kotlin. Una referencia a función tiene un tipo que hereda del tipo función correspondiente y añade metadatos; una referencia a propiedad tiene un tipo que además de ser función expone las operaciones de acceso. Esa relación de herencia es la razón exacta de que se pueda pasar una referencia a cualquier parámetro que espere una lambda: es un subtipo, no una conversión.
val ref = Usuario::nombre // el tipo inferido no es solo funcion
val leido: String = ref.get(usuario) // acceso explicito de lectura
val comoFuncion: (Usuario) -> String = ref // valida por subtipado
val vinculadaAProp = usuario::edad // referencia vinculada a propiedad
val valorActual: Int = vinculadaAProp.get()
La distinción tiene una consecuencia práctica sobre las dependencias del proyecto. Las capacidades básicas de una referencia funcionan sin nada especial, porque el compilador genera lo necesario; pero interrogar una referencia sobre su nombre, sus anotaciones o sus parámetros es reflexión completa y requiere la librería de reflexión de Kotlin como dependencia adicional, con el peso que eso añade al artefacto. Diseñar una API basada en referencias a propiedad es cómodo y expresivo, y conviene saber a partir de qué punto deja de ser gratis.
Queda por señalar un vecino sintáctico que se confunde con todo lo anterior: el operador seguido de la palabra class no produce una referencia a función sino una referencia al tipo mismo, y pertenece a otro dominio. Comparten sintaxis y no comparten nada más.
Detrás de la comodidad de escribir dos puntos y un nombre hay una idea con mucho más alcance del que sugiere su tamaño, y es la idea de que las declaraciones de un programa pueden dejar de ser solo instrucciones para el compilador y convertirse en valores que el propio programa manipula. Cuando se escribe una lambda se está creando código nuevo; cuando se escribe una referencia se está señalando código que ya existe, y esa diferencia de naturaleza tiene efectos en cadena. Señalar en vez de crear significa que el vínculo con la declaración original es directo, de modo que renombrar el miembro rompe la referencia en tiempo de compilación en lugar de dejar una lambda que sigue compilando porque llama a otra cosa. Señalar significa también que el valor obtenido conserva información sobre lo señalado: su nombre, su firma, sus anotaciones, la capacidad de escribir si la propiedad es mutable, y esa información es la materia prima de todas las librerías de Kotlin que parecen adivinar la estructura de tus datos, desde las que validan formularios hasta las que construyen consultas de base de datos con nombres de propiedad en vez de cadenas de texto. Y señalar significa, finalmente, que el receptor se convierte en un grado de libertad explícito: puedes dejarlo abierto para que sea un parámetro y obtener una operación aplicable a cualquier instancia, o fijarlo a una y obtener una operación ya situada, y esa elección entre lo general y lo situado es literalmente la elección entre una función y un método, hecha visible y ofrecida al programador como un valor en lugar de como una diferencia inamovible del lenguaje. Quien entiende esto deja de ver las referencias como una forma abreviada de escribir lambdas y empieza a verlas por lo que son: el punto donde la estructura estática del programa se vuelve accesible a su ejecución.
- Recorre un archivo tuyo y localiza tres lambdas cuyo cuerpo sea una única llamada. Sustitúyelas por referencias y comprueba que el tipo sigue encajando sin anotaciones.
- Escribe la referencia no vinculada y la vinculada de un mismo miembro, declara ambos tipos a mano y explica por qué se diferencian en un parámetro.
- Reemplaza una fábrica escrita a mano por una referencia a constructor y razona qué ocurre con los parámetros que tenían valor por defecto.
- Construye una función que reciba una referencia a propiedad mutable y escriba un valor a través de ella. Justifica por qué una lambda no podría cumplir ese papel.
- Identifica en tu código un registro de callback hecho con una referencia vinculada a
thisy explica qué objeto queda vivo mientras el registro exista.