Aritméticos y asignación: plus frente a plusAssign
Los operadores aritméticos devuelven valores nuevos y las variantes terminadas en Assign modifican el receptor, pero la misma notación compuesta puede resolverse hacia cualquiera de las dos según lo que exista y según si la variable es reasignable. Esta lección expone la regla de desambiguación completa, el papel de val y var, la expansión del incremento y del acceso indexado, y el catálogo de errores clásicos que produce confundir copia con mutación.
La notación compuesta de suma es el único punto del lenguaje donde una misma expresión escrita del mismo modo puede compilarse hacia dos programas de comportamiento opuesto: uno que construye un valor nuevo y reasigna la variable, y otro que modifica en el sitio el objeto que ya estaba ahí. La elección no la hace quien escribe la línea, sino el compilador, aplicando una regla que depende de qué funciones existen para ese tipo y de si la variable es reasignable. En la librería estándar esa regla está calibrada con mucho cuidado, y por eso funciona sin que nadie la piense; en el código propio, en cambio, es la fuente de un tipo de error especialmente desagradable, porque no produce un fallo sino un cambio silencioso en la semántica de aliasing o en la complejidad asintótica de un bucle. Entender la regla no es un tecnicismo: es la diferencia entre saber si tu programa copia o comparte.
- Aplicar la regla completa de desambiguación de la asignación compuesta y predecir hacia qué llamada se compila.
- Explicar el papel de
valyvaren esa decisión y por qué una variable inmutable puede admitir la notación compuesta. - Expandir mentalmente el incremento, el decremento y la asignación compuesta sobre un acceso indexado.
- Diagnosticar los errores clásicos de copia frente a mutación y diseñar el par de operaciones sin ambigüedad.
Los aritméticos producen valores, las variantes asignadoras modifican
Las funciones aritméticas de la convención tienen una expectativa semántica clara aunque el compilador no la exija: plus, minus, times, div y rem deben devolver un valor nuevo y dejar intactos ambos operandos. Las variantes terminadas en Assign hacen lo contrario: modifican el receptor y no devuelven nada, y el lenguaje exige que su tipo de retorno sea Unit porque el valor no tendría dónde ir.
class Cesta(private val items: MutableList<String> = mutableListOf()) {
operator fun plus(item: String): Cesta = Cesta((items + item).toMutableList())
operator fun plusAssign(item: String) { items += item }
}
Declarar las dos parece generoso y es exactamente el error que hay que evitar, como veremos. Antes conviene fijar un detalle que sorprende: el símbolo del resto se traduce a rem, no a mod. Son operaciones distintas cuando hay signos negativos, porque rem conserva el signo del dividendo y mod el del divisor, y mod es una función ordinaria sin notación asociada precisamente para que la diferencia sea visible en el sitio de llamada. La misma advertencia vale para la división: sobre enteros trunca hacia cero, y ese comportamiento se hereda en cualquier div propio que delegue en la aritmética entera sin pensarlo.
Las formas unarias completan el grupo y no reciben ningún parámetro, porque el único operando ya es el receptor. Son el lugar natural para las operaciones que producen la contrapartida de un valor sin cambiar su tipo, y la negación lógica se traduce a not, que es una función ordinaria más y no un privilegio del tipo booleano.
@JvmInline
value class Saldo(val centimos: Long) {
operator fun unaryMinus() = Saldo(-centimos)
operator fun not() = Saldo(0) // legible solo si el dominio lo justifica
}
val deuda = -saldo // saldo.unaryMinus()
El segundo caso de ese ejemplo está puesto a propósito como contraejemplo: la negación lógica sobre algo que no es una condición no significa nada para quien lee, y ese tipo de licencias es lo que convierte un catálogo de operadores en un dialecto privado.
La regla de desambiguación
Ante una expresión de asignación compuesta, el compilador construye dos candidatos y decide entre ellos con una regla que conviene memorizar tal cual.
flowchart TD
A[Expresion a mas igual b] --> B{Existe plusAssign aplicable}
B -- No --> C{Existe plus aplicable}
C -- No --> D[Error: ninguna convencion aplica]
C -- Si --> E{a es reasignable y el tipo encaja}
E -- No --> D
E -- Si --> F[Compila como a igual a.plus b]
B -- Si --> G{Existe ademas plus con a reasignable}
G -- No --> H[Compila como a.plusAssign b]
G -- Si --> I[Error: ambiguedad de operadores de asignacion]De esa regla se deducen tres consecuencias que explican casi todo lo que se ve en la práctica. La primera es que una variable declarada con val admite la notación compuesta siempre que exista la variante asignadora, porque la rama que reasigna nunca llega a ser candidata; no hay contradicción alguna, ya que lo inmutable es el enlace y no el objeto. La segunda es que ofrecer las dos operaciones sobre un tipo cuya suma devuelve el mismo tipo hace que la notación compuesta deje de compilar sobre variables reasignables, con un mensaje de ambigüedad que desconcierta a quien no conoce la regla. La tercera es más sutil y es la que gobierna las colecciones de la librería estándar.
var inmutable: List<Int> = listOf(1, 2)
inmutable += 3 // plus: crea una lista nueva y reasigna
val mutable = mutableListOf(1, 2)
mutable += 3 // plusAssign: modifica la lista existente
var ambas: MutableList<Int> = mutableListOf(1, 2)
ambas += 3 // plusAssign: plus devuelve List y no encaja en el tipo declarado
El tercer caso es el interesante y no es casualidad. La extensión que suma un elemento a una colección devuelve List, nunca MutableList, y esa decisión de firma es lo que impide que el candidato reasignador sea aplicable cuando la variable está declarada como lista mutable. Sin ella habría ambigüedad en un caso extremadamente común. Es un ejemplo excelente de cómo el tipo de retorno de una operación se elige para gobernar una resolución, y no solo para describir un resultado.
Las tres líneas del ejemplo se leen igual y hacen cosas muy distintas. La primera asigna, copia toda la lista y no afecta a ningún otro nombre que apuntara a la anterior. La segunda y la tercera no copian nada y son visibles para cualquier otra referencia al mismo objeto. Dentro de un bucle, la primera forma convierte una acumulación en cuadrática. Cambiar el tipo declarado de una variable de lista a lista mutable, o al revés, altera a la vez el coste y la semántica de aliasing de todas las líneas que usen la notación compuesta, sin tocar ninguna de ellas.
Incremento, decremento y el sitio indexado
El incremento tiene una expansión propia que aclara por qué inc debe devolver un valor y no modificar nada. La forma sufija guarda el valor previo, reasigna la variable con el resultado de la llamada y produce el valor guardado; la forma prefija reasigna primero y produce el nuevo. En ambos casos hay una asignación, de modo que el incremento exige var sin excepciones.
// a++ equivale a
val previo = a
a = previo.inc()
// el valor de la expresion es previo
@JvmInline
value class Version(val numero: Int) {
operator fun inc(): Version = Version(numero + 1) // devuelve, no muta
}
La asignación compuesta sobre un acceso indexado combina dos convenciones y merece verse expandida, porque es donde más gente se equivoca al razonar sobre efectos secundarios. Si el elemento admite la variante asignadora, se llama sobre el elemento y el contenedor no recibe ninguna escritura; si no la admite, se lee, se suma y se escribe de vuelta.
matriz[fila, columna] += 1
// con plusAssign en el elemento: matriz.get(fila, columna).plusAssign(1)
// sin ella: matriz.set(fila, columna, matriz.get(fila, columna).plus(1))
La diferencia importa cuando get devuelve una copia defensiva: en ese caso la primera variante modificaría un objeto temporal y el contenedor quedaría intacto, y el error sería completamente silencioso. Es la razón por la que un get que copia y un elemento con operaciones mutadoras forman una combinación que conviene no ofrecer nunca.
Errores clásicos y disciplina de diseño
Un plus que muta
Devolver el receptor tras modificarlo hace que la expresión parezca pura y no lo sea. Quien escriba una suma dentro de una expresión mayor obtendrá efectos que nadie ve en el sitio de llamada.
Ofrecer plus y plusAssign a la vez
Sobre un tipo cuya suma devuelve el mismo tipo, produce ambigüedad en toda variable reasignable. Elige una de las dos según el tipo sea de valor o contenedor mutable.
Acumular en un bucle
La notación compuesta sobre tipos inmutables copia en cada vuelta. Con cadenas y listas convierte un recorrido lineal en cuadrático sin que nada lo indique.
Nombres que mienten
Usar el símbolo de división para una operación que no divide, o el de resto para un módulo con otro signo, rompe la única garantía que tiene el lector, que es su intuición aritmética.
Hay un quinto error que no cabe en una tarjeta porque es de proceso y no de código. Añadir una variante asignadora a un tipo que ya publicaba la operación pura no es una adición compatible: convierte en ambiguas todas las líneas de todos los consumidores que usaran la notación compuesta sobre una variable reasignable, y lo hace con un mensaje que apunta a su código y no al tuyo. En una librería, el par de operaciones forma parte de la superficie pública con la misma fuerza que una firma, y ampliarlo después rompe a quien ya lo usaba.
// version 1 de la libreria
operator fun Presupuesto.plus(otro: Presupuesto): Presupuesto = ...
// version 2 anade la variante asignadora
operator fun Presupuesto.plusAssign(otro: Presupuesto) { ... }
// en el codigo del consumidor, que no ha cambiado
var total = Presupuesto.CERO
total += mensual // deja de compilar: ambiguedad
La disciplina que se deduce de todo lo anterior es corta. Si el tipo es de valor, inmutable y con semántica algebraica, implementa solo las funciones que devuelven, deja que la reasignación la haga la regla y no declares ninguna variante asignadora. Si el tipo es un contenedor mutable cuya identidad importa, implementa solo la variante asignadora y ofrece la copia mediante una función con nombre explícito. Ofrecer las dos únicamente tiene sentido cuando los tipos de retorno hacen imposible la ambigüedad, como en la librería estándar, y esa es una situación que hay que buscar a propósito y documentar.
Detrás de esta regla hay una tensión que ningún lenguaje ha resuelto de forma satisfactoria, y conviene nombrarla porque explica por qué el mecanismo es tan incómodo de diseñar y tan cómodo de usar. La notación compuesta nació en los lenguajes imperativos como abreviatura de una lectura, una operación y una escritura sobre una celda de memoria; en un mundo de valores inmutables significa algo distinto, que es construir un valor nuevo y volver a enlazar un nombre. Ambos significados son legítimos y ambos merecen la misma notación, porque quien la escribe piensa lo mismo en los dos casos: quiero que esto crezca. La tradición ha respondido de dos formas insatisfactorias. Los lenguajes puramente funcionales suprimen la notación y obligan a escribir la reasignación entera, con lo que la intención se lee peor. Los lenguajes puramente imperativos la fijan a la mutación, con lo que los tipos de valor quedan como ciudadanos de segunda. Kotlin toma un tercer camino: mantiene una sola notación y traslada la decisión al sistema de tipos, de modo que el significado de una línea depende del tipo declarado de la variable que aparece a la izquierda. La ganancia es real y se nota al escribir, porque un mismo idioma sirve para una cadena, para un entero, para una lista persistente y para un acumulador mutable, y en cada caso hace lo que corresponde a ese tipo sin que haya que recordarlo. La pérdida también es real y hay que asumirla con los ojos abiertos: la localidad de la lectura desaparece. Al leer una línea con notación compuesta ya no sabes qué hace, porque su significado no está en la línea sino en una declaración que puede estar treinta líneas más arriba, en un parámetro de función o en un campo de otra clase. Ese es el punto que separa esta lección de un mero recordatorio de sintaxis: los operadores no son azúcar neutro sobre llamadas, son un lugar donde el lenguaje ha decidido que la información relevante viva en los tipos y no en las expresiones. Si aceptas ese reparto, la consecuencia práctica es que los tipos de tus colecciones y de tus acumuladores dejan de ser un detalle de documentación y pasan a ser parte del contrato de rendimiento y de aliasing de todo el código que los toca; y que cambiar una declaración de inmutable a mutable, algo que suele hacerse sin pensar para silenciar un error del compilador, puede alterar en silencio el comportamiento de líneas que nadie ha revisado.
- Declara un tipo con las dos operaciones de suma y prueba la notación compuesta sobre una variable reasignable y sobre una inmutable. Explica cada resultado con la regla.
- Cambia el tipo de retorno de tu
plusa un supertipo y observa cómo desaparece la ambigüedad. Argumenta por qué la librería estándar hace exactamente eso. - Escribe un bucle que acumule con notación compuesta sobre una lista inmutable, mide el tiempo con cien mil elementos y compáralo con la versión mutable.
- Implementa
incmutando el receptor y devuelve el propio objeto. Escribe una expresión donde ese atajo produzca un resultado distinto del esperado. - Construye un contenedor cuyo
getdevuelva una copia y comprueba qué ocurre al aplicar la notación compuesta sobre un elemento indexado.