Clases restringidas o selladas (sealed classes) en Kotlin

Clases restringidas o selladas (sealed classes) en Kotlin

Antes de entrar en las clases selladas vamos a recordar lo que vimos en su día sobre los enumerados o enum class en Kotlin con un ejemplo:
enum class DiaSemana {LUNES, MARTES, MIERCOLES, JUEVES, VIERNES, SABADO, DOMINGO}

fun main() {
    val diaSemana = DiaSemana.LUNES
    println(diaSemana) // LUNES
}
También se pueden inicializar haciendo referencia a una propiedad definida en el constructor (un valor):
enum class DiaSemana(val dia: Int) {
    LUNES(1),
    MARTES(2),
    MIERCOLES(3),
    JUEVES(4),
    VIERNES(5),
    SABADO(6),
    DOMINGO(7)
}
Pero además de valor pueden tener comportamiento, de dos maneras:
  1. Definiendo sus propios métodos, abstractos o no:
  2. enum class DiaSemana(val dia: Int) {
        LUNES(1) {
            override fun esFinDeSemana() = false
        },
        MARTES(2) {
            override fun esFinDeSemana() = false
        },
        MIERCOLES(3) {
            override fun esFinDeSemana() = false
        },
        JUEVES(4) {
            override fun esFinDeSemana() = false
        },
        VIERNES(5) {
            override fun esFinDeSemana() = false
        },
        SABADO(6) {
            override fun esFinDeSemana() = true
        },
        DOMINGO(7) {
            override fun esFinDeSemana() = true
        };
    
        abstract fun esFinDeSemana(): Boolean
    
        fun anteriorA(diaSemana: DiaSemana): Boolean = dia < diaSemana.dia
    }
    
    fun main() {
        val dia1 = DiaSemana.JUEVES
        val dia2 = DiaSemana.DOMINGO
        println(dia1.esFinDeSemana()) // false
        println(dia2.esFinDeSemana()) // true
        println(dia1.anteriorA(dia2)) // true
        println(dia2.anteriorA(dia1)) // false
    }
  3. Implementando interfaces, aunque no extendiendo otras clases:
  4. interface Dia {
        fun esFinDeSemana(): Boolean
        fun esDiaSemana(): Boolean
    }
    
    enum class DiaSemana(val dia: Int) : Dia {
        LUNES(1) {
            override fun esFinDeSemana() = false
        },
        MARTES(2) {
            override fun esFinDeSemana() = false
        },
        MIERCOLES(3) {
            override fun esFinDeSemana() = false
        },
        JUEVES(4) {
            override fun esFinDeSemana() = false
        },
        VIERNES(5) {
            override fun esFinDeSemana() = false
        },
        SABADO(6) {
            override fun esFinDeSemana() = true
        },
        DOMINGO(7) {
            override fun esFinDeSemana() = true
        };
    
        override fun esDiaSemana(): Boolean = true
    }
Hasta aquí los enumerados se ven fuertes y con muchas potencialidades, pero su principal limitación es que todos los subtipos tienen una misma estructura (en el ejemplo, todos comparten un valor y unos métodos, ya sean individuales o uno general para todos los tipos). La solución son las clases selladas (sealed classes), donde cada subtipo (subclase) es una clase que puede contener sus propios valores y métodos independientes.

Imagina que queremos controlar los posibles errores de acceso en una pantalla de identificación con usuario y contraseña; tendremos un error si alguno de los campos está en blanco, otro si el usuario no está registrado, otro si la contraseña no corresponde al usuario registrado y otro desconocido por lo que pueda pasar. Esto lo podemos modelar de esta manera (para declarar una clase sellada colocamos el modificador sealed antes del nombre de la clase):
sealed class LoginError {
    data class Incompleto(val username: String, val password: String) : LoginError()
    class NoRegistrado(val username: String) : LoginError()
    class PassError(val password: String) : LoginError()
    object Indefinido : LoginError()
}
Las llaves son opcionales, por lo que podemos escribir los mismo de esta manera:
sealed class LoginError
data class Incompleto(val username: String, val password: String) : LoginError()
class NoRegistrado(val username: String) : LoginError()
class PassError(val password: String) : LoginError()
object Indefinido : LoginError()
Donde LoginError es una clase abstracta que tiene únicamente cuatro hijos: Incompleto, NoRegistrado, PassError e Indefinido, y por tanto cuando manejamos un LoginError éste solo puede ser de uno de estos tipos. A partir de aquí podemos utilizar la expresión when de esta manera (con is en el caso de las clases y sin is en el caso de los objectos, y como una expresión no requiere else al contemplar todas las opciones posibles):
sealed class LoginError {
    data class Incompleto(val username: String, val password: String) : LoginError()
    class NoRegistrado(val username: String) : LoginError()
    class PassError(val password: String) : LoginError()
    object Indefinido : LoginError()
}

