Pérenniser votre code Android, partie 1 : fondements de la programmation fonctionnelle et réactive
Publié: 2022-08-31Écrire du code propre peut être difficile : les bibliothèques, les frameworks et les API sont temporaires et deviennent rapidement obsolètes. Mais les concepts et paradigmes mathématiques sont durables ; ils nécessitent des années de recherche universitaire et peuvent même nous survivre.
Ce n'est pas un didacticiel pour vous montrer comment faire X avec la bibliothèque Y. Au lieu de cela, nous nous concentrons sur les principes durables de la programmation fonctionnelle et réactive afin que vous puissiez créer une architecture Android fiable et à l'épreuve du temps, et évoluer et s'adapter aux changements sans compromis. Efficacité.
Cet article pose les bases, et dans la partie 2, nous plongerons dans une implémentation de la programmation réactive fonctionnelle (FRP), qui combine à la fois la programmation fonctionnelle et réactive.
Cet article est écrit en pensant aux développeurs Android, mais les concepts sont pertinents et bénéfiques pour tout développeur ayant une expérience des langages de programmation généraux.
Programmation fonctionnelle 101
La programmation fonctionnelle (FP) est un modèle dans lequel vous construisez votre programme comme une composition de fonctions, transformant les données de $A$ en $B$, en $C$, etc., jusqu'à ce que la sortie souhaitée soit atteinte. Dans la programmation orientée objet (POO), vous dites à l'ordinateur quoi faire instruction par instruction. La programmation fonctionnelle est différente : vous abandonnez le flux de contrôle et définissez une « recette de fonctions » pour produire votre résultat à la place.
FP provient des mathématiques, en particulier du calcul lambda, un système logique d'abstraction de fonctions. Au lieu de concepts POO tels que les boucles, les classes, le polymorphisme ou l'héritage, FP traite strictement de l'abstraction et des fonctions d'ordre supérieur, des fonctions mathématiques qui acceptent d'autres fonctions en entrée.
En un mot, FP a deux « acteurs » principaux : les données (le modèle ou les informations requises pour votre problème) et les fonctions (représentations du comportement et des transformations entre les données). En revanche, les classes OOP lient explicitement une structure de données spécifique à un domaine particulier - et les valeurs ou l'état associés à chaque instance de classe - aux comportements (méthodes) qui sont destinés à être utilisés avec elle.
Nous examinerons de plus près trois aspects clés de la PF :
- FP est déclaratif.
- FP utilise la composition de fonctions.
- Les fonctions FP sont pures.
Un bon point de départ pour plonger davantage dans le monde de la FP est Haskell, un langage fortement typé et purement fonctionnel. Je recommande Learn You a Haskell for Great Good! didacticiel interactif comme une ressource utile.
Ingrédient PF #1 : Programmation déclarative
La première chose que vous remarquerez à propos d'un programme de PF est qu'il est écrit dans un style déclaratif, par opposition à un style impératif. En bref, la programmation déclarative indique à un programme ce qui doit être fait au lieu de comment le faire. Fondons cette définition abstraite sur un exemple concret de programmation impérative versus déclarative pour résoudre le problème suivant : étant donné une liste de noms, renvoyez une liste contenant uniquement les noms avec au moins trois voyelles et avec les voyelles indiquées en majuscules.
Solution impérative
Examinons d'abord la solution impérative de ce problème dans Kotlin :
fun namesImperative(input: List<String>): List<String> { val result = mutableListOf<String>() val vowels = listOf('A', 'E', 'I', 'O', 'U','a', 'e', 'i', 'o', 'u') for (name in input) { // loop 1 var vowelsCount = 0 for (char in name) { // loop 2 if (isVowel(char, vowels)) { vowelsCount++ if (vowelsCount == 3) { val uppercaseName = StringBuilder() for (finalChar in name) { // loop 3 var transformedChar = finalChar // ignore that the first letter might be uppercase if (isVowel(finalChar, vowels)) { transformedChar = finalChar.uppercaseChar() } uppercaseName.append(transformedChar) } result.add(uppercaseName.toString()) break } } } } return result } fun isVowel(char: Char, vowels: List<Char>): Boolean { return vowels.contains(char) } fun main() { println(namesImperative(listOf("Iliyan", "Annabel", "Nicole", "John", "Anthony", "Ben", "Ken"))) // [IlIyAn, AnnAbEl, NIcOlE] }
Nous allons maintenant analyser notre solution impérative en gardant à l'esprit quelques facteurs clés de développement :
La plus efficace : cette solution a une utilisation optimale de la mémoire et fonctionne bien dans l'analyse Big O (sur la base d'un nombre minimum de comparaisons). Dans cet algorithme, il est logique d'analyser le nombre de comparaisons entre les caractères car c'est l'opération prédominante dans notre algorithme. Soit $n$ le nombre de noms, et soit $k$ la longueur moyenne des noms.
- Nombre de comparaisons dans le pire des cas : $n(10k)(10k) = 100nk^2$
- Explication : $n$ (boucle 1) * $10k$ (pour chaque caractère, nous comparons à 10 voyelles possibles) * $10k$ (nous exécutons à nouveau la vérification
isVowel()
pour décider s'il faut mettre le caractère en majuscule—encore une fois, dans le pire des cas, cela se compare à 10 voyelles). - Résultat : puisque la longueur moyenne d'un nom ne dépassera pas 100 caractères, nous pouvons dire que notre algorithme s'exécute en un temps $O(n)$ .
- Complexe avec une mauvaise lisibilité : Comparée à la solution déclarative que nous allons considérer ensuite, cette solution est beaucoup plus longue et difficile à suivre.
- Sujet aux erreurs : le code mute le
result
, le nombre devowelsCount
et le caractèretransformedChar
; ces mutations d'état peuvent conduire à des erreurs subtiles comme oublier de remettre à 0 lesvowelsCount
. Le flux d'exécution peut également devenir compliqué, et il est facile d'oublier d'ajouter l'instructionbreak
dans la troisième boucle. - Mauvaise maintenabilité : étant donné que notre code est complexe et sujet aux erreurs, la refactorisation ou la modification du comportement de ce code peut être difficile. Par exemple, si le problème était modifié pour sélectionner des noms avec trois voyelles et cinq consonnes, il faudrait introduire de nouvelles variables et changer les boucles, laissant de nombreuses opportunités de bugs.
Notre exemple de solution illustre à quel point le code impératif peut sembler complexe, bien que vous puissiez améliorer le code en le refactorisant en fonctions plus petites.
Solution déclarative
Maintenant que nous comprenons ce que la programmation déclarative n'est pas , dévoilons notre solution déclarative dans Kotlin :
fun namesDeclarative(input: List<String>): List<String> = input.filter { name -> name.count(::isVowel) >= 3 }.map { name -> name.map { char -> if (isVowel(char)) char.uppercaseChar() else char }.joinToString("") } fun isVowel(char: Char): Boolean = listOf('A', 'E', 'I', 'O', 'U', 'a', 'e', 'i', 'o', 'u').contains(char) fun main() { println(namesDeclarative(listOf("Iliyan", "Annabel", "Nicole", "John", "Anthony", "Ben", "Ken"))) // [IlIyAn, AnnAbEl, NIcOlE] }
En utilisant les mêmes critères que nous avons utilisés pour évaluer notre solution impérative, voyons comment le code déclaratif résiste :
- Efficace : les implémentations impératives et déclaratives s'exécutent toutes deux en temps linéaire, mais l'impératif est un peu plus efficace car j'ai utilisé
name.count()
ici, qui continuera à compter les voyelles jusqu'à la fin du nom (même après avoir trouvé trois voyelles ). Nous pouvons facilement résoudre ce problème en écrivant une simplehasThreeVowels(String): Boolean
. Cette solution utilise le même algorithme que la solution impérative, donc la même analyse de complexité s'applique ici : notre algorithme s'exécute en un temps $O(n)$ . - Concis avec une bonne lisibilité : La solution impérative est de 44 lignes avec une grande indentation par rapport à la longueur de notre solution déclarative de 16 lignes avec une petite indentation. Les lignes et les tabulations ne font pas tout, mais il est évident d'un coup d'œil sur les deux fichiers que notre solution déclarative est beaucoup plus lisible.
- Moins sujet aux erreurs : dans cet exemple, tout est immuable. Nous transformons une
List<String>
de tous les noms en uneList<String>
de noms avec trois voyelles ou plus, puis transformons chaque motString
en un motString
avec des voyelles majuscules. Dans l'ensemble, l'absence de mutation, de boucles imbriquées ou de ruptures et l'abandon du flux de contrôle simplifient le code avec moins de marge d'erreur. - Bonne maintenabilité : Vous pouvez facilement refactoriser le code déclaratif en raison de sa lisibilité et de sa robustesse. Dans notre exemple précédent (disons que le problème a été modifié pour sélectionner des noms avec trois voyelles et cinq consonnes), une solution simple serait d'ajouter les déclarations suivantes dans la condition de
filter
:val vowels = name.count(::isVowel); vowels >= 3 && name.length - vowels >= 5
val vowels = name.count(::isVowel); vowels >= 3 && name.length - vowels >= 5
.
De plus, notre solution déclarative est purement fonctionnelle : chaque fonction de cet exemple est pure et n'a aucun effet secondaire. (Plus d'informations sur la pureté plus tard.)
Solution déclarative bonus
Jetons un coup d'œil à l'implémentation déclarative du même problème dans un langage purement fonctionnel comme Haskell pour montrer comment il se lit. Si vous n'êtes pas familier avec Haskell, notez que le .
l'opérateur dans Haskell se lit comme "après". Par exemple, solution = map uppercaseVowels . filter hasThreeVowels
solution = map uppercaseVowels . filter hasThreeVowels
se traduit par "mapper les voyelles en majuscules après avoir filtré les noms qui ont trois voyelles".
import Data.Char(toUpper) namesSolution :: [String] -> [String] namesSolution = map uppercaseVowels . filter hasThreeVowels hasThreeVowels :: String -> Bool hasThreeVowels s = count isVowel s >= 3 uppercaseVowels :: String -> String uppercaseVowels = map uppercaseVowel where uppercaseVowel :: Char -> Char uppercaseVowel c | isVowel c = toUpper c | otherwise = c isVowel :: Char -> Bool isVowel c = c `elem` vowels vowels :: [Char] vowels = ['A', 'E', 'I', 'O', 'U', 'a', 'e', 'i', 'o', 'u'] count :: (a -> Bool) -> [a] -> Int count _ [] = 0 count pred (x:xs) | pred x = 1 + count pred xs | otherwise = count pred xs main :: IO () main = print $ namesSolution ["Iliyan", "Annabel", "Nicole", "John", "Anthony", "Ben", "Ken"] -- ["IlIyAn","AnnAbEl","NIcOlE"]
Cette solution fonctionne de la même manière que notre solution déclarative Kotlin, avec quelques avantages supplémentaires : elle est lisible, simple si vous comprenez la syntaxe de Haskell, purement fonctionnelle et paresseuse.
Points clés à retenir
La programmation déclarative est utile à la fois pour la programmation FP et réactive (que nous aborderons dans une section ultérieure).
- Il décrit « ce que » vous voulez réaliser, plutôt que « comment » y parvenir, avec l'ordre exact d'exécution des instructions.
- Il résume le flux de contrôle d'un programme et se concentre plutôt sur le problème en termes de transformations (c'est-à-dire, $A \rightarrow B \rightarrow C \rightarrow D$).
- Il encourage un code moins complexe, plus concis et plus lisible, plus facile à refactoriser et à modifier. Si votre code Android ne se lit pas comme une phrase, vous faites probablement quelque chose de mal.
Si votre code Android ne se lit pas comme une phrase, vous faites probablement quelque chose de mal.
Tweeter
Pourtant, la programmation déclarative a certains inconvénients. Il est possible de se retrouver avec un code inefficace qui consomme plus de RAM et dont les performances sont inférieures à celles d'une implémentation impérative. Le tri, la rétropropagation (dans l'apprentissage automatique) et d'autres «algorithmes de mutation» ne conviennent pas au style de programmation immuable et déclaratif.
Ingrédient PF #2 : Composition de la fonction
La composition de fonctions est le concept mathématique au cœur de la programmation fonctionnelle. Si la fonction $f$ accepte $A$ en entrée et produit $B$ en sortie ($f : A \rightarrow B$), et la fonction $g$ accepte $B$ et produit $C$ ($g : B \rightarrow C$), alors vous pouvez créer une troisième fonction, $h$, qui accepte $A$ et produit $C$ ($h : A \rightarrow C$). Nous pouvons définir cette troisième fonction comme la composition de $g$ avec $f$, également notée $g \circ f$ ou $g(f())$ :
Chaque solution impérative peut être traduite en une solution déclarative en décomposant le problème en problèmes plus petits, en les résolvant indépendamment et en recomposant les solutions plus petites dans la solution finale par la composition de fonctions. Regardons notre problème de noms de la section précédente pour voir ce concept en action. Nos petits problèmes de la solution impérative sont :
-
isVowel :: Char -> Bool
: Étant donné unChar
, retourne s'il s'agit d'une voyelle ou non (Bool
). -
countVowels :: String -> Int
: étant donné uneString
, renvoie le nombre de voyelles qu'elle contient (Int
). -
hasThreeVowels :: String -> Bool
: étant donné unString
, retourne s'il a au moins trois voyelles (Bool
). -
uppercaseVowels :: String -> String
: étant donné unString
, renvoie un nouveauString
avec des voyelles en majuscules.
Notre solution déclarative, obtenue grâce à la composition de fonctions, est map uppercaseVowels . filter hasThreeVowels
map uppercaseVowels . filter hasThreeVowels
.
Cet exemple est un peu plus compliqué qu'une simple formule $A \rightarrow B \rightarrow C$, mais il démontre le principe de la composition de fonctions.
Points clés à retenir
La composition de fonctions est un concept simple mais puissant.
- Il fournit une stratégie pour résoudre des problèmes complexes dans laquelle les problèmes sont divisés en étapes plus petites et plus simples et combinés en une seule solution.
- Il fournit des blocs de construction, vous permettant d'ajouter, de supprimer ou de modifier facilement des parties de la solution finale sans vous soucier de casser quelque chose.
- Vous pouvez composer $g(f())$ si la sortie de $f$ correspond au type d'entrée de $g$.
Lors de la composition de fonctions, vous pouvez transmettre non seulement des données, mais également des fonctions en tant qu'entrées à d'autres fonctions, un exemple de fonctions d'ordre supérieur.
Ingrédient PF #3 : Pureté
Il y a un autre élément clé de la composition des fonctions que nous devons aborder : les fonctions que vous composez doivent être pures , un autre concept dérivé des mathématiques. En mathématiques, toutes les fonctions sont des calculs qui produisent toujours la même sortie lorsqu'elles sont appelées avec la même entrée ; c'est la base de la pureté.
Regardons un exemple de pseudocode utilisant des fonctions mathématiques. Supposons que nous ayons une fonction, makeEven
, qui double une entrée entière pour la rendre paire, et que notre code exécute la ligne makeEven(x) + x
en utilisant l'entrée x = 2
. En mathématiques, ce calcul se traduirait toujours par un calcul de $2x + x = 3x = 3(2) = 6$ et est une fonction pure. Cependant, ce n'est pas toujours vrai en programmation - si la fonction makeEven(x)
muté x
en le doublant avant que le code ne renvoie notre résultat, alors notre ligne calculerait $2x + (2x) = 4x = 4(2) = 8$ et, pire encore, le résultat changerait à chaque appel makeEven
.
Explorons quelques types de fonctions qui ne sont pas pures mais qui nous aideront à définir plus précisément la pureté :
- Fonctions partielles : il s'agit de fonctions qui ne sont pas définies pour toutes les valeurs d'entrée, telles que la division. Du point de vue de la programmation, ce sont des fonctions qui lèvent une exception :
fun divide(a: Int, b: Int): Float
lèvera uneArithmeticException
pour l'entréeb = 0
causée par la division par zéro. - Fonctions totales : ces fonctions sont définies pour toutes les valeurs d'entrée, mais peuvent produire une sortie différente ou des effets secondaires lorsqu'elles sont appelées avec la même entrée. Le monde Android regorge de fonctions totales :
Log.d
,LocalDateTime.now
etLocale.getDefault
ne sont que quelques exemples.
Avec ces définitions à l'esprit, nous pouvons définir les fonctions pures comme des fonctions totales sans effets secondaires. Les compositions de fonctions construites uniquement à l'aide de fonctions pures produisent un code plus fiable, prévisible et testable.
Conseil : Pour rendre une fonction totale pure, vous pouvez abstraire ses effets secondaires en les transmettant en tant que paramètre de fonction d'ordre supérieur. De cette façon, vous pouvez facilement tester les fonctions totales en passant une fonction d'ordre supérieur simulée. Cet exemple utilise l'annotation @SideEffect
d'une bibliothèque que nous examinerons plus tard dans le didacticiel, Ivy FRP :
suspend fun deadlinePassed( deadline: LocalDate, @SideEffect currentDate: suspend () -> LocalDate ): Boolean = deadline.isAfter(currentDate())
Points clés à retenir
La pureté est l'ingrédient final requis pour le paradigme de la programmation fonctionnelle.
- Soyez prudent avec les fonctions partielles, elles peuvent planter votre application.
- La composition de fonctions totales n'est pas déterministe ; il peut produire un comportement imprévisible.
- Dans la mesure du possible, écrivez des fonctions pures. Vous bénéficierez d'une stabilité accrue du code.
Une fois notre aperçu de la programmation fonctionnelle terminé, examinons le composant suivant du code Android à l'épreuve du futur : la programmation réactive.
Programmation réactive 101
La programmation réactive est un modèle de programmation déclarative dans lequel le programme réagit aux changements de données ou d'événements au lieu de demander des informations sur les changements.
Les éléments de base d'un cycle de programmation réactive sont les événements, le pipeline déclaratif, les états et les observables :
- Les événements sont des signaux provenant du monde extérieur, généralement sous la forme d'entrées utilisateur ou d'événements système, qui déclenchent des mises à jour. Le but d'un événement est de transformer un signal en entrée de pipeline.
- Le pipeline déclaratif est une composition de fonctions qui accepte
(Event, State)
en entrée et transforme cette entrée en un nouvelState
(la sortie) :(Event, State) -> f -> g -> … -> n -> State
. Les pipelines doivent fonctionner de manière asynchrone pour gérer plusieurs événements sans bloquer les autres pipelines ni attendre qu'ils se terminent. - Les états sont la représentation du modèle de données de l'application logicielle à un instant donné. La logique de domaine utilise l'état pour calculer l'état suivant souhaité et effectuer les mises à jour correspondantes.
- Les observables écoutent les changements d'état et informent les abonnés de ces changements. Dans Android, les observables sont généralement implémentés à l'aide de
Flow
,LiveData
ouRxJava
, et ils informent l'interface utilisateur des mises à jour d'état afin qu'elle puisse réagir en conséquence.
Il existe de nombreuses définitions et implémentations de la programmation réactive. Ici, j'ai adopté une approche pragmatique axée sur l'application de ces concepts à des projets réels.
Relier les points : programmation réactive fonctionnelle
La programmation fonctionnelle et réactive sont deux paradigmes puissants. Ces concepts vont au-delà de la courte durée de vie des bibliothèques et des API et amélioreront vos compétences en programmation pour les années à venir.
De plus, la puissance de la FP et de la programmation réactive se multiplie lorsqu'elle est combinée. Maintenant que nous avons des définitions claires de la programmation fonctionnelle et réactive, nous pouvons assembler les pièces. Dans la partie 2 de ce didacticiel, nous définissons le paradigme de la programmation réactive fonctionnelle (FRP) et le mettons en pratique avec un exemple d'implémentation d'application et des bibliothèques Android pertinentes.
Le blog Toptal Engineering exprime sa gratitude à Tarun Goyal pour avoir examiné les exemples de code présentés dans cet article.