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

Result: errores como valores

Al terminar: Devolver un error como un valor con Result y decidir entre excepción y Result.

Hasta ahora Torneo.agregar(partido) lanzaba una excepción si algo estaba mal, y quien la llamaba tenía que acordarse de capturarla. Al terminar esta lección Torneo.registrar(...) devolverá un Result<Partido>: el partido registrado, o el motivo por el que no se pudo. Un gol de alguien que no juega, un minuto 200 o un equipo que no está inscrito dejarán de tumbar el programa.

Temas: Result con runCatching, las formas de abrirlo, cuándo usar una excepción y cuándo un Result, y el paso que cambia agregar por registrar. En la lección siguiente, Ficheros y depurador, usarás try como expresión para convertir textos en dorsales, escribirás en un fichero y pararás el programa a mitad con un breakpoint.

Result: el error como un valor

Una excepción es una buena herramienta para lo excepcional. Que alguien registre un gol de un jugador que no está en la plantilla no es excepcional: pasa con datos escritos a mano. Para lo esperable, conviene que el error viaje en el tipo de retorno y que quien llama no pueda olvidarlo.

runCatching { … } ejecuta el bloque y devuelve Result.success(valor) si termina o Result.failure(excepción) si lanza algo. Después, maneras de abrir el sobre:

FunciónQué hace
onSuccess { } / onFailure { }Ejecutan un bloque según el caso y devuelven el mismo Result
fold(onSuccess, onFailure)Convierte los dos casos en un único valor
map { }Transforma el valor si hay éxito; el fallo pasa intacto
getOrElse { }El valor, o lo que devuelva el bloque si hubo fallo
getOrThrow()El valor, o vuelve a lanzar la excepción
exceptionOrNull()La excepción si hubo fallo, o null si salió bien

Cuándo usar cada cosa:

SituaciónHerramienta
Un argumento imposible: es un fallo del programadorrequire y excepción
Un texto que puede no ser un númerotry como expresión, devolviendo null
Una operación que puede fallar por datos o por el entornoResult
Un recurso que hay que cerrar pase lo que pase (un fichero)use, en la lección siguiente
ejemplo mínimo: Result y runCatching (con comentarios)
class Socio(val nombre: String)
 
class Club(private val socios: List<Socio>) {
    // Devuelve un Result: o el socio, o la excepción que explica por qué no
    fun prestar(libro: String, socio: Socio): Result<Socio> = runCatching {
        validar(socio)                                       // si lanza, runCatching lo guarda como fallo
        require(libro.isNotBlank()) { "Falta el título" }
        socio                                                // la última línea es el valor del éxito
    }
 
    // private: solo la usa el propio Club
    private fun validar(socio: Socio) {
        require(socio in socios) { "${socio.nombre} no es socio" }
    }
}
 
fun main() {
    val eva = Socio("Eva")
    val club = Club(listOf(eva))
 
    club.prestar("Rayuela", eva)
        .onSuccess { println("Prestado a ${it.nombre}") }    // solo si salió bien
        .onFailure { println("Error: ${it.message}") }       // solo si falló
    val fallo = club.prestar("Rayuela", Socio("Leo"))
    println(fallo)                                           // un Result se imprime tal cual: Failure(…) con la excepción dentro
    println(fallo.exceptionOrNull()?.message)                // solo el texto del error, o null si salió bien
    println(fallo.map { it.nombre }.getOrElse { "nadie" })   // map transforma el éxito; getOrElse da el plan B
    println(fallo.fold({ "ok" }, { "falló" }))               // dos lambdas: una por caso, un único valor
    println(club.prestar("Niebla", eva).getOrThrow().nombre) // el valor; si fuera fallo, lanzaría
    println(club.prestar("Niebla", eva).map { it.nombre })   // un Result se imprime como Success(…) o Failure(…)
}

Al ejecutarlo, imprime:

Prestado a Eva
Failure(java.lang.IllegalArgumentException: Leo no es socio)
Leo no es socio
nadie
falló
Eva
Success(Eva)