fun main() {
    val error = LoginError.PassError("1234")
    println(getError(error))
}

fun getError(error: LoginError): String = when (error) {
    is LoginError.Incompleto -> "Campos de usuario y contraseña requeridos."
    is LoginError.NoRegistrado -> "El usuario ${error.username} no está registrado."
    is LoginError.PassError -> "Contraseña ${error.password} no reconocida"
    LoginError.Indefinido -> "Error desconocido."
}
Esto hace que si entramos, por ejemplo, en la opción NoRegistrado nuestra variable error sea inmediatamente (por conversión implícita o smart cast) un NoRegistrado en ese ámbito. Observa que, en el ejemplo, estamos accediendo al atributo password que no es compartido por todos los elementos.

Como vemos, frente a los enumerados, la principal ventaja de las clases selladas es el hecho de que los elementos son clases (class, data class, object e incluso sealed class), y que cada subclase puede tener sus propios valores y métodos (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).

Por tanto, las clases selladas, además de entenderse como una extensión de la clase enum o superenumerados, son 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; representan jerarquías de clases restringidas en las que un valor debe tener uno de los tipos de un conjunto limitado, pero no puede tener ningún otro tipo. Y aunque el conjunto de valores para un tipo enum también está restringido, para cada constante enum existe solo una única instancia (sólo tenemos un único objeto por tipo) mientras que una subclase de una clase sellada puede tener múltiples instancias que pueden contener estado.

Vamos a ver unos ejemplos utilizando sealed classes:
sealed class Expr
data class Const(val number: Double) : Expr()
data class Sum(val e1: Expr, val e2: Expr) : Expr()
object NotANumber : Expr()

fun main() {
    val pi = Const(3.1415)
    println(eval(pi)) // 3.1415
    println(eval(Sum(pi, pi))) // 6.283
}

fun eval(expr: Expr): Double = when (expr) {
    is Const -> expr.number
    is Sum -> eval(expr.e1) + eval(expr.e2)
    NotANumber -> Double.NaN
    // else no es requerido porque cubrimos todos los casos
}
Otro ejemplo:
// enum class PuntoCardinal { NORTE, SUR, OESTE, ESTE }
sealed class PuntoCardinal {
    class Norte(val value: Int) : PuntoCardinal()
    class Sur(val value: Int) : PuntoCardinal()
    class Oeste(val value: Int) : PuntoCardinal()
    class Este(val value: Int) : PuntoCardinal()
}

fun main() {
    var orientacion: PuntoCardinal
    orientacion = PuntoCardinal.Sur(30)
    println(rumbo(orientacion))  // 210
    orientacion = PuntoCardinal.Este(10)
    println(rumbo(orientacion))  // 100
}

fun rumbo(orientacion: PuntoCardinal) = when (orientacion) {
    is PuntoCardinal.Norte -> orientacion.value + 0
    is PuntoCardinal.Este -> orientacion.value + 90
    is PuntoCardinal.Sur -> orientacion.value + 180
    is PuntoCardinal.Oeste -> orientacion.value + 270
}
Y otro más:
sealed class Operacion {
    class Suma(val valor: Int) : Operacion()
    class Resta(val valor: Int) : Operacion()
    class Multiplicacion(val valor: Int) : Operacion()
    class Division(val valor: Int) : Operacion()
}

fun main() {
    val numero = 5
    var operacion: Operacion
    operacion = Operacion.Suma(3)
    val numero2 = calcular(numero, operacion)
    println(numero2)  // 8
    operacion = Operacion.Multiplicacion(6)
    println(calcular(numero2, operacion))  // 48
}

fun calcular(x: Int, op: Operacion) = when (op) {
    is Operacion.Suma -> x + op.valor
    is Operacion.Resta -> x - op.valor
    is Operacion.Multiplicacion -> x * op.valor
    is Operacion.Division -> x / op.valor
}

Comentarios

Entradas populares

Recursos gratis para aprender Kotlin

I/O: entrada y salida de datos en consola

Lectura y escritura de archivos