Skip to main content

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:

Desestructuración de una data class
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.
Uso en una lista
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:

Filtrar y transformar con combinadores
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:

Uso de copy para modificar datos sin mutar 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.
Recomendación

Cuando requieras mutabilidad, separa el modelo inmutable del estado mutable. Por ejemplo:

Separación entre modelo y estado mutable
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.

Rendimiento

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)?

Escenariodata 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
En resumen
  • 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.
Consejo adicional

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.

Propiedad calculada y función auxiliar
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!")
}
¿Qué acabamos de hacer?

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.

No abuses de esto

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.

Recomendación

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:

Constructores en data class
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'.")
}
}
¿Qué acabamos de hacer?

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 bloque init del constructor primario 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.

¿Podríamos haber usado parámetros por defecto?

Sí, y de hecho, en Kotlin se prefiere usar parámetros con valores por defecto en el constructor primario cuando sea posible. Es más conciso, más idiomático y evita tener que declarar un constructor secundario si la lógica es trivial:

Parámetros por defecto en el constructor primario
data class TwentiethCenturyBook(
val title: String,
val author: String = "Unknown",
val year: Int
) {
init {
require(year in 1900..1999)
}
}

📝 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:

CampoTipoReglas de dominio
groupStringNo vacío, minúsculas y puntos (com.example)
nameStringNo vacío, sin espacios
versionStringFormato semver MAJOR.MINOR.PATCH, p. ej. 1.2.3
  1. Declara DependencyMetadata y valida en init 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, usa Regex.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.
  2. Algunos módulos internos aún no se versionan; permite crearlos sin version. Si no se indica, usa "0.1.0‑SNAPSHOT".

  3. Añade una propiedad calculada isSnapshot que sea true si version termina en "SNAPSHOT".

    Ver hint
    • Para verificar si una cadena termina en un sufijo, usa String.endsWith(String): Boolean.
Solución
Declaración y validación de artefacto (DependencyMetadata.kt)
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 secundario por defecto (DependencyMetadata.kt)
constructor(group: String, name: String) : this(group, name, "0.1.0-SNAPSHOT") {
println("No version provided — using default snapshot.")
}
Propiedad calculada isSnapshot (DependencyMetadata.kt)
val isSnapshot: Boolean
get() = version.endsWith("SNAPSHOT")

🎯 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 comparación por contenido, generación automática de métodos comunes y su integración natural con herramientas como la desestructuración, combinadores funcionales y validación de reglas de dominio.

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, seguro y mantenible —especialmente en bibliotecas reutilizables.

🔑 Puntos clave

  • Las data class son la forma idiomática en Kotlin de definir registros: estructuras de datos con nombre, contenido significativo y comportamiento predecible.
  • Permiten comparar objetos por contenido, generar copias de manera segura y desestructurar valores con una sintaxis clara.
  • Aunque pueden incluir lógica adicional, su propósito principal es modelar datos, no encapsular comportamiento complejo ni estados mutables.
  • Su uso adecuado contribuye a diseños más expresivos, seguros y fáciles de mantener.

🧰 ¿Qué nos llevamos?

Diseñar estructuras de datos con intención semántica clara no es solo una cuestión de estilo: es una herramienta clave para mejorar la expresividad, mantenibilidad y seguridad de nuestras bibliotecas.

Al preferir data class para representar tipos producto:

  • Evitamos código repetido
  • Reducimos errores en comparaciones o copias
  • Promovemos un estilo más funcional y predecible

Comprender sus límites y posibilidades es esencial para diseñar APIs claras, robustas y sostenibles, que otras personas puedan usar con confianza y sin sorpresas.

📖 Referencias

🔥 Recomendadas

  • 🌐 "Data classes" en la documentación oficial de Kotlin: Explica en detalle cómo funcionan las data class en Kotlin y qué genera automáticamente el compilador (como equals, hashCode, toString, copy, y componentN). Es relevante para esta lección porque respalda y amplía los conceptos presentados, mostrando requisitos técnicos, restricciones y casos de uso idiomáticos fundamentales para modelar tipos producto de forma segura y expresiva.

🔹 Adicionales

  • 📕 "Data Classes" en Beginner’s Guide to Kotlin Programming de John Hunt: Este capítulo introduce las data classes, una característica del lenguaje Kotlin diseñada para representar estructuras de datos inmutables con propiedades, pero con poca o ninguna lógica asociada. Se explican las ventajas de usar estas clases, como la generación automática de métodos como toString(), equals(), hashCode() y copy(), basados exclusivamente en las propiedades del constructor. También se detalla cómo funcionan con propiedades no incluidas en el constructor, cómo pueden implementar interfaces, extender clases abiertas y utilizarse con desestructuración. Finalmente, se incluye un ejercicio práctico para crear una clase de datos Customer en un contexto de aplicación financiera.