Result<T, E> vs. par de callbacks

#1

Básicamente eso, estaba aburrido, me bajé el código de cierta app de noticias para chusmear, y veo que las llamadas a backend no usan algo tipo result, sino que usan un par de callbacks para success y failure. Entiendo que es más práctico porque no tenés el boilerplate de

switch result {
    case .success(let response):
        doSomething(with: response)
    case .failure(let error):
        fatalError("*** handle properly later – \(error.localizedDescription)")
}

en cada llamada, pero no sé, algo me llama siempre a usar Result, pero no con Rx ni nada similar.

Opiniones? Pros? Cons?

:kissing_smiling_eyes:

1 me gusta
#2

:joy:

Por costumbre siempre metí los bloques de success y fail. Es algo que vengo arrastrando desde Objective-C cuando los enums no eran tan copados como ahora.

Creo que es más agradable de leer la forma que compartiste vos (con result).

Ahora, no estoy muy seguro cómo haría para reutilizar el fragmento de fail como en este ejemplo:

Para obtener las noticias de espectáculos tengo 3 clases involucradas: NewsTableViewController, CategoryNewsDataSource y NewsService, y se llaman en ese orden.

En el caso de éxito, cada clase hace lo suyo:

En el caso de un error en NewsService, se hace un pasamanos del closure de fail y el único que lo implementa es NewsTableViewController.

Dicho sea de paso, no tomen mi código como modelo a seguir. Estoy seguro que Canillitapp tiene varias partes de código donde da vergüenza ajena :sweat_smile:

Funny thing, estuve viendo un poco de rust y tu snippet me resulta muy familiar a
https://doc.rust-lang.org/std/result/

#3

El Result tiene un map, como el Optional, porque ambos son mónadas. Una mónada es un concepto de programación funcional (FP), tiene un definición formal y bleh, pero a fines prácticos se lo puede pensar como un wrapper (o una cajita). ¿Qué wrappea? Un valor del tipo Success en caso del Result, o de tipo Wrapped en caso del Optional.

Un Result puede estar en dos estados: en success con un valor de tipo Success asociado, o en failure con un valor de tipo Failure (que conforme Swift.Error). El optional también puede estar en dos estados, en some con un valor del tipo Wrapped o en none sin ningún valor asociado.

Cuando mapeas un Result, al map le pasás como parámetro una función que va desde el tipo de lo que tiene adentro en caso de success (Success) a otro tipo cualquiera sea. Es decir, pasas de un Result<A, MyError> a un Result<B, MyError>. Esa función se aplica al valor wrappeado SOLO SI el result "está en success". En caso contrario, en caso de que el result "esté en failure", no hace nada, sigue estando en failure con el mismo error.

A mi en su momento me cayó la ficha al ver la implementación del map (o implementarlo), así que la dejo por acá por si sirve para aclarar. Podría ser algo así:

enum Result<Success, Failure: Swift.Error> {
  case .success(Success)
  case .failure(Failure)

  ...

  func map<T>(_ fx: (Success) -> T) -> Result<T, Failure> {
    switch self {
      case .success(let value): return Result<T, Failure>.success(fx(value))
      case .failure(let error): return Result<T, Failure>.failure(error)
    }
  }

  ...

}

Hay que presentar atención a los tipos. Entra Result<Success, Failure>, sale Result<T, Failure>, porque fx va de (Success) -> T. Wrappeabas un Success, ahora wrappeas un T. El tipo del error NO cambia.

Al usar Results (o cualquier mónada) la idea es operar sobre ellos SIN importante lo que tiene adentro hasta el final. Entonces, en tu caso, tu servicio te va a devolver un Result<[New], NewsError>. No te interesa si está ok o tiene error por ahora, vos mapeas tranquilo y a la salida tenés otro result, que podría ser del mismo tipo (Result<[New], NewsError>) u otro (depende de las funciones que le pases al map). Vas pasando ese result, vas haciendo el pasamanos de ese result hasta llegar a donde te interese abrirlo. Generalmente eso va a pasar en la vista, ahí lo vas a abrir haciendo el switch que mostró @nico más arriba. Si en algún punto hubo un error, ese result va a estar en .failure con el error correspondiente, si fue todo bien, lo vas a tener en .success con el valor correspondiente (el array de news).

Al margen, al Optional también lo podés abrir con un switch:

let comments: String?
switch comments {
  case .some(let comments): print(comments)
  case .none: print("No comments")
}

Obvio que en este caso conviene la sintaxis sugar de ??, y queda print(comments ?? "No Comments"). Pero la cosa es que se puede switchear un Optional porque está implementado como un enum. También podés mapear un Optional.

El año pasado tuve la suerte de laburar con un ayudante de la materia de la UTN que enseña FP (le voy a decir que se venga para este foro ya que sigue laburando en iOS). Yo le enseñe algo de iOS, y él me enseñó algo de FP. Y todo este tema de las mónadas es super super interesante desde el punto de vista práctico (de fondo tiene definiciones super formales como todo lo que es FP, pero ya con lo práctico es más que suficiente). Por ejemplo, la función que le pasas al map puede tener distintas firmas y en base a eso te abre todo un mundo:

  • si la función puede fallar y devuelve otro result, te va a quedar un result de result (Result<Result<T, ErrorA>, ErrorB>). En ese caso tenés que usar flatMap.
  • si la función throwea podés catchear el error dentro del map y devolver un result en .failure conteniendo ese error.
  • a veces te puede interesar mapear el error (el Result de Swift 5 viene con un mapError).

Y ya que arrancamos hablando del Result para llamadas a servicios, hace un tiempo había posteado algo al respecto por acá. Ahora veo esos trys y no los usuaría, de hecho va como ñapi un Result y maps ahí, que es lo que estoy haciendo últimamente en la capa de servicios. Pero el concepto es el mismo, el primer result te viene de algo que puede fallar (la llamada al servicio), ponele que tenés un Result<Data, Error>. Eso lo podés mapear a un JSON y te queda un Result<JSON, Error>. Eso lo mapeas con el parser y te queda Result<Model, Error>. Y en la vista lo abrís, si algo falló, lo vas a tener en el error asociado al failure. Si fue todo ok, vas a tener lo que querés en el valor asociado al success.

1 me gusta
#4

Maaaan, gracias por la explicación. Creo que se viene un refactor aplicando esto :joy:

#5

Tremenda toda la data que nos compartió @LeanLinardos :clap:t2:

Lo de los switch para obtener el valor también se puede mezclar con throwing functions.

extension Result {
    func get() throws -> Value {
        switch self {
        case .success(let value):
            return value
        case .failure(let error):
            throw error
        }
    }
}

Y teniendo en cuenta que en Swift 5 ya es un feature del lenguaje, todas estas extensions y eso las tenemos de arriba. Y si falta alguna extension copada, la podemos compartir por acá.

Acá está la proposal en la que habla de las ventajas y demás para temas de asincronismo, y de las cosas que se tuvieron que cambiar para que incluirlo en la standard library sea posible (como la self conformance del protocolo Error).

Y si les interesa acá hay un episodio de un podcast que sigo, que debatió sobre el tema cuando salió la proposal.

2 me gusta
#6

¡De una! ¡Chiflá que mandamos mano! Queda super limpia la capa de servicios.

1 me gusta