Les fonctions d’écriture peuvent impérativement rendre le code plus difficile à lire
Le gourou de la programmation fonctionnelle s’extasie sur la façon dont l’adoption de la programmation fonctionnelle peut rendre les développeurs beaucoup plus productifs.
Pourtant, de nombreuses entreprises ne voient pas la productivité de leurs ingénieurs logiciels augmenter lorsqu’elles adoptent des langages de programmation fonctionnels.
Une des raisons est qu’ils écrivent de la programmation fonctionnelle comme la programmation impérative.
Les fonctions d’écriture peuvent impérativement rendre le code plus difficile à lire.
Je souhaite partager cinq principes et meilleures pratiques pour écrire du code propre et fonctionnel dans cet article.
Ce ne sont pas des règles strictes auxquelles tout le monde doit obéir, mais ces cinq principes peuvent aider à augmenter la productivité de votre équipe.
Le pattern matching est un outil trop général ; de nombreux autres outils sont plus spécifiques à la résolution du problème.
En raison de sa syntaxe détaillée et répétitive, il est facile de faire des erreurs que le vérificateur de type ne détectera pas.
Souvent, l’écriture de correspondance de modèles nécessite la récursivité, et la récursivité est toujours plus difficile à comprendre qu’une fonction d’ordre supérieur normale.
La correspondance de motifs est très polyvalente et flexible. La correspondance de modèles peut créer toutes les logiques de programmation grâce à la correspondance de modèles.
Par exemple, trouver un doublon consécutif dans une liste d’éléments peut être fait de cette manière :
// Standard recursive.
def compressRecursive[A](ls: List[A]): List[A] =
ls match {
case Nil => Nil
case h :: tail => h :: compressRecursive(tail.dropWhile(_ == h))
}
Cependant, il serait préférable de tirer parti de la nature déclarative de la programmation fonctionnelle.
On peut écrire l’exemple ci-dessus avec foldLeft
, qui est plus déclaratif. Voici à quoi cela ressemble :
def compressFonctionnel[A](ls : liste[A]): Lister[A] =
ls.foldRight(Liste[A()) { (h, r) =>
if (r.isEmpty || r.head != h) h :: r else r
}
In addition, pattern matching often tries to reinvent the wheel. For example, many code bases use pattern matching on an option. They use code like this:
someOpt match {
case Some(e) =>
case None =>
}
These functions can be much easier to understand with getOrElse
.
Lastly, I often see people replacing if/else statement with pattern matching because the programming language supports it. Here’s what that can look like:
val (decimal, roman) = number match {
case x if x > 1000 => (1000, "M")
case x if x > 900 => (900, "CM")
case x if x > 500 => (500, "D")
case x if x > 400 => (400, "CD")
case x if x > 100 => (100, "C")
case x if x > 90 => (90, "XC")
case x if x > 50 => (50, "L")
case x if x > 40 => (40, "XL")
case x if x > 10 => (10, "X")
case x if x > 9 => (9, "IX")
case x if x > 5 => (5, "V")
case x if x > 4 => (4, "IV")
case _ => (1, "I")
}
You can see that there is a lot of repetition here. Moreover, you always need to do a default catch-all expression at the end.
What if there is no catch-all?
What you can do with this instead is to put the above value into a list and match it against the list, as shown below:
val conversions = List(
(1000, "M"),
(900, "CM"),
(500, "D"),
(400, "CD"),
(100, "C"),
(90, "XC"),
(50, "L"),
(40, "XL"),
(10, "X"),
(9, "IX"),
(5, "V"),
(4, "IV"),
(1, "I") )conversions dropWhile (_._1 > number)).head
Remember that in functional programming, everything is a value. Putting things into value and working with them as a value is the greatest strength in functional programming. In imperative programming, you must use pattern matching and case statements to do if/else.
A callback function is very flexible if you want to write a quick prototype. However, too many callback parameters in a function is confusing.
I’ve seen a code base in one of our payment systems such as the following:
def authorize[A,E]( authFn : (entier, chaîne) => chaîne,
analyticsFn : (chaîne) => Futur[Unit],
désérialisé : (String => A)) : Futur[E]
Un nouveau développeur ne comprendra pas ce authorize
Est-ce que.
Pourquoi?
Parce que cette fonction essaie de faire trop de choses, pousse l’opération trop loin en faisant abstraction de tout.
Vous vous retrouvez avec une fonction boueuse qui fait tout quand vous faites abstraction de tout.
Cela viole le principe de responsabilité unique.
Au lieu d’en faire une fonction de rappel, faites-en une fonction réelle et utilisez-les à l’intérieur du authorize
une fonction.
def authorize[E](in: String): Future[E] =
for {
serialized <- desrialized[A](in)
resp <- callAuth(serialized)
_ <- callAnalytics(in)
} yield (resp) def deserialize[A](in: String) : Future[A] = ???
def callAnalytics(in: String): Future[Unit] = ???
Règle d’or : Évitez d’utiliser les fonctions lambda sauf si cela est nécessaire et l’exige.
Le temps de compilation est sûr et prévisible. Vous n’avez pas besoin de les capturer dans l’environnement de production.
Qu’est-ce qu’une exception, après tout ?
L’exception doit transmettre des informations à l’utilisateur sur l’échec du programme, et le compilateur peut attraper la plupart d’entre eux avec des données utiles.
L’exception que vous rencontrez souvent dans l’environnement de production est l’exception d’exécution.
Une exception d’exécution est difficile à détecter car elle est imprévisible.
La programmation fonctionnelle a créé des classes telles que l’un ou l’autre et l’option pour manipuler des exceptions pendant le temps de compilation, ainsi vous n’avez pas à traiter des bogues compliqués dans le temps d’exécution.
Cette valeur n’est-elle pas obligatoire ? Forcez l’appelant à les gérer en mettant un type Option.
La fonction que vous appelez provoque-t-elle une erreur ? Voulez-vous savoir quelle est cette erreur ? Informez l’appelant de la définition de la fonction et forcez-le à gérer l’exception avec le type Soit.
L’utilisation de types pour les exceptions vous aide à suivre où l’exception est appelée – vous n’avez pas besoin de savoir où l’exception est levée pendant l’exécution car vous pouvez facilement suivre l’exception via les types de fonction. Avoir ces effets types obligeront l’appelant à les gérer.
Le cerveau humain comprend mieux les variables intermédiaires. Permettez-moi de vous dire une analogie.
Imaginez lire un article sans aucune période. Il sera difficile de comprendre le point. Vous devrez peut-être relire l’article plusieurs fois pour comprendre le contenu.
Faire passer le message demandera beaucoup de travail.
Un one-liner sans variables intermédiaires rendra votre code plus difficile à comprendre. D’autres ingénieurs doivent faire des efforts supplémentaires pour visualiser cette variable intermédiaire.
Au lieu de laisser leur cerveau travailler dur pour stocker ces variables intermédiaires imaginaires lors de la lecture de votre code, pourquoi ne pas les aider à le traiter plus facilement en stockant les variables intermédiaires dans le code?
Le code ci-dessous provient de Stack Overflow :
//
def foo(arguments: Seq[(String, String)],
merges: Seq[(String, String)]) = {
val metadata: Seq[Attribute] =
(arguments ++ merges).groupBy(_._1).map {
case (key, value) => Attribute(None, key, Text(value.map(_._2).mkString(" ") ), Null)
}
var result: MetaData = Null
for(m <- metadata) result = result.append( m ) result
}
Ils ne savent pas comment éliminer les var
et si on élimine le var
Il ressemblera à ceci:
def foo(arguments: Seq[(String, String)],
merges: Seq[(String, String)]) =
(arguments ++ merges).groupBy(_._1).map {
case (key, value) =>
Attribute(None, key, Text(value.map(_._2).mkString(" ")), Null)
}.fold(Null)((soFar, attr) => soFar append attr)
C’est génial et très fonctionnel, mais ça me fait mal aux yeux.
Heureusement, nous pouvons toujours conserver les métadonnées et la pureté en créant une variable intermédiaire, que vous pouvez voir ici :
type PairSeq = Seq[(String, String)] def combineText(text: PairSeq): Text =
Text(text.map(_._2).mkString(" "))
def foo(arguments: PairSeq, merges: PairSeq) = {
val metadata = (arguments ++ merges)
.groupBy(_._1)
.map{ case (key, value) =>
Attribute(None, key, combineText(value), Null)
}
metadata.fold(Null)((xs, attr) => xs append attr)
}
Bien qu’un one-liner soit agréable à écrire, une variable intermédiaire vous fera gagner du temps et vous aidera à intégrer plus rapidement votre nouveau membre d’équipe.
La manière impérative d’écrire des fonctions ne se soucie pas des opérations d’E/S puisqu’elles ne retournent pas en fonction du type.
Cependant, en écrivant du code fonctionnel, on sera obligé de penser aux opérations d’E/S car le système de type applique les opérations d’E/S.
Garder votre funIO
dans une petite section du code et votre fonction pure peut vous aider beaucoup.
Les fonctions pures sont plus faciles à raisonner – réorganiser, refactoriser, paralléliser, vérifier le type, partager des données entre elles et tester.
On voit souvent une fonction ayant plusieurs IO
appels, soit logging
ou alors println
entre les appels.
Par exemple, nous avons une fonction pour calculer la division d’un élément. Voici à quoi ça ressemble :
def div(x: Int, y: Int) : Unit =
if(y == 0) {
println("Cannot divide by zero here.")
} else {
val res = x/y
println(res)
}
def div(x:Int, y: Int): Either[IllegalArgument, Int] =
if(y == 0)
Left(new IllegaleArgumentException("Cannot divide by 0"))
else { x / y }def printResult(x: Int, y:Int) = div(x, y).foreach(println)
Chaque fois que vous voyez un IO
opération, réfléchissez-y à deux fois et voyez si vous pouvez réorganiser et refactoriser votre fonction et pousser le IO
jusqu’au bout du monde (lien).
Ce ne sont pas des règles strictes auxquelles nous devons obéir pour écrire un code lisible et fonctionnel. Cependant, avec ces règles, vous pouvez augmenter la maintenabilité et la lisibilité de votre base de code fonctionnel.
Récapitulons les cinq principes :
- Évitez d’utiliser le pattern matching pour tout. Déterminez si des fonctions d’ordre supérieur dans la bibliothèque standard peuvent vous aider à résoudre le problème. Utilisez la correspondance des motifs en dernier recours dans votre ceinture à outils.
- Évitez d’utiliser une fonction de rappel pour tout.
- Déplacez votre exception au moment de la compilation aussi souvent que possible, car les exceptions au moment de la compilation sont beaucoup plus faciles à raisonner que les exceptions d’exécution.
- N’essayez pas d’éliminer une variable intermédiaire.
- Isolez vos E/S
Quels autres principes utilisez-vous pour aider l’équipe à écrire du code propre et fonctionnel ?