La programmation fonctionnelle est-elle une alternative viable aux modèles d'injection de dépendance?

21

J'ai récemment lu un livre intitulé Functional Programming in C # et il me semble que la nature immuable et sans état de la programmation fonctionnelle produit des résultats similaires aux modèles d'injection de dépendance et est peut-être même une meilleure approche, en particulier en ce qui concerne les tests unitaires.

Je serais reconnaissant si quelqu'un qui a de l'expérience avec les deux approches pouvait partager ses pensées et ses expériences afin de répondre à la question principale: la programmation fonctionnelle est-elle une alternative viable aux modèles d'injection de dépendance?

Matt Cashatt
la source
10
Cela n'a pas beaucoup de sens pour moi, l'immuabilité ne supprime pas les dépendances.
Telastyn
Je suis d'accord qu'il ne supprime pas les dépendances. C'est probablement ma compréhension qui est incorrecte, mais j'ai fait cette déduction parce que si je ne peux pas changer l'objet d'origine, cela doit obliger à le transmettre (à l'injecter) à n'importe quelle fonction qui l'utilise.
Matt Cashatt
5
Il y a aussi How to Trick OO Programmers Into Loving Functional Programming , qui est vraiment une analyse détaillée de DI à la fois du point de vue OO et FP.
Robert Harvey
1
Cette question, les articles auxquels elle renvoie et la réponse acceptée peuvent également être utiles: stackoverflow.com/questions/11276319/… Ignorez le mot effrayant de Monade. Comme Runar le souligne dans sa réponse, ce n'est pas un concept complexe dans ce cas (juste une fonction).
itsbruce

Réponses:

27

La gestion des dépendances est un gros problème dans la POO pour les deux raisons suivantes:

  • Le couplage étroit des données et du code.
  • Utilisation omniprésente des effets secondaires.

La plupart des programmeurs OO considèrent que le couplage étroit des données et du code est tout à fait bénéfique, mais cela a un coût. La gestion du flux de données à travers les couches est une partie inévitable de la programmation dans n'importe quel paradigme. Le couplage de vos données et de votre code ajoute le problème supplémentaire que si vous souhaitez utiliser une fonction à un certain point, vous devez trouver un moyen pour que son objet atteigne ce point.

L'utilisation d'effets secondaires crée des difficultés similaires. Si vous utilisez un effet secondaire pour certaines fonctionnalités, mais que vous souhaitez pouvoir échanger son implémentation, vous n'avez pratiquement pas d'autre choix que d'injecter cette dépendance.

Prenons comme exemple un programme de spammeur qui gratte les pages Web pour les adresses e-mail puis les envoie par e-mail. Si vous avez un état d'esprit DI, en ce moment, vous pensez aux services que vous encapsulerez derrière les interfaces, et quels services seront injectés où. Je vais laisser cette conception comme un exercice pour le lecteur. Si vous avez un état d'esprit FP, en ce moment, vous pensez aux entrées et sorties pour la couche de fonctions la plus basse, comme:

  • Saisissez une adresse de page Web, affichez le texte de cette page.
  • Saisissez le texte d'une page, affichez une liste de liens à partir de cette page.
  • Saisissez le texte d'une page, affichez une liste d'adresses e-mail sur cette page.
  • Saisissez une liste d'adresses e-mail, affichez une liste d'adresses e-mail dont les doublons ont été supprimés.
  • Saisissez une adresse e-mail, envoyez un e-mail de spam pour cette adresse.
  • Saisissez un e-mail de spam, affichez les commandes SMTP pour envoyer cet e-mail.

Lorsque vous pensez en termes d'entrées et de sorties, il n'y a pas de dépendances de fonction, uniquement des dépendances de données. C'est ce qui les rend si faciles à tester unitairement. Le calque suivant vous permet d'alimenter la sortie d'une fonction dans l'entrée de la suivante, et peut facilement échanger les différentes implémentations selon vos besoins.

Dans un sens très réel, la programmation fonctionnelle vous pousse naturellement à toujours inverser vos dépendances de fonction, et donc vous n'avez généralement pas à prendre de mesures spéciales pour le faire après coup. Lorsque vous le faites, des outils tels que des fonctions d'ordre supérieur, des fermetures et des applications partielles facilitent l'exécution avec moins de passe-partout.

Notez que ce ne sont pas les dépendances elles-mêmes qui posent problème. Ce sont les dépendances qui pointent dans le mauvais sens. La couche suivante peut avoir une fonction comme:

processText = spamToSMTP . emailAddressToSpam . removeEmailDups . textToEmailAddresses

Il est parfaitement normal que cette couche ait des dépendances codées en dur comme celle-ci, car son seul but est de coller les fonctions de la couche inférieure ensemble. L'échange d'une implémentation est aussi simple que de créer une composition différente:

processTextFancy = spamToSMTP . emailAddressToFancySpam . removeEmailDups . textToEmailAddresses

Cette recomposition facile est rendue possible par un manque d'effets secondaires. Les fonctions de la couche inférieure sont complètement indépendantes les unes des autres. La couche suivante peut choisir celle qui processTextest réellement utilisée en fonction d'une configuration utilisateur:

actuallyUsedProcessText = if (config == "Fancy") then processTextFancy else processText

Encore une fois, ce n'est pas un problème car toutes les dépendances pointent dans un sens. Nous n'avons pas besoin d'inverser certaines dépendances pour les faire pointer toutes de la même manière, car les fonctions pures nous y obligent déjà.

Notez que vous pouvez rendre cela beaucoup plus couplé en passant configpar la couche la plus basse au lieu de la vérifier en haut. FP ne vous empêche pas de faire cela, mais cela a tendance à le rendre beaucoup plus ennuyeux si vous essayez.

Karl Bielefeldt
la source
3
"L'utilisation d'effets secondaires crée des difficultés similaires. Si vous utilisez un effet secondaire pour certaines fonctionnalités, mais que vous souhaitez pouvoir échanger son implémentation, vous n'avez pratiquement pas d'autre choix que d'injecter cette dépendance." Je ne pense pas que les effets secondaires aient quoi que ce soit à voir avec cela. Si vous souhaitez échanger des implémentations dans Haskell, vous devez toujours effectuer une injection de dépendance . Désugar les classes de types et vous passez une interface comme premier argument à chaque fonction.
Doval
2
Le nœud du problème est que presque tous les langages vous obligent à coder en dur des références à d'autres modules de code, donc la seule façon d'échanger des implémentations est d'utiliser la répartition dynamique partout, et vous êtes alors coincé à résoudre vos dépendances au moment de l'exécution. Un système de modules vous permettrait d'exprimer le graphique des dépendances au moment de la vérification de type.
Doval
@ Doval - Merci pour vos commentaires intéressants et stimulants. Je vous ai peut-être mal compris, mais ai-je raison de déduire de vos commentaires que si je devais utiliser un style de programmation fonctionnel sur un style DI (dans le sens traditionnel de C #), j'éviterais les éventuelles frustrations de débogage associées à l'exécution résolution des dépendances?
Matt Cashatt
@MatthewPatrickCashatt Ce n'est pas une question de style ou de paradigme, mais de fonctionnalités linguistiques. Si le langage ne prend pas en charge les modules en tant que choses de première classe, vous devrez effectuer une répartition dynamique des formes et une injection de dépendances pour échanger les implémentations, car il n'y a aucun moyen d'exprimer les dépendances de manière statique. Pour le dire un peu différemment, si votre programme C # utilise des chaînes, il a une dépendance codée en dur sur System.String. Un système de modules vous permettrait de remplacer System.Stringpar une variable afin que le choix de l'implémentation de chaîne ne soit pas codé en dur, mais toujours résolu au moment de la compilation.
Doval
8

La programmation fonctionnelle est-elle une alternative viable aux modèles d'injection de dépendance?

Cela me semble être une question étrange. Les approches de programmation fonctionnelle sont largement tangentielles à l'injection de dépendances.

Bien sûr, avoir un état immuable peut vous pousser à ne pas "tricher" en ayant des effets secondaires ou en utilisant l'état de classe comme contrat implicite entre les fonctions. Cela rend le transfert de données plus explicite, ce qui, je suppose, est la forme la plus élémentaire d'injection de dépendance. Et le concept de programmation fonctionnelle de passer des fonctions rend cela beaucoup plus facile.

Mais cela ne supprime pas les dépendances. Vos opérations ont toujours besoin de toutes les données / opérations dont elles avaient besoin lorsque votre état était modifiable. Et vous devez toujours y mettre ces dépendances. Je ne dirais donc pas que les approches de programmation fonctionnelles remplacent DI, donc il n'y a aucune sorte d'alternative.

Si quoi que ce soit, ils viennent de vous montrer à quel point le code OO peut créer des dépendances implicites auxquelles les programmeurs pensent rarement.

Telastyn
la source
Merci encore d'avoir contribué à la conversation, Telastyn. Comme vous l'avez souligné, ma question n'est pas très bien construite (mes mots), mais grâce aux commentaires ici, je commence à mieux comprendre ce qui me fait réfléchir dans tout cela: nous sommes tous d'accord (Je pense) que les tests unitaires peuvent être un cauchemar sans DI. Malheureusement, l'utilisation de DI, en particulier avec les conteneurs IoC, peut créer une nouvelle forme de cauchemar de débogage grâce au fait qu'elle résout les dépendances lors de l'exécution. Semblable à DI, FP facilite les tests unitaires, mais sans les problèmes de dépendance à l'exécution.
Matt Cashatt
(suite d'en haut). . C'est ma compréhension actuelle de toute façon. Veuillez me faire savoir si je manque la marque. Cela ne me dérange pas d'admettre que je suis un simple mortel parmi les géants ici!
Matt Cashatt
@MatthewPatrickCashatt - DI n'implique pas nécessairement des problèmes de dépendance à l'exécution, qui, comme vous le constatez, sont horribles.
Telastyn
7

La réponse rapide à votre question est: Non .

Mais comme d’autres l’ont affirmé, la question associe deux concepts quelque peu indépendants.

Faisons cette étape par étape.

DI donne un style non fonctionnel

Au cœur de la programmation des fonctions se trouvent des fonctions pures - des fonctions qui mappent l'entrée à la sortie, de sorte que vous obtenez toujours la même sortie pour une entrée donnée.

DI signifie généralement que votre unité n'est plus pure puisque la sortie peut varier en fonction de l'injection. Par exemple, dans la fonction suivante:

const bookSeats = ( seatCount, getBookedSeatCount ) => { ... }

getBookedSeatCount(une fonction) peut varier et produire des résultats différents pour la même entrée donnée. Cela rend bookSeatsaussi impur.

Il existe des exceptions à cela - vous pouvez injecter l'un des deux algorithmes de tri qui implémentent le même mappage d'entrée-sortie, bien qu'en utilisant des algorithmes différents. Mais ce sont des exceptions.

Un système ne peut pas être pur

Le fait qu'un système ne peut pas être pur est également ignoré comme cela est affirmé dans les sources de programmation fonctionnelles.

Un système doit avoir des effets secondaires, les exemples évidents étant:

  • UI
  • Base de données
  • API (dans l'architecture client-serveur)

Une partie de votre système doit donc impliquer des effets secondaires et cette partie peut également impliquer un style impératif ou un style OO.

Le paradigme shell-core

En empruntant les termes de l'excellent discours de Gary Bernhardt sur les frontières , une bonne architecture de système (ou module) comprendra ces deux couches:

  • Coeur
    • Fonctions pures
    • Ramification
    • Pas de dépendances
  • coquille
    • Impur (effets secondaires)
    • Pas de branchement
    • Dépendances
    • Peut être impératif, impliquer un style OO, etc.

Le point clé à retenir est de «diviser» le système en sa partie pure (le noyau) et la partie impure (la coque).

Bien qu'il offre une solution (et une conclusion) légèrement erronée, cet article de Mark Seemann propose le même concept. L'implémentation Haskell est particulièrement intéressante car elle montre que tout peut être fait en utilisant FP.

DI et FP

L'emploi de DI est parfaitement raisonnable même si la majeure partie de votre application est pure. La clé est de confiner le DI dans la coquille impure.

Un exemple sera les stubs API - vous voulez la vraie API en production, mais utilisez des stubs dans les tests. Adhérer au modèle shell-core aidera beaucoup ici.

Conclusion

FP et DI ne sont donc pas exactement des alternatives. Vous êtes susceptible d'avoir les deux dans votre système, et le conseil est d'assurer la séparation entre la partie pure et la partie impure du système, où FP et DI résident respectivement.

Izhaki
la source
Lorsque vous vous référez au paradigme shell-core, comment ne réaliserait-on pas de branchement dans le shell? Je peux penser à de nombreux exemples où une application aurait besoin de faire une chose impure ou une autre en fonction d'une valeur. Cette règle de non-branchement est-elle applicable dans des langages comme Java?
jrahhali
@jrahhali Veuillez consulter le discours de Gary Bernhardt pour plus de détails (lié dans la réponse).
Izhaki
un autre relavent Seemann series blog.ploeh.dk/2017/01/27/…
jk.
1

Du point de vue POO, les fonctions peuvent être considérées comme des interfaces à méthode unique.

L'interface est un contrat plus fort qu'une fonction.

Si vous utilisez une approche fonctionnelle et faites beaucoup de DI, alors en comparaison avec une approche POO, vous obtiendrez plus de candidats pour chaque dépendance.

void DoStuff(Func<DateTime> getDateTime) {}; //Anything that satisfies the signature can be injected.

contre

void DoStuff(IDateTimeProvider dateTimeProvider) {}; //Only types implementing the interface can be injected.
Tanière
la source
3
N'importe quelle classe peut être encapsulée pour implémenter l'interface, donc le «contrat plus fort» n'est pas beaucoup plus fort. Plus important encore, donner à chaque fonction un type différent rend impossible la composition des fonctions.
Doval
La programmation fonctionnelle ne signifie pas «Programmation avec des fonctions d'ordre supérieur», elle fait référence à un concept beaucoup plus large, les fonctions d'ordre supérieur ne sont qu'une technique utile dans le paradigme.
Jimmy Hoffa