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.

Un rectangle vert sur la gauche avec le texte "Entrée : x" a une flèche pointant vers un rectangle gris clair intitulé "Fonction : f". À l'intérieur du rectangle gris clair, il y a trois cylindres avec des flèches pointant vers la droite : le premier est bleu clair étiqueté "A(x)", le second est bleu foncé étiqueté "B(x)" et le troisième est gris foncé étiqueté "C (X)." À droite du rectangle gris clair, il y a un rectangle vert avec le texte "Sortie : f(x)". Le bas du rectangle gris clair comporte une flèche pointant vers le texte "Effets secondaires".
Le modèle de programmation fonctionnelle

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 de vowelsCount et le caractère transformedChar ; ces mutations d'état peuvent conduire à des erreurs subtiles comme oublier de remettre à 0 les vowelsCount . Le flux d'exécution peut également devenir compliqué, et il est facile d'oublier d'ajouter l'instruction break 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 simple hasThreeVowels(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 une List<String> de noms avec trois voyelles ou plus, puis transformons chaque mot String en un mot String 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())$ :

Une boîte bleue étiquetée « A » a une flèche, « f », pointant vers une boîte bleue étiquetée « B » qui a une flèche, « g », pointant vers une boîte bleue étiquetée « C ». La case "A" a également une flèche parallèle, "g o f", pointant directement vers la case "C".
Fonctions f, g et h, la composition de g avec 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 :

  1. isVowel :: Char -> Bool : Étant donné un Char , retourne s'il s'agit d'une voyelle ou non ( Bool ).
  2. countVowels :: String -> Int : étant donné une String , renvoie le nombre de voyelles qu'elle contient ( Int ).
  3. hasThreeVowels :: String -> Bool : étant donné un String , retourne s'il a au moins trois voyelles ( Bool ).
  4. uppercaseVowels :: String -> String : étant donné un String , renvoie un nouveau String 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 .

Un diagramme du haut comporte trois cases bleues "[String]" reliées par des flèches pointant vers la droite. La première flèche est étiquetée "filter has3Vowels" et la seconde est étiquetée "map uppercaseVowels". Ci-dessous, un deuxième diagramme comporte deux cases bleues à gauche, "Char" en haut et "String" en bas, pointant vers une case bleue à droite, "Bool". La flèche de "Char" à "Bool" est étiquetée "isVowel" et la flèche de "String" à "Bool" est étiquetée "has3Vowels". La boîte "Chaîne" a également une flèche pointant vers elle-même intitulée "Voyelles majuscules".
Un exemple de composition de fonction utilisant notre problème de noms.

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 une ArithmeticException pour l'entrée b = 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 et Locale.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.

Deux boîtes bleues principales, "Observable" et "State", ont deux chemins principaux entre elles. La première est via "Observe (écoute les changements)". La seconde est via "Notifie (du dernier état)", à la boîte bleue "UI (API dans le back-end)", qui passe par "Transforme l'entrée de l'utilisateur en" à la boîte bleue "Evénement", qui passe par "Déclencheurs" au bleu case "Composition de la fonction", et enfin via "Produit (état neuf)". "État" se connecte alors également à "Composition de fonction" via "Agit comme entrée pour".
Le cycle général de programmation réactive.

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 nouvel State (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 ou RxJava , 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.