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.
En Kotlin, las data class
son una de las herramientas más poderosas y expresivas para modelar datos. Su diseño se alinea con los principios de inmutabilidad, comparación estructural y simplicidad declarativa, convirtiéndolas en una opción ideal para representar tipos producto —estructuras que agrupan múltiples valores con significado semántico.
En esta lección exploraremos el uso idiomático de las data class
, enfocándonos en sus ventajas frente a clases tradicionales o tuplas, su comportamiento predeterminado, y su rol en el diseño de bibliotecas reutilizables. Veremos cómo desestructurar datos, crear nuevas instancias inmutables con copy
, aprovechar constructores alternativos, y mantener un diseño limpio incluso cuando se requiere mutabilidad controlada.
Esta lección no solo te enseñará a usar data class
con fluidez, sino que te dará herramientas para tomar decisiones de diseño informadas al modelar estructuras de datos claras, robustas y fáciles de mantener.
- Tipo producto: Agrupa múltiples valores con nombre, como
Point(x, y)
. - Registro (record): Tipo producto inmutable, comparable por contenido. En Kotlin:
data class
. - Inmutabilidad por convención: Usar
val
para evitar modificar objetos. - Comparación por contenido: Evalúa igualdad de campos (
==
), no de referencias (===
). - Desestructuración: Extraer campos como variables usando
componentN()
.
🎁 Desestructuración
Una de las ventajas de las data class
es que generan automáticamente funciones componentN()
que permiten extraer sus campos de forma clara y concisa, sin necesidad de acceder a ellos uno por uno.
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 es 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)")
}
♻️ Mutabilidad controlada con copy
Las data class
en Kotlin son inmutables por convención. Aunque técnicamente permiten declarar propiedades mutables (var
), en bibliotecas bien diseñadas se recomienda usar solo propiedades inmutables (val
), promoviendo un estilo funcional y seguro.
En lugar de modificar un objeto existente, puedes usar el método copy()
para crear una nueva instancia con los cambios deseados, 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 permite que los objetos sean seguros, predecibles y fáciles de testear, cualidades fundamentales en el desarrollo de bibliotecas reutilizables o sistemas concurrentes.
🔄 ¿Cuándo es razonable usar mutabilidad?
Aunque las data class
promueven la inmutabilidad por convención, existen escenarios en los que la mutabilidad está justificada:
- Estás modelando estado que cambia naturalmente con el tiempo, como una sesión de usuario, un contador o una conexión activa.
- Necesitas actualizar datos de forma intensiva o frecuente, donde crear copias sería costoso o innecesariamente complejo (por ejemplo, en simulaciones, videojuegos o sistemas de alto rendimiento).
- Estás trabajando con estructuras internas o transitorias, que no forman parte del contrato público de tu biblioteca y pueden beneficiarse de una representación mutable.
Cuando requieras mutabilidad, considera separar el estado mutable del modelo inmutable. Un enfoque común es utilizar una data class
como modelo de referencia o configuración inicial, y representar el estado cambiante con una clase mutable dedicada:
data class FighterStats(val maxHp: Int, val maxStamina: Int)
class CombatSession(stats: FighterStats) {
var currentHp: Int = stats.maxHp
private set
var currentStamina: Int = stats.maxStamina
private set
fun receiveDamage(amount: Int) {
currentHp = (currentHp - amount).coerceAtLeast(0)
}
fun consumeStamina(amount: Int) {
currentStamina = (currentStamina - amount).coerceAtLeast(0)
}
}
En este patrón, FighterStats
representa un modelo inmutable de referencia (por ejemplo, los valores base del personaje), mientras que CombatSession
gestiona el estado mutable de la instancia en ejecución. Así se evita crear copias constantes y se mantiene clara la separación entre datos persistentes y estado transitorio.
En contextos donde se realizan cientos o miles de copias por segundo —como en motores físicos, sistemas de renderizado o procesamiento en tiempo real—, la creación constante de nuevas instancias puede convertirse en un cuello de botella. En estos casos, usar clases mutables puede ser una decisión válida si mejora significativamente el rendimiento sin comprometer la claridad ni la seguridad del diseño.
🧩 ¿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.
🔧 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.
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.
🏗️ Constructores
En Kotlin, las data class
pueden tener constructores primarios y secundarios, los cuales permiten definir formas alternativas de crear una instancia sin repetir la lógica de inicialización. Esto es útil cuando algunos datos pueden asumir valores por defecto o si quieres ofrecer una API más flexible.
En el siguiente ejemplo, modelamos libros publicados en el siglo XX. El constructor principal exige título, autor y año, pero también ofrecemos una alternativa que asume que el autor es desconocido si no se especifica:
- Código esencial
- Código completo
data class TwentiethCenturyBook(val title: String, val author: String, val year: Int) {
init {
require(year in 1900..1999) {
"Only books published between 1900 and 1999 are allowed. Received: $year"
}
}
constructor(title: String, year: Int) : this(title, "Unknown", year) {
println("No author provided — using 'Unknown'.")
}
}
data class TwentiethCenturyBook(val title: String, val author: String, val year: Int) {
init {
require(year in 1900..1999) {
"Only books published between 1900 and 1999 are allowed. Received: $year"
}
}
constructor(title: String, year: Int) : this(title, "Unknown", year) {
println("No author provided — using 'Unknown'.")
}
}
fun main() {
val book1 = TwentiethCenturyBook("The Hobbit", "J. R. R. Tolkien", 1937)
println("Book 1: $book1")
val book2 = TwentiethCenturyBook("The Shadow Over Innsmouth", "H. P. Lovecraft", 1936)
println("Book 2: $book2")
val book3 = TwentiethCenturyBook("Primary Colors", 1996)
println("Book 3: $book3")
val book4 = TwentiethCenturyBook("Fire & Blood", "George R. R. Martin", 2018)
println("Book 4: $book4")
}
En este ejemplo aprendimos a usar un constructor secundario para cubrir un caso especial: cuando el autor de un libro no es conocido. Además, usamos el constructor primario init
para validar las reglas del dominio (solo libros publicados entre 1900 y 1999), manteniendo el modelo robusto y consistente. Si se entrega un año fuera del rango, el programa lanza una excepción.
Sí.
📝 Ejercicio práctico — Gestiona tu catálogo de dependencias
Ejercicio
Imagina que mantienes un repositorio interno con artefactos Maven/Gradle. Cada artefacto se identifica por:
Campo | Tipo | Reglas de dominio |
---|---|---|
group | String | No vacío, minúsculas y puntos (com.example ) |
name | String | No vacío, sin espacios |
version | String | Formato semver MAJOR.MINOR.PATCH , p. ej. 1.2.3 |
-
Declara
DependencyMetadata
y valida eninit
que:- Ningún campo esté en blanco.
version
cumpla el patrón semver.
Ver hints
- Puedes verificar que un string no esté vacío con
String.isNotBlank(): Boolean
. - Para validar el formato de
version
, usaRegex.matches(String): Boolean
. Por ejemplo,"""\d+\.\d+\.\d+""".toRegex().matches("19.3.7")
. - Puedes ver que un string esté en minúsculas con
String.lowercase(): String
.
-
Algunos módulos internos aún no se versionan; permite crearlos sin
version
. Si no se indica, usa"0.1.0‑SNAPSHOT"
. -
Añade una propiedad calculada
isSnapshot
que seatrue
siversion
termina en"SNAPSHOT"
.Ver hint
- Para verificar si una cadena termina en un sufijo, usa
String.endsWith(String): Boolean
.
- Para verificar si una cadena termina en un sufijo, usa
Solución
- Código esencial
- Código completo
data class DependencyMetadata(
val group: String,
val name: String,
val version: String
) {
private val semver = Regex("""\d+\.\d+\.\d+""")
init {
require(group.isNotBlank()) { "Group must not be blank" }
require(name.isNotBlank()) { "Name must not be blank" }
require(group == group.lowercase() && '.' in group) {
"Group must be lowercase and dot-separated"
}
require(semver.matches(version)) {
"Version must follow semver format. Got: $version"
}
}
}
constructor(group: String, name: String) : this(group, name, "0.1.0-SNAPSHOT") {
println("No version provided — using default snapshot.")
}
val isSnapshot: Boolean
get() = version.endsWith("SNAPSHOT")
data class DependencyMetadata(
val group: String,
val name: String,
val version: String
) {
private val semver = Regex("""\d+\.\d+\.\d+""")
init {
require(group.isNotBlank()) { "Group must not be blank" }
require(name.isNotBlank()) { "Name must not be blank" }
require(group == group.lowercase() && '.' in group) {
"Group must be lowercase and dot-separated"
}
require(semver.matches(version)) {
"Version must follow semver format. Got: $version"
}
}
constructor(group: String, name: String) : this(group, name, "0.1.0-SNAPSHOT") {
println("No version provided — using default snapshot.")
}
val isSnapshot: Boolean
get() = version.endsWith("SNAPSHOT")
}
fun main() {
val dep1 = DependencyMetadata("com.example", "analytics", "1.2.3")
println("Dependency: $dep1")
println("Is snapshot: ${dep1.isSnapshot}") // false
val dep2 = DependencyMetadata("com.example", "logging")
println("Dependency: $dep2")
println("Is snapshot: ${dep2.isSnapshot}") // true
}
🎯 Conclusiones
En esta lección profundizamos en el uso idiomático de las data class
en Kotlin como mecanismo fundamental para representar tipos producto. Exploramos sus ventajas frente a clases tradicionales y tuplas, incluyendo su comparación por contenido, generación automática de métodos comunes, y su integración natural con herramientas como desestructuración, combinadores funcionales y validación en el constructor.
También discutimos buenas prácticas sobre mutabilidad, cuándo está justificada, y cómo estructurar el código para mantener un diseño claro y robusto, especialmente en el contexto del desarrollo de bibliotecas reutilizables.
🔑 Puntos clave
- Las
data class
son la forma idiomática en Kotlin de definir registros: tipos que agrupan datos con nombre y estructura clara. - Permiten comparar objetos por contenido, generar copias de manera segura y desestructurar valores de forma concisa.
- Aunque permiten lógica adicional, su uso debe centrarse en modelar datos y evitar estados mutables o comportamientos complejos.
- Su uso adecuado contribuye a diseños más expresivos, seguros y fáciles de mantener.