Comment refaçonner un programme OO en un programme fonctionnel?

26

J'ai du mal à trouver des ressources sur la façon d'écrire des programmes dans un style fonctionnel. Le sujet le plus avancé que j'ai pu trouver discuté en ligne était l'utilisation du typage structurel pour réduire les hiérarchies de classes; la plupart traitent simplement comment utiliser map / fold / réduire / etc pour remplacer les boucles impératives.

Ce que j'aimerais vraiment trouver, c'est une discussion approfondie sur une implémentation OOP d'un programme non trivial, ses limites et comment le refactoriser dans un style fonctionnel. Pas seulement un algorithme ou une structure de données, mais quelque chose avec plusieurs rôles et aspects différents - un jeu vidéo peut-être. Au fait, j'ai lu la programmation fonctionnelle dans le monde réel de Tomas Petricek, mais je m'en veux encore.

Asik
la source
6
je ne pense pas que ce soit possible. vous devez tout repenser (et réécrire) à nouveau.
Bryan Chen
18
-1, ce post est biaisé par la mauvaise hypothèse selon laquelle la POO et le style fonctionnel sont contraires. Ce sont principalement des concepts orthogonaux, et à mon humble avis, c'est un mythe qu'ils ne le sont pas. "Fonctionnel" est plus opposé à "Procédure", et les deux styles peuvent être utilisés en conjonction avec la POO.
Doc Brown
11
@DocBrown, OOP s'appuie trop fortement sur un état mutable. Les objets sans état ne correspondent pas bien à la pratique actuelle de conception de POO.
SK-logic
9
@ SK-logic: les clés ne sont pas des objets sans état, mais des objets immuables. Et même lorsque les objets sont mutables, ils peuvent souvent être utilisés dans une partie fonctionnelle du système tant qu'ils ne sont pas modifiés dans le contexte donné. De plus, je suppose que vous savez que les objets et les fermetures sont interchangeables. Tout cela montre donc que la POO et "fonctionnelle" ne sont pas contraires.
Doc Brown
12
@DocBrown: Je pense que les constructions de langage sont orthogonales, tandis que les mentalités ont tendance à s'affronter. Les gens de la POO ont tendance à demander "quels sont les objets et comment collaborent-ils?"; les personnes fonctionnelles ont tendance à se demander "quelles sont mes données et comment puis-je les transformer?". Ce ne sont pas les mêmes questions et elles mènent à des réponses différentes. Je pense également que vous avez mal lu la question. Ce n'est pas "OOP bave et règles FP, comment puis-je me débarrasser de OOP?", C'est "J'obtiens OOP et je n'obtiens pas FP, est-il un moyen de transformer un programme OOP en un programme fonctionnel, donc je peux obtenir un aperçu? ".
Michael Shaw

Réponses:

31

Définition de la programmation fonctionnelle

L'introduction de The Joy of Clojure dit ce qui suit:

La programmation fonctionnelle est l'un de ces termes informatiques qui a une définition amorphe. Si vous demandez à 100 programmeurs leur définition, vous recevrez probablement 100 réponses différentes ...

La programmation fonctionnelle concerne et facilite l'application et la composition des fonctions ... Pour qu'un langage soit considéré comme fonctionnel, sa notion de fonction doit être de premier ordre. Les fonctions de première classe peuvent être stockées, transmises et renvoyées comme n'importe quelle autre donnée. Au-delà de ce concept de base, [les définitions de la PF peuvent inclure] la pureté, l'immuabilité, la récursivité, la paresse et la transparence référentielle.

Programmation dans Scala 2nd Edition p. 10 a la définition suivante:

La programmation fonctionnelle est guidée par deux idées principales. La première idée est que les fonctions sont des valeurs de première classe ... Vous pouvez passer des fonctions comme arguments à d'autres fonctions, les renvoyer comme résultats de fonctions ou les stocker dans des variables ...

La deuxième idée principale de la programmation fonctionnelle est que les opérations d'un programme doivent mapper les valeurs d'entrée aux valeurs de sortie plutôt que de modifier les données en place.

Si nous acceptons la première définition, alors la seule chose que vous devez faire pour rendre votre code "fonctionnel" est de retourner vos boucles à l'envers. La deuxième définition inclut l'immuabilité.

Fonctions de première classe

