Saltar al contenido
K
Kotlin: el lenguajePaso a paso35 min de lectura · 5 min de máquina Intermedio

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.

ejemplo mínimo: un object (con comentarios)
// 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 reloj

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

ejemplo mínimo: sealed class y when (con comentarios)
// 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
Gratis

Tres cosas para leer con calma:

  • is Pago.Tarjeta pregunta «¿este pago es de tipo Tarjeta?». Si la respuesta es sí, dentro de esa rama el compilador ya trata pago como Pago.Tarjeta y por eso puedes leer pago.ultimos4 sin convertir nada. Es el mismo smart cast de Nulabilidad, esta vez entre clases en vez de entre String? y String.
  • Sin data, println(Pago.Gratis) imprimiría un nombre de clase seguido de un código. Con data object imprime Gratis.
  • 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:

ejemplo mínimo: sealed interface con las variantes al lado (con comentarios)
// 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 Alarma

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

con el bug: else en un tipo sellado
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:

corregido: una rama por variante, sin else
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 classsealed class / sealed interface
Qué esUn conjunto fijo de valoresUna familia cerrada de tipos
Cuántos objetos de cada opciónUna sola (ColorDeTarjeta.ROJA es siempre el mismo objeto)Tantas como quieras (cada Gol es uno nuevo)
Datos de cada opciónLos mismos para todasDistintos en cada una
Ejemplo en MarcadorColorDeTarjeta: amarilla o rojaEventoDePartido: 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 stock
Ver solución
solución: pedido con estados
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.

torneo/
kotlin+java/
com.sebastiannarvaez.marcador.torneo
EventoDePartido.ktse modifica
Resultado.ktse crea
Main.ktse modifica
Lo que tocas, en la vista Android: un fichero nuevo y dos que cambian.

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.

  1. 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), una Tarjeta (a quién y de qué color) o un Cambio (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.
  2. El resultado. Un partido acaba en victoria de alguien (con ganador y perdedor) o en empate.
  3. Probarlo en main. En Main.kt, sustituye el for provisional 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 Luis

Cuando 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):

torneo/src/main/java/com/sebastiannarvaez/marcador/torneo/EventoDePartido.kt
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:

torneo/src/main/java/com/sebastiannarvaez/marcador/torneo/Resultado.kt
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.

torneo/src/main/java/com/sebastiannarvaez/marcador/torneo/Main.kt
    …
 
    // 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:run termina en BUILD SUCCESSFUL y 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:test pasa. 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 de PuntosTest, 6 de JugadorTest, 7 de EquipoTest y 6 de PartidoTest.
  • En EventoDePartido.kt el when de describir no tiene else. Prueba a quitar la rama is Cambio ->: Android Studio y Gradle dicen 'when' expression must be exhaustive. Devuélvela.
  • Partido no ha cambiado: sigue guardando golesLocal y golesVisitante.

Comprueba lo que entendiste

6 preguntas · necesitas 5 para completar la lección

  1. 1.Un EventoDePartido puede ser un gol, una tarjeta o un cambio, y cada uno lleva datos distintos. ¿Qué herramienta encaja?

  2. 2.¿Por qué un when sobre un tipo sellado no necesita else?

  3. 3.Tu describir(evento) tiene un else -> "Evento" y añades la variante Penal. ¿Qué pasa?

  4. 4.¿Qué aporta data object Empate frente a un object Empate normal?

  5. 5.En sealed class Pago(val monto: Int) { class Efectivo(monto: Int) : Pago(monto) }, la clase Pago no lleva open y aun así Efectivo hereda de ella. ¿Por qué?

  6. 6.En recibo(pago: Pago), una rama dice is Pago.Tarjeta -> … pago.ultimos4 …. ¿Por qué se puede leer ultimos4 ahí?

Faltan 6 de 6