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):
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)
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)
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 {
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
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.