Imaginez que vous obtenez actuellement une liste de passagers de votre objet Bus et que vous itérez dessus en diminuant le compte bancaire de chaque passager du montant du tarif du bus. La manière fonctionnelle d'effectuer cette même action serait d'avoir une méthode sur Bus, peut-être appelée forEachPassenger qui prend en fonction un argument. Ensuite, Bus itérerait sur ses passagers, mais cela est préférable et votre code client qui facture le prix du trajet serait mis en fonction et transmis à forEachPassenger. Voila! Vous utilisez une programmation fonctionnelle.

Impératif:

for (Passenger p : Bus.getPassengers()) {
    p.debit(fare);
}

Fonctionnel (en utilisant une fonction anonyme ou "lambda" dans Scala):

myBus = myBus.forEachPassenger(p:Passenger -> { p.debit(fare) })

Version Scala plus sucrée:

myBus = myBus.forEachPassenger(_.debit(fare))

Fonctions non de première classe

Si votre langue ne prend pas en charge les fonctions de première classe, cela peut devenir très moche. Dans Java 7 ou version antérieure, vous devez fournir une interface "Functional Object" comme celle-ci:

// Java 8 has java.util.function.Consumer, but in earlier
// versions you have to roll your own:
public interface Consumer<T> {
    public void accept(T t);
}

Ensuite, la classe Bus fournit un itérateur interne:

public void forEachPassenger(Consumer<Passenger> c) {
    for (Passenger p : passengers) {
        c.accept(p);
    }
}

Enfin, vous passez un objet fonction anonyme au bus:

