Clases en Kotlin: data class, sealed, object y enum

Lección 5 de 8. Hasta ahora has usado tipos que ya existían: Int, String, List. Aquí empiezas a crear los tuyos. Kotlin tiene una variante para cada necesidad: class para objetos con comportamiento, data class para datos, object para un ejemplar único, enum para valores fijos y sealed para jerarquías cerradas. Elegir bien entre ellas es buena parte de escribir Kotlin claro.

Una clase en una línea

Una clase describe un tipo de objeto: qué datos guarda y qué sabe hacer. En Kotlin, lo más común se escribe en muy poco espacio. El constructor primario va pegado al nombre de la clase, y si sus parámetros llevan val o var se convierten directamente en propiedades. Para crear un objeto no hace falta new:

class Producto(val nombre: String, var precio: Double)

fun main() {
    val p = Producto("Café de Chiapas", 189.0)
    println(p.nombre)
    p.precio = 199.0
    println(p.precio)
    println(p)
}
salidaCafé de Chiapas
199.0
Producto@6d06d69c

Con val la propiedad es de solo lectura y con var se puede cambiar. La última línea muestra lo que escribe una clase normal al imprimirla: su nombre, una arroba y un número que identifica al objeto. Más abajo verás cómo las data classes lo mejoran.

Si vienes de Java: adiós al código repetitivo

Esa línea equivale en Java a una clase con dos campos privados, un constructor que los asigna, dos getters y un setter. Por dentro Kotlin genera exactamente eso: desde Java, p.precio se ve como getPrecio() y setPrecio(). Por eso las clases de Kotlin se pueden usar desde Java y al revés sin adaptadores.

init, valores por omisión y validación

El constructor primario no tiene cuerpo. Si necesitas ejecutar código al crear el objeto, va en un bloque init. Lo típico es validar los datos con require, que lanza IllegalArgumentException si la condición no se cumple. Los parámetros pueden tener valores por omisión, como cualquier función:

class Cuenta(val titular: String, saldoInicial: Double = 0.0) {
    var saldo: Double = saldoInicial
        private set

    init {
        require(titular.isNotBlank()) { "El titular no puede estar vacío" }
        require(saldoInicial >= 0) { "Saldo inicial negativo: $saldoInicial" }
    }

    fun depositar(monto: Double) {
        require(monto > 0) { "El depósito debe ser positivo" }
        saldo += monto
    }
}

fun main() {
    val c = Cuenta("Ana")
    c.depositar(1500.0)
    println("${c.titular}: ${c.saldo}")

    val d = Cuenta("Luis", 300.0)
    println("${d.titular}: ${d.saldo}")

    Cuenta("Eva", -50.0)
}
salida con errorAna: 1500.0
Luis: 300.0
Exception in thread "main" java.lang.IllegalArgumentException: Saldo inicial negativo: -50.0
    at Cuenta.<init>(init_require.kt:7)

saldoInicial no lleva val: es solo un parámetro, que se usa para inicializar la propiedad saldo y luego desaparece. Y saldo tiene private set: se puede leer desde fuera, pero solo la propia clase la cambia, a través de depositar.

El error que te ahorra: cambiar desde fuera lo que la clase protege

class Cuenta(val titular: String) {
    var saldo: Double = 0.0
        private set
}

fun main() {
    val c = Cuenta("Ana")
    c.saldo = 1_000_000.0
    println(c.saldo)
}
kotlincsetter_privado.kt:8:7: error: cannot access 'saldo': it is private in 'Cuenta'.
    c.saldo = 1_000_000.0
      ^^^^^

Nadie puede ponerse un millón en la cuenta saltándose las reglas de depositar: el compilador lo impide. Lo mismo pasa si intentas asignar una propiedad val.

Propiedades con lógica: get y set

Una propiedad puede calcularse cada vez que se lee, con un get() propio, o validar lo que se le asigna con un set(). Dentro del set, la palabra field es el valor guardado de verdad (el backing field):

class Termometro(celsiusInicial: Double) {
    var celsius: Double = celsiusInicial
        set(valor) {
            require(valor >= -273.15) { "Más frío que el cero absoluto: $valor" }
            field = valor
        }

    val fahrenheit: Double
        get() = celsius * 9 / 5 + 32

    val descripcion: String
        get() = when {
            celsius < 15 -> "frío"
            celsius < 26 -> "agradable"
            else -> "calor"
        }
}

