Acceso y llamada: get, set, invoke, contains e iterator
Cuatro convenciones bastan para que un tipo propio se comporte como una colección indexada, como una función, como un conjunto al que se pregunta por pertenencia y como algo recorrible con un bucle. Esta lección detalla la indexación de aridad libre con get y set, la llamada mediante invoke y su papel en los tipos función, la inversión de receptor que impone contains y el desazucarado exacto del for, con los riesgos de opacidad que introduce cada una.
Las convenciones aritméticas y de comparación permiten que un tipo propio se parezca a un número. Las cuatro de esta lección van bastante más lejos: permiten que se parezca a una colección, a una función o a una estructura recorrible, y lo hacen sin exigir que implemente ninguna interfaz, sin heredar de nada y sin pedir permiso a quien escribió el tipo original. Aquí es donde el carácter estructural del mecanismo deja de ser una curiosidad de diseño y se convierte en una capacidad práctica de primer orden, porque permite retroadaptar tipos sellados de terceros a la sintaxis del lenguaje. Y es también donde aparece el coste que las lecciones anteriores solo insinuaban, porque tres de estas cuatro convenciones borran el nombre de la operación del sitio de llamada, y un sitio de llamada sin nombre es un sitio de llamada que no se puede buscar, leer ni estimar.
- Implementar indexación de lectura y escritura con varios índices y conocer qué se hace con el valor devuelto por la escritura.
- Usar
invokeen instancias y en compañeros, y relacionarlo con la representación de los tipos función. - Explicar la inversión de receptor de
containsy distinguir los dos usos gramaticales de la palabrain. - Reproducir el desazucarado del bucle
fory aprovecharlo para recorrer tipos que no implementan ninguna interfaz de iteración.
Indexación: get y set con aridad libre
La notación de corchetes admite cualquier número de índices y de cualquier tipo, porque la traducción se limita a trasladar los argumentos tal cual. En la escritura, el valor asignado se añade siempre como último parámetro, y lo que devuelva la función se descarta, de modo que la firma debe declarar Unit.
class Tablero(private val ancho: Int, private val alto: Int) {
private val celdas = CharArray(ancho * alto) { ' ' }
operator fun get(x: Int, y: Int): Char = celdas[y * ancho + x]
operator fun set(x: Int, y: Int, valor: Char) { celdas[y * ancho + x] = valor }
}
val t = Tablero(8, 8)
t[3, 4] = 'R' // t.set(3, 4, 'R')
val pieza = t[3, 4] // t.get(3, 4)
Los índices se sobrecargan como cualquier parámetro, y ahí está uno de los usos más productivos de la convención: un tipo de configuración puede indexarse por clave textual, por clave tipada y por posición a la vez, y cada variante devolver un tipo distinto. Conviene, eso sí, recordar una expectativa que el lector trae puesta y que el compilador no protege: los corchetes prometen una operación barata y previsible. Un get que abre una conexión, lanza excepciones por elementos ausentes o recorre una lista entera traiciona esa promesa aunque compile perfectamente.
invoke: objetos que se llaman
La notación de llamada sobre algo que no es una función se traduce a invoke. La consecuencia más importante no es que puedas hacerlo con tus tipos, sino que explica cómo funciona el lenguaje entero: un tipo función es una interfaz con un método invoke, y por eso una variable que contiene una lambda se llama con paréntesis sin que exista ningún caso especial en la gramática.
class Regla(val nombre: String, private val test: (String) -> Boolean) {
operator fun invoke(entrada: String): Boolean = test(entrada)
}
val noVacia = Regla("no vacia") { it.isNotBlank() }
val valido = noVacia("hola") // noVacia.invoke("hola")
Declarado en un compañero, invoke produce el idioma de la factoría que se lee como una construcción, con el constructor primario cerrado y toda la validación concentrada en un solo punto de entrada. Es un recurso legítimo y muy usado en la construcción de lenguajes internos, donde el objeto que se llama es a la vez un nombre con significado y una operación.
class Puerto private constructor(val numero: Int) {
companion object {
operator fun invoke(numero: Int): Puerto {
require(numero in 1..65535) { "puerto fuera de rango" }
return Puerto(numero)
}
}
}
val p = Puerto(8080) // parece un constructor, es Puerto.Companion.invoke
La ventaja frente a una función de nivel superior con el mismo nombre del tipo es que la operación queda dentro del espacio de nombres de la clase y se importa con ella. La desventaja es la de siempre en este nivel: el sitio de llamada ya no distingue entre construir y ejecutar una lógica arbitraria, y quien lea esa línea supondrá lo primero.
Con invoke el sitio de llamada pierde el verbo. Ver una línea con un identificador y unos paréntesis no dice qué ocurre, y ninguna búsqueda por el nombre de la operación va a encontrarla, porque el nombre no está escrito en ningún sitio. La regla práctica es exigir que el objeto sea conceptualmente una función, de modo que la única operación posible sobre él sea aplicarlo. Si el tipo tiene además otros métodos interesantes, la llamada anónima es una mala idea y conviene un nombre explícito.
contains: el operando de la izquierda es el argumento
La pertenencia es la única convención de este nivel donde el receptor de la llamada es el operando derecho, y esa inversión sorprende cada vez. La forma negada se traduce a la misma llamada, negada, y la función debe devolver Boolean.
data class Rect(val x0: Int, val y0: Int, val x1: Int, val y1: Int) {
operator fun contains(p: Punto): Boolean = p.x in x0..x1 && p.y in y0..y1
}
if (raton in areaBoton) pulsar() // areaBoton.contains(raton)
Esa notación también funciona dentro de un when sin argumento de comparación explícito, lo que permite escribir condiciones de pertenencia muy legibles sobre tipos propios. Pero hay que separar con cuidado dos usos gramaticales de la misma palabra que no tienen nada que ver entre sí. En una expresión de condición, la palabra in significa pertenencia y llama a contains. En la cabecera de un bucle, la misma palabra forma parte de otra producción gramatical, significa recorrido y llama a iterator. Un tipo puede ofrecer una convención sin la otra, y de hecho es frecuente y deseable: un conjunto de intervalos puede responder a la pertenencia en tiempo logarítmico sin que tenga ningún sentido recorrer sus infinitos puntos.
iterator: el desazucarado del bucle
El bucle for no exige ninguna interfaz. Exige tres funciones que cumplan una forma, y las busca por nombre sobre el tipo de la expresión recorrida y sobre el tipo del iterador devuelto.
flowchart TD
A[for x in c] --> B[c.iterator]
B --> C{it.hasNext devuelve verdadero}
C -- No --> F[fin del bucle]
C -- Si --> D[val x igual it.next]
D --> E[cuerpo del bucle]
E --> CComo la búsqueda es por nombre, la capacidad de recorrer un tipo puede añadirse desde fuera con una extensión, incluso sobre tipos sellados de una librería ajena que jamás previeron ser iterables.
operator fun ClosedRange<java.time.LocalDate>.iterator(): Iterator<java.time.LocalDate> =
object : Iterator<java.time.LocalDate> {
private var actual = start
override fun hasNext() = actual <= endInclusive
override fun next() = actual.also { actual = it.plusDays(1) }
}
for (dia in inicio..fin) registrar(dia)
Hay una obligación implícita en ese protocolo que conviene enunciar, porque su incumplimiento produce fallos difíciles de atribuir: cada llamada a iterator debe devolver un recorrido nuevo desde el principio. Si la función entrega siempre el mismo objeto, el primer bucle lo agota y el segundo no ejecuta ni una vuelta, sin lanzar nada y sin dejar rastro. Los flujos de un solo uso son la excepción legítima a esa regla, y por eso la librería estándar los tipa aparte en lugar de disfrazarlos de colección.
get y set
Indexación con cualquier aridad y cualquier tipo de índice. La escritura recibe el valor como último parámetro y su resultado se descarta.
invoke
Aplicación del objeto. Base de los tipos función, del idioma de factoría en el compañero y de casi todo lenguaje interno con receptor.
contains
Pertenencia con el receptor a la derecha. Debe devolver un booleano y debe ser barata, porque se escribe dentro de condiciones.
iterator
Protocolo de tres funciones sin interfaz obligatoria. Permite volver recorrible un tipo ajeno con una sola extensión.
Hay dos ideas de fondo en esta lección y conviene verlas juntas porque tiran en direcciones opuestas. La primera es que el bucle for de Kotlin no es una construcción cerrada del lenguaje sino un protocolo publicado, y eso tiene consecuencias que van mucho más allá de la comodidad. En la plataforma sobre la que se ejecuta, la iteración canónica pasa por una interfaz genérica, y toda interfaz genérica implica que el elemento viaje como referencia: recorrer un millón de enteros a través de ella significa construir un millón de objetos envoltorio, con su asignación, su presión sobre el recolector y su indirección en cada acceso. Kotlin evita eso íntegramente sin inventar ninguna sintaxis especial para los tipos primitivos, y lo hace por la vía estructural: como el compilador busca tres funciones por nombre y no una interfaz por tipo, un rango de enteros puede devolver un iterador especializado cuya función de avance entrega un entero sin caja, y el bucle resultante no asigna ni un solo objeto. La abstracción existe en el código fuente y desaparece en el código generado, que es la definición exacta de abstracción sin coste, y se consigue precisamente porque el lenguaje se negó a atarse a un contrato nominal. La misma propiedad es la que permite que un tipo compilado hace quince años, sellado y sin fuentes, se vuelva recorrible con una extensión de seis líneas escrita en tu módulo. La segunda idea es el reverso, y es lo que separa a quien usa estas convenciones con criterio de quien las usa porque puede. Tres de las cuatro borran el nombre de la operación del punto donde ocurre. Unos corchetes no dicen si detrás hay un acceso a un vector contiguo o una consulta remota; unos paréntesis tras un identificador no dicen qué se está aplicando; una palabra de dos letras no dice si la pertenencia cuesta una comparación o un recorrido completo. Todo el conocimiento que un lector necesita para estimar el coste y el efecto de esa línea se ha trasladado a la declaración del tipo, que está en otro archivo, y ninguna búsqueda textual por el nombre de la operación va a llevarle hasta allí, porque el nombre no aparece escrito en ninguna parte. Ese traslado es exactamente el mismo fenómeno que estudiamos con la asignación compuesta, y la conclusión general del nivel empieza a dibujarse aquí: las convenciones no simplifican el programa, redistribuyen dónde vive la información necesaria para entenderlo. Merecen la pena cuando el símbolo que las invoca ya significa universalmente lo que tu tipo hace, porque entonces el lector no necesita buscar nada: los corchetes sobre algo que es indiscutiblemente una tabla, los paréntesis sobre algo que es indiscutiblemente una función, la pertenencia sobre algo que es indiscutiblemente un conjunto. En cuanto hay que explicar por qué se eligió el símbolo, la elección ya está mal hecha, y una función con nombre habría sido más corta de leer aunque fuese más larga de escribir.
La indexación admite cualquier aridad y coloca el valor asignado al final de la escritura. La llamada sobre un objeto pasa por invoke, que es también la razón de que los tipos función se apliquen con paréntesis. La pertenencia invierte el receptor y debe devolver un booleano. El bucle pide tres funciones por nombre y ninguna interfaz, lo que permite recorrer tipos ajenos con una extensión y recorrer primitivos sin asignar objetos. Las tres primeras eliminan el nombre de la operación del sitio de uso, así que solo están justificadas cuando el símbolo ya dice lo que tu tipo hace.
- Implementa un tablero con indexación de dos índices y añade una sobrecarga de lectura por coordenada textual. Comprueba qué ocurre al mezclar ambas en la misma expresión.
- Escribe un
setque devuelva el valor anterior en lugar deUnity explica el mensaje del compilador con la traducción de la notación. - Convierte una clase con un único método en un objeto invocable y valora si el sitio de llamada resultante sigue siendo legible sin conocer el tipo.
- Da pertenencia y recorrido a un tipo de intervalo temporal mediante extensiones, y justifica un caso donde solo tenga sentido ofrecer la primera.
- Recorre un rango de enteros y otro de enteros envueltos, inspecciona el código generado y localiza dónde aparecen las asignaciones de objeto.