El principio: implementar un nombre, no sobrecargar un símbolo
Kotlin no permite redefinir símbolos: permite implementar funciones cuyo nombre elige el lenguaje y marcarlas con el modificador operator. Esta lección establece la traducción sintáctica que hay detrás de cada notación, la tabla completa de correspondencias entre símbolo y nombre convenido, qué comprueba el compilador de una función de operador y qué deja en manos del diseñador, y por qué el conjunto de símbolos, su precedencia y su asociatividad son inalterables por diseño.
Hay una diferencia conceptual entre lo que hace Kotlin y lo que casi todo el mundo cree que hace cuando escribe una suma sobre un tipo propio. La creencia habitual, heredada de C++, es que el lenguaje permite dar un significado nuevo a un símbolo, como si el símbolo fuese una entidad declarable bajo la cual uno registra comportamientos. Kotlin no ofrece nada de eso. Cada símbolo es sintaxis fija del analizador, con una precedencia fija, una asociatividad fija y una traducción fija hacia una llamada a una función cuyo nombre elige el lenguaje y no tú. Lo único que aporta el programador es la implementación de esa función, sobre el tipo que quiera, con el modificador operator como declaración explícita de que sabe que está participando en una convención. Toda la potencia y todos los límites de este nivel salen de esa inversión de responsabilidades: no se extiende la gramática, se completa un protocolo de nombres que la gramática ya conoce de antemano.
- Explicar la traducción sintáctica que el compilador aplica a cada símbolo y por qué no constituye una sobrecarga del símbolo.
- Reproducir la correspondencia entre notación, nombre convenido y forma esperada de la firma.
- Distinguir lo que el compilador comprueba de una función marcada
operatorde lo que deja sin comprobar. - Justificar por qué no se pueden inventar operadores nuevos ni alterar la precedencia, y qué mecanismo cubre ese hueco.
Un símbolo es azúcar de una llamada con nombre fijo
La regla operativa cabe en una frase: al ver una notación de operador, el compilador la reescribe como una llamada a un miembro o a una extensión con un nombre predeterminado, y después resuelve esa llamada con las reglas ordinarias de resolución de sobrecargas. No hay ninguna tabla de símbolos definibles ni ningún registro global; hay una reescritura y, a partir de ahí, código normal.
data class Vec2(val x: Double, val y: Double)
operator fun Vec2.plus(otro: Vec2) = Vec2(x + otro.x, y + otro.y)
operator fun Vec2.times(k: Double) = Vec2(x * k, y * k)
operator fun Double.times(v: Vec2) = v * this // la otra mano, explícita
// lo que escribes
val destino = posicion + velocidad * dt
// lo que el compilador entiende
val destinoBis = posicion.plus(velocidad.times(dt))
Conviene notar dos cosas de ese ejemplo. La primera es que la precedencia del producto sobre la suma no la aporta nadie de los tipos implicados: viene de la gramática y se aplica antes de saber siquiera qué tipos hay. La segunda es que la conmutatividad no es gratis. El símbolo del producto se traduce siempre a una llamada cuyo receptor es el operando izquierdo, de modo que si quieres escribir el escalar a la izquierda debes declarar una segunda función sobre Double. El lenguaje no deduce nada del álgebra: obedece a la letra.
La resolución posterior es la habitual, con la consecuencia práctica de que los operadores se sobrecargan por tipo de parámetro exactamente igual que cualquier otra función, y de que un miembro gana a una extensión cuando ambos son aplicables.
class Dinero(val centimos: Long) {
operator fun plus(otro: Dinero) = Dinero(centimos + otro.centimos)
operator fun times(veces: Int) = Dinero(centimos * veces)
}
La tabla de correspondencias
| Notación | Llamada generada | Forma esperada |
|---|---|---|
a + b |
a.plus(b) |
tipo de retorno libre |
a - b |
a.minus(b) |
tipo de retorno libre |
a * b |
a.times(b) |
tipo de retorno libre |
a / b |
a.div(b) |
tipo de retorno libre |
a % b |
a.rem(b) |
tipo de retorno libre |
a..b |
a.rangeTo(b) |
tipo de retorno libre |
a..<b |
a.rangeUntil(b) |
tipo de retorno libre |
+a |
a.unaryPlus() |
sin parámetros |
-a |
a.unaryMinus() |
sin parámetros |
!a |
a.not() |
sin parámetros |
a++ |
a.inc() |
devuelve el tipo del receptor |
a-- |
a.dec() |
devuelve el tipo del receptor |
a += b |
a.plusAssign(b) o a = a.plus(b) |
la variante asignadora devuelve Unit |
a == b |
a?.equals(b) ?: (b === null) |
devuelve Boolean |
a > b |
a.compareTo(b) > 0 |
devuelve Int |
a in b |
b.contains(a) |
devuelve Boolean |
a[i] |
a.get(i) |
aridad libre |
a[i] = v |
a.set(i, v) |
el último parámetro es el valor |
a(x) |
a.invoke(x) |
aridad libre |
for (x in c) |
c.iterator() y luego hasNext con next |
protocolo de tres funciones |
val (p, q) = t |
t.component1() y t.component2() |
sin parámetros |
Merece la pena leer esa tabla como lo que es: la lista completa y cerrada de puntos de extensión sintáctica del lenguaje. No hay más, y ninguno de ellos admite negociación sobre el nombre. Un tipo que quiera comportarse como un número, como una colección, como una función o como una tupla lo hace implementando exactamente esos nombres, ni uno más.
Salvo en los casos que devuelven Boolean o Int por exigencia de la traducción, el lenguaje no impone nada sobre los tipos. plus puede recibir un tipo y devolver otro distinto del receptor, y eso es justo lo que hace la librería estándar cuando suma un instante y una duración para obtener otro instante. La convención regula la forma sintáctica, no la semántica algebraica.
Qué comprueba el compilador y qué no
El modificador operator no es decorativo ni opcional. Su ausencia sobre una función con nombre convenido hace que la notación no compile, y su presencia activa un conjunto de comprobaciones estructurales que dependen del nombre concreto.
class Caja(val n: Int) {
fun plus(otra: Caja) = Caja(n + otra.n) // función normal
}
// Caja(1) + Caja(2) error: operator modifier is required
class Marcador {
operator fun compareTo(otro: Marcador): Boolean = true // error: debe devolver Int
operator fun unaryMinus(x: Int) = x // error: no admite parámetros
}
La comprobación es puramente estructural: nombre correcto, aridad correcta y, cuando la traducción lo necesita, tipo de retorno correcto. Nada más. El compilador no verifica en ningún momento que tu plus sea asociativo, que tu compareTo defina un orden total, que tu equals sea simétrico ni que tu get termine. Toda la parte que un lector va a dar por supuesta al leer un símbolo queda enteramente bajo tu responsabilidad, y ese desfase entre lo comprobado y lo esperado es el material de las tres lecciones siguientes.
Hay dos detalles de herencia que conviene fijar ahora. El primero es que la propiedad de ser operador se hereda: al sobrescribir un miembro que ya era operator, la implementación no necesita repetir el modificador, y por eso equals se escribe con override a secas. El segundo es que se puede declarar como extensión, lo que convierte a este mecanismo en la vía canónica para dar notación de operador a tipos que no controlas.
No hay operadores nuevos, y eso es la mitad del diseño
flowchart TD
A[Expresion con simbolo] --> B[La gramatica fija precedencia y asociatividad]
B --> C[Reescritura al nombre convenido]
C --> D{Existe miembro o extension con ese nombre}
D -- No --> E[Error de compilacion]
D -- Si --> F{Lleva el modificador operator}
F -- No --> E
F -- Si --> G{Aridad y tipo de retorno validos}
G -- No --> E
G -- Si --> H[Llamada ordinaria resuelta por sobrecarga]No se puede declarar un símbolo que el lenguaje no tenga, ni cambiar la precedencia de uno existente, ni volver asociativo por la derecha algo que la gramática hace por la izquierda. Esa clausura es deliberada: si la precedencia dependiera de las bibliotecas importadas, leer una expresión exigiría conocer todas las declaraciones visibles, y ninguna herramienta podría analizar un archivo antes de resolverlo entero. Kotlin prefiere que la forma de una expresión se entienda antes que su significado.
Para las operaciones que merecen notación pero no símbolo existe una salida distinta y honesta: la función infix, que se escribe sin puntos ni paréntesis, tiene una única precedencia propia por debajo de los operadores aritméticos y nunca se confunde con ellos.
infix fun Vec2.proyectadoSobre(eje: Vec2): Vec2 = eje * ((this dot eje) / (eje dot eje))
infix fun Vec2.dot(otro: Vec2): Double = x * otro.x + y * otro.y
La ganancia de esa forma es que conserva el nombre de la operación en el sitio de llamada, que es justo lo que un símbolo elimina. Cuando dudes entre inventar un uso para un símbolo existente y escribir una función infija, la infija gana casi siempre, porque no pide al lector que adivine nada.
Aritmética y asignación
plus, minus, times, div, rem, sus variantes unary y las asignadoras terminadas en Assign, junto con inc y dec.
Igualdad y orden
equals para el símbolo de igualdad y compareTo para los cuatro relacionales, con contratos que el compilador no vigila.
Acceso y llamada
get, set, invoke, contains e iterator, que hacen que un tipo propio se comporte como colección o como función.
Convenciones sin símbolo
componentN para el desestructurado, getValue con setValue para la delegación y provideDelegate para su creación. Misma idea, sin notación asociada.
Vale la pena mirar este mecanismo desde arriba, porque revela una decisión de diseño mucho más profunda que la comodidad de sumar vectores. Cuando un lenguaje quiere que ciertos tipos participen en una construcción sintáctica, tiene dos caminos clásicos. El primero es el nominal por interfaz: obliga a implementar un tipo declarado, como hace Java con la interfaz que habilita el bucle mejorado, de modo que la relación entre tu clase y la construcción del lenguaje queda escrita en la jerarquía de herencia. El segundo es el estructural: exige únicamente que existan funciones con cierta forma, sin importar de dónde vengan ni qué implemente el tipo. Kotlin eligió el segundo, y lo hizo con una vuelta de tuerca decisiva, porque la comprobación estructural ocurre en tiempo de compilación y las funciones pueden ser extensiones. La consecuencia es que la relación entre un tipo y una construcción sintáctica deja de ser una propiedad del tipo para convertirse en una propiedad del ámbito léxico donde se escribe la expresión: un tipo compilado hace veinte años, sellado, sin fuentes y sin ninguna intención de participar en nada, puede recorrerse con un bucle, indexarse, compararse y desestructurarse dentro de tu archivo porque tú has importado las funciones adecuadas, y puede no poder hacerlo en el archivo de al lado. Eso resuelve el problema que hunde a las jerarquías de interfaces, que es la imposibilidad de retroadaptar un tipo ajeno a un contrato posterior a él. Y explica de paso una ganancia que se pasa por alto: como no hay interfaz que implementar, tampoco hay boxing ni despacho virtual obligatorio, y por eso recorrer un rango de enteros en Kotlin no crea ni un objeto, mientras que hacerlo mediante una interfaz genérica crearía uno por elemento. El precio de esta libertad es exactamente el que anuncia la lección: al no haber interfaz, no hay documentación ejecutable del contrato, y el compilador solo puede vigilar la forma. Las leyes que hacen útil a un operador (que la suma sea asociativa, que el orden sea total, que la igualdad sea simétrica, que la indexación sea barata) no viven en el sistema de tipos y nunca van a vivir ahí. Kotlin te da un mecanismo que verifica la sintaxis y te deja la semántica entera, y ese reparto, que parece un descuido, es la razón por la que el mecanismo es a la vez tan barato y tan fácil de usar mal.
- Escribe un tipo con
plussin el modificadoroperatory traduce el mensaje del compilador a la reescritura concreta que falló. - Declara
timessobre un tipo propio y comprueba que la multiplicación por un escalar a la izquierda no compila. Arréglalo con una extensión sobre el tipo numérico. - Implementa
compareTodevolviendoBooleany explica por qué la traducción del símbolo de mayor que hace imposible aceptarlo. - Da notación de indexación a un tipo de una librería ajena mediante una extensión
get, e importa la extensión en un solo archivo para observar que la capacidad es local. - Busca en la librería estándar tres implementaciones de
pluscuyo tipo de retorno no coincida con el del receptor y argumenta qué comunica cada una.