Al imprimir un Result directamente sale Success(…) con el valor dentro, o Failure(…) con la excepción, y delante del nombre de la excepción aparece java.lang., el paquete donde viven las excepciones básicas. Kotlin las toma prestadas de Java por debajo (en JDK y verificación está qué es Java).

Reto · Ejercicio: un minuto que puede fallar

En el Kotlin Playground, fuera de tu proyecto:

  1. Una función que convierte un texto en un minuto de partido (de 0 a 120; el texto puede traer espacios a los lados, y trim() los quita de los dos lados) y devuelve el minuto o el motivo del fallo, sin lanzar nunca. Es el mismo problema del ejercicio de la lección anterior, pero ahora el fallo no se pierde en un null: viaja en un Result.
  2. Pruébala con "45", "200" y "diez" e imprime los tres resultados tal cual.
  3. Con el de "200", imprime «ok» seguido del minuto, o «error:» seguido del mensaje del error, con una sola expresión.

Salida esperada:

Success(45)
Failure(java.lang.IllegalArgumentException: Minuto fuera del partido: 200)
Failure(java.lang.NumberFormatException: For input string: "diez")
error: Minuto fuera del partido: 200
Ver solución
solución del reto
fun minutoDesdeTexto(texto: String): Result<Int> = runCatching {
    val minuto = texto.trim().toInt()
    require(minuto in 0..120) { "Minuto fuera del partido: $minuto" }
    minuto
}
 
fun main() {
    println(minutoDesdeTexto("45"))
    println(minutoDesdeTexto("200"))
    println(minutoDesdeTexto("diez"))
    println(minutoDesdeTexto("200").fold({ "ok $it" }, { "error: ${it.message}" }))
}

Salida:

Success(45)
Failure(java.lang.IllegalArgumentException: Minuto fuera del partido: 200)
Failure(java.lang.NumberFormatException: For input string: "diez")
error: Minuto fuera del partido: 200

Fíjate en que las dos formas de fallar (texto que no es número y número fuera de rango) llegan por el mismo canal, aunque sean excepciones distintas.

Paso · Torneo.registrar devuelve un Result

Objetivo. Sustituir agregar por registrar(partido): Result<Partido> con las validaciones dentro, adaptar main y los tests. Al ejecutar verás mensajes de error legibles en lugar de un stack trace.

torneo/
kotlin+java → com.sebastiannarvaez.marcador.torneo
Torneo.ktse modifica
Main.ktse modifica
kotlin+java → com.sebastiannarvaez.marcador.torneo (test)
FormatoTest.kt · InmutabilidadTest.kt · ReglamentoTest.ktse sustituyen
Dos ficheros cambian y tres tests se sustituyen. Los nodos son los de la vista Android.

Los bloques de código llevan como título la ruta del fichero en tu proyecto. En la vista Android, esos ficheros están en torneo → kotlin+java → com.sebastiannarvaez.marcador.torneo.

Piezas nuevas de este paso: runCatching en una función que devuelve Result<…> y las formas de abrir el sobre (onSuccess, onFailure, map, getOrElse, fold, getOrThrow); una función private que valida (solo la usa el propio torneo); + entre dos listas, que las une en una lista nueva (partido.local.plantilla + partido.visitante.plantilla son los jugadores de los dos equipos); y a !== b, la negación de === (la comparación por identidad de data class e igualdad): partido.local !== partido.visitante es verdadero cuando son dos objetos distintos y falso cuando es el mismo equipo dos veces. Todo lo demás está en el ejemplo del club de arriba.

Y dos piezas más. Una sobrecarga: dos funciones registrar con el mismo nombre (de Inmutabilidad y referencias); una recibe un Partido ya hecho y la otra recibe local, visitante y los eventos y arma el Partido por ti. Y un nombre repetido a propósito: un método es una función que pertenece a una clase, y el receptor es el objeto sobre el que lo llamas. Torneo tiene un método registrar y Partido tiene otro, también llamado registrar, que añade un evento al partido. Comparten nombre y no chocan porque tienen receptor distinto: en Main.kt, registrar(alFinal) es el del torneo y Partido(toros, lobos).registrar(Gol(...)) es el del partido.

