Rangos y destructuring: rangeTo, componentN y el buen gusto
Los dos puntos construyen un objeto mediante rangeTo y su variante abierta mediante rangeUntil, mientras que otras notaciones que parecen operadores son en realidad funciones infijas sin ningún privilegio gramatical. Esta lección completa el catálogo con componentN y el desestructurado, muestra cómo extender ambas convenciones a tipos ajenos y cierra el nivel con los criterios que separan un operador que se entiende solo de uno que obliga a leer su implementación.
Quedan por cubrir dos convenciones que completan el catálogo y que tienen algo en común: ninguna de las dos hace lo que su apariencia sugiere. Los dos puntos no describen un intervalo abstracto sino que construyen un objeto concreto mediante una llamada, con su tipo, su coste y sus operaciones; y el desestructurado no reparte campos por nombre sino que invoca funciones numeradas cuya correspondencia con las propiedades es puramente posicional. Entender ambas cosas literalmente evita dos clases de error muy distintas y prepara el terreno para la única pregunta que queda por responder en este nivel, que no es técnica sino de criterio. Después de cinco lecciones sabiendo exactamente qué se puede hacer, corresponde decidir qué conviene hacer, y ese juicio tiene un solo destinatario: quien lea el código sin haber leído la implementación.
- Explicar qué objeto construye cada forma de rango y cómo interactúan con la pertenencia y con el recorrido.
- Distinguir los operadores auténticos de las funciones infijas que se les parecen y saber por qué la diferencia importa.
- Implementar y extender
componentN, reconociendo la fragilidad del emparejamiento posicional. - Aplicar un criterio explícito para decidir si una operación merece notación de operador o un nombre.
Los dos puntos construyen un objeto
La notación de rango cerrado se traduce a rangeTo y la de rango abierto por la derecha a rangeUntil. La librería estándar las declara como extensiones genéricas sobre cualquier tipo comparable, y por eso funcionan sobre fechas, versiones o cadenas sin que nadie haya escrito nada específico para ellas.
// firmas de la libreria estandar, simplificadas
public operator fun <T : Comparable<T>> T.rangeTo(that: T): ClosedRange<T>
public operator fun <T : Comparable<T>> T.rangeUntil(that: T): OpenEndRange<T>
val temporada = inicio..fin // inicio.rangeTo(fin)
val jornada = apertura..<cierre // apertura.rangeUntil(cierre)
Un rango así construido responde a la pertenencia porque su contains está definido en términos de compareTo, lo que enlaza directamente con el contrato de orden de la tercera lección: si tu comparación no es coherente, tus rangos mienten. En cambio no responde al recorrido, porque para eso hace falta la convención iterator, y entre dos fechas comparables no existe ninguna noción canónica de paso siguiente que la librería pueda suponer. Los rangos numéricos sí son recorribles porque sus tipos concretos implementan además la interfaz de iteración, y ahí el compilador aplica una optimización que conviene conocer: cuando los extremos son primitivos y el rango se usa directamente en una condición o en la cabecera de un bucle, no se construye ningún objeto y se generan comparaciones y un contador.
Nada obliga a que rangeTo devuelva un tipo de la librería. Declararlo sobre un tipo de dominio y devolver un tipo propio permite añadir a ese intervalo las operaciones que tenga sentido tener, como intersección, duración o solapamiento, y mantener la notación breve en el sitio de uso. Es una de las pocas ocasiones en que crear un operador propio casi nunca sorprende, porque los dos puntos significan intervalo en todas partes desde mucho antes de que existiera la programación.
class Temporada(val desde: Fecha, val hasta: Fecha) : ClosedRange<Fecha> {
override val start get() = desde
override val endInclusive get() = hasta
infix fun solapaCon(otra: Temporada) = desde <= otra.hasta && otra.desde <= hasta
}
operator fun Fecha.rangeTo(otra: Fecha) = Temporada(this, otra)
val alta = junio..septiembre
val cruce = alta solapaCon vacaciones
Ese ejemplo enseña de paso una jerarquía de decisiones que se repite: el intervalo merece el operador porque los dos puntos ya significaban eso, y el solapamiento merece una función infija porque no existe ningún símbolo que todo el mundo asocie con esa idea. Mezclar ambos registros en el mismo tipo no es una incoherencia, es exactamente el criterio bien aplicado.
Lo que parece un operador y no lo es
Tres notaciones muy usadas en los rangos numéricos no son operadores en absoluto: son funciones infijas ordinarias, sin ninguna traducción especial y sin ningún privilegio gramatical. La diferencia es visible en cuanto se mezclan con aritmética, porque toda función infija tiene una única precedencia común, situada por debajo de la de los operadores.
for (i in 10 downTo 1) { } // funcion infija
for (i in 0 until n step 2) { } // dos funciones infijas encadenadas
val r = 0..n - 1 // el operador tiene mas precedencia que la resta
Esa asimetría explica por qué la primera y la tercera línea se leen distinto de lo que uno espera si las considera equivalentes. Y explica también una recomendación práctica de la librería estándar: la forma con la notación abierta por la derecha es preferible a la función infija equivalente, porque siendo operador conserva la precedencia esperada y admite tipos que no son enteros.
componentN: la convención sin símbolo
El desestructurado no tiene símbolo propio, pero funciona exactamente igual que el resto: una declaración con varios nombres se traduce en llamadas a funciones numeradas, tantas como nombres haya, en el orden en que están escritos.
data class Medida(val valor: Double, val unidad: String)
val (v, u) = medida
// val v = medida.component1()
// val u = medida.component2()
for ((clave, dato) in mapa) { } // component1 y component2 sobre la entrada
lista.forEach { (v, u) -> registrar(v, u) }
La clase de datos genera esas funciones a partir de las propiedades del constructor primario y en su mismo orden, que es la fuente de la fragilidad que ya estudiamos: reordenar dos propiedades del mismo tipo compila sin una sola advertencia en el sitio de la declaración y cambia el significado de todos los desestructurados del proyecto. El desestructurado por nombre que Kotlin 2.3.20 introdujo desactiva esa trampa emparejando por identificador, y reserva la forma con corchetes para los tipos donde la posición sí es la información. La convención numerada sigue existiendo debajo, y sigue siendo la única vía cuando el tipo no es tuyo.
operator fun java.time.LocalDate.component1() = year
operator fun java.time.LocalDate.component2() = monthValue
operator fun java.time.LocalDate.component3() = dayOfMonth
val (anio, mes, dia) = fecha
Ese es el uso más sano de la convención: dar reparto posicional a un tipo ajeno cuyos componentes tienen un orden universalmente aceptado. Cuando el orden no es evidente para todo el mundo, tres nombres numerados son una invitación a equivocarse, y por eso conviene no pasar de dos o tres componentes y no ofrecerlos nunca sobre tipos cuyos campos podrían reordenarse.
Dos detalles finales completan el mecanismo. El guion bajo omite un componente y, además, evita la llamada correspondiente, de modo que saltarse una posición cuyo cálculo sea caro no cuesta nada. Y en una lambda, unos nombres entre paréntesis no declaran varios parámetros sino uno solo que se desestructura, distinción que produce errores desconcertantes cuando se confunden ambas formas.
val (_, unidad) = medida // component1 no llega a llamarse
lista.map { (v, u) -> "$v $u" } // un parametro desestructurado
lista.map { v, u -> "" } // error: la lambda no tiene dos parametros
Diseñar operadores que no sorprendan
flowchart TD
A[Quiero notacion de operador para esta operacion] --> B{El simbolo ya significa esto en todas partes}
B -- No --> N[Funcion con nombre o funcion infija]
B -- Si --> C{La operacion es barata y total}
C -- No --> N
C -- Si --> D{Respeta las leyes que el lector supondra}
D -- No --> N
D -- Si --> E{El resultado se lee sin abrir la implementacion}
E -- No --> N
E -- Si --> F[Operador justificado]Significado prestado
Un símbolo solo funciona si tu tipo hereda un significado que el lector ya tiene. Sumar vectores, dinero o duraciones se entiende solo; sumar un usuario y un permiso, no.
Barato y total
Los símbolos se escriben dentro de condiciones y de bucles. Una indexación que consulta una base de datos o una comparación que lanza excepciones traicionan la expectativa que crean.
Leyes respetadas
Asociatividad donde el símbolo la sugiere, simetría en la igualdad, orden total en la comparación y ausencia de efectos en todo lo que devuelve un valor.
Legible sin fuente
Si para entender una línea hay que abrir la declaración del tipo, el operador ha empeorado el código aunque lo haya acortado.
Queda una comprobación final que resume las anteriores y que conviene hacer siempre en voz alta. Lee la expresión que has habilitado como si no supieras nada del proyecto y pregúntate qué crees que hace. Si tu respuesta coincide con lo que hace, el operador está bien elegido. Si tu respuesta necesita una condición previa del tipo de si esto es una lista mutable entonces, el operador está mal elegido y una función con nombre lo habría dicho todo en una palabra. Es un criterio poco sofisticado y no se equivoca casi nunca.
Este nivel termina donde empiezan las decisiones que ninguna documentación puede tomar por ti, y merece la pena cerrarlo con la economía real del mecanismo, porque está desequilibrada de una forma que no se ve mientras se escribe el código. Implementar un operador es barato: una función, un modificador, cinco minutos. Usarlo es aún más barato, y ese es el problema, porque la brevedad en el punto de uso se siente inmediatamente como una ganancia mientras que su coste llega después y recae sobre otra persona. Un símbolo transmite muchísima información por carácter, pero la transmite por referencia y no por valor: no dice lo que hace, remite a lo que el lector ya cree que significa. Cuando esa creencia es correcta, la comunicación es perfecta y ninguna función con nombre puede competir, porque una expresión algebraica escrita con símbolos se lee de un vistazo y la misma expresión escrita con llamadas encadenadas hay que descifrarla de dentro hacia fuera. Cuando la creencia es incorrecta, en cambio, no ocurre lo que ocurriría con un nombre mal elegido, que es que el lector se detenga y sospeche. Ocurre algo peor: el lector no sospecha nada, porque un símbolo no invita a la duda, y sigue leyendo con una idea equivocada de lo que hace el programa. Esa asimetría entre el fallo de un nombre y el fallo de un símbolo es la razón última de todo el diseño que hemos estudiado. Explica por qué el conjunto de símbolos es cerrado y la precedencia inamovible, para que la forma de una expresión nunca dependa de lo que hayas importado. Explica por qué el modificador es obligatorio, para que nadie participe en una convención por accidente al elegir un nombre común. Explica por qué existe la función infija, que ofrece brevedad sin usurpar un significado universal, y por qué es casi siempre la respuesta correcta cuando la operación es tuya y su nombre importa. Y explica por qué la librería estándar, que podría haber definido decenas de operadores, define muy pocos y todos sobre conceptos que no admiten discusión. La conclusión que conviene llevarse no es que haya que evitar los operadores, sino que su justificación nunca está en la comodidad de quien escribe. Un operador está justificado cuando el símbolo ya era el nombre correcto de esa operación antes de que tú llegaras. En cualquier otro caso, lo que estás haciendo no es acortar el código, sino trasladar de tu cabeza a la del lector el trabajo de averiguar qué significa; y esa transferencia, repetida a lo largo de un proyecto, es exactamente lo que convierte una base de código ingeniosa en una base de código que nadie quiere tocar.
En Kotlin no se sobrecarga un símbolo: se implementa una función con un nombre fijado por el lenguaje y se marca con operator, y la gramática se encarga del resto con una precedencia que nadie puede alterar. El compilador comprueba nombre, aridad y, cuando la traducción lo exige, tipo de retorno; no comprueba ninguna de las leyes que el lector va a suponer. La notación compuesta elige entre copiar y mutar según lo que exista y según el tipo declarado de la variable. La igualdad y el orden son relaciones matemáticas de las que dependen algoritmos ya compilados. La indexación, la llamada y la pertenencia borran el verbo del sitio de uso. Y el criterio final para declarar cualquiera de ellos cabe en una frase: el símbolo debe ser el nombre que la operación ya tenía antes de que tú llegaras.
- Define
rangeTosobre un tipo de dominio propio devolviendo un tipo de intervalo con nombre, y añádele una operación de solapamiento que la librería no te daría. - Compara el resultado de una expresión que mezcle un rango con una resta y otra que mezcle una función infija con la misma resta. Explica la diferencia con la precedencia.
- Extiende el desestructurado a un tipo ajeno de tres componentes y argumenta si el orden que has elegido es universal o particular tuyo.
- Busca en tu proyecto todos los operadores declarados y pásale a cada uno las cuatro preguntas del diagrama. Reescribe como función infija los que fallen alguna.
- Coge la expresión más densa que hayas escrito con operadores propios, enséñasela a alguien ajeno al módulo y anota literalmente lo que cree que hace.