fun main() {
    val t = Termometro(18.0)
    println("${t.celsius} °C = ${t.fahrenheit} °F, ${t.descripcion}")
    t.celsius = 31.5
    println("${t.celsius} °C = ${t.fahrenheit} °F, ${t.descripcion}")
    t.celsius = -300.0
}
salida con error18.0 °C = 64.4 °F, agradable
31.5 °C = 88.7 °F, calor
Exception in thread "main" java.lang.IllegalArgumentException: Más frío que el cero absoluto: -300.0
    at Termometro.setCelsius(get_set.kt:4)

fahrenheit y descripcion no guardan nada: se calculan cada vez a partir de celsius, así que siempre están al día. Desde fuera no se nota la diferencia entre una propiedad guardada y una calculada.

Herencia: las clases son final por omisión

En Kotlin, una clase no se puede heredar a menos que lo permitas con open. Es una decisión de diseño: heredar de una clase que no se pensó para eso suele romper cosas, así que hay que pedirlo:

El error que te ahorra: heredar de una clase que no es open

class Animal(val nombre: String)

class Perro(nombre: String) : Animal(nombre)

fun main() {
    println(Perro("Firulais").nombre)
}
kotlincherencia_final.kt:3:31: error: this type is final, so it cannot be extended.
class Perro(nombre: String) : Animal(nombre)
                              ^^^^^^

En Java es al revés: todo se puede heredar salvo lo que marcas final. Kotlin elige lo contrario porque es más seguro, y lo mismo vale para los métodos: para poder sobrescribir uno, la clase madre lo marca open y la hija lo sobrescribe con override, que es obligatorio.

open class Empleado(val nombre: String, val sueldoBase: Double) {
    open fun sueldo(): Double = sueldoBase
    override fun toString() = "$nombre: ${"%,.2f".format(sueldo())} MXN"
}

class Gerente(nombre: String, sueldoBase: Double, val bono: Double) : Empleado(nombre, sueldoBase) {
    override fun sueldo(): Double = super.sueldo() + bono
}

fun main() {
    val plantilla: List<Empleado> = listOf(
        Empleado("Ana", 28_000.0),
        Gerente("Luis", 45_000.0, 12_500.0),
    )
    for (e in plantilla) println(e)
}
salidaAna: 28,000.00 MXN
Luis: 57,500.00 MXN

La lista es de Empleado, pero al llamar a sueldo() se ejecuta la versión de cada objeto: la de Gerente suma el bono. Eso es el polimorfismo. super.sueldo() llama a la versión de la clase madre.

Clases abstractas e interfaces

Una clase abstract no se puede instanciar y puede dejar métodos sin cuerpo para que los complete cada hija. Una interface describe lo que un tipo sabe hacer; una clase puede implementar varias, y las interfaces de Kotlin pueden traer métodos con un cuerpo por omisión:

interface Describible {
    val nombre: String
    fun describir(): String = "Soy $nombre"
}

abstract class Figura(override val nombre: String) : Describible {
    abstract fun area(): Double
    override fun describir() = "${super.describir()} y mido ${"%.2f".format(area())}"
}

class Circulo(val radio: Double) : Figura("un círculo") {
    override fun area() = Math.PI * radio * radio
}

class Rectangulo(val base: Double, val altura: Double) : Figura("un rectángulo") {
    override fun area() = base * altura
}

fun main() {
    val figuras = listOf(Circulo(1.0), Rectangulo(3.0, 4.5))
    figuras.forEach { println(it.describir()) }
    println(figuras.sumOf { it.area() })
}
salidaSoy un círculo y mido 3.14
Soy un rectángulo y mido 13.50
16.641592653589793

data class: clases para guardar datos

Muchas clases existen solo para agrupar datos: un pedido, un punto, una respuesta de una API. Para ellas está la data class. Con poner data delante, el compilador genera a partir de las propiedades del constructor primario un toString legible, equals y hashCode que comparan por valor, copy y las funciones componentN para la desestructuración:

data class Persona(val nombre: String, val edad: Int)
class PersonaNormal(val nombre: String, val edad: Int)