Intenta escribirlo tú. Solo registrar, que es lo importante; validar y los cambios de main van escritos más abajo, para pegar.

  1. En Torneo, agregar desaparece y entra registrar(partido), que ya no lanza: devuelve el partido si es válido y el error si no, sin dejar caer el programa. Un partido no es válido si un equipo juega contra sí mismo, si algún equipo no está inscrito o si alguno de sus eventos está mal (esto último lo revisa validar, que ya te doy escrita: partido.eventos.forEach { validar(it, partido) }).
  2. Una segunda versión de registrar recibe el local, el visitante y los eventos (opcionales) y arma el partido por ti.
  3. Dos mensajes deben tener exactamente esta forma, porque los tests los comprueban: «Los dos equipos deben estar inscritos en Copa» (con el nombre del torneo) y «Un equipo no puede jugar contra sí mismo».

Con todo pegado, main imprimirá tras las líneas de antes:

No se registró: Intruso no juega en Lobos
Marcador: 1-0
Eventos (o -1 si falla): -1

Qué se sustituye y dónde:

  1. Torneo.kt: borra la función agregar y pon en su lugar registrar (las dos versiones) y validar. Todo lo demás queda igual.
  2. Main.kt, tres cambios, cada uno por separado: las tres llamadas agregar(…) del apply de la jornada pasan a registrar(…).getOrThrow(); torneoAntiguo.agregar(alFinal) pasa a torneoAntiguo.registrar(alFinal); y el bloque de errores va al final de main, antes de la llave de cierre.
  3. Tests: se copian con el botón Copiar fichero, con los pasos de después del bloque.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código

Torneo.kt: desaparece agregar y entra registrar, en sus dos versiones:

torneo/src/main/java/com/sebastiannarvaez/marcador/torneo/Torneo.kt
    // Registrar puede fallar (equipo no inscrito, gol de alguien ajeno…), y eso
    // no es una situación excepcional sino esperable. Por eso devuelve un
    // `Result<Partido>`: o el partido registrado, o el error que lo impidió.
    // `runCatching` ejecuta el bloque y convierte lo que lance en un Result.failure.
    fun registrar(partido: Partido): Result<Partido> = runCatching {
        require(partido.local !== partido.visitante) { "Un equipo no puede jugar contra sí mismo" }
        require(partido.local in equipos && partido.visitante in equipos) {
            "Los dos equipos deben estar inscritos en $nombre"
        }
        partido.eventos.forEach { validar(it, partido) }
        partido.also { partidosJugados.add(it) }
    }
 
    fun registrar(
        local: Equipo,
        visitante: Equipo,
        eventos: List<EventoDePartido> = emptyList(),
    ): Result<Partido> = registrar(Partido(local, visitante, eventos))

Pega validar justo debajo de registrar, dentro de Torneo. Revisa cada evento: un minuto fuera de 0 a 120, un gol de un equipo que no juega o de un jugador que no es de ese equipo, o una tarjeta o un cambio con alguien que no juega el partido. Tiene que tratar todos los tipos de evento.

Torneo.kt: la función validar (se pega)
    private fun validar(evento: EventoDePartido, partido: Partido) {
        require(evento.minuto in 0..120) { "Minuto fuera del partido: ${evento.minuto}" }
        val jugadoresDelPartido = partido.local.plantilla + partido.visitante.plantilla
        // Un `when` que no devuelve nada, sobre un tipo sellado: también debe cubrir todos los casos.
        when (evento) {
            is Gol -> {
                require(evento.equipo == partido.local || evento.equipo == partido.visitante) {
                    "El gol es de un equipo que no juega: ${evento.equipo.nombre}"
                }
                require(evento.jugador in evento.equipo.plantilla) {
                    "${evento.jugador.nombre} no juega en ${evento.equipo.nombre}"
                }
            }
            is Tarjeta -> require(evento.jugador in jugadoresDelPartido) {
                "${evento.jugador.nombre} no juega este partido"
            }
            is Cambio -> require(evento.sale in jugadoresDelPartido && evento.entra in jugadoresDelPartido) {
                "El cambio implica a alguien que no juega este partido"
            }
        }
    }

