
Les articles sur la programmation fonctionnelle écrivent beaucoup sur la façon dont l'approche FP améliore le développement: il devient facile d'écrire, de lire, de diffuser, de coder, de tester, de construire une architecture médiocre et les cheveux deviennent doux et soyeux .
Un inconvénient est le seuil d'entrée élevé. En essayant de comprendre la FP, je suis tombé sur une énorme quantité de théorie, de foncteurs, de monades, de théorie des catégories et de types de données algébriques. Et comment appliquer la FA dans la pratique n'était pas claire. De plus, des exemples ont été donnés dans des langues que je ne connais pas - le haskell et le rock.
Ensuite, j'ai décidé de comprendre le FP dès le début. J'ai compris et dit à codefest que FP est vraiment juste que nous l'utilisons déjà dans Swift et que nous pouvons l'utiliser encore plus efficacement.
Programmation fonctionnelle: fonctions pures et manque d'états
Déterminer ce que signifie écrire dans un paradigme particulier n'est pas une tâche facile. Les paradigmes ont été formés pendant des décennies par des personnes aux visions différentes, incarnés dans des langues aux approches différentes, et sont entourés d'outils. Ces outils et approches sont considérés comme faisant partie intégrante des paradigmes, mais en réalité ils ne le sont pas.
Par exemple, on pense que la programmation orientée objet repose sur trois piliers - héritage, encapsulation et polymorphisme. Mais l'encapsulation et le polymorphisme sont mis en œuvre sur les fonctions avec la même facilité que sur les objets. Ou fermetures - ils sont nés dans des langages fonctionnels purs, mais ont migré si longtemps vers des langages industriels qu'ils ont cessé d'être associés à la PF. Les monades pénètrent également dans les langages industriels, mais n'ont pas encore perdu leur appartenance au Haskell conditionnel dans l'esprit des gens.
En conséquence, il s'avère qu'il est impossible de déterminer clairement ce qu'est un paradigme particulier. Encore une fois, je suis tombé sur cela au codefest 2019, où tous les experts de la FP, parlant du paradigme fonctionnel, ont appelé des choses différentes.
Personnellement, j'ai aimé la définition du wiki:
"La programmation fonctionnelle est une section de mathématiques discrètes et un paradigme de programmation dans lequel le processus de calcul est traité comme calculant les valeurs des fonctions dans la compréhension mathématique de ces dernières (par opposition aux fonctions comme sous-programmes dans la programmation procédurale)."
Qu'est-ce qu'une fonction mathématique? Il s'agit d'une fonction dont le résultat ne dépend que des données auxquelles il est appliqué.
Un exemple de fonction mathématique sur quatre lignes de code ressemble à ceci:
func summ(a: Int, b: Int) -> Int { return a + b } let x = summ(a: 2, b: 3)
En appelant la fonction somm avec les arguments d'entrée 2 et 3, nous obtenons 5. Ce résultat est inchangé. Modifiez le programme, le thread, le lieu d'exécution - le résultat restera le même.
Et une fonction non mathématique est lorsqu'une variable globale est déclarée quelque part.
var z = 5
La fonction somme ajoute maintenant les arguments d'entrée et la valeur de z.
func summ(a: Int, b: Int) -> Int { return a + b + z } let x = summ(a: 2, b: 3)
Dépendance supplémentaire à l'égard de l'état global. Il est maintenant impossible de prédire sans ambiguïté la valeur de x. Il changera constamment en fonction du moment où la fonction a été appelée. Nous appelons la fonction 10 fois de suite, et chaque fois nous pouvons obtenir un résultat différent.
Une autre version de la fonction non mathématique:
func summ(a: Int, b: Int) -> Int { z = b - a return a + b }
En plus de renvoyer la somme des arguments d'entrée, la fonction modifie la variable globale z. Cette fonctionnalité a un effet secondaire.
La programmation fonctionnelle a un terme spécial pour les fonctions mathématiques - les fonctions pures. Une fonction pure est une fonction qui renvoie le même résultat pour le même ensemble de valeurs d'entrée et n'a pas d'effets secondaires.
Les fonctions pures sont la pierre angulaire de la FP, tout le reste est secondaire. On suppose que, suivant ce paradigme, nous les utilisons uniquement. Et si vous ne travaillez pas avec des états globaux ou modifiables, ils ne seront pas dans l'application.
Classes et structures dans un paradigme fonctionnel
Au départ, je pensais que FP ne concernait que les fonctions, et les classes et les structures ne sont utilisées qu'en POO. Mais il s'est avéré que les cours s'inscrivent également dans le concept de FP. Seulement, ils devraient être, disons, «propres».
Une classe «pure» est une classe dont toutes les méthodes sont des fonctions pures et les propriétés sont immuables. (Il s'agit d'un terme non officiel, inventé en préparation du rapport).
Jetez un oeil à cette classe:
class User { let name: String let surname: String let email: String func getFullname() -> String { return name + " " + surname } }
Elle peut être considérée comme une encapsulation de données ...
class User { let name: String let surname: String let email: String }
et des fonctions pour travailler avec eux.
func getFullname() -> String { return name + " " + surname }
Du point de vue de FP, l'utilisation de la classe User n'est pas différente de l'utilisation de primitives et de fonctions.
Déclarez la valeur - utilisateur Vanya.
let ivan = User( name: "", surname: "", email: "ivanov@example.com" )
Appliquez-lui la fonction getFullname.
let fullName = ivan.getFullname()
En conséquence, nous obtenons une nouvelle valeur - le nom d'utilisateur complet. Comme vous ne pouvez pas modifier les paramètres de la propriété ivan, le résultat de l'appel à getFullname est inchangé.
Bien sûr, un lecteur attentif peut dire: «Attendez une minute, la méthode getFullname renvoie le résultat en fonction de ses valeurs globales - les propriétés de classe, pas les arguments. Mais en réalité, une méthode n'est qu'une fonction dans laquelle un objet est passé en argument.
Swift prend même explicitement en charge cette entrée:
let fullName = User.getFullname(ivan)()
Si nous devons modifier une valeur de l'objet, par exemple un e-mail, nous devrons créer un nouvel objet. Cela peut être fait par la méthode appropriée.
class User { let name: String let surname: String let email: String func change(email: String) -> User { return User(name: name, surname: surname, email: email) } } let newIvan = ivan.change(email: "god@example.com")
Attributs fonctionnels dans Swift
J'ai déjà écrit que de nombreux outils, implémentations et approches qui sont considérés comme faisant partie d'un paradigme peuvent en fait être utilisés dans d'autres paradigmes. Par exemple, les monades, les types de données algébriques, l'inférence de type automatique, le typage strict, les types dépendants, la vérification de l'exactitude du programme pendant la compilation sont considérés comme faisant partie du FP. Mais beaucoup de ces outils que nous pouvons trouver dans Swift.
La frappe forte et l'inférence de type font partie de Swift. Ils n'ont pas besoin d'être compris ou introduits dans le projet, nous les avons juste.
Il n'y a pas de types dépendants, même si je ne refuserais pas de vérifier la chaîne par le compilateur que c'est un email, un tableau, qu'il n'est pas vide, un dictionnaire, qu'il contient la clé apple. Soit dit en passant, il n'y a pas non plus de types dépendants dans Haskell.
Les types de données algébriques sont disponibles, et c'est une chose mathématique cool, mais difficile à comprendre. La beauté est qu'il n'a pas besoin d'être compris mathématiquement pour pouvoir l'utiliser. Par exemple, Int, enum, Facultatif, Hashable sont des types algébriques. Et si Int est dans de nombreux langages et que Protocol est en Objective-C, alors l'énumération avec les valeurs associées, les protocoles avec implémentation par défaut et les types associatifs sont loin d'être partout.
La validation de la compilation est souvent mentionnée lorsque l'on parle de langues comme la rouille ou le haskell. Il est entendu que le langage est si expressif qu'il vous permet de décrire tous les cas limites afin qu'ils soient vérifiés par le compilateur. Donc, si le programme a été compilé, il fonctionnera certainement. Personne ne conteste qu'il puisse contenir des erreurs dans la logique, car vous avez incorrectement filtré les données à afficher pour l'utilisateur. Mais cela ne tombera pas, car vous n'avez pas reçu de données de la base de données, le serveur a renvoyé la mauvaise réponse pour laquelle vous vous attendiez, ou l'utilisateur a entré sa date de naissance sous la forme d'une chaîne, pas d'un nombre.
Je ne peux pas dire que la compilation de code rapide peut attraper tous les bogues: par exemple, il est facile d’éviter une fuite de mémoire. Mais une frappe forte et une protection facultative contre de nombreuses erreurs stupides. L'essentiel est de limiter l'extraction forcée.
Monades: ne fait pas partie du paradigme de la PF, mais un outil (facultatif)
Assez souvent, les FP et les monades sont utilisés dans la même application. À un moment donné, je pensais même que les monades sont une programmation fonctionnelle. Quand je les ai compris (mais ce n'est pas exact), j'ai tiré plusieurs conclusions:
- ils sont simples;
- ils sont confortables;
- les comprendre éventuellement, il suffit de pouvoir postuler;
- vous pouvez facilement vous en passer.
Swift a déjà deux monades standard - facultative et résultat. Les deux sont nécessaires pour faire face aux effets secondaires. En option protège contre zéro possible. Résultat - de diverses situations exceptionnelles.
Prenons l'exemple amené au point d'absurdité. Supposons que nous ayons des fonctions qui renvoient un entier de la base de données et du serveur. La seconde peut retourner nil, mais nous utilisons l'extraction implicite pour obtenir le comportement Objective-C-time.
func getIntFromDB() -> Int func getIntFromServer() -> Int!
Nous continuons d'ignorer Facultatif et implémentons une fonction pour additionner ces nombres.
func summInts() -> Int! { let intFromDB = getIntFromDB() let intFromServer = getIntFromServer()! let summ = intFromDB + intFromServer return summ }
Nous appelons la fonction finale et utilisons le résultat.
let result = summInts() print(result)
Cet exemple fonctionnera-t-il? Eh bien, il se compile définitivement, mais personne ne sait si nous obtenons le crash au moment de l'exécution. Ce code est bon, il montre parfaitement nos intentions (nous avons besoin de la somme de deux nombres environ) et il ne contient rien de superflu. Mais il est dangereux. Par conséquent, seuls les juniors et les personnes confiantes écrivent de cette façon.
Modifiez l'exemple pour le rendre sûr.
func getIntFromDB() -> Int func getIntFromServer() -> Int? func summInts() -> Int? { let intFromDB = getIntFromDB() let intFromServer = getIntFromServer() if let intFromServer = intFromServer { let summ = intFromDB + intFromServer return summ } else { return nil } } if let result = summInts() { print(result) }
Ce code est bon, il est sûr. En utilisant une extraction explicite, nous nous sommes défendus contre un zéro possible. Mais cela est devenu lourd, et parmi les contrôles sûrs, il est déjà difficile de discerner notre intention. Nous avons encore besoin de la somme de deux chiffres, pas d'un contrôle de sécurité.
Dans ce cas, Optional a une méthode de mappage, héritée du type Maybe de Haskell. Nous l'appliquons et l'exemple va changer.
func getIntFromDB() -> Int func getIntFromServer() -> Int? func summInts() -> Int? { let intFromDB = getIntFromDB() let intFromServer = getIntFromServer() return intFromServer.map { x in x + intFromDB } } if let result = summInts() { print(result) }
Ou encore plus compact.
func getIntFromDB() -> Int func getintFromServer() -> Int? func summInts() -> Int? { return getintFromServer().map { $0 + getIntFromDB() } } if let result = summInts() { print(result) }
Nous avons utilisé map pour convertir intFromServer au résultat dont nous avons besoin sans extraction.
Nous nous sommes débarrassés du chèque à l'intérieur des sommets, mais nous l'avons laissé au niveau supérieur. Cela se fait intentionnellement, car à la fin de la chaîne de calcul, nous devons choisir une méthode pour traiter le manque de résultat.
Éjecter
if let result = summInts() { print(result) }
Utiliser la valeur par défaut
print(result ?? 0)
Ou affichez un avertissement si les données ne sont pas reçues.
if let result = summInts() { print(result) } else { print("") }
Maintenant, le code dans l'exemple ne contient pas trop, comme dans le premier exemple, et est sûr, comme dans le second.
Mais la carte ne fonctionne pas toujours comme il se doit
let a: String? = "7" let b = a.map { Int($0) } type(of: b)
Si nous transmettons une fonction à la carte dont le résultat est facultatif, nous obtenons un double facultatif. Mais nous n'avons pas besoin d'une double protection contre zéro. Un suffit. La méthode flatMap permet de résoudre le problème, c'est un analogue de carte à une différence près, elle déploie les poupées gigognes.
let a: String? = "7" let b = a.flatMap { Int($0) } type(of: b)
Un autre exemple où map et flatMap ne sont pas très pratiques à utiliser.
let a: Int? = 3 let b: Int? = 7 let c = a.map { $0 + b! }
Et si une fonction prend deux arguments et qu'ils sont tous les deux optionnels? Bien sûr, FP a une solution - il s'agit d'un foncteur applicatif et d'un curry. Mais ces outils semblent plutôt maladroits sans utiliser d'opérateurs spéciaux qui ne sont pas dans notre langue, et écrire des opérateurs personnalisés est considéré comme une mauvaise forme. Par conséquent, nous considérons une manière plus intuitive: nous écrivons une fonction spéciale.
@discardableResult func perform<Result, U, Z>( _ transform: (U, Z) throws -> Result, _ optional1: U?, _ optional2: Z?) rethrows -> Result? { guard let optional1 = optional1, let optional2 = optional2 else { return nil } return try transform(optional1, optional2) }
Il prend deux valeurs facultatives comme arguments et une fonction avec deux arguments. Si les deux options ont des valeurs, une fonction leur est appliquée.
Maintenant, nous pouvons travailler avec plusieurs options sans les déployer.
let a: Int? = 3 let b: Int? = 7 let result = perform(+, a, b)
La deuxième monade, Result, a également des méthodes map et flatMap. Vous pouvez donc travailler avec lui exactement de la même manière.
func getIntFromDB() -> Int func getIntFromServer() -> Result<Int, ServerError> func summInts() -> Result<Int, ServerError> { let intFromDB = getIntFromDB() let intFromServer = getIntFromServer() return intFromServer.map { x in x + intFromDB } } if case .success(let result) = summInts() { print(result) }
En fait, c'est ce qui rassemble les monades - la possibilité de travailler avec la valeur à l'intérieur du conteneur sans l'enlever. À mon avis, cela rend le code concis. Mais si vous ne l'aimez pas, utilisez simplement des extraits explicites, cela ne contredit pas le paradigme FP.
Exemple: réduction du nombre de fonctions sales
Malheureusement, dans les programmes réels, les états globaux et les effets secondaires sont partout - demandes de réseau, sources de données, interfaces utilisateur. Et seules les fonctions pures ne peuvent être supprimées. Mais cela ne veut pas dire que le FP nous est complètement inaccessible: on peut essayer de réduire le nombre de fonctions sales, qui sont généralement très nombreuses.
Regardons un petit exemple proche du développement de la production. Créez une interface utilisateur, en particulier un formulaire d'inscription. Le formulaire présente certaines limites:
1) Connectez-vous pas moins de 3 caractères
2) Mot de passe d'au moins 6 caractères
3) Le bouton «Connexion» est actif si les deux champs sont valides.
4) La couleur du cadre de champ reflète son état, noir - est valide, rouge - n'est pas valide
Le code décrivant ces restrictions peut ressembler à ceci:
Gérer toute entrée utilisateur
@IBAction func textFieldTextDidChange() {
Traitement de l'achèvement de la connexion:
@IBAction func loginDidEndEdit() { let color: CGColor
Traitement de l'achèvement du mot de passe:
@IBAction func passwordDidEndEdit() { let color: CGColor
En appuyant sur le bouton Entrée:
@IBAction private func loginPressed() {
Ce code n'est peut-être pas le meilleur, mais dans l'ensemble, il est bon et fonctionne. Certes, il a un certain nombre de problèmes:
- 4 extraits explicites;
- 4 dépendances de l'état global;
- 8 effets secondaires;
- états finaux non évidents;
- écoulement non linéaire.
Le principal problème est que vous ne pouvez pas simplement prendre et dire ce qui se passe avec notre écran. En regardant une méthode, nous voyons ce qu'elle fait avec un état global, mais nous ne savons pas qui, où et quand elle touche l'état. Par conséquent, pour comprendre ce qui se passe, vous devez trouver tous les points de travail avec les vues et comprendre dans quel ordre les influences se produisent. Garder tout cela à l'esprit est très difficile.
Si le processus de changement d'état est linéaire, vous pouvez l'étudier étape par étape, ce qui réduira la charge cognitive du programmeur.
Essayons de changer l'exemple pour le rendre plus fonctionnel.
Tout d'abord, nous définissons un modèle qui décrit l'état actuel de l'écran. Cela vous permettra de savoir exactement quelles informations sont nécessaires pour le travail.
struct LoginOutputModel { let login: String let password: String var loginIsValid: Bool { return login.count > 3 } var passwordIsValid: Bool { return password.count > 6 } var isValid: Bool { return loginIsValid && passwordIsValid } }
Un modèle qui décrit les changements appliqués à l'écran. Elle a besoin de savoir exactement ce que nous allons changer.
struct LoginInputModel { let loginBorderColor: CGColor? let passwordBorderColor: CGColor? let loginButtonEnable: Bool? let popupErrorMessage: String? }
Événements pouvant conduire à un nouvel état d'écran. Nous saurons donc exactement quelles actions changent l'écran.
enum Event { case textFieldTextDidChange case loginDidEndEdit case passwordDidEndEdit case loginPressed case authFailure(Error) }
Nous décrivons maintenant la principale méthode de changement. Cette fonction pure, basée sur l'événement d'état actuel, collecte un nouvel état de l'écran.
func makeInputModel( event: Event, outputModel: LoginOutputModel?) -> LoginInputModel { switch event { case .textFieldTextDidChange: let mapValidToColor: (Bool) -> CGColor? = { $0 ? normalColor : nil } return LoginInputModel( loginBorderColor: outputModel .map { $0.loginIsValid } .flatMap(mapValidToColor), passwordBorderColor: outputModel .map { $0.passwordIsValid } .flatMap(mapValidToColor), loginButtonEnable: outputModel?.passwordIsValid ) case .loginDidEndEdit: return LoginInputModel() case .passwordDidEndEdit: return LoginInputModel() case .loginPressed: return LoginInputModel() case .authFailure(let error) where error is AuthError: return LoginInputModel() case .authFailure: return LoginInputModel() } }
La chose la plus importante est que cette méthode est la seule autorisée à s'engager dans la construction d'un nouvel État - et c'est propre. Il peut être étudié étape par étape. Voyez comment les événements transforment l'écran du point A au point B. Si quelque chose se brise, le problème est exactement là. Et c'est facile à tester.
Ajoutez une propriété auxiliaire pour obtenir l'état actuel, c'est la seule méthode qui dépend de l'état global.
var outputModel: LoginOutputModel? { return perform(LoginOutputModel.init, loginView.text, passwordView.text) }
Ajoutez une autre méthode «sale» pour créer les effets secondaires du changement d'écran.
func updateView(_ event: Event) { let inputModel = makeInputModel(event: event, outputModel: outputModel) if let color = inputModel.loginBorderColor { loginView.layer.borderColor = color } if let color = inputModel.passwordBorderColor { passwordView.layer.borderColor = color } if let isEnable = inputModel.loginButtonEnable { loginButton.isEnabled = isEnable } if let error = inputModel.popupErrorMessage { showPopup(error) } }
Bien que la méthode updateView ne soit pas propre, c'est le seul endroit où les propriétés de l'écran changent. Premier et dernier élément de la chaîne de calculs. Et si quelque chose a mal tourné, c'est là que le point d'arrêt sera.
Il ne reste plus qu'à démarrer la conversion aux bons endroits.
@IBAction func textFieldTextDidChange() { updateView(.textFieldTextDidChange) } @IBAction func loginDidEndEdit() { updateView(.loginDidEndEdit) } @IBAction func passwordDidEndEdit() { updateView(.passwordDidEndEdit) }
La méthode loginPressed est sortie un peu unique.
@IBAction private func loginPressed() { updateView(.loginPressed) let completion: (Result<User, Error>) -> Void = { [weak self] result in switch result { case .success(let user): case .failure(let error): self?.updateView(.authFailure(error)) } } outputModel.map { auth(login: $0.login, password: $0.password, completion: completion) } }
Le fait est que cliquer sur le bouton «Connexion» lance deux chaînes de calculs, ce qui n'est pas interdit.
Conclusion
Avant d'étudier la PF, j'ai mis un fort accent sur les paradigmes de programmation. Il était important pour moi que le code suive la POO, je n'aimais pas les fonctions statiques ou les objets sans état, je n'écrivais pas les fonctions globales.
Maintenant, il me semble que toutes ces choses que je considérais comme faisant partie d'un paradigme sont plutôt arbitraires. L'essentiel est un code propre et compréhensible. Pour atteindre cet objectif, vous pouvez utiliser tout ce qui est possible: fonctions pures, classes, monades, héritage, composition, inférence de type. Ils s'entendent tous bien et améliorent le code - il suffit de les appliquer à l'endroit.
Quoi d'autre à lire sur le sujet
Définition de la programmation fonctionnelle de Wikipedia
Livre de démarrage Haskell
Explication des foncteurs, monades et foncteurs applicatifs sur les doigts
Livre Haskell sur les pratiques d'utilisation de Maybe (facultatif)
Livre sur la nature fonctionnelle de Swift
Définir des types de données algébriques à partir d'un wiki
Un article sur les types de données algébriques
Un autre article sur les types de données algébriques
Rapport Yandex sur la programmation fonctionnelle sur Swift
Implémentation de la bibliothèque standard Prelude (Haskell) sur Swift
Bibliothèque avec des outils fonctionnels sur Swift
Une autre bibliothèque
Et un de plus