Salut, Habr. Je m'appelle Maxim, je suis développeur iOS chez FINCH. Aujourd'hui, je vais vous montrer quelques pratiques d'utilisation de la programmation fonctionnelle que nous avons développées dans notre département.
Je tiens à noter tout de suite que je ne vous exhorte pas à utiliser la programmation fonctionnelle partout - ce n'est pas une panacée pour tous les problèmes. Mais il me semble, dans certains cas, que FP peut fournir les solutions les plus flexibles et élégantes à des problèmes non standard.
FP est un concept populaire, donc je ne vais pas expliquer les bases. Je suis sûr que vous utilisez déjà map, réduire, compactMap, d'abord (où :) et des technologies similaires dans vos projets. L'article se concentrera sur la résolution du problème des requêtes multiples et sur l'utilisation du réducteur.
Problème de requête multiple
Je travaille dans l'externalisation de la production, et il y a des situations où un client avec ses sous-traitants s'occupe de créer un backend. C'est loin d'être le backend le plus pratique et vous devez faire des requêtes multiples et parallèles.
Parfois, je pouvais écrire quelque chose comme:
networkClient.sendRequest(request1) { result in switch result { case .success(let response1):
Dégoûtant, non? Mais c'est la réalité avec laquelle j'avais besoin de travailler.
J'ai dû envoyer trois demandes d'autorisation consécutives. Lors de la refactorisation, j'ai pensé que ce serait une bonne idée de diviser chaque demande en méthodes distinctes et de les appeler à l'intérieur de l'achèvement, déchargeant ainsi une énorme méthode. Il s'est avéré quelque chose comme:
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) } }
On peut voir que dans chacune des méthodes privées, je dois proxy
completion: @escaping (Result<AuthResponse>) -> Void
et je n'aime pas vraiment ça.
Puis la pensée m'est venue à l'esprit - «pourquoi ne pas recourir à une programmation fonctionnelle?» De plus, swift, avec son sucre magique et syntaxique, permet de décomposer le code en éléments individuels de manière intéressante et digeste.
Composition et réducteur
La programmation fonctionnelle est étroitement liée au concept de composition - mélanger, combiner quelque chose. Dans la programmation fonctionnelle, la composition suggère que nous combinions le comportement de blocs individuels, puis, à l'avenir, travailler avec lui.
La composition d'un point de vue mathématique est quelque chose comme:
func compose<A,B,C>(_ f: @escaping (A) -> B, and g: @escaping (B) -> C) -> (A) -> C { return { a in g(f(a)) } }
Il existe des fonctions f et g qui spécifient en interne les paramètres de sortie et d'entrée. Nous voulons obtenir une sorte de comportement résultant de ces méthodes d'entrée.
Par exemple, vous pouvez effectuer deux fermetures, dont l'une augmente le nombre d'entrée de 1 et la seconde se multiplie par elle-même.
let increment: (Int) -> Int = { value in return value + 1 } let multiply: (Int) -> Int = { value in return value * value }
Par conséquent, nous voulons appliquer ces deux opérations:
let result = compose(multiply, and: increment) result(10)
Malheureusement mon exemple n'est pas associatif
(si nous échangeons incrément et multiplions, nous obtenons le nombre 121), mais pour l'instant, omettons ce moment.
let result = compose(increment, and: multiply) result(10)
PS J'essaie spécifiquement de simplifier mes exemples pour que ce soit aussi clair que possible)
En pratique, vous devez souvent faire quelque chose comme ceci:
let value: Int? = array .lazy .filter { $0 % 2 == 1 } .first(where: { $0 > 10 })
Telle est la composition. Nous définissons l'action d'entrée et obtenons un effet de sortie. Mais ce n'est pas seulement l'ajout de certains objets - c'est l'ajout de tout un comportement.
Et maintenant, réfléchissons de manière plus abstraite :)
Dans notre application, nous avons une sorte d'état. Cela peut être l'écran que l'utilisateur voit actuellement ou les données actuelles qui sont stockées dans l'application, etc.
De plus, nous avons une action - c'est l'action que l'utilisateur peut faire (cliquer sur le bouton, faire défiler la collection, fermer l'application, etc.). En conséquence, nous opérons sur ces deux concepts et les relions l'un à l'autre, c'est-à-dire que nous combinons, hmmm, nous combinons (quelque part je l'ai déjà entendu).
Mais que se passe-t-il si vous créez une entité qui combine simplement mon état et mon action ensemble?
Nous obtenons donc un réducteur
struct Reducer<S, A> { let reduce: (S, A) -> S }
Nous donnerons l'état et l'action actuels à l'entrée de la méthode de réduction, et à la sortie, nous obtiendrons un nouvel état, qui a été formé à l'intérieur de réduire.
Nous pouvons décrire cette structure de plusieurs manières: en définissant un nouvel état, en utilisant une méthode fonctionnelle ou en utilisant des modèles 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 première option est «classique».
Le second est plus fonctionnel. Le fait est que nous ne renvoyons pas l'état, mais une méthode qui prend des mesures, qui retourne déjà à son tour l'état. Il s'agit essentiellement du curry de la méthode de réduction.
La troisième option consiste à utiliser l'état par référence. Avec cette approche, nous n'émettons pas seulement l'état, mais travaillons avec une référence à l'objet qui entre. Il me semble que cette méthode n'est pas très bonne, car de tels modèles (mutables) sont mauvais. Il est préférable de reconstruire le nouvel état (instance) et de le renvoyer. Mais pour des raisons de simplicité et de démonstration d'autres exemples, nous acceptons d'utiliser cette dernière option.
Appliquer un réducteur
Nous appliquons le concept Reducer au code existant - créez un RequestState, puis initialisez-le et définissez-le.
class RequestState {
Pour la synchronisation des demandes, j'ai ajouté DispatchSemaphore
Allez-y. Nous devons maintenant créer une RequestAction avec, disons, trois requêtes.
enum RequestAction { case sendFirstRequest(FirstRequest) case sendSecondRequest(SecondRequest) case sendThirdRequest(ThirdRequest) }
Créez maintenant un réducteur qui a un RequestState et RequestAction. Nous définissons le comportement - que voulons-nous faire avec la première, deuxième, troisième demande.
let requestReducer = Reducer<RequestState, RequestAction> { state, action in switch action { case .sendFirstRequest(let request): state.sendRequest(request) { (result: Result<FirstResponse>) in
En fin de compte, nous appelons ces méthodes. Il s'avère un style plus déclaratif, dans lequel il est clair que les première, deuxième et troisième demandes arrivent. Tout est lisible et clair.
var state = RequestState() requestReducer.reduce(&state, .sendFirstRequest(FirstRequest())) requestReducer.reduce(&state, .sendSecondRequest(SecondRequest())) requestReducer.reduce(&state, .sendThirdRequest(ThirdRequest()))
Conclusion
N'ayez pas peur d'apprendre de nouvelles choses et n'ayez pas peur d'apprendre la programmation fonctionnelle. Je pense que les meilleures pratiques sont au carrefour de la technologie. Essayez de combiner et de mieux tirer parti des différents paradigmes de programmation.
S'il y a une tâche non triviale, il est logique de la regarder sous un angle différent.