Pega los cambios de Main.kt. Primero, las tres llamadas del apply, que sustituyen a las tres de agregar:

Main.kt: las tres llamadas del apply (se pega)
        registrar(alFinal).getOrThrow()
        registrar(Partido(toros, lobos).registrar(Gol(20, ivan, toros))).getOrThrow()
        registrar(Partido(lobos, rayo).registrar(Gol(40, ana, rayo))).getOrThrow()

Después, la llamada al torneo antiguo, que sustituye a torneoAntiguo.agregar(alFinal):

Main.kt: la llamada al torneo antiguo (se pega)
    torneoAntiguo.registrar(alFinal)

Y el bloque de errores, antes de la llave de cierre de main:

Main.kt: el bloque de errores (se pega)
    // Errores como valores: un registro inválido no tumba el programa.
    val intruso = Jugador("Intruso", 99)
    val golInvalido = torneo.registrar(lobos, toros, listOf(Gol(10, intruso, lobos)))
    golInvalido
        .onSuccess { println("Registrado: $it") }
        .onFailure { println("No se registró: ${it.message}") }
    val golValido = torneo.registrar(lobos, toros, listOf(Gol(10, lobos.plantilla.first(), lobos)))
    println("Marcador: " + golValido.fold(
        onSuccess = { "${it.golesLocal}-${it.golesVisitante}" },
        onFailure = { "error" },
    ))
    println("Eventos (o -1 si falla): " + torneo.registrar(lobos, lobos).map { it.eventos.size }.getOrElse { -1 })

Cuando compile, entrega los tests. Los tres sustituyen a los tuyos, con agregar ya cambiado por registrar(...).getOrThrow() (con agregar borrado, esos tres dejan de compilar). Se copian con el botón (copia al portapapeles y tú pegas). Repite con cada uno: ábrelo con doble clic, en la vista Android, dentro del paquete com.sebastiannarvaez.marcador.torneo (test), pulsa su Copiar fichero, selecciona todo y pega encima: Cmd+A / Cmd+V.

FormatoTest.kt

InmutabilidadTest.kt

ReglamentoTest.kt

Un Result que se ignora a propósito

torneoAntiguo.registrar(alFinal) ignora el Result. Funciona porque alFinal es válido en ese torneo. Si no lo fuera, nadie se enteraría: el compilador no avisa cuando descartas un Result. Regla práctica: cada Result se consume, con onFailure, fold o getOrThrow().

Punto de control

  • En la Terminal de Android Studio (View → Tool Windows → Terminal), ./gradlew :torneo:test :torneo:run termina en BUILD SUCCESSFUL. Si un test falla, Gradle lo dice con FAILED y no llega a BUILD SUCCESSFUL.
  • Abre el informe de tests como en Bucles y tu primer test (open torneo/build/reports/tests/test/index.html) y comprueba que arriba pone 65 tests, los mismos que antes de esta lección.
  • La salida trae No se registró: Intruso no juega en Lobos, Marcador: 1-0 y Eventos (o -1 si falla): -1.
  • Ya no queda ninguna llamada a agregar en el proyecto: ni en Main.kt, ni en los tests, y Android Studio no marca nada en rojo.

Comprueba lo que entendiste

4 preguntas · necesitas 3 para completar la lección

  1. 1.torneo.registrar(rayo, rayo) devuelve un Result. ¿Qué contiene?

  2. 2.En la salida de Main.kt aparece Eventos (o -1 si falla): -1. ¿Qué significa ese -1?

  3. 3.Descartas el Result de registrar(...) sin mirarlo. ¿Qué ocurre?

  4. 4.Una función convierte un texto escrito por el usuario en un número y puede fallar a menudo. ¿Qué conviene más según la lección?

Faltan 4 de 4