fun main() {
    val a = Persona("Ana", 31)
    val b = Persona("Ana", 31)
    println(a)
    println(a == b)
    println(a === b)

    val x = PersonaNormal("Ana", 31)
    val y = PersonaNormal("Ana", 31)
    println(x == y)

    val mayor = a.copy(edad = 32)
    println(mayor)

    val (nombre, edad) = mayor
    println("$nombre tiene $edad años")

    println(setOf(a, b, mayor).size)
}
salidaPersona(nombre=Ana, edad=31)
true
false
false
Persona(nombre=Ana, edad=32)
Ana tiene 32 años
2

Dos objetos con los mismos datos, nombre Ana y edad 31. Si Persona es una clase normal, a == b es falso porque se comparan referencias. Si es una data class, a == b es verdadero porque el compilador genera un equals que compara las propiedades del constructorMismos datos, ¿mismo objeto? class frente a data classclass Personaanombre = Anaedad = 31bnombre = Anaedad = 31a == b es falseequals de Any compara referencias:son dos objetos distintos.data class Personaanombre = Anaedad = 31bnombre = Anaedad = 31a == b es trueEl compilador genera equals: comparalas propiedades del constructor.

Con una clase normal, == compara si son el mismo objeto; con una data class, compara los datos. Esquema: decodigo.com.

Fíjate en los dos operadores. == llama a equals: en la data class compara los datos y da true. === pregunta si son el mismo objeto en memoria, y no lo son. En la clase normal, equals no está sobrescrito y == también compara referencias. El conjunto final tiene 2 elementos porque a y b cuentan como iguales.

copy crea un objeto nuevo cambiando solo lo que indicas: es la forma natural de «modificar» datos inmutables. Ojo con un detalle: solo cuentan las propiedades del constructor primario. Una propiedad declarada en el cuerpo no entra en equals, ni en toString, ni en copy:

data class Pedido(val folio: Int) {
    var notas: String = ""
}

fun main() {
    val a = Pedido(1001).apply { notas = "Entregar en la tarde" }
    val b = Pedido(1001)
    println(a == b)
    println(a)
    println(a.copy().notas.isEmpty())
}
salidatrue
Pedido(folio=1001)
true

Los dos pedidos son «iguales» aunque uno tiene notas y el otro no, y la copia pierde las notas. Puede ser justo lo que quieres (dos pedidos con el mismo folio son el mismo pedido) o un error difícil de ver; ahora ya lo sabes.

Si vienes de Java: data class frente a record

Java tiene desde la versión 16 los records, que resuelven lo mismo: record Persona(String nombre, int edad) genera constructor, accesores, equals, hashCode y toString. Las diferencias: un record es siempre inmutable, mientras que una data class admite var; la data class trae copy con parámetros con nombre, y desestructuración con val (a, b) = .... Si una biblioteca de Java necesita un record de verdad, Kotlin puede generarlo con la anotación @JvmRecord.

object: un único ejemplar

Con object declaras una clase y su único objeto a la vez. Es un singleton: sirve para configuraciones, registros o utilidades que no necesitan varias copias. Se crea la primera vez que se usa:

object Contador {
    private var total = 0
    fun siguiente(): Int {
        total++
        return total
    }
}

fun main() {
    println(Contador.siguiente())
    println(Contador.siguiente())
    println(Contador.siguiente())
}
salida1
2
3

companion object: lo que en Java sería static

Kotlin no tiene static. Lo que pertenece a la clase y no a cada objeto va en su companion object: constantes y funciones de fábrica, sobre todo. Se usan con el nombre de la clase delante. Aquí, además, el constructor es private para obligar a pasar por la fábrica, que valida el dato:

class Rfc private constructor(val valor: String) {
    companion object {
        const val LARGO_PERSONA_MORAL = 12
        const val LARGO_PERSONA_FISICA = 13

        fun crear(texto: String): Rfc? {
            val limpio = texto.trim().uppercase()
            return if (limpio.length == LARGO_PERSONA_MORAL || limpio.length == LARGO_PERSONA_FISICA) Rfc(limpio) else null
        }
    }

    override fun toString() = "RFC $valor"
}

fun main() {
    println(Rfc.crear(" xaxx010101000 "))
    println(Rfc.crear("abc"))
    println(Rfc.LARGO_PERSONA_FISICA)
}
salidaRFC XAXX010101000
null
13

La fábrica devuelve Rfc?: null si el texto no tiene el largo correcto, con lo que se aprovecha todo lo que viste en la lección de null safety. Es una validación de juguete (un RFC real tiene más reglas), pero muestra el patrón.

