Result y runCatching: potencia con letra pequeña
La biblioteca estándar ofrece un contenedor de éxito o fallo y una función que convierte cualquier lanzamiento en ese contenedor. La lección abre la representación real del tipo, reconstruye la restricción que el compilador impuso durante dos versiones y explica por qué su argumento de diseño sobrevive a la retirada de la prohibición, y examina el riesgo mayor de la captura universal: tragarse errores fatales y señales de cancelación junto con los fallos que sí se querían tratar.
De todas las herramientas de este nivel, la pareja formada por el contenedor de resultado y la función que lo produce es la que más a menudo se adopta sin leer la letra pequeña, y también la que más deuda genera cuando se adopta así. La razón es que resuelve de verdad un problema real —convertir un lanzamiento en un valor que se puede pasar, transformar y almacenar— y lo hace con una ergonomía tan agradable que invita a usarla en todas partes. El problema es que su diseño está afinado para un caso concreto, el de la frontera donde una operación ajena puede fallar de cualquier manera, y fuera de ese caso paga costes que no siempre se ven: un tipo de error que ha perdido toda su información, una captura tan ancha que incluye cosas que jamás debieron capturarse, y una firma pública que comunica mucho menos de lo que parece. Esta lección mira el tipo por dentro antes de opinar sobre él.
- Describir la representación real de
Resultcomo clase de valor y prever cuándo se produce empaquetado en memoria. - Reconstruir la restricción histórica sobre su uso como tipo de retorno y decidir si el argumento de diseño sigue siendo válido tras su retirada.
- Manejar con criterio las operaciones de transformación, distinguiendo las que capturan de las que dejan pasar el lanzamiento.
- Identificar las tres familias de excepciones que
runCatchingatrapa y que nunca deberían quedar atrapadas.
Cómo está hecho por dentro
El contenedor no es una clase sellada con dos subtipos, aunque se comporte casi como si lo fuera. Es una clase de valor que envuelve un único campo de tipo Any?. Cuando representa éxito, ese campo guarda directamente el valor; cuando representa fallo, guarda una instancia de una clase interna que a su vez contiene el lanzamiento. Esta decisión persigue que el caso de éxito no cueste una asignación de memoria adicional.
val r: Result<Int> = runCatching { texto.toInt() }
r.isSuccess // consulta barata
r.getOrNull() // valor o nulo
r.exceptionOrNull() // lanzamiento o nulo
r.getOrElse { -1 } // respaldo con acceso al lanzamiento
r.getOrThrow() // relanza tal cual si es fallo
r.fold(onSuccess = { it * 2 }, onFailure = { 0 })
El tipo no nació como una comodidad para el usuario final, y saberlo explica casi todas sus decisiones. Nació para las corrutinas: la función que reanuda una continuación recibe precisamente un valor de este tipo, porque reanudar significa entregar a la máquina de estados o bien un resultado o bien un lanzamiento, y hacía falta un vehículo capaz de transportar ambas cosas a través de una frontera asíncrona sin construir nada caro. Todo lo que resulta chocante al usarlo en el dominio deja de serlo cuando se recuerda para quién se diseñó.
// La firma que motivo el tipo: reanudar con valor o con lanzamiento
public interface Continuation<in T> {
public fun resumeWith(result: Result<T>)
}
La promesa de coste cero tiene condiciones. Como toda clase de valor, se desenvuelve solo mientras aparece como tipo estático directo; en cuanto se usa como argumento de un genérico, se guarda en una colección, se asigna a una variable de tipo Any? o cruza una frontera que exige una referencia, vuelve a empaquetarse. Una lista de resultados no es gratis, y un flujo de resultados tampoco lo es.
Un Result de un tipo nulable hace que la ausencia y el fallo convivan en el mismo valor con semánticas distintas, y obliga a quien lo consume a dos comprobaciones encadenadas. Un Result de otro Result aparece con facilidad al encadenar transformaciones y no se aplana solo, porque el tipo no ofrece una operación de aplanado. Si te encuentras con cualquiera de las dos formas, la señal no es que necesites más operadores: es que el error debía ser un tipo del dominio y no un contenedor genérico.
La restricción que hubo y el argumento que queda
Durante las versiones 1.3 y 1.4 el compilador prohibía usar este tipo como retorno de una función pública. La razón declarada era conservadora: el equipo se reservaba la posibilidad de cambiar la semántica de las funciones que lo devolvieran, en particular la interacción con los operadores de nulabilidad, y no quería romper código ajeno. En la versión 1.5 se estudió esa evolución, se concluyó que no había un camino viable y, al desaparecer el motivo, la prohibición se retiró.
Que el compilador ya no lo impida no convierte la práctica en buena idea, y conviene separar las dos cosas con cuidado. El argumento técnico contra la prohibición era sobre compatibilidad futura; el argumento de diseño contra usarlo como tipo público es otro y sigue intacto: este contenedor borra el error. Su parámetro de fallo no es un parámetro, es siempre el supertipo de todo lo lanzable, de modo que la firma de la función anuncia que algo puede fallar pero no dice qué, no permite exhaustividad y no ofrece a quien la consume ninguna manera de saber qué casos debe contemplar.
flowchart TD
A[Funcion publica que puede fallar] --> B{El fallo interesa al invocador}
B -- No, es una averia --> C[Lanzar excepcion y documentarla]
B -- Si, y hay casos distinguibles --> D[Tipo sellado del dominio]
B -- Si, pero solo importa si hubo fallo --> E[Nulable o Result en ambito interno]
D --> F[Exhaustividad comprobada por el compilador]
E --> G[Sin exhaustividad: el error queda como Throwable]// Comunica que algo falla, pero no comunica nada util sobre que
fun cargarPerfil(id: Long): Result<Perfil>
// Comunica los casos y el compilador obliga a tratarlos
sealed interface CargaPerfil {
data class Ok(val perfil: Perfil) : CargaPerfil
data object NoEncontrado : CargaPerfil
data class SinPermiso(val motivo: String) : CargaPerfil
}
Hay una segunda carencia que se descubre al primer intento de componer. La biblioteca estándar no incluye una operación de encadenamiento aplanado, es decir, no hay forma directa de enlazar una operación que devuelve el contenedor con otra que también lo devuelve. Quien lo necesita acaba escribiendo el aplanado a mano sobre fold, con lo que ha reconstruido a mano la mitad de una biblioteca funcional sin obtener a cambio ni el tipado del error ni la exhaustividad.
// El encadenamiento que falta, escrito a mano
inline fun <T, R> Result<T>.flatMap(f: (T) -> Result<R>): Result<R> =
fold(onSuccess = f, onFailure = { Result.failure(it) })
Hay un uso donde encaja de forma natural y no compite con nada mejor: dentro de un módulo, como vehículo temporal entre el punto en el que se ejecuta algo ajeno que puede lanzar cualquier cosa y el punto, muy cercano, donde ese lanzamiento se traduce a un tipo del dominio. Ahí su falta de expresividad no importa, porque el valor no sobrevive a esa traducción.
La captura universal y sus tres víctimas
La función que produce el contenedor ejecuta un bloque y captura el supertipo de todo lo lanzable. No captura excepciones, captura absolutamente todo, y esa amplitud es su rasgo más peligroso porque en la misma red caen tres cosas que nunca deberían caer.
La primera familia son los errores irrecuperables de la máquina virtual: memoria agotada, desbordamiento de pila, fallo de enlazado de clases. Continuar tras uno de ellos no es recuperación, es funcionamiento indefinido con el registro de diagnóstico ya perdido. La segunda son los errores de programación, que deberían tumbar la operación de forma visible durante el desarrollo en lugar de convertirse en un fallo silencioso indistinguible de un problema de red. La tercera, y la más costosa en sistemas reales, es la excepción de cancelación de las corrutinas: capturarla rompe la cooperación del mecanismo y produce trabajo que sigue vivo después de que su ámbito haya muerto, con el resultado de tareas zombis y fugas.
// Dentro de una corrutina esto rompe la cancelacion
val datos = runCatching { descargar(url) }.getOrNull()
// Reparacion minima: devolver la cancelacion a su camino
suspend fun descargarSeguro(url: String): Result<Datos> =
try {
Result.success(descargar(url))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
Captura demasiado ancha
Atrapa errores fatales, defectos de programación y señales de cancelación junto con los fallos que sí querías tratar.
Transformar no siempre captura
La transformación ordinaria deja escapar cualquier lanzamiento de la lambda; solo la variante que captura vuelve a envolverlo. Confundirlas produce fallos que atraviesan una cadena aparentemente segura.
El fallo silencioso
Obtener el valor o nulo descarta el lanzamiento sin registrarlo. Es la forma más rápida de perder para siempre la causa de un incidente.
Conviene además saber que la captura universal no es simétrica respecto a la reanudación de corrutinas ni respecto a la propagación de fallos entre hermanas dentro de un ámbito concurrente, un asunto que la última lección de este nivel trata en detalle. Basta con retener aquí que la regla no es opcional: en cualquier función suspendida, capturar el supertipo de lo lanzable exige devolver explícitamente la cancelación a su camino.
// Extension util: capturar todo menos lo que nunca debe capturarse
inline fun <T> runCatchingCancelable(bloque: () -> T): Result<T> =
try {
Result.success(bloque())
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
Result.failure(e)
}
Merece un apunte final la asimetría entre las dos familias de transformación. La operación que transforma el valor no protege la lambda: si esta lanza, el lanzamiento sale de la cadena. Solo la variante con captura vuelve a envolver. Lo mismo ocurre con la recuperación en sus dos formas. Quien asume que una vez dentro del contenedor ya nada puede escaparse escribe cadenas que parecen seguras y no lo son.
Conviene entender por qué una herramienta tan bien construida produce tanto código mediocre, porque la explicación no está en la herramienta sino en la pregunta que se le hace. Este contenedor nació para un propósito muy concreto y muy poco glamuroso: dar a las corrutinas una forma de transportar el desenlace de un bloque de código ajeno a través de una frontera de continuación, es decir, para un lugar donde por definición no se sabe qué puede fallar porque el código es de otro. Para esa pregunta, borrar el tipo del error y capturarlo todo es exactamente la respuesta correcta, porque no hay nada que preservar ni nada que distinguir. La pregunta que en cambio se le hace en la mayoría de las bases de código es otra completamente distinta: cómo comunicar a mi invocador que esta operación de mi dominio puede terminar de tres maneras conocidas. Y para esa pregunta, el mismo diseño se convierte en una pérdida neta de información, porque toma tres casos que quien escribió la función conocía perfectamente, los aplasta contra el supertipo de todo lo lanzable y obliga a quien consume el resultado a redescubrirlos comparando clases en tiempo de ejecución, sin exhaustividad, sin documentación y sin ninguna ayuda del compilador. La consecuencia práctica es un criterio bastante nítido: si al escribir la firma puedes enumerar los fallos, tienes un tipo suma esperando a ser declarado, y usar el contenedor genérico es tirar información que ya tenías. Si no puedes enumerarlos porque el fallo viene de fuera de tu frontera, entonces el contenedor es justo lo que necesitas, pero solo hasta que cruces esa frontera hacia dentro, donde su trabajo termina y empieza el del tipo del dominio.
- Busca en tu código una función pública que devuelva este tipo y enumera los fallos que puede producir. Si puedes listarlos, escribe el tipo sellado equivalente.
- Localiza una llamada dentro de una corrutina que capture de forma universal y comprueba qué ocurre al cancelar el ámbito mientras se ejecuta.
- Construye una cadena de transformaciones donde una lambda lance, y observa la diferencia entre la variante que captura y la que no.
- Encuentra un sitio donde se obtenga el valor o nulo descartando el lanzamiento. Añade el registro que falta y valora si la información perdida era importante.
- Explica con la representación interna del tipo en la mano por qué una lista de resultados no es una estructura sin coste.