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
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.
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 comportamiento
classfinal por omisión;openpara heredar - Solo datos
data classequals, toString y copy con las propiedades del constructor - Validar al crear
init { require(...) }lanzaIllegalArgumentException - Una propiedad calculada
val x get() = ...fielden unsetpropio - Leer sí, cambiar no
private seterror de compilación si se asigna fuera - Un único objeto
objectse crea al usarse por primera vez - Lo que en Java es static
companion objectconstantes conconst val - Valores fijos
enum classentries,valueOf,ordinal - Casos con datos distintos
sealed interfacewhensinelse - 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.
