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.


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.

Glosario rápido
  • 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:

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 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.
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)")
}

♻️ 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:

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 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.
Recomendación

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:

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

Rendimiento

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

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.

🔧 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.

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 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.

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

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:

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 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.

🧰 ¿Qué nos llevamos?

Diseñar estructuras de datos con intención semántica clara no es solo una cuestión de estilo, sino una herramienta clave para mejorar la expresividad, la mantenibilidad y la seguridad de nuestras bibliotecas. Las data class permiten comunicar de forma directa el propósito de un tipo, garantizando al mismo tiempo un comportamiento coherente por parte del compilador.

Al preferir data class para representar tipos producto, evitamos repetir código, reducimos errores relacionados con comparaciones, y promovemos un diseño más funcional y predecible. Comprender sus límites y posibilidades es esencial para escribir APIs limpias, reutilizables y alineadas con los principios del diseño moderno de software.

📖 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.