Desestructurado: del orden peligroso al nombre seguro
Cómo funciona por dentro el desestructurado posicional con las funciones componentN, por qué reordenar el constructor rompe a tus usuarios en silencio, y el desestructurado basado en nombres que Kotlin 2.3.20 introdujo para desactivar esa trampa.
El desestructurado es la única característica de las data class que se apoya en un contrato invisible y frágil: el orden de declaración de las propiedades. Durante quince años, cambiar dos parámetros de sitio en un constructor fue una refactorización que ningún compilador señalaba y que podía intercambiar dos valores en cada punto donde alguien desestructuraba tu tipo. Kotlin 2.3.20 rompió con esa herencia introduciendo el desestructurado basado en nombres, que empareja variables con propiedades por identificador y no por posición. Es probablemente el cambio más importante que le ha pasado a las data class desde que existen.
- Explicar a qué se traduce exactamente una declaración desestructurada posicional.
- Reproducir el fallo silencioso que provoca reordenar el constructor primario.
- Escribir desestructurado basado en nombres y saber cuándo el orden sí es el contrato.
- Planificar una migración usando los tres modos del compilador.
La mecánica del desestructurado posicional
Una declaración desestructurada no es una característica de las data class: es una convención de operadores, como plus o invoke. El compilador reescribe la declaración en llamadas numeradas:
data class Usuario(val nombre: String, val email: String)
val (a, b) = usuario
// se traduce literalmente a
val a = usuario.component1()
val b = usuario.component2()
Lo que una data class aporta es la generación automática de esas funciones operator fun componentN, una por cada propiedad del constructor primario y en el orden en que aparecen. Cualquier tipo que declare esos operadores, aunque sea mediante funciones de extensión, se puede desestructurar; así es como funcionan Pair, Triple y las entradas de un mapa:
for ((clave, valor) in mapa) { /* usa Map.Entry.component1 y component2 */ }
lista.forEachIndexed { i, x -> /* dos parametros, no desestructurado */ }
mapa.mapValues { (_, valor) -> valor.trim() } // un par desestructurado
El guion bajo salta un componente y, además, evita la llamada al operador correspondiente. Y hay una diferencia sintáctica que confunde a mucha gente: en una lambda, dos parámetros separados por coma son dos parámetros, mientras que los mismos nombres entre paréntesis son un único parámetro que se desestructura.
val f = { a: Int, b: Int -> a + b } // dos parametros
val g = { (a, b): Pair<Int, Int> -> a + b } // un parametro desestructurado
Como las componentN son operadores ordinarios, puedes habilitar el desestructurado en tipos ajenos que no controlas, incluidos los de la biblioteca estándar de la plataforma, con simples funciones de extensión. Es un mecanismo abierto, y esa apertura es justamente lo que lo hace tan difícil de auditar:
operator fun java.time.LocalDate.component1(): Int = year
operator fun java.time.LocalDate.component2(): Int = monthValue
operator fun java.time.LocalDate.component3(): Int = dayOfMonth
val (anio, mes, dia) = java.time.LocalDate.now()
El orden es el contrato, y nadie lo vigila
Aquí está el defecto de diseño. Los nombres que escribes en la declaración desestructurada son locales y arbitrarios: el compilador no los compara con nada. Solo cuenta posiciones:
data class Usuario(val nombre: String, val email: String)
val usuario = Usuario("alicia", "alicia@ejemplo.test")
val (email, nombre) = usuario
println(email) // alicia
println(nombre) // alicia@ejemplo.test
El código compila sin un solo aviso y hace exactamente lo contrario de lo que se lee. Peor todavía es el escenario evolutivo: publicas un tipo con nombre y email en ese orden, alguien desestructura correctamente, y seis meses después alguien reordena el constructor por estética o al añadir un parámetro. Como ambas propiedades son String, el desestructurado del consumidor sigue compilando y ahora asigna los valores cruzados. No hay error de tipos, no hay aviso de deprecación, no hay nada. El fallo aparece a kilómetros del cambio, normalmente en un correo enviado a la dirección equivocada.
Si las propiedades reordenadas tienen tipos distintos, el error se convierte en fallo de compilación y te salvas. Pero eso no es una garantía del lenguaje, es una casualidad de tu modelo. En cuanto un tipo tiene dos o más propiedades del mismo tipo, cosa habitualísima, el desestructurado posicional deja de tener red.
Kotlin 2.3.20: emparejar por nombre
La solución que introdujo Kotlin 2.3.20, todavía en estado experimental, invierte el criterio: las variables se corresponden con las propiedades por nombre, y la posición deja de significar nada. La forma explícita declara cada variable con su propio val y, si quieres, la liga a una propiedad concreta:
// Forma explicita: cada variable dice de que propiedad viene
(val correo = email, val alias = nombre) = usuario
println(alias) // alicia
println(correo) // alicia@ejemplo.test
Ahora el orden en que escribas los elementos es irrelevante, y si el nombre de la propiedad desaparece o cambia, el compilador falla en el punto exacto del uso en vez de asignar valores cruzados. La refactorización deja de ser una operación de fe.
Junto con esta forma llega una segunda novedad sintáctica: los corchetes pasan a ser el desestructurado posicional explícito, reservado para los tipos donde el orden sí es la semántica, como listas, colecciones ordenadas y tuplas sin nombre como Pair o Triple:
val punto = Pair(10, 20)
val [x, y] = punto // aqui la posicion es el contrato, y esta bien que lo sea
flowchart TD
A[Declaracion desestructurada] --> B{Que significa la posicion}
B -->|Nada, hay nombres| C[Parentesis con nombres de propiedad]
B -->|Todo, es una tupla| D[Corchetes posicionales]
C --> E[El compilador falla si el nombre no existe]
D --> F[El compilador usa componentN por orden]
E --> G[Refactorizar el constructor es seguro]
F --> H[Reordenar cambia el significado en silencio]La distinción es conceptualmente limpia: los paréntesis son para registros, donde cada campo tiene nombre y el orden es un accidente de escritura; los corchetes son para secuencias, donde el orden es la información. Que durante quince años ambas cosas compartieran sintaxis es exactamente la razón de que el problema existiera.
Paréntesis para registros
data class, objetos de estado, respuestas de red, cualquier tipo cuyos campos tengan nombre propio. Aquí el orden del constructor es una decisión interna del autor y ningún consumidor debería depender de ella.
Corchetes para secuencias
Pair, Triple, listas, coordenadas, resultados de una partición. Aquí el índice es la única identidad que existe, y hacer explícito que estás leyendo por posición es información útil para quien lea el código.
Migrar sin sustos: los tres modos del compilador
La característica se activa con la opción -Xname-based-destructuring, que admite tres modos pensados para recorrerse en orden y no de un salto.
kotlin {
compilerOptions {
freeCompilerArgs.add("-Xname-based-destructuring=name-mismatch")
}
}
El modo only-syntax habilita la forma explícita con nombres y la forma posicional con corchetes, sin cambiar el comportamiento de nada de lo que ya tienes escrito: es aditivo y no puede romper nada. El modo name-mismatch es el interesante durante la transición, porque emite avisos allí donde un desestructurado posicional sobre una data class usa nombres de variable que no coinciden con los de las propiedades; es decir, te enseña la lista exacta de sitios donde el significado del código cambiaría, que resulta ser también la lista de sitios donde probablemente ya tenías un fallo latente. El modo complete da el paso final: la forma corta entre paréntesis pasa a emparejar por nombre, y el desestructurado posicional queda relegado a los corchetes.
El itinerario responsable es evidente. Activa name-mismatch, resuelve todos los avisos renombrando variables o adoptando la forma explícita, revisa que ningún desestructurado sobre Pair, Triple o colecciones se haya quedado con paréntesis, y solo entonces pasa a complete. Saltar directamente al último modo en una base de código grande es la manera más eficiente de convertir un problema latente en un incidente.
flowchart LR A[Estado inicial sin la opcion] --> B[only-syntax] B --> C[name-mismatch con avisos] C --> D[Revisar cada aviso uno a uno] D --> E[Reescribir con nombres o corchetes] E --> F[complete]
Merece la pena insistir en que la característica es experimental y que eso tiene dos implicaciones prácticas. La primera es que la sintaxis y los detalles de los modos pueden afinarse en versiones posteriores, de modo que no conviene apoyar en ella la superficie pública de una librería que otros consuman antes de que se estabilice. La segunda, más optimista, es que el modo name-mismatch es útil aunque nunca llegues a activar complete: funciona como un analizador estático gratuito que te señala desestructurados sospechosos, y ese valor lo obtienes sin comprometerte con ningún cambio de comportamiento. Activarlo en integración continua y tratar sus avisos como deuda a revisar es probablemente la decisión de mejor relación entre coste y beneficio que puedes tomar hoy sobre este asunto.
Merece la pena entender por qué este cambio es más profundo que una comodidad sintáctica, porque toca el corazón de qué es un tipo de datos. Una data class es un registro: una colección de campos con nombre, donde el nombre es la identidad del campo y el orden en que los escribiste es un accidente de la redacción, tan irrelevante como el orden en que declaras los métodos. Una tupla es otra cosa: una secuencia posicional donde el índice es la única identidad disponible, precisamente porque renunciaste a poner nombres. Kotlin, al implementar el desestructurado sobre el mecanismo de las componentN, convirtió todo registro en una tupla en el momento exacto en que alguien lo desestructuraba, y con ello introdujo una dependencia oculta y no verificada entre el orden de declaración de un tipo y el código de todos sus consumidores. La gravedad de esa dependencia no está en que produzca fallos, sino en la categoría de fallo que produce: es silencioso, porque compila; es no local, porque el cambio ocurre en el productor y el daño aparece en el consumidor, quizá en otro módulo o en otro repositorio; es indetectable por revisión, porque el diff que lo causa es una simple reordenación que parece cosmética y el código dañado ni siquiera aparece en el diff; y es tipo-invisible siempre que dos campos compartan tipo, que es el caso normal en cualquier modelo con varios identificadores o varios textos. Añádele que la sintaxis animaba a renombrar libremente las variables locales, con lo que el código de llamada podía decir nombre mientras contenía un correo electrónico y la lectura humana quedaba activamente desinformada. El desestructurado basado en nombres no arregla un detalle: restituye la correspondencia entre lo que un tipo declara ser y lo que sus consumidores están obligados a conocer, y devuelve el orden de los parámetros al terreno donde debió estar siempre, el de las decisiones internas que se pueden cambiar sin romper a nadie. Que la sintaxis posicional sobreviva bajo corchetes es la otra mitad del acierto, porque hay estructuras donde la posición sí es la semántica y forzarlas a tener nombres habría sido cambiar un error por otro.
- Escribe una
data classcon dos propiedades del mismo tipo, desestructúrala con los nombres cruzados y comprueba que compila y miente. - Activa
-Xname-based-destructuring=name-mismatchen un módulo real y cuenta cuántos avisos aparecen: esa cifra es tu deuda latente. - Recorre esos avisos y clasifica cada caso en dos montones, los que eran un fallo real y los que solo eran nombres perezosos.
- Busca tus desestructurados sobre
Pair,Tripleo entradas de mapa y reescríbelos con corchetes: ahí la posición sí es el contrato y conviene que se lea así.