enum class

Una enum class es un tipo con una lista cerrada de valores. Cada valor puede llevar propiedades, y entries los recorre todos:

enum class Talla(val etiqueta: String, val centimetros: Int) {
    CHICA("CH", 90), MEDIANA("M", 100), GRANDE("G", 110);

    fun siguiente(): Talla? = entries.getOrNull(ordinal + 1)
}

fun main() {
    for (t in Talla.entries) {
        println("${t.name} (${t.etiqueta}): ${t.centimetros} cm, sigue ${t.siguiente()}")
    }
    println(Talla.valueOf("MEDIANA").etiqueta)
}
salidaCHICA (CH): 90 cm, sigue MEDIANA
MEDIANA (M): 100 cm, sigue GRANDE
GRANDE (G): 110 cm, sigue null
M

sealed: una jerarquía cerrada

Un enum sirve cuando cada valor es fijo. Pero a veces cada caso lleva datos distintos: un pago en efectivo lleva el monto recibido, uno con tarjeta los últimos dígitos y una transferencia la clave de rastreo. Para eso están las sealed class y sealed interface: todas sus implementaciones tienen que estar en el mismo paquete y módulo, así que el compilador las conoce todas. Y gracias a eso, un when que las cubre no necesita else:

sealed interface Pago {
    val monto: Double
}
data class Efectivo(override val monto: Double, val recibido: Double) : Pago
data class Tarjeta(override val monto: Double, val ultimos4: String) : Pago
data class Transferencia(override val monto: Double, val claveRastreo: String) : Pago

fun recibo(p: Pago): String = when (p) {
    is Efectivo -> "Efectivo: cambio de ${p.recibido - p.monto}"
    is Tarjeta -> "Tarjeta terminación ${p.ultimos4}"
    is Transferencia -> "SPEI, rastreo ${p.claveRastreo}"
}

fun main() {
    val pagos = listOf(Efectivo(180.0, 200.0), Tarjeta(950.0, "4821"), Transferencia(12_000.0, "MBAN0100"))
    pagos.forEach { println(recibo(it)) }
}
salidaEfectivo: cambio de 20.0
Tarjeta terminación 4821
SPEI, rastreo MBAN0100

Dentro de cada rama, el smart cast que viste en la lección anterior vuelve a actuar: después de is Efectivo, p ya es un Efectivo y tiene recibido.

Una sealed interface Pago con tres variantes: Efectivo, Tarjeta y Transferencia. Un when que cubre las tres no necesita else, y si se añade una variante nueva el when deja de compilar hasta que se trateUna sealed interface y su when exhaustivosealed interface Pagotodas sus variantes, aquíEfectivoTarjetaTransferenciawhen (pago) { is Efectivo -> … is Tarjeta -> … is Transferencia -> … }sin else: si mañana añades una variante, este when deja de compilar

El compilador conoce todas las variantes de una sealed interface, y por eso puede comprobar que el when las cubre todas. Esquema: decodigo.com.

El error que te ahorra: olvidar un caso nuevo en un when

sealed interface Pago
data class Efectivo(val monto: Double) : Pago
data class Tarjeta(val monto: Double) : Pago
data class Vales(val monto: Double) : Pago

fun recibo(p: Pago): String = when (p) {
    is Efectivo -> "Efectivo"
    is Tarjeta -> "Tarjeta"
}

