Índice de contenidos
- 1. Introducción
- 2. Enumerados
- 3. Clases selladas
- 4. Manejo de enumerados y clases selladas
- 5. Conclusión
- 6. Referencias
1. Introducción
Las clases selladas (sealed class) en Kotlin son aquellas empleadas para construir una herencia cerrada en la que el compilador conoce cuáles son las únicas clases hijas, ya que no puede haber otras.
Otra definición —rápida e informal— de las clases selladas podría ser la de «enumerados híper vitaminados». Esto es porque su uso se da en los mismos casos que los de los enumerados —cuando tenemos un valor que puede ser de un solo tipo de un conjunto específico de tipos— pero nos permiten que cada elemento de ese «híper enumerado» sea una clase, con las ventajas que ello conlleva frente a las limitaciones de los clásicos enumerados.
2. Enumerados
Antes de ver las clases selladas, echemos un vistazo a los enumerados en Kotlin. Estos se escriben de la siguiente manera:
enum class Planet {
MERCURY, VENUS, EARTH, MARS, JUPITER, SATURN, URANUS, NEPTUNE
}
Pueden tener valor y comportamiento:
2.1. Valor
enum class Planet(val radius: Double) {
MERCURY(2_439.7),
VENUS(6_051.8),
EARTH(6_371.0),
MARS(3_389.5),
JUPITER(69_911.0),
SATURN(58_232.0),
URANUS(25_362.0),
NEPTUNE(24_622.0)
}
Por cierto, los guiones bajos empleados como separador decimal sirven únicamente para mejorar la legibilidad del código y su uso es opcional.
2.2. Comportamiento
Pueden tener comportamiento de dos maneras:
a) Definiendo sus propios métodos, abstractos o no:
enum class Planet(val radius: Double) {
MERCURY(2_439.7) {
override fun spanishName() = "Mercurio"
},
...
NEPTUNE(24_622.0) {
override fun spanishName() = "Neptuno"
};
abstract fun spanishName(): String
fun isBiggerThan(astronomicalBody: AstronomicalBody): Boolean = radius > astronomicalBody.radius
}
b) Implementando interfaces, aunque no extendiendo otras clases:
interface AstronomicalBody {
fun getDensity(): Double
fun isPlanet(): Boolean
}
enum class Planet(val radius: Double) : AstronomicalBody {
MERCURY(2_439.7) {
override fun getDensity() = 5.42
},
...
NEPTUNE(24_622.0) {
override fun getDensity() = 1.64
};
override fun isPlanet() = true
}
Como vemos, podemos implementar los métodos de las interfaces tipo a tipo (getDensity()) o de forma única para todos los tipos (isPlanet()).
Sí, están fuertes los enumerados de Kotlin, pero espera a ver las clases selladas, que son más versátiles.
3. Clases selladas
La principal limitación de los enumerados es que todos los subtipos siguen una misma estructura. Por ejemplo, en el caso de los planetas todos tenían un radio, un método para obtener su densidad, otro para obtener su nombre en español, etc. Sin embargo, cada subtipo (subclase) de una clase sellada es una clase que puede ser de la manera que quiera, con sus propios valores y métodos.
3.1. Ejemplo
Imaginemos que hacemos peticiones a un repositorio y queremos controlar posibles errores. Sabemos que tendremos un error si el usuario no está autorizado, otro si no tiene permisos para acceder al recurso y otro desconocido por lo que pueda pasar. En el caso de no estar autorizado, el repo nos dice el nombre del usuario, mientras que en el caso de falta de permisos el repo nos responde con una lista de roles de nuestro usuario; en caso de error desconocido, no tenemos ninguna información adicional.
Para modelar este conjunto de errores ya no nos sirven los enumerados pero… ¡sí las clases selladas!
3.2. Definición
El ejemplo anterior lo modelaríamos de la siguiente manera:
sealed class RepositoryFailure {
class Unauthorized(val username: String) : RepositoryFailure()
data class Forbidden(val userRoles: List) : RepositoryFailure()
object Unknown : RepositoryFailure()
}
Las llaves son opcionales, por lo que es válido también lo siguiente:
sealed class RepositoryFailure
class Unauthorized(val username: String) : RepositoryFailure()
data class Forbidden(val userRoles: List) : RepositoryFailure()
object Unknown : RepositoryFailure()
RepositoryFailure es una clase abstracta que tiene únicamente tres hijas: Unauthorized, Forbidden y Unknown. Por tanto, cuando manejemos un RepositoryFailure este solamente podrá ser de estos tipos. Por cierto, estamos obligados a definir las subclases en el mismo fichero que la clase sellada madre.
3.3. Ventajas de las clases selladas
Las ventajas frente a los enumerados vienen dadas por lo que ya hemos comentado, que es el hecho de que los elementos sean clases (class, data class, object e incluso sealed class):
- La más importante es que cada subclase puede tener sus propios valores y sus propios métodos, a diferencia de los enumerados, cuyos elementos siguen todos la misma estructura.
- Además, los enumerados solamente pueden tener una instancia, mientras que las subclases de clases selladas pueden tener varias instancias, cada una con su estado, o una si la definimos como object.
sealed class Animal {
sealed class Mammal : Animal() {
class Dog : Mammal() {
fun bark() { ... }
}
data class Cat(val remainingLives: Int) : Mammal() {
fun meow() { ... }
}
}
...
}
4. Manejo de enumerados y clases selladas
Dado un enumerado o una clase sellada, su uso en un condicional puede ser como el siguiente:
when (planet) {
MERCURY -> "El más cercano al Sol."
EARTH -> "El único con vida."
JUPITER -> "El más grande."
}
Como vemos, simplemente estamos definiendo comportamiento para un subconjunto de los planetas pero, normalmente, queremos definirlo para todos los casos. Podríamos poner todos los subtipos en el when, pero el código tendría un problema: si en un futuro Plutón da el estirón y pasa a ser considerado planeta, aunque nosotros lo metamos en el enumerado, en el código anterior no le estaremos dando comportamiento. Podríamos pensar en crear los condicionales con un else, pero lo que en realidad estaría chulo es que el compilador nos avisase de aquellos fragmentos de código en los que no hemos tenido en cuenta al pobre Plutón. Y esto en Kotlin se puede hacer si utilizamos los condicionales como expresiones.
4.1. Condicionales como expresiones
Una (otra) de las maravillas de Kotlin es que los condicionales se pueden usar no solamente como declaraciones, sino también como expresiones, es decir, como sentencias que devuelven valor. Por ejemplo:
val min: Int = if (a < b) a else b
o:
val text: String = when {
a < 0 -> "Menor que cero"
a == 0 -> "Cero"
else -> "Mayor que cero"
}
Cuando los condicionales son utilizados como expresiones, el compilador nos obliga a contemplar todos los casos, lo cual hacemos con un else en el par de ejemplos anteriores.
4.2. Enumerados y clases selladas en condicionales como expresiones
En el caso de los enumerados y clases selladas no es necesario añadir ningún else para que el compilador no se queje, sino que nos basta con definir todos los casos:
val description: String = when (planet) {
MERCURY -> "El más cercano al Sol."
VENUS -> "..."
EARTH -> "El único con vida."
MARS -> "..."
JUPITER -> "El más grande."
SATURN -> "..."
URANUS -> "..."
NEPTUNE -> "..."
}
Además, si ahora añadimos Plutón al enumerado de planetas, el código anterior no compilaría. Y esto es lo deseable, ya que los errores los queremos en tiempo de compilación, no de ejecución.
Y para terminar, si empleamos clases selladas, entra en juego el smart cast y ya nuestra experiencia de programación es colosal:
fun getError(failure: RepositoryFailure): String = when (failure) {
is Unauthorized -> "El usuario ${failure.username} no está autorizado."
is Forbidden -> "No se puede acceder al recurso con ninguno de los siguientes roles: ${failure.userRoles.joinToString()}"
Unknown -> "Error desconocido."
}
El smart cast de Kotlin hace que, si entramos en la rama de Unauthorized, por ejemplo, nuestra variable failure sea ya un Unauthorized en ese ámbito y nos evitar tener que hacer un cast. Como vemos, en el ejemplo estamos accediendo al atributo username que solamente el tipo Unauthorized tiene.
Por cierto, el condicional de las ramas de un when para comprobar el tipo de una instancia se construye con is en el caso de las clases y sin is en el caso de los objects.
4.3. Extra: exhaustive
Personalmente, intento utilizar siempre condicionales como expresiones para manejar enumerados y clases selladas, así estoy obligado a declarar el comportamiento para cada subtipo. Sin embargo, no siempre quiero estructurar el código así, por la razón que sea, pero tampoco quiero perder la detección del compilador de que me estoy dejando algo.
Pues bien, esto lo podemos solucionar añadiendo al final del when una llamada a exhaustive:
// No compila.
when (planet) {
MERCURY -> "El más cercano al Sol."
EARTH -> "El único con vida."
JUPITER -> "El más grande."
}.exhaustive
Al hacer esto, nuestro código deja de compilar porque no se están contemplando todos los casos.
No lo entiendo.
Vayamos por partes. exhaustive es una propiedad de extensión que nos creamos así:
val Any?.exhaustive
get() = Unit
Como extensión que es, se puede llamar desde cualquier tipo (Any?). Definimos el get() para que no devuelva nada, pues si quisiésemos el valor devuelto por el when, entonces usaríamos este directamente como expresión, sin tener que recurrir al exhaustive.
Al añadirlo a un when de tipo declaración lo convertimos a expresión porque el exhaustive es una extensión de un tipo, que será el que el when devuelva. Y, al ser una expresión, ya estamos obligados a dar comportamiento a todos los tipos.
En fin, un pequeño hack aprovechando las posibilidades de Kotlin.
5. Conclusión
Las clases selladas son la alternativa a utilizar cuando los enumerados se nos quedan cortos, cuando queremos una herencia cerrada. Son muy útiles, por ejemplo, para modelar errores en el caso en el que no todos tengan la misma estructura. Y su uso con when como expresión y smart cast hace nuestro código robusto y legible, respectivamente.
6. Referencias
Documentación oficial de Kotlin: