Uso idiomático de data class
⏱ Dedicación recomendada: 0 minutos
Esto considera el contenido visible y relevante, e ignora texto colapsado o marcado como opcional.
r8vnhill/algebraic-data-types-kt
En Kotlin, las data class
son una de las herramientas más expresivas y útiles para modelar datos. Su diseño favorece la inmutabilidad, la comparación estructural y la simplicidad declarativa, lo que las convierte en una excelente opción para representar tipos producto: estructuras que agrupan múltiples valores con significado semántico.
En esta lección exploraremos su uso idiomático, enfocándonos en sus ventajas frente a clases tradicionales o tuplas, su comportamiento por defecto y su rol en el diseño de bibliotecas reutilizables. Veremos cómo desestructurar instancias, crear copias inmutables con copy
, utilizar constructores alternativos y mantener un diseño claro incluso cuando se requiere cierta mutabilidad.
Más que enseñarte a usar data class
, esta lección te dará criterios para tomar decisiones de diseño informadas al modelar estructuras de datos claras, robustas y fáciles de mantener.
🎁 Desestructuración
Una de las ventajas de las data class
es que generan automáticamente funciones componentN()
, lo que permite extraer sus campos de forma clara y concisa, sin necesidad de acceder a cada propiedad manualmente.
Por ejemplo, al representar una canción de Aerosmith:
data class Song(val title: String, val year: Int)
val (title, year) = Song("Dream On", 1973)
println("'$title' se lanzó en $year")
Este tipo de desestructuración resulta especialmente útil cuando:
- Iteras sobre listas de objetos
- Trabajas con funciones puras que devuelven múltiples valores
- Usas combinadores como
map
,filter
,fold
, etc.
val playlist = listOf(
Song("Dream On", 1973),
Song("I Don't Want to Miss a Thing", 1998)
)
for ((title, year) in playlist) {
println("$title ($year)")
}
🧪 Ejemplo con combinadores
Los combinadores como filter
y map
permiten transformar y seleccionar datos de forma declarativa. Gracias a la desestructuración, esto es muy legible:
val classics = playlist
.filter { (_, year) -> year < 1980 }
.map { (title, _) -> title.uppercase() }
println(classics) // [DREAM ON]
Aquí usamos:
(_, year)
para ignorar el título y filtrar por año.(title, _)
para ignorar el año y transformar el título.
Esto muestra cómo la desestructuración simplifica operaciones típicas sobre colecciones.
♻️ Mutabilidad controlada con copy
Las data class
en Kotlin son inmutables por convención. Aunque es posible declarar propiedades mutables (var
), en bibliotecas bien diseñadas se recomienda utilizar solo propiedades inmutables (val
). Esto favorece un estilo funcional, más seguro y predecible.
Cuando necesitas "modificar" una instancia, en lugar de cambiarla directamente, puedes usar el método copy()
para crear una nueva instancia con los valores actualizados, sin alterar el original:
data class Mecha(val name: String, val power: Int)
val gurren = Mecha("Gurren", 3000)
val gurrenLagann = gurren.copy(name = "Gurren Lagann", power = 9000)
println(gurren) // Mecha(name=Gurren, power=3000)
println(gurrenLagann) // Mecha(name=Gurren Lagann, power=9000)
Este patrón favorece objetos seguros, predecibles y fáciles de testear —cualidades esenciales para construir bibliotecas reutilizables, componentes funcionales y sistemas concurrentes.
🔄 ¿Cuándo es razonable usar mutabilidad?
Aunque las data class
promueven la inmutabilidad por convención, existen escenarios donde la mutabilidad está justificada:
- Estás modelando estado que cambia naturalmente con el tiempo (una sesión, una conexión, etc.).
- Necesitas actualizar datos frecuentemente, donde
copy()
sería costoso o complejo (como en videojuegos o simulaciones). - Trabajas con estructuras internas o efímeras que no forman parte del contrato público de tu biblioteca.
Cuando requieras mutabilidad, separa el modelo inmutable del estado mutable. Por ejemplo:
data class FighterStats(val maxHp: Int, val maxStamina: Int)
class CombatSession(stats: FighterStats) {
var currentHp = stats.maxHp
private set
var currentStamina = stats.maxStamina
private set
fun receiveDamage(amount: Int) {
currentHp = (currentHp - amount).coerceAtLeast(0)
}
fun consumeStamina(amount: Int) {
currentStamina = (currentStamina - amount).coerceAtLeast(0)
}
}
Aquí, FighterStats
define un modelo de referencia inmutable, mientras que CombatSession
gestiona un estado mutable que puede cambiar en tiempo de ejecución.
En contextos de altas tasas de actualización (como motores físicos o renderizado), crear múltiples copias puede ser un cuello de botella. En esos casos, una clase mutable puede ser una alternativa válida si mejora el rendimiento sin sacrificar claridad ni seguridad.
🧩 ¿Cuándo usar data class
, class
o tuplas (Pair
/ Triple
)?
Escenario | data class 🟢 | class ⚙️ | Tupla (Pair / Triple ) 🔹 |
---|---|---|---|
Modelar datos estructurados con nombre | ✅ Ideal | ⚠️ Posible, pero más verboso | ❌ Nombres implícitos dificultan la claridad |
Comparación por contenido (== ) | ✅ Generada automáticamente | ❌ Solo por referencia | ⚠️ Disponible, pero sin semántica explícita |
Métodos como copy , toString | ✅ Generados automáticamente | ❌ Manuales | ✅ Limitados a aridad 2 o 3 |
Lógica adicional o comportamiento | ⚠️ Posible, pero no idiomático | ✅ Ideal para encapsular comportamiento | ⚠️ Vía extensiones, pero no se recomienda |
Datos temporales o resultados intermedios | ⚠️ Posible, pero puede ser innecesario | ❌ Verboso para estructuras efímeras | ✅ Excelente para datos rápidos y sin contexto |
Uso interno o en contextos de rendimiento | ⚠️ Evaluar según el caso | ✅ Control total | ✅ Muy livianas, ideales para estructuras internas |
- Usa
data class
para representar datos estructurados con semántica clara, donde la comparación por contenido, los métodos generados y la legibilidad son importantes. - Usa
class
cuando necesites lógica adicional, comportamiento mutable, herencia, o un control más fino sobre el ciclo de vida del objeto. - Usa tuplas (
Pair
/Triple
) solo para datos temporales o intermedios, donde los nombres de los campos no son relevantes y la concisión es prioritaria.
Si necesitas representar más de tres elementos, considera siempre una data class
con nombres explícitos. Las tuplas de aridad alta son difíciles de mantener y comprender.
🔧 Funciones y propiedades en data class
Aunque las data class
están diseñadas para modelar datos estructurados, eso no impide que incluyan propiedades calculadas o funciones auxiliares.
Esto puede ser útil para agregar lógica derivada, validaciones simples o representaciones alternativas sin romper la semántica del tipo.
data class Wizard(val name: String, val magic: String, val power: Int) {
val isArchmage: Boolean
get() = power > 9000
fun shout() = println("$name casts $magic at power $power!")
}
En este ejemplo, isArchmage
es una propiedad calculada que no forma parte del constructor, pero que proporciona información derivada a partir de los campos. La función shout()
encapsula un comportamiento asociado al tipo, mejorando su expresividad sin afectar su estructura.
Aunque es válido incluir funciones y propiedades adicionales en una data class
, no deberías cargarla con lógica compleja, efectos secundarios o estado mutable. Su propósito principal es representar datos inmutables, estructurados y comparables por contenido.
Además, las data class
forman parte del contrato público de tu biblioteca o API: cualquier campo fuera del constructor primario no participará en equals
, hashCode
, copy
ni toString
, lo que puede generar inconsistencias sutiles o errores difíciles de detectar.
Si una propiedad es esencial para la identidad del objeto, debe declararse en el constructor. Si la clase empieza a mezclar demasiada lógica con datos, considera extraer esa lógica a otra clase o usar composición.