fun main() {
    println(recibo(Vales(100.0)))
}
kotlincsealed_falta.kt:6:31: error: 'when' expression must be exhaustive. Add the 'is Vales' branch or an 'else' branch.
fun recibo(p: Pago): String = when (p) {
                              ^^^^

Alguien añadió los pagos con vales y no actualizó la función. Con un else el código habría compilado y los vales habrían caído en un caso genérico sin que nadie se diera cuenta. Sin else, kotlinc señala cada when que hay que revisar, y hasta te dice qué rama falta. Por eso conviene no poner else en un when sobre una sealed o un enum.

value class: un tipo sin costo

¿Un precio en centavos es un Long? Sí, pero también lo es una cantidad de piezas, y mezclarlos sería un error. Una value class envuelve un único valor para darle un tipo propio, y el compilador la quita al compilar siempre que puede, así que en ejecución no hay ningún objeto extra:

@JvmInline
value class Centavos(val valor: Long) {
    operator fun plus(otro: Centavos) = Centavos(valor + otro.valor)
    override fun toString() = "$" + "%,.2f".format(valor / 100.0)
}

@JvmInline
value class Piezas(val valor: Int)

fun total(precio: Centavos, cantidad: Piezas) = Centavos(precio.valor * cantidad.valor)

fun main() {
    val precio = Centavos(129_999)
    val envio = Centavos(9_900)
    println(total(precio, Piezas(3)) + envio)
}
salida$3,998.97

Si alguien llama total(Piezas(3), precio), con los argumentos al revés, no compila. Con dos Long sí habría compilado. Además, operator fun plus permite usar + entre dos Centavos: Kotlin deja definir operadores con nombres fijos (plus, minus, times…).

Resumen de la lección

  • ParaUsaRecuerda
  • Un objeto con datos y comportamientoclassfinal por omisión; open para heredar
  • Solo datosdata classequals, toString y copy con las propiedades del constructor
  • Validar al crearinit { require(...) }lanza IllegalArgumentException
  • Una propiedad calculadaval x get() = ...field en un set propio
  • Leer sí, cambiar noprivate seterror de compilación si se asigna fuera
  • Un único objetoobjectse crea al usarse por primera vez
  • Lo que en Java es staticcompanion objectconstantes con const val
  • Valores fijosenum classentries, valueOf, ordinal
  • Casos con datos distintossealed interfacewhen sin else
  • Un tipo propio sin costo@JvmInline value classun solo valor dentro

Retos

Reto 1. Crea una data class Libro(titulo, autor, anio) y una lista con tres libros, uno de ellos repetido. Imprime cuántos libros distintos hay y una copia del primero con el año cambiado.

Ver la solución
data class Libro(val titulo: String, val autor: String, val anio: Int)

fun main() {
    val libros = listOf(
        Libro("Pedro Páramo", "Juan Rulfo", 1955),
        Libro("Aura", "Carlos Fuentes", 1962),
        Libro("Pedro Páramo", "Juan Rulfo", 1955),
    )
    println(libros.toSet().size)
    println(libros.first().copy(anio = 2005))
}
salida2
Libro(titulo=Pedro Páramo, autor=Juan Rulfo, anio=2005)

toSet() descarta los repetidos usando el equals que genera la data class. Con una clase normal habría 3.

Reto 2. Modela el estado de una descarga con una sealed interface: Pendiente, EnCurso(porcentaje), Completada(ruta) y Fallida(motivo). Escribe una función que devuelva un texto para cada caso con un when sin else.

Ver la solución
sealed interface Descarga
data object Pendiente : Descarga
data class EnCurso(val porcentaje: Int) : Descarga
data class Completada(val ruta: String) : Descarga
data class Fallida(val motivo: String) : Descarga

fun texto(d: Descarga): String = when (d) {
    Pendiente -> "En espera"
    is EnCurso -> "Descargando: ${d.porcentaje} %"
    is Completada -> "Guardado en ${d.ruta}"
    is Fallida -> "Error: ${d.motivo}"
}

fun main() {
    listOf(Pendiente, EnCurso(42), Completada("/tmp/foto.jpg"), Fallida("sin conexión"))
        .forEach { println(texto(it)) }
    println(Pendiente)
}
salidaEn espera
Descargando: 42 %
Guardado en /tmp/foto.jpg
Error: sin conexión
Pendiente

Pendiente no lleva datos, así que es un data object: un único objeto, que se compara sin is y que se imprime con su nombre, como muestra la última línea.

Reto 3. ¿Qué escribe este programa? class A(val x: Int) y data class B(val x: Int); luego println(A(1) == A(1)) y println(B(1) == B(1)).

Ver la solución
class A(val x: Int)
data class B(val x: Int)

fun main() {
    println(A(1) == A(1))
    println(B(1) == B(1))
}
salidafalse
true

La clase normal hereda el equals de Any, que compara referencias: son dos objetos distintos. La data class compara las propiedades del constructor.

Todo el código de esta lección se compiló con Kotlin 2.4.21 (kotlinc) y se ejecutó en la JVM de Temurin 25.0.5 en un contenedor limpio y sin red antes de publicarla; los mensajes del compilador y las salidas están copiados de esa ejecución. ¿Algo no te compila? Revisa tu versión con kotlinc -version.