Sealed: estados con datos
Al terminar: Modelar casos distintos que llevan datos con sealed class y sealed interface, y escribir los eventos de un partido.
Al terminar esta lección sabrás describir cosas que son de varios tipos distintos, cada uno con sus propios datos, y dejar que el compilador compruebe que no te dejas ninguno. Es la herramienta con la que Marcador describe lo que pasa en un partido —goles, tarjetas, cambios—, y en esta lección creas esas piezas. Temas: object, sealed class y sealed interface, is, data object y el error de poner else.
Cuando cada opción lleva datos distintos
Un enum sirve mientras todas las opciones tienen la misma forma. Pero un partido tiene eventos que no se parecen: un gol necesita quién marcó y para qué equipo; una tarjeta, a quién y de qué color; un cambio, quién sale y quién entra. Cada uno necesita datos distintos, y además puede haber muchos goles, cada uno con los suyos. Para eso Kotlin tiene los tipos sellados. Antes de verlos hacen falta dos ideas pequeñas, object y la interfaz, además de la herencia que ya conoces.
Herencia, un repaso
En Propiedades y visibilidad viste que heredar es crear una clase (la subclase) que parte de otra: class Perro(nombre: String) : Animal(nombre) significa «un Perro es un Animal» y pasa su nombre al constructor de la clase de la que parte. Allí también viste que, para poder heredar, la clase de la que se parte lleva open. Con los tipos sellados no hace falta open: una clase sealed ya admite herederos, pero solo los que se escriben en su mismo paquete y módulo (el módulo es el subproyecto de Gradle, como :torneo; lo viste en Gradle básico).
object: un tipo con un solo ejemplar
Una clase normal puede crear muchos objetos (Perro("Toby"), Perro("Rex")…). Un object es un tipo del que existe un único ejemplar (un único objeto de ese tipo; «ejemplar» e «instancia» significan aquí lo mismo que «objeto»), y no se crea con paréntesis: se usa directamente por su nombre. Ojo con la coincidencia de nombres: «objeto» sigue siendo cualquier ejemplar de una clase; object es la palabra que declara un tipo que ya viene con su único ejemplar.
// object: declara el tipo y crea su único ejemplar a la vez
object Reloj {
val formato = "24 horas" // un dato del único ejemplar
fun saludo() = "Hola desde el reloj" // y una función suya
}
fun main() {
println(Reloj.formato) // se usa por su nombre, sin crearlo con paréntesis
println(Reloj.saludo())
}24 horas
Hola desde el relojSirve para un caso que no necesita datos propios, como «un pago gratis». Los object se ven a fondo en Object y companion.
Sellar la familia
Una clase sellada (sealed) es una familia cerrada (una jerarquía: una clase y las que heredan de ella, sus variantes): todas sus variantes tienen que estar escritas en el mismo paquete y módulo. Así el compilador conoce la lista completa, igual que con un enum, pero cada variante puede llevar datos distintos y tener tantos ejemplares como quieras:
// La clase madre guarda lo que comparten todas las variantes: el monto.
sealed class Pago(val monto: Int) {
class Efectivo(monto: Int) : Pago(monto) // un Efectivo es un Pago (sin open: sealed ya lo permite)
class Tarjeta(monto: Int, val ultimos4: String) : Pago(monto) // esta variante añade un dato propio
// Una variante sin datos propios: un object, hay un único ejemplar.
// data object = object + un texto legible al imprimirlo (escribe «Gratis»).
data object Gratis : Pago(0)
}
// when sobre el tipo sellado: una rama por variante y ningún else
fun recibo(pago: Pago): String = when (pago) {
is Pago.Efectivo -> "Efectivo: ${pago.monto}" // is: ¿este pago es un Efectivo?
is Pago.Tarjeta -> "Tarjeta ${pago.ultimos4}: ${pago.monto}" // si es Tarjeta, aquí pago ya es una Tarjeta: se puede leer ultimos4
Pago.Gratis -> "Gratis" // sin is: solo hay un ejemplar, y se compara con él por igualdad
}
fun main() {
println(recibo(Pago.Efectivo(20)))
println(recibo(Pago.Tarjeta(35, "4421")))
println(recibo(Pago.Gratis))
println(Pago.Gratis)
}Efectivo: 20
Tarjeta 4421: 35
Gratis
GratisTres cosas para leer con calma:
is Pago.Tarjetapregunta «¿este pago es de tipoTarjeta?». Si la respuesta es sí, dentro de esa rama el compilador ya tratapagocomoPago.Tarjetay por eso puedes leerpago.ultimos4sin convertir nada. Es el mismo smart cast de Nulabilidad, esta vez entre clases en vez de entreString?yString.- Sin
data,println(Pago.Gratis)imprimiría un nombre de clase seguido de un código. Condata objectimprimeGratis. - No hay
else, y no hace falta: el compilador sabe que solo existen esas tres variantes.
sealed interface: la familia sin datos comunes guardados
Una interfaz es una lista de cosas que un tipo promete tener (datos o funciones), sin decir cómo. Un tipo dice que la cumple con dos puntos después de su nombre, y la palabra override («esto cumple o sustituye algo que viene de arriba») marca cada cosa prometida. En este curso solo la necesitas aquí; se ve a fondo en Herencia, interfaces y composición.
Una sealed interface es una familia cerrada igual que la sealed class, con una diferencia: la clase puede guardar datos comunes en su constructor (como monto en Pago) y solo se hereda de una; la interfaz no guarda nada, solo promete (un mismo tipo puede cumplir varias). Con la interfaz, cada variante puede ser una data class, que guarda ella misma el dato prometido:
// La interfaz promete que todo aviso tiene una hora
sealed interface Aviso {
val hora: Int
}
// Variantes AL LADO de la interfaz: se nombran sin prefijo (Llamada, Alarma).
// override val hora: la clase cumple la promesa y guarda la hora en su constructor.
data class Llamada(override val hora: Int, val quien: String) : Aviso
data class Alarma(override val hora: Int) : Aviso
fun texto(aviso: Aviso): String = when (aviso) {
is Llamada -> "${aviso.hora}h Llamada de ${aviso.quien}"
is Alarma -> "${aviso.hora}h Alarma"
}
fun main() {
println(texto(Llamada(9, "Ana")))
println(texto(Alarma(7)))
}9h Llamada de Ana
7h AlarmaEn Pago las variantes iban dentro de la clase y se nombran con prefijo (Pago.Efectivo); aquí van al lado y se nombran sin él (Llamada). Las dos formas son válidas y significan lo mismo: lo único que exige sealed es que las variantes estén en el mismo paquete y módulo. Anidar agrupa los nombres bajo el tipo (útil cuando el nombre suelto, como Empate, sería ambiguo); ponerlas al lado ahorra escribir el prefijo.
Error típico: el else que apaga el aviso
Lo primero que se escribe cuando el when protesta es else. Compila, y deja de proteger:
fun describir(pago: Pago): String = when (pago) {
is Pago.Efectivo -> "Efectivo: ${pago.monto}"
is Pago.Tarjeta -> "Tarjeta ${pago.ultimos4}: ${pago.monto}"
else -> "Pago" // ¿y Gratis? Cae aquí sin avisar
}Si mañana añades Transferencia a Pago, este when sigue compilando y las transferencias se describen como «Pago». El fallo aparece en pantalla, no en el compilador. Sin else, cada variante nueva rompe todos los when que la ignoran:
fun describir(pago: Pago): String = when (pago) {
is Pago.Efectivo -> "Efectivo: ${pago.monto}"
is Pago.Tarjeta -> "Tarjeta ${pago.ultimos4}: ${pago.monto}"
Pago.Gratis -> "Gratis"
}Si quitas la rama de Pago.Gratis de la versión corregida, el compilador de Kotlin dice:
'when' expression must be exhaustive. Add the 'Gratis' branch or an 'else' branch.El mensaje sugiere else como salida. Ignora esa segunda opción: la primera es la buena.
En el módulo de Swift verás su equivalente: allí un solo enum con valores asociados cubre las dos herramientas, y el switch sin default hace de when sin else.
enum class | sealed class / sealed interface | |
|---|---|---|
| Qué es | Un conjunto fijo de valores | Una familia cerrada de tipos |
| Cuántos objetos de cada opción | Una sola (ColorDeTarjeta.ROJA es siempre el mismo objeto) | Tantas como quieras (cada Gol es uno nuevo) |
| Datos de cada opción | Los mismos para todas | Distintos en cada una |
| Ejemplo en Marcador | ColorDeTarjeta: amarilla o roja | EventoDePartido: gol, tarjeta o cambio |
Reto · Ejercicio: un pedido con tres estados
Fuera de tu proyecto, en el Kotlin Playground (https://play.kotlinlang.org).
Una tienda en línea sigue sus pedidos. Un pedido puede estar pendiente (sin datos), en camino (con el nombre del repartidor) o entregado (con los minutos que tardó). Escribe un mensaje para cada estado, con una función mensaje que use un when y sin rama por defecto. Después añade un cuarto estado, cancelado, que guarda el motivo, y compila sin tocar la función de los mensajes: anota qué dice el compilador y arréglalo. Por último, en main, muestra el mensaje de un pedido pendiente, de uno que lleva a Rosa, de uno entregado en 25 minutos y de uno cancelado «sin stock». Antes de añadir el cuarto estado, la consola debe mostrar las tres primeras líneas; con él, las cuatro:
Esperando repartidor
Va Rosa
Llegó en 25 minutos
Cancelado: sin stockVer solución
sealed interface EstadoDePedido {
data object Pendiente : EstadoDePedido
data class EnCamino(val repartidor: String) : EstadoDePedido
data class Entregado(val minutos: Int) : EstadoDePedido
data class Cancelado(val motivo: String) : EstadoDePedido
}
fun mensaje(estado: EstadoDePedido): String = when (estado) {
EstadoDePedido.Pendiente -> "Esperando repartidor"
is EstadoDePedido.EnCamino -> "Va ${estado.repartidor}"
is EstadoDePedido.Entregado -> "Llegó en ${estado.minutos} minutos"
is EstadoDePedido.Cancelado -> "Cancelado: ${estado.motivo}"
}
fun main() {
println(mensaje(EstadoDePedido.Pendiente))
println(mensaje(EstadoDePedido.EnCamino("Rosa")))
println(mensaje(EstadoDePedido.Entregado(25)))
println(mensaje(EstadoDePedido.Cancelado("sin stock")))
}Antes de añadir la rama de Cancelado, el compilador dice 'when' expression must be exhaustive. Add the 'is Cancelado' branch or an 'else' branch. y señala el when de mensaje. Lo correcto es añadir la rama, no el else.
Al ejecutarlo imprime las cuatro líneas de arriba.
Paso · Los eventos del partido
Objetivo. Ampliar EventoDePartido.kt con EventoDePartido (una interfaz sellada con Gol, Tarjeta y Cambio) y la función describir, crear Resultado.kt (una interfaz sellada con victoria y empate) y probarlo en Main.kt. Al terminar, ./gradlew :torneo:run imprime tres líneas que describen un gol, una tarjeta y un cambio.
Crea el fichero nuevo. En la vista Android, despliega torneo → kotlin+java. Haz clic derecho sobre el paquete com.sebastiannarvaez.marcador.torneo (el que no lleva (test) detrás) → New → Kotlin Class/File → File → escribe Resultado y pulsa Intro. EventoDePartido.kt ya existe: se amplía.
Piezas nuevas de este paso, todas vistas arriba: una interfaz sellada con una propiedad prometida, clases de datos que la cumplen con override, variantes al lado de la interfaz y dentro de ella, y un when sobre un tipo sellado sin else. Una sola más: lowercase() devuelve un texto en minúsculas, y se pide con el punto igual que .nombre (de "Amarilla" saca "amarilla").
Intenta escribirlo tú, en este orden. Los nombres exactos, porque la lección siguiente los usa: EventoDePartido con minuto; Gol con jugador y equipo; Tarjeta con jugador y color; Cambio con sale y entra; describir; Resultado con Victoria (ganador y perdedor) y Empate.
- Los eventos. Un evento de partido ocurre en un minuto y es una de tres cosas: un
Gol(quién marcó y para qué equipo), unaTarjeta(a quién y de qué color) o unCambio(quién sale y quién entra).describir(evento)convierte cualquier evento en una línea como las de abajo, y si mañana hubiera un cuarto tipo de evento, el compilador debe avisar de que falta describirlo. - El resultado. Un partido acaba en victoria de alguien (con ganador y perdedor) o en empate.
- Probarlo en
main. EnMain.kt, sustituye elforprovisional de los colores por esto: describe un gol de Ana para el Rayo FC en el minuto 12, una tarjeta amarilla para Iván (dorsal 7) en el 30 y un cambio en el 85 en el que sale Ana y entra Luis (sin dorsal).
Tras las líneas de la lección anterior (la última es Con otro dorsal: …), la salida debe acabar así:
12' Gol de Ana (Rayo FC)
30' Tarjeta amarilla para Iván
85' Cambio: sale Ana, entra LuisCuando termines, abre el bloque y compara con tu código. Los bloques con ruta llevan la del fichero en tu proyecto.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
Sustituye todo el contenido de EventoDePartido.kt por esto (el enum ColorDeTarjeta ya lo tenías):
package com.sebastiannarvaez.marcador.torneo
// `enum class`: un conjunto cerrado de valores, todos con el mismo aspecto.
// Cada constante puede llevar datos (aquí, la etiqueta para mostrar).
enum class ColorDeTarjeta(val etiqueta: String) {
AMARILLA("Amarilla"),
ROJA("Roja"),
}
// `sealed interface`: los únicos implementadores posibles están en este mismo
// paquete y módulo. Por eso el compilador sabe cuáles son TODOS y puede
// exigir que un `when` los cubra sin `else`.
// A diferencia de un enum, cada variante puede llevar datos distintos.
sealed interface EventoDePartido {
val minuto: Int
}
data class Gol(
override val minuto: Int,
val jugador: Jugador,
val equipo: Equipo,
) : EventoDePartido
data class Tarjeta(
override val minuto: Int,
val jugador: Jugador,
val color: ColorDeTarjeta,
) : EventoDePartido
data class Cambio(
override val minuto: Int,
val sale: Jugador,
val entra: Jugador,
) : EventoDePartido
// `when` como expresión sobre un tipo sellado: sin `else`. Si mañana se añade
// una variante nueva, esta función deja de compilar y avisa de dónde falta.
fun describir(evento: EventoDePartido): String = when (evento) {
is Gol -> "${evento.minuto}' Gol de ${evento.jugador.nombre} (${evento.equipo.nombre})"
is Tarjeta -> "${evento.minuto}' Tarjeta ${evento.color.etiqueta.lowercase()} para ${evento.jugador.nombre}"
is Cambio -> "${evento.minuto}' Cambio: sale ${evento.sale.nombre}, entra ${evento.entra.nombre}"
}El fichero nuevo, Resultado.kt, lleva esto:
package com.sebastiannarvaez.marcador.torneo
// Un partido acaba en victoria (de alguien) o en empate.
sealed interface Resultado {
// Lleva datos: quién ganó y quién perdió.
data class Victoria(val ganador: Equipo, val perdedor: Equipo) : Resultado
// No lleva datos: basta un único objeto. `data object` da un toString legible.
data object Empate : Resultado
}En Main.kt, borra el bloque provisional de la lección anterior, desde el comentario // Provisional: los colores de tarjeta. hasta la última } de main, y pega esto en su lugar (la } va incluida abajo; el resto del fichero no cambia). Es provisional: la lección siguiente lo sustituye.
…
// Provisional: describir cada tipo de evento.
println(describir(Gol(12, ana, rayo)))
println(describir(Tarjeta(30, Jugador("Iván", 7), ColorDeTarjeta.AMARILLA)))
println(describir(Cambio(85, sale = ana, entra = Jugador("Luis", null))))
}En Main.kt el … solo marca el código que ya tienes. Ejecuta ./gradlew :torneo:run en la Terminal de Android Studio (View → Tool Windows → Terminal, que se abre en la carpeta del proyecto, donde está gradlew). Las tres últimas líneas de la salida son las del punto de control de abajo. Los tests no cambian en este paso: ./gradlew :torneo:test sigue pasando con los mismos 24.
Punto de control
./gradlew :torneo:runtermina enBUILD SUCCESSFULy las tres últimas líneas de la salida son:
12' Gol de Ana (Rayo FC)
30' Tarjeta amarilla para Iván
85' Cambio: sale Ana, entra Luis./gradlew :torneo:testpasa. Abre el informe (open torneo/build/reports/tests/test/index.html, como en Bucles y tu primer test) y comprueba que pone 24 tests en total: 5 dePuntosTest, 6 deJugadorTest, 7 deEquipoTesty 6 dePartidoTest.- En
EventoDePartido.ktelwhendedescribirno tieneelse. Prueba a quitar la ramais Cambio ->: Android Studio y Gradle dicen'when' expression must be exhaustive. Devuélvela. Partidono ha cambiado: sigue guardandogolesLocalygolesVisitante.
Comprueba lo que entendiste
6 preguntas · necesitas 5 para completar la lección
1.Un
EventoDePartidopuede ser un gol, una tarjeta o un cambio, y cada uno lleva datos distintos. ¿Qué herramienta encaja?2.¿Por qué un
whensobre un tipo sellado no necesitaelse?3.Tu
describir(evento)tiene unelse -> "Evento"y añades la variantePenal. ¿Qué pasa?4.¿Qué aporta
data object Empatefrente a unobject Empatenormal?5.En
sealed class Pago(val monto: Int) { class Efectivo(monto: Int) : Pago(monto) }, la clasePagono llevaopeny aun asíEfectivohereda de ella. ¿Por qué?6.En
recibo(pago: Pago), una rama diceis Pago.Tarjeta -> … pago.ultimos4 …. ¿Por qué se puede leerultimos4ahí?