Cómo trabajar con múltiples consultas. Composición, Reductor, FP

Hola Habr Mi nombre es Maxim, soy desarrollador de iOS en FINCH. Hoy les mostraré algunas prácticas de uso de la programación funcional que hemos desarrollado en nuestro departamento.

Quiero señalar de inmediato que no le insto a que use la programación funcional en todas partes, esto no es una panacea para todos los problemas. Pero me parece que, en algunos casos, FP puede proporcionar las soluciones más flexibles y elegantes para problemas no estándar.

FP es un concepto popular, por lo que no explicaré los conceptos básicos. Estoy seguro de que ya utiliza map, reduce, compactMap, first (where :) y tecnologías similares en sus proyectos. El artículo se centrará en resolver el problema de múltiples consultas y trabajar con reductor.

Problema de consultas múltiples


Trabajo en la producción de outsourcing, y hay situaciones en las que un cliente con sus subcontratistas se encarga de crear un backend. Esto está lejos de ser el backend más conveniente y debe realizar consultas múltiples y paralelas.

A veces podría escribir algo como:

networkClient.sendRequest(request1) { result in switch result { case .success(let response1): // ... self.networkClient.sendRequest(request2) { result in // ... switch result { case .success(let response2): // ...  -     response self.networkClient.sendRequest(request3) { result in switch result { case .success(let response3): // ...  -     completion(Result.success(response3)) case .failure(let error): completion(Result.failure(.description(error))) } } case .failure(let error): completionHandler(Result.failure(.description(error))) } } case .failure(let error): completionHandler(Result.failure(.description(error))) } } 

Asqueroso, ¿verdad? Pero esta es la realidad con la que necesitaba trabajar.

Necesitaba enviar tres solicitudes consecutivas de autorización. Durante la refactorización, pensé que sería una buena idea dividir cada solicitud en métodos separados y llamarlos a su finalización, descargando así un método enorme. Resultó algo como:

 func obtainUserStatus(completion: @escaping (Result<AuthResponse>) -> Void) { let endpoint= AuthEndpoint.loginRoute networkService.request(endpoint: endpoint, cachingEnabled: false) { [weak self] (result: Result<LoginRouteResponse>) in switch result { case .success(let response): self?.obtainLoginResponse(response: response, completion: completion) case .failure(let error): completion(.failure(error)) } } } private func obtainLoginResponse(_ response: LoginRouteResponse, completion: @escaping (Result<AuthResponse>) -> Void) { let endpoint= AuthEndpoint.login networkService.request(endpoint: endpoint, cachingEnabled: false) { [weak self] (result: Result<LoginResponse>) in switch result { case .success(let response): self?.obtainAuthResponse(response: response, completion: completion) case .failure(let error): completion(.failure(error)) } } private func obtainAuthResponse(_ response: LoginResponse, completion: @escaping (Result<AuthResponse>) -> Void) { let endpoint= AuthEndpoint.auth networkService.request(endpoint: endpoint, cachingEnabled: false) { (result: Result<AuthResponse>) in completion(result) } } 

Se puede ver que en cada uno de los métodos privados tengo que proxy

 completion: @escaping (Result<AuthResponse>) -> Void 

y realmente no me gusta

Entonces se me ocurrió la idea: "¿por qué no recurrir a la programación funcional?" Además, Swift, con su azúcar mágica y sintáctica, permite dividir el código en elementos individuales de una manera interesante y digerible.

Composición y reductor


La programación funcional está estrechamente relacionada con el concepto de composición: mezclar, combinar algo. En la programación funcional, la composición sugiere que combinemos el comportamiento de bloques individuales y luego, en el futuro, trabajemos con él.

La composición desde un punto de vista matemático es algo así como:

 func compose<A,B,C>(_ f: @escaping (A) -> B, and g: @escaping (B) -> C) -> (A) -> C { return { a in g(f(a)) } } 

Hay funciones fyg que especifican internamente parámetros de salida y entrada. Queremos obtener algún tipo de comportamiento resultante de estos métodos de entrada.

Como ejemplo, puede hacer dos cierres, uno de los cuales aumenta el número de entrada en 1, y el segundo se multiplica por sí mismo.

 let increment: (Int) -> Int = { value in return value + 1 } let multiply: (Int) -> Int = { value in return value * value } 

Como resultado, queremos aplicar ambas operaciones:

 let result = compose(multiply, and: increment) result(10) //     101 


Lamentablemente mi ejemplo no es asociativo
(si intercambiamos incrementar y multiplicar, obtenemos el número 121), pero por ahora omita este momento.

 let result = compose(increment, and: multiply) result(10) //     121 

PD: específicamente trato de simplificar mis ejemplos para que sea lo más claro posible)

En la práctica, a menudo necesitas hacer algo como esto:

 let value: Int? = array .lazy .filter { $0 % 2 == 1 } .first(where: { $0 > 10 }) 

Esta es la composición. Establecemos la acción de entrada y obtenemos algún efecto de salida. Pero esto no es solo la adición de algunos objetos, es la adición de un comportamiento completo.

Y ahora pensemos de manera más abstracta :)


En nuestra aplicación, tenemos algún tipo de estado. Esta puede ser la pantalla que el usuario ve actualmente o los datos actuales que están almacenados en la aplicación, etc.
Además, tenemos acción: esta es la acción que el usuario puede hacer (haga clic en el botón, desplácese por la colección, cierre la aplicación, etc.). Como resultado, operamos en estos dos conceptos y los relacionamos entre sí, es decir, combinamos, hmmm, combinamos (en algún lugar ya lo escuché).

Pero, ¿qué pasa si crea una entidad que simplemente combina mi estado y acción juntos?

Entonces obtenemos Reductor

 struct Reducer<S, A> { let reduce: (S, A) -> S } 

Le daremos el estado actual y la acción a la entrada del método de reducción, y en la salida obtendremos un nuevo estado, que se formó dentro de reducir.

Podemos describir esta estructura de varias maneras: definiendo un nuevo estado, utilizando un método funcional o utilizando modelos mutables.

 struct Reducer<S, A> { let reduce: (S, A) -> S } struct Reducer<S, A> { let reduce: (S) -> (A) -> S } struct Reducer<S, A> { let reduce: (inout S, A) -> Void } 

La primera opción es "clásica".

El segundo es más funcional. El punto es que no estamos devolviendo el estado, sino un método que toma medidas, que ya a su vez devuelve el estado. Esto es esencialmente el curry del método de reducción.

La tercera opción es trabajar con estado por referencia. Con este enfoque, no solo emitimos estado, sino que trabajamos con una referencia al objeto que entra. Me parece que este método no es muy bueno, porque tales modelos (mutables) son malos. Es mejor reconstruir el nuevo estado (instancia) y devolverlo. Pero para simplificar y demostrar más ejemplos, aceptamos utilizar la última opción.

Aplicando reductor


Aplicamos el concepto Reductor al código existente: cree un RequestState, luego inicialícelo y configúrelo.

 class RequestState { // MARK: - Private properties private let semaphore = DispatchSemaphore(value: 0) private let networkClient: NetworkClient = NetworkClientImp() // MARK: - Public methods func sendRequest<Response: Codable>(_ request: RequestProtocol, completion: ((Result<Response>) -> Void)?) { networkClient.sendRequest(request) { (result: Result<Response>) in completion?(result) self.semaphore.signal() } semaphore.wait() } } 

Para la sincronización de solicitudes, agregué DispatchSemaphore

Adelante Ahora necesitamos crear una RequestAction con, digamos, tres solicitudes.

 enum RequestAction { case sendFirstRequest(FirstRequest) case sendSecondRequest(SecondRequest) case sendThirdRequest(ThirdRequest) } 

Ahora cree un reductor que tenga RequestState y RequestAction. Establecemos el comportamiento: ¿qué queremos hacer con la primera, segunda y tercera solicitud?

 let requestReducer = Reducer<RequestState, RequestAction> { state, action in switch action { case .sendFirstRequest(let request): state.sendRequest(request) { (result: Result<FirstResponse>) in // 1 Response } case .sendSecondRequest(let request): state.sendRequest(request) { (result: Result<SecondResponse>) in // 2 Response } case .sendThirdRequest(let request): state.sendRequest(request) { (result: Result<ThirdResponse>) in // 3 Response } } } 

Al final, llamamos a estos métodos. Resulta un estilo más declarativo, en el que está claro que las solicitudes primera, segunda y tercera están llegando. Todo es legible y claro.

 var state = RequestState() requestReducer.reduce(&state, .sendFirstRequest(FirstRequest())) requestReducer.reduce(&state, .sendSecondRequest(SecondRequest())) requestReducer.reduce(&state, .sendThirdRequest(ThirdRequest())) 

Conclusión


No tenga miedo de aprender cosas nuevas y no tenga miedo de aprender programación funcional. Creo que las mejores prácticas están en la encrucijada de la tecnología. Intente combinar y aprovechar mejor los diferentes paradigmas de programación.

Si hay alguna tarea no trivial, entonces tiene sentido mirarla desde un ángulo diferente.

Source: https://habr.com/ru/post/455840/


All Articles