// Java 8 has syntactic sugar to make this look more like
// the Scala solution, but earlier versions require manually
// instantiating a "Function Object," in this case, a
// Consumer:
Bus.forEachPassenger(new Consumer<Passenger>() {
    @Override
    public void accept(final Passenger p) {
        p.debit(fare);
    }
}

Java 8 permet aux variables locales d'être capturées dans le cadre d'une fonction anonyme, mais dans les versions antérieures, ces varibales doivent être déclarées finales. Pour contourner ce problème, vous devrez peut-être créer une classe wrapper MutableReference. Voici une classe spécifique à un entier qui vous permet d'ajouter un compteur de boucles au code ci-dessus:

public static class MutableIntWrapper {
    private int i;
    private MutableIntWrapper(int in) { i = in; }
    public static MutableIntWrapper ofZero() {
        return new MutableIntWrapper(0);
    }
    public int value() { return i; }
    public void increment() { i++; }
}

final MutableIntWrapper count = MutableIntWrapper.ofZero();
Bus.forEachPassenger(new Consumer<Passenger>() {
    @Override
    public void accept(final Passenger p) {
        p.debit(fare);
        count.increment();
    }
}

System.out.println(count.value());

Même avec cette laideur, il est parfois avantageux d'éliminer la logique compliquée et répétée des boucles réparties dans votre programme en fournissant un itérateur interne.

Cette laideur a été corrigée dans Java 8, mais la gestion des exceptions vérifiées dans une fonction de première classe est toujours vraiment laide et Java porte toujours l'hypothèse de la mutabilité dans toutes ses collections. Ce qui nous amène aux autres objectifs souvent associés à la PF:

Immutabilité

L'article 13 de Josh Bloch est «Préférer l'immuabilité». Malgré le fait que les ordures parlent le contraire, la POO peut être effectuée avec des objets immuables, ce qui le rend beaucoup mieux. Par exemple, String en Java est immuable. StringBuffer, OTOH doit être modifiable pour construire une chaîne immuable. Certaines tâches, comme travailler avec des tampons, nécessitent intrinsèquement une mutabilité.

Pureté

Chaque fonction doit au moins être mémorisable - si vous lui donnez les mêmes paramètres d'entrée (et qu'elle ne devrait avoir aucune entrée en dehors de ses arguments réels), elle devrait produire la même sortie à chaque fois sans provoquer "d'effets secondaires" comme changer l'état global, effectuer I / O, ou lever des exceptions.

Il a été dit que dans la programmation fonctionnelle, "un peu de mal est généralement nécessaire pour faire le travail". La pureté à 100% n'est généralement pas l'objectif. La minimisation des effets secondaires est.

Conclusion

Vraiment, de toutes les idées ci-dessus, l'immuabilité a été la plus grande victoire unique en termes d'applications pratiques pour simplifier mon code - que ce soit OOP ou FP. La transmission de fonctions aux itérateurs est la deuxième plus grande victoire. La documentation Java 8 Lambdas explique le mieux pourquoi. La récursivité est idéale pour traiter les arbres. La paresse vous permet de travailler avec des collections infinies.

Si vous aimez la JVM, je vous recommande de jeter un œil à Scala et Clojure. Les deux sont des interprétations perspicaces de la programmation fonctionnelle. Scala est de type sécurisé avec une syntaxe quelque peu similaire à C, bien qu'il ait vraiment autant de syntaxe en commun avec Haskell qu'avec C. Clojure n'est pas de type sécurisé et c'est un Lisp. J'ai récemment publié une comparaison de Java, Scala et Clojure en ce qui concerne un problème de refactorisation spécifique. La comparaison de Logan Campbell avec Game of Life inclut également Haskell et Clojure.

PS

Jimmy Hoffa a souligné que ma classe de bus est modifiable. Plutôt que de corriger l'original, je pense que cela démontrera exactement le type de refactorisation de cette question. Cela peut être résolu en faisant de chaque méthode sur le bus une usine pour produire un nouveau bus, chaque méthode sur le passager une usine pour produire un nouveau passager. J'ai donc ajouté un type de retour à tout ce qui signifie que je vais copier java.util.function.Function de Java 8 au lieu de l'interface consommateur:

public interface Function<T,R> {
    public R apply(T t);
    // Note: I'm leaving out Java 8's compose() method here for simplicity
}

Puis en bus:

public Bus mapPassengers(Function<Passenger,Passenger> c) {
    // I have to use a mutable collection internally because Java
    // does not have immutable collections that return modified copies
    // of themselves the way the Clojure and Scala collections do.
    List<Passenger> newPassengers = new ArrayList(passengers.size());
    for (Passenger p : passengers) {
        newPassengers.add(c.apply(p));
    }
    return Bus.of(driver, Collections.unmodifiableList(passengers));
}

Enfin, l'objet fonction anonyme renvoie l'état modifié des choses (un nouveau bus avec de nouveaux passagers). Cela suppose que p.debit () retourne maintenant un nouveau passager immuable avec moins d'argent que l'original:

Bus b = b.mapPassengers(new Function<Passenger,Passenger>() {
    @Override
    public Passenger apply(final Passenger p) {
        return p.debit(fare);
    }
}

J'espère que vous pouvez maintenant prendre votre propre décision sur la façon dont vous voulez rendre votre langage impératif fonctionnel, et décider s'il serait préférable de repenser votre projet en utilisant un langage fonctionnel. Dans Scala ou Clojure, les collections et autres API sont conçues pour faciliter la programmation fonctionnelle. Les deux ont une très bonne interopérabilité Java, vous pouvez donc mélanger et faire correspondre les langues. En fait, pour l'interopérabilité Java, Scala compile ses fonctions de première classe en classes anonymes qui sont presque compatibles avec les interfaces fonctionnelles Java 8. Vous pouvez lire les détails dans Scala in Depth sect. 1.3.2 .

GlenPeterson
la source
J'apprécie l'effort, l'organisation et la communication claire dans cette réponse; mais je dois prendre un léger problème avec certains des détails techniques. L'une des clés, comme mentionné en haut, est la composition des fonctions, cela revient à pourquoi l'encapsulation des fonctions à l'intérieur des objets ne donne pas de but: si une fonction est à l'intérieur d'un objet, elle doit être là pour agir sur cet objet; et s'il agit sur cet objet, il doit changer ses internes. Maintenant, je pardonnerai que tout le monde n'a pas besoin de transparence référentielle ou d'immuabilité, mais s'il change l'objet en place, il n'a plus besoin de le retourner
Jimmy Hoffa
Et dès qu'une fonction ne renvoie pas de valeur, soudain, la fonction ne peut pas être composée avec d'autres, et vous perdez toute l'abstraction de la composition fonctionnelle. Vous pouvez demander à la fonction de modifier l'objet en place, puis de renvoyer l'objet, mais si elle le fait, pourquoi ne pas simplement faire en sorte que la fonction prenne l'objet en paramètre et le libère des confins de son objet parent? Libéré de l'objet parent, il pourra également travailler sur d' autres types, ce qui est un autre élément important de FP qui vous manque: l'abstraction de type. votre forEachPasenger ne fonctionne que contre les passagers ...
Jimmy Hoffa
1
La raison pour laquelle vous faites abstraction des choses à mapper et à réduire, et ces fonctions ne sont pas liées au fait de contenir des objets, c'est qu'elles peuvent être utilisées sur une myriade de types par polymorphisme paramétrique. C'est la conflagration de ces abstractions variées que vous ne trouvez pas dans les langages OOP qui définit vraiment la FP et la pousse à avoir de la valeur. Ce n'est pas que la paresse, la transparence référentielle, l'immuabilité ou même le système de type HM sont nécessaires pour créer la FP, ces choses sont plutôt des effets secondaires de la création de langages destinés à une composition fonctionnelle où les fonctions peuvent s'abstraire sur les types en général
Jimmy Hoffa
@JimmyHoffa Vous avez fait une critique très juste de mon exemple. J'ai été séduit par la mutabilité par l'interface Java8 Consumer. De plus, la définition chouser / fogus de FP n'incluait pas l'immuabilité et j'ai ajouté la définition Odersky / Spoon / Venners plus tard. J'ai laissé l'exemple d'origine, mais j'ai ajouté une nouvelle version immuable sous une section "PS" en bas. C'est moche. Mais je pense que cela démontre des fonctions agissant sur des objets pour produire de nouveaux objets plutôt que de changer les internes des originaux. Grand commentaire!
GlenPeterson
1
Cette conversation se poursuit sur le tableau
GlenPeterson
12

J'ai une expérience personnelle "accomplissant" ceci. En fin de compte, je n'ai pas trouvé quelque chose de purement fonctionnel, mais j'ai trouvé quelque chose qui me satisfait. Voici comment je l'ai fait:

  • Convertissez tous les états externes en un paramètre de la fonction. EG: si la méthode d'un objet est modifiée x, faites en sorte que la méthode soit passée xau lieu d'appeler this.x.
  • Supprimez le comportement des objets.
    1. Rendre les données de l'objet accessibles au public
    2. Convertissez toutes les méthodes en fonctions que l'objet appelle.
    3. Demandez au code client qui appelle l'objet d'appeler la nouvelle fonction en transmettant les données d'objet. EG: convertir x.methodThatModifiesTheFooVar()enfooFn(x.foo)
    4. Supprimer la méthode d'origine de l'objet
  • Remplacer autant de boucles itératives que vous pouvez avec des fonctions d'ordre supérieur aiment map, reduce, filter, etc.

Je ne pouvais pas me débarrasser de l'état mutable. C'était tout simplement trop non idiomatique dans ma langue (JavaScript). Mais, en faisant passer et / ou renvoyer tous les états, chaque fonction peut être testée. Ceci est différent de la POO où la configuration de l'état prendrait trop de temps ou la séparation des dépendances nécessite souvent de modifier d'abord le code de production.

De plus, je peux me tromper sur la définition, mais je pense que mes fonctions sont référentiellement transparentes: mes fonctions auront le même effet étant donné la même entrée.

modifier

Comme vous pouvez le voir ici , il n'est pas possible de créer un objet vraiment immuable en JavaScript. Si vous êtes diligent et que vous contrôlez qui appelle votre code, vous pouvez le faire en créant toujours un nouvel objet au lieu de muter celui en cours. Cela ne valait pas la peine pour moi.

Mais si vous utilisez Java, vous pouvez utiliser ces techniques pour rendre vos classes immuables.

Daniel Kaplan
la source
+1 En fonction de ce que vous essayez de faire exactement, c'est probablement aussi loin que vous pouvez vraiment aller sans apporter de modifications de conception qui iraient au-delà du simple «refactoring».
Evicatos
@Evicatos: Je ne sais pas, si JavaScript avait un meilleur support pour l'état immuable, je pense que ma solution serait aussi fonctionnelle que dans un langage fonctionnel dynamique comme Clojure. Quel est un exemple de quelque chose qui nécessiterait quelque chose au-delà de la simple refactorisation?
Daniel Kaplan du
Je pense que se débarrasser de l'état mutable serait admissible. Je ne pense pas que ce soit juste une question de meilleur support dans la langue, je pense que passer de mutable à immuable exigera fondamentalement toujours des changements architecturaux fondamentaux qui constituent essentiellement une réécriture. Ymmv en fonction de votre définition du refactoring.
Evicatos
@Evicatos voir mon montage
Daniel Kaplan
1
@tieTYT oui, c'est triste que JS soit si mutable, mais au moins Clojure peut compiler en JavaScript: github.com/clojure/clojurescript
GlenPeterson
3

Je ne pense pas qu'il soit vraiment possible de refactoriser complètement le programme - il faudrait repenser et réimplémenter dans le bon paradigme.

J'ai vu le refactoring de code défini comme une "technique disciplinée pour restructurer un corps de code existant, en modifiant sa structure interne sans changer son comportement externe".

Vous pourriez rendre certaines choses plus fonctionnelles, mais vous avez toujours un programme orienté objet. Vous ne pouvez pas simplement changer de petits morceaux pour l'adapter à un paradigme différent.

Uri
la source
J'ajouterais qu'une bonne première marque est de rechercher la transparence référentielle. Une fois que vous avez cela, vous obtenez ~ 50% des avantages de la programmation fonctionnelle.
Daniel Gratzer
3

Je pense que cette série d'articles est exactement ce que vous voulez:

Rétrogames purement fonctionnels

http://prog21.dadgum.com/23.html Partie 1

http://prog21.dadgum.com/24.html Partie 2

http://prog21.dadgum.com/25.html Partie 3

http://prog21.dadgum.com/26.html Partie 4

http://prog21.dadgum.com/37.html Suivi

Le résumé est:

L'auteur suggère une boucle principale avec des effets secondaires (les effets secondaires doivent se produire quelque part, non?) Et la plupart des fonctions renvoient de petits enregistrements immuables détaillant comment ils ont changé l'état du jeu.

Bien sûr, lors de l'écriture d'un programme du monde réel, vous mélangerez et assortirez plusieurs styles de programmation, en utilisant chacun où cela aide le plus. Cependant, c'est une bonne expérience d'apprentissage pour essayer d'écrire un programme de la manière la plus fonctionnelle / immuable et aussi de l'écrire de la manière la plus spaghetti, en utilisant uniquement des variables globales :-) (faites-le comme une expérience, pas en production, s'il vous plaît)

marcus
la source
2

Vous devrez probablement retourner tout votre code car OOP et FP ont deux approches opposées pour organiser le code.

La POO organise le code autour des types (classes): différentes classes peuvent implémenter la même opération (une méthode avec la même signature). Par conséquent, la POO est plus appropriée lorsque l'ensemble des opérations ne change pas beaucoup alors que de nouveaux types peuvent être ajoutés très souvent. Par exemple, considérons une bibliothèque graphique dans laquelle chaque widget a un ensemble de méthodes fixes ( hide(), show(), paint(), move(), etc.) , mais de nouveaux widgets pourraient être ajoutés comme la bibliothèque est prolongée. Dans OOP, il est facile d'ajouter un nouveau type (pour une interface donnée): il suffit d'ajouter une nouvelle classe et d'implémenter toutes ses méthodes (changement de code local). D'un autre côté, l'ajout d'une nouvelle opération (méthode) à une interface peut nécessiter de changer toutes les classes qui implémentent cette interface (même si l'héritage peut réduire la quantité de travail).

FP organise le code autour des opérations (fonctions): chaque fonction implémente une opération qui peut traiter différents types de différentes manières. Ceci est généralement réalisé en répartissant le type via la correspondance de modèle ou un autre mécanisme. Par conséquent, FP est plus approprié lorsque l'ensemble des types est stable et que de nouvelles opérations sont ajoutées plus souvent. Prenons par exemple un ensemble fixe de formats d'image (GIF, JPEG, etc.) et certains algorithmes que vous souhaitez implémenter. Chaque algorithme peut être implémenté par une fonction qui se comporte différemment selon le type de l'image. L'ajout d'un nouvel algorithme est facile car il vous suffit d'implémenter une nouvelle fonction (changement de code local). L'ajout d'un nouveau format (type) nécessite de modifier toutes les fonctions que vous avez implémentées jusqu'à présent pour le prendre en charge (changement non local).

Conclusion: OOP et FP sont fondamentalement différents dans la façon dont ils organisent le code, et changer une conception OOP en une conception FP impliquerait de changer tout votre code pour refléter cela. Cela peut cependant être un exercice intéressant. Voir également ces notes de cours du livre SICP cité par mikemay, en particulier les diapositives 13.1.5 à 13.1.10.

Giorgio
la source