«Tout est une carte», est-ce que je le fais bien?


69

J'ai regardé le discours de Thuart Sierra " Thinking In Data " et en ai tiré l'une des idées comme principe de conception dans le jeu que je réalise. La différence est qu'il travaille à Clojure et je travaille en JavaScript. Je vois des différences majeures entre nos langues en ce sens:

  • Clojure est une programmation idiomatiquement fonctionnelle
  • La plupart des états sont immuables

J'ai repris l'idée de la diapositive "Tout est une carte" (de 11 minutes, 6 secondes à> 29 minutes). Certaines choses qu'il dit sont:

  1. Chaque fois que vous voyez une fonction qui prend 2 ou 3 arguments, vous pouvez justifier de la transformer en une carte et de ne la transmettre qu'à une carte. Cela présente de nombreux avantages:
    1. Vous n'avez pas à vous soucier de l'ordre des arguments
    2. Vous n'avez pas à vous soucier d'informations supplémentaires. S'il y a des clés supplémentaires, ce n'est pas vraiment notre préoccupation. Ils ne font que passer, ils n'interfèrent pas.
    3. Vous n'êtes pas obligé de définir un schéma
  2. Contrairement à la transmission d'un objet, aucune donnée ne se cache. Mais, il soutient que le masquage de données peut causer des problèmes et est surestimé:
    1. Performance
    2. Facilité de mise en œuvre
    3. Dès que vous communiquez sur le réseau ou entre les processus, vous devez de toute façon avoir un accord sur la représentation des données. C'est un travail supplémentaire que vous pouvez ignorer si vous travaillez uniquement sur des données.
  3. Plus pertinent pour ma question. Cela fait 29 minutes: "Rendez vos fonctions composables". Voici l'exemple de code qu'il utilise pour expliquer le concept:

    ;; Bad
    (defn complex-process []
      (let [a (get-component @global-state)
            b (subprocess-one a) 
            c (subprocess-two a b)
            d (subprocess-three a b c)]
        (reset! global-state d)))
    
    ;; Good
    (defn complex-process [state]
      (-> state
        subprocess-one
        subprocess-two
        subprocess-three))
    

    Je comprends que la majorité des programmeurs ne connaissent pas Clojure, je vais donc réécrire ceci dans un style impératif:

    ;; Good
    def complex-process(State state)
      state = subprocess-one(state)
      state = subprocess-two(state)
      state = subprocess-three(state)
      return state
    

    Voici les avantages:

    1. Facile à tester
    2. Facile à regarder ces fonctions isolément
    3. Facile à commenter une ligne de cela et voir ce que le résultat est en supprimant une seule étape
    4. Chaque sous-processus pourrait ajouter plus d'informations à l'état. Si le sous-processus un doit communiquer quelque chose au sous-processus trois, il suffit simplement d'ajouter une clé / valeur.
    5. Pas de passe-partout pour extraire les données dont vous avez besoin hors de l'état juste pour pouvoir les sauvegarder. Passez simplement l'état entier et laissez le sous-processus affecter ce dont il a besoin.

Maintenant, revenons à ma situation: j'ai pris cette leçon et je l'ai appliquée à mon jeu. Autrement dit, presque toutes mes fonctions de haut niveau prennent et renvoient un gameStateobjet. Cet objet contient toutes les données du jeu. EG: Une liste de badGuys, une liste de menus, le butin au sol, etc. Voici un exemple de ma fonction de mise à jour:

update(gameState)
  ...
  gameState = handleUnitCollision(gameState)
  ...
  gameState = handleLoot(gameState)
  ...

Je suis ici pour vous demander si j'ai créé une abomination qui a perverti une idée qui n'est pratique que dans un langage de programmation fonctionnel. JavaScript n'est pas idiomatiquement fonctionnel (bien qu'il puisse être écrit de cette façon) et il est très difficile d'écrire des structures de données immuables. Une chose qui me préoccupe est qu’il suppose que chacun de ces sous-processus est pur. Pourquoi cette hypothèse doit-elle être faite? Il est rare que l' un de mes fonctions sont pures (par là, je veux dire qu'ils modifient souvent gameState. Je n'ai pas d' autres effets secondaires complexes autres que). Ces idées s'effondrent-elles si vous ne disposez pas de données immuables?

Je crains qu'un jour je me réveille et réalise que toute cette conception est un simulacre et que je viens vraiment de mettre en œuvre l' anti-motif Big Ball Of Mud .


Honnêtement, je travaille sur ce code depuis des mois et c’est génial. Je sens que je reçois tous les avantages qu'il a revendiqués. Mon code est très facile pour moi de raisonner. Mais je suis un one man team alors j'ai la malédiction de la connaissance.

Mise à jour

Je code depuis plus de 6 mois avec ce modèle. Habituellement, à ce moment-là, j'oublie ce que j'ai fait et c'est là que "ai-je écrit cela d'une manière propre?" entre en jeu. Si je ne l'avais pas fait, je me battrais vraiment. Jusqu'à présent, je ne me bats pas du tout.

Je comprends combien d’autres yeux seraient nécessaires pour valider sa maintenabilité. Tout ce que je peux dire, c'est que je tiens avant tout à la facilité d'entretien. Je suis toujours l'évangéliste le plus bruyant pour le code propre, peu importe où je travaille.

Je souhaite répondre directement à ceux qui ont déjà une mauvaise expérience personnelle avec cette méthode de codage. Je ne le savais pas à l'époque, mais je pense que nous parlons en réalité de deux manières différentes d'écrire du code. La façon dont je l'ai fait semble être plus structurée que ce que d'autres ont connu. Quand quelqu'un a une mauvaise expérience personnelle avec "Tout est une carte", il explique à quel point il est difficile à maintenir parce que:

  1. Vous ne connaissez jamais la structure de la carte requise par la fonction
  2. N'importe quelle fonction peut transformer l'entrée de manière inattendue. Vous devez parcourir la base de code pour savoir comment une clé particulière est entrée dans la carte ou pourquoi elle a disparu.

Pour ceux qui ont une telle expérience, la base de code était peut-être «Tout prend 1 des N types de cartes». Le mien est, "Tout prend 1 de 1 type de carte". Si vous connaissez la structure de ce type, vous connaissez la structure de tout. Bien entendu, cette structure se développe généralement avec le temps. C'est pourquoi...

Il y a un endroit où rechercher l'implémentation de référence (c'est-à-dire: le schéma). Cette implémentation de référence est le code utilisé par le jeu pour ne pas devenir obsolète.

En ce qui concerne le deuxième point, je n’ajoute / retire pas de clés à la carte en dehors de l’implémentation de référence, je mute simplement ce qui est déjà là. J'ai aussi une grande suite de tests automatisés.

Si cette architecture finit par s'effondrer sous son propre poids, j'ajouterai une deuxième mise à jour. Sinon, supposons que tout se passe bien :)


2
Question cool (+1)! Je trouve cet exercice très utile d'essayer de mettre en œuvre des idiomes fonctionnels dans un langage non fonctionnel (ou pas très fortement fonctionnel).
Giorgio

15
Quiconque vous dira que le masquage d'informations de style OO (avec des propriétés et des fonctions d'accesseur) est une mauvaise chose en raison de l'impact négatif (généralement négligeable) sur les performances, puis vous demande de transformer tous vos paramètres en une carte, ce qui vous donne le surcoût (beaucoup plus important) d'une recherche de hachage chaque fois que vous essayez de récupérer une valeur peut être ignoré en toute sécurité.
Mason Wheeler

4
@MasonWheeler permet de dire que vous avez raison à ce sujet. Allez-vous annuler toutes les autres remarques qu'il fait à cause de cette chose qui ne va pas?
Daniel Kaplan

9
En Python (et je crois que la plupart des langages dynamiques, y compris Javascript), objet n’est en réalité que du sucre syntaxique pour un dict / map.
Lie Ryan

6
@EvanPlaice: La notation Big-O peut être trompeuse. Le fait est que tout est lent comparé à un accès direct avec deux ou trois instructions de code machine individuelles, et sur quelque chose qui se produit aussi souvent qu’un appel de fonction, cette surcharge s’ajoute très rapidement.
Mason Wheeler

Réponses:


42

J'ai déjà supporté une application dans laquelle "tout est une carte" auparavant. C'est une idée terrible. SVP ne le faites pas!

Lorsque vous spécifiez les arguments passés à la fonction, il est très facile de savoir quelles valeurs ont besoin de la fonction. Cela évite de transmettre des données superflues à la fonction qui distrait simplement le programmeur - chaque valeur transmise implique la nécessité de le faire, ce qui oblige le programmeur prenant en charge votre code à comprendre pourquoi les données sont nécessaires.

D'autre part, si vous transmettez tout en tant que carte, le programmeur prenant en charge votre application devra comprendre parfaitement la fonction appelée de toutes les manières pour savoir quelles valeurs la carte doit contenir. Pire encore, il est très tentant de réutiliser la carte transmise à la fonction actuelle afin de transmettre des données aux fonctions suivantes. Cela signifie que le programmeur prenant en charge votre application doit connaître toutes les fonctions appelées par la fonction actuelle afin de comprendre ce que fait la fonction actuelle. C’est exactement le contraire de l’objectif de l’écriture de fonctions - résumer les problèmes pour ne pas avoir à y penser! Maintenant, imaginez 5 appels en profondeur et 5 appels en largeur chacun. C'est beaucoup de choses à garder à l'esprit et beaucoup d'erreurs à faire.

"Tout est une carte" semble également conduire à utiliser la carte comme valeur de retour. Je l'ai vu. Et encore une fois, c'est pénible. Les fonctions appelées ne doivent jamais écraser la valeur de retour de l'autre - à moins de connaître la fonctionnalité de tout et de savoir que la valeur de mappage en entrée X doit être remplacée pour le prochain appel à une fonction. Et la fonction actuelle a besoin de modifier la carte pour renvoyer sa valeur, qui doit parfois écraser la valeur précédente et parfois non.

edit - exemple

Voici un exemple de situation problématique. C'était une application web. Les entrées utilisateur ont été acceptées à partir de la couche d'interface utilisateur et placées dans une carte. Ensuite, des fonctions ont été appelées pour traiter la demande. Le premier ensemble de fonctions vérifiera les entrées erronées. S'il y avait une erreur, le message d'erreur serait placé dans la carte. La fonction appelante vérifie la carte pour cette entrée et écrit la valeur dans l'interface utilisateur si elle existait.

L'ensemble de fonctions suivant démarrera la logique métier. Chaque fonction prendrait la carte, enlèverait des données, modifierait certaines données, agirait sur les données de la carte et placerait le résultat dans la carte, etc. Afin de corriger un bogue dans une fonction ultérieure, vous deviez examiner toutes les fonctions précédentes ainsi que l'appelant afin de déterminer où la valeur attendue aurait pu être définie.

Les fonctions suivantes extrairaient des données de la base de données. Ou plutôt, ils transmettraient la carte à la couche d'accès aux données. Le DAL vérifierait si la carte contenait certaines valeurs pour contrôler le mode d'exécution de la requête. Si 'justcount' était une clé, alors la requête serait 'compter select foo from bar'. N'importe laquelle des fonctions précédemment appelées aurait pu être celle qui a ajouté 'justcount' à la carte. Les résultats de la requête seraient ajoutés à la même carte.

Les résultats apparaîtront à l'appelant (logique métier) qui vérifiera la carte pour savoir quoi faire. Cela provient en partie d'éléments ajoutés à la carte par la logique métier initiale. Certains proviendraient des données de la base de données. La seule façon de savoir d'où cela venait était de trouver le code qui l'a ajouté. Et l'autre endroit qui peut aussi l'ajouter.

Le code était en réalité un gâchis monolithique qu'il fallait comprendre dans son intégralité pour savoir d'où venait une seule entrée sur la carte.


2
Votre deuxième paragraphe a un sens pour moi et ça sonne vraiment comme si c'était nul. Votre troisième paragraphe donne l’impression que nous ne parlons pas vraiment de la même conception. "réutiliser" est le point. Il serait faux de l'éviter. Et je ne peux vraiment pas me rapporter à votre dernier paragraphe. J'ai toutes les fonctions prendre le gameStatesans savoir quoi que ce soit avant ou après. Il réagit simplement aux données fournies. Comment êtes-vous arrivé à une situation où les fonctions se mettraient sur les pieds? Pouvez-vous donner un exemple?
Daniel Kaplan

2
J'ai ajouté un exemple pour essayer de le rendre un peu plus clair. J'espère que ça aide. En outre, il existe une différence entre faire circuler un objet d'état bien défini et contourner un blob qui définit des paramètres modifiés pour de nombreuses raisons pour de nombreuses raisons: mélange de la logique ui, de la logique métier et de la logique d'accès à une base de données
atk

28

Personnellement, je ne recommanderais pas ce modèle dans l'un ou l'autre paradigme. Cela facilite l'écriture initiale au lieu de rendre plus difficile de raisonner plus tard.

Par exemple, essayez de répondre aux questions suivantes sur chaque fonction de sous-processus:

  • De quels champs a- statet-il besoin?
  • Quels champs modifie-t-il?
  • Quels champs sont inchangés?
  • Pouvez-vous réorganiser l'ordre des fonctions en toute sécurité?

Avec ce modèle, vous ne pouvez pas répondre à ces questions sans lire l'intégralité de la fonction.

Dans un langage orienté objet, le motif a encore moins de sens, car l’état de suivi est ce que les objets font.


2
"Les avantages de l'immutabilité diminuent au fur et à mesure que grandissent vos objets immuables" Pourquoi? Est-ce un commentaire sur les performances ou la maintenabilité? S'il vous plaît élaborer sur cette phrase.
Daniel Kaplan

8
@tieTYT Immutables fonctionne bien lorsqu'il y a quelque chose de petit (un type numérique par exemple). Vous pouvez les copier, les créer, les jeter, les piloter avec un coût relativement faible. Lorsque vous commencez à gérer des états de jeu complets constitués de cartes profondes et volumineuses, d'arbres, de listes et de dizaines, voire de centaines de variables, le coût de la copie ou de la suppression augmente (et les poids volants deviennent impraticables).

3
Je vois. S'agit-il d'un problème de "données immuables dans des langues impérieuses" ou d'un problème de "données immuables"? IE: Peut-être que ce n'est pas un problème dans le code Clojure. Mais je peux voir comment cela se passe dans JS. C'est également pénible d'écrire tout le code standard pour le faire.
Daniel Kaplan

3
@MichaelT et Karl: pour être juste, vous devriez vraiment mentionner l'autre côté de l'histoire d'immutabilité / efficacité. Oui, l'utilisation naïve peut être terriblement inefficace, c'est pourquoi les gens ont proposé de meilleures approches. Voir le travail de Chris Okasaki pour plus d'informations.

3
@ MattFenwick Personnellement, j'aime bien les immuables. En ce qui concerne le filetage, je sais des choses sur l'immuable, je peux travailler et les copier en toute sécurité. Je le mets dans un appel de paramètre et le passe à un autre sans craindre que quelqu'un le modifie à son retour. Si l'on parle d'un état de jeu complexe (la question l'a utilisé à titre d'exemple - je serais horrifié de penser à quelque chose d'aussi simple que l'état de jeu Nethack en tant qu'immuable), l'immuabilité est probablement la mauvaise approche.

12

Ce que vous semblez faire est en réalité une monade d’État manuelle; ce que je voudrais faire est de construire un combinateur de liaisons (simplifié) et de ré-exprimer les connexions entre vos étapes logiques en utilisant ceci:

function stateBind() {
    var computation = function (state) { return state; };
    for ( var i = 0 ; i < arguments.length ; i++ ) {
        var oldComp = computation;
        var newComp = arguments[i];
        computation = function (state) { return newComp(oldComp(state)); };
    }
    return computation;
}

...

stateBind(
  subprocessOne,
  subprocessTwo,
  subprocessThree,
);

Vous pouvez même utiliser stateBindpour construire les différents sous-processus à partir de sous-processus et poursuivre dans une arborescence de combinateurs de liaison pour structurer votre calcul de manière appropriée.

Pour une explication de la monade d'État complète et non simplifiée et une excellente introduction à la monade en général en JavaScript, consultez ce billet de blog .


1
OK, je vais examiner cela (et commenter plus tard). Mais que pensez-vous de l'idée d'utiliser le motif?
Daniel Kaplan

1
@tieTYT Je pense que le motif lui-même est une très bonne idée. la monade d'état en général est un outil utile de structuration de code pour les algorithmes pseudo-mutables (algorithmes immuables mais imitant la mutabilité).
La flamme de Ptharien

2
+1 pour noter que ce modèle est essentiellement une monade. Cependant, je ne suis pas d’accord avec le fait que c’est une bonne idée de parler dans une langue qui est réellement mutable. Monad est un moyen de fournir la capacité de globals / mutable state dans un langage qui ne permet pas la mutation. OMI, dans un langage qui n'impose pas l'immuabilité, le motif Monad est juste une masturbation mentale.
Lie Ryan

6
@LieRyan Les monades en général n'ont en réalité rien à voir avec la mutabilité ou les globals; seule la monade d’État le fait (car c’est précisément ce à quoi elle est destinée). Je ne suis pas non plus d'accord sur le fait que la monade d'État n'est pas utile dans un langage avec mutabilité, bien qu'une implémentation reposant sur la mutabilité en dessous puisse être plus efficace que celle immuable que j'ai donnée (bien que je n'en sois pas du tout sûr). L'interface monadique peut fournir des capacités de haut niveau qui ne sont pas autrement facilement accessibles, le stateBindcombinateur que j'ai donné en est un exemple très simple.
La flamme de Ptharien le

1
@LieRyan Je partage le commentaire de Ptharien: la plupart des monades ne parlent ni d'état ni de mutabilité, et même celle qui le concerne ne concerne pas spécifiquement l' état global . En réalité, les monades fonctionnent assez bien dans les langues OO / imperative / mutable.

11

Donc, il semble y avoir beaucoup de discussions sur l'efficacité de cette approche dans Clojure. Je pense qu'il pourrait être utile d'examiner la philosophie de Rich Hickey pour expliquer pourquoi il a créé Clojure afin de prendre en charge les abstractions de données de la manière suivante :

Fogus: Donc, une fois que les complexités accidentelles ont été réduites, comment Clojure peut-il aider à résoudre le problème? Par exemple, le paradigme idéalisé orienté objet est destiné à favoriser la réutilisation, mais Clojure n’est pas classiquement orienté objet. Comment pouvons-nous structurer notre code en vue de sa réutilisation?

Hickey: Je dirais que l’utilisation de l’OA et la réutilisation sont importantes, mais le fait de pouvoir réutiliser des choses simplifie le problème car vous ne réinventez pas les roues au lieu de construire des voitures. De plus, Clojure étant sur la machine virtuelle, de nombreuses molettes, des bibliothèques, sont disponibles. Qu'est-ce qui rend une bibliothèque réutilisable? Il devrait bien faire une ou plusieurs choses, être relativement autonome et imposer peu d'exigences au code client. Rien de tout cela ne relève de OO, et toutes les bibliothèques Java ne répondent pas à ce critère, mais beaucoup le font.

Lorsque nous passons au niveau de l'algorithme, je pense que OO peut sérieusement entraver la réutilisation. En particulier, l'utilisation d'objets pour représenter des données informationnelles simples est presque criminelle dans la génération de micro-langages par information, c'est-à-dire les méthodes de classe, par rapport à des méthodes beaucoup plus puissantes, déclaratives et génériques telles que l'algèbre relationnelle. Inventer une classe avec sa propre interface pour contenir une information revient à inventer un nouveau langage pour écrire chaque nouvelle. Ceci est anti-réutilisation et, je pense, entraîne une explosion de code dans les applications OO typiques. Clojure évite cela et préconise plutôt un modèle associatif simple pour l'information. Avec lui, on peut écrire des algorithmes qui peuvent être réutilisés pour tous les types d’informations.

Ce modèle associatif n'est qu'une des nombreuses abstractions fournies avec Clojure, et ce sont les véritables fondements de son approche de la réutilisation: les fonctions sur les abstractions. Avoir un ensemble de fonctions ouvert et volumineux opère sur un ensemble ouvert et réduit d'abstractions extensibles est la clé de la réutilisation algorithmique et de l'interopérabilité des bibliothèques. La grande majorité des fonctions de Clojure sont définies en fonction de ces abstractions, et les auteurs des bibliothèques conçoivent également leurs formats d'entrée et de sortie, réalisant ainsi une interopérabilité considérable entre des bibliothèques développées indépendamment. Cela contraste vivement avec les DOM et autres choses similaires que vous voyez dans OO. Bien sûr, vous pouvez faire une abstraction similaire dans OO avec des interfaces, par exemple les collections java.util, mais vous pouvez tout aussi bien ne pas le faire, comme dans java.io.

Fogus réitère ces points dans son livre Javascript fonctionnel :

Tout au long de ce livre, je vais utiliser l'approche consistant à utiliser des types de données minimaux pour représenter les abstractions, des ensembles aux arbres en passant par les tables. En JavaScript, cependant, bien que ses types d’objets soient extrêmement puissants, les outils fournis pour les utiliser ne sont pas entièrement fonctionnels. Au lieu de cela, le modèle d'utilisation plus large associé aux objets JavaScript consiste à associer des méthodes aux fins de la répartition polymorphe. Heureusement, vous pouvez également afficher un objet JavaScript non nommé (non construit via une fonction constructeur) comme un simple magasin de données associatif.

Si les seules opérations que nous pouvons effectuer sur un objet Book ou une instance d'un type Employé sont setTitle ou getSSN, nous avons verrouillé nos données dans des micro-langages par information (Hickey 2011). Une approche plus flexible de la modélisation des données est une technique de données associative. Les objets JavaScript, même les machines prototypes, sont des véhicules idéaux pour la modélisation de données associative, où les valeurs nommées peuvent être structurées pour former des modèles de données de niveau supérieur, accessibles de manière uniforme.

Bien que les outils de manipulation et d'accès aux objets JavaScript en tant que cartes de données soient rares dans JavaScript, heureusement, Underscore fournit une foule d'opérations utiles. Parmi les fonctions les plus simples à comprendre figurent _.keys, _.values ​​et _.pluck. _.Keys et _.values ​​sont nommés en fonction de leur fonctionnalité, qui consiste à prendre un objet et à renvoyer un tableau de ses clés ou de ses valeurs ...


2
J'ai lu cette interview de Fogus / Hickey auparavant, mais je n'étais pas capable de comprendre de quoi il parlait jusqu'à présent. Merci pour votre réponse. Toujours pas sûr si Hickey / Fogus donnerait ma conception leur bénédiction si. Je suis inquiet d'avoir poussé l'esprit de leurs conseils à l'extrême.
Daniel Kaplan

9

l'avocat du Diable

Je pense que cette question mérite l'avocat du diable (mais bien sûr, je suis partial). Je pense que @KarlBielefeldt avance de très bons arguments et j'aimerais y répondre. Je veux d’abord dire que ses arguments sont excellents.

Comme il a mentionné que ce modèle ne convient pas, même en programmation fonctionnelle, je prendrai en compte JavaScript et / ou Clojure dans mes réponses. Une similitude extrêmement importante entre ces deux langues est leur typage dynamique. Je serais plus d'accord avec ses remarques si je mettais cela en œuvre dans un langage typé statiquement comme Java ou Haskell. Mais je vais considérer que l’alternative au motif "Tout est une carte" est une conception POO traditionnelle en JavaScript et non dans un langage typé de manière statique (j’espère que je ne mets pas en place un argument de type homme de paille en faisant cela, s'il vous plaît, faites-moi savoir).

Par exemple, essayez de répondre aux questions suivantes sur chaque fonction de sous-processus:

  • Quels champs d'état faut-il?

  • Quels champs modifie-t-il?

  • Quels champs sont inchangés?

Dans une langue à typage dynamique, comment répondriez-vous normalement à ces questions? Le premier paramètre d'une fonction peut être nommé foo, mais qu'est-ce que c'est? Un tableau? Un objet? Un objet de tableaux d'objets? Comment trouvez-vous? Le seul moyen que je connaisse est de

  1. lire la documentation
  2. regarde le corps de la fonction
  3. regarde les tests
  4. devinez et exécutez le programme pour voir si cela fonctionne.

Je ne pense pas que le motif "Tout est une carte" fait une différence ici. Ce sont toujours les seuls moyens que je connaisse pour répondre à ces questions.

N'oubliez pas non plus qu'en JavaScript et dans les langages de programmation les plus impératifs, n'importe qui functionpeut exiger, modifier ou ignorer tout état auquel il peut accéder et que la signature ne fait aucune différence: la fonction / méthode peut faire quelque chose avec l'état global ou avec un singleton. Les signatures mentent souvent .

Je n'essaie pas de créer une fausse dichotomie entre "Tout est une carte" et un code OO mal conçu . J'essaie simplement de souligner que le fait d'avoir des signatures qui prennent moins / plus de paramètres à granularité grossière / grossière ne garantit pas que vous savez comment isoler, configurer et appeler une fonction.

Mais, si vous me permettez d’utiliser cette fausse dichotomie: comparé à l’écriture de JavaScript à la manière traditionnelle de la programmation orientée objet, "Tout est une carte" semble mieux. Selon la méthode OOP traditionnelle, la fonction peut nécessiter, modifier ou ignorer l’état que vous transmettez ou indiquez que vous ne le transmettez pas. dans.

  • Pouvez-vous réorganiser l'ordre des fonctions en toute sécurité?

Dans mon code, oui. Voir mon deuxième commentaire à la réponse de @ Evicatos. Peut-être que c'est uniquement parce que je fais un jeu, je ne peux pas le dire. Dans un jeu qui est mise à jour 60x une seconde, il n'a pas vraiment d' importance si dead guys drop lootalors , good guys pick up lootou vice versa. Chaque fonction fait toujours exactement ce qu'elle est supposée faire, quel que soit l'ordre dans lequel elle est exécutée. Les mêmes données sont simplement introduites dans ces updateappels lors de différents appels si vous échangez la commande. Si vous avez good guys pick up lootalors dead guys drop loot, les bons gars vont ramasser le butin dans la prochaine updateet ce n'est pas grave. Un humain ne sera pas capable de remarquer la différence.

Au moins, cela a été mon expérience générale. Je me sens vraiment vulnérable d'admettre cela publiquement. Peut-être que considérer que tout va bien est une très , très mauvaise chose à faire. Faites-moi savoir si j'ai commis une terrible erreur ici. Mais, si je l’ai fait, il est extrêmement facile de réorganiser les fonctions, l’ordre est dead guys drop lootdonc à good guys pick up lootnouveau. Cela prendra moins de temps que le temps nécessaire pour écrire ce paragraphe: P

Peut-être pensez-vous que "les morts devraient laisser tomber le butin en premier. Ce serait mieux si votre code faisait respecter cet ordre". Mais, pourquoi les ennemis devraient-ils laisser tomber leur butin avant que vous puissiez le récupérer? Pour moi, cela n'a pas de sens. Peut-être que le butin a été largué updatesil y a 100 ans. Il n'est pas nécessaire de vérifier si un méchant arbitraire doit récupérer un butin déjà au sol. C'est pourquoi je pense que l'ordre de ces opérations est complètement arbitraire.

Il est naturel d'écrire des étapes découplées avec ce modèle, mais il est difficile de remarquer vos étapes couplées dans la POO traditionnelle. Si j’écrivais de la POO traditionnelle, la façon naturelle et naïve de penser est de faire du dead guys drop lootretour un Lootobjet que je dois transmettre à la good guys pick up loot. Je ne serais pas en mesure de réorganiser ces opérations car le premier retourne l'entrée de second.

Dans un langage orienté objet, le motif a encore moins de sens, car l’état de suivi est ce que les objets font.

Les objets ont un état et il est idiomatique de le modifier, de sorte que son histoire disparaisse ... sauf si vous écrivez manuellement du code pour le suivre. En quoi l'état de suivi est-il "ce qu'ils font"?

En outre, les avantages de l'immutabilité diminuent d'autant plus que vos objets immuables sont volumineux.

Comme je l’ai dit, "c’est rare que mes fonctions soient pures". Ils opèrent toujours uniquement sur leurs paramètres, mais ils modifient leurs paramètres. C’est un compromis que j’ai estimé devoir faire lors de l’application de ce modèle à JavaScript.


4
"Le premier paramètre d'une fonction peut être nommé foo, mais qu'est-ce que c'est?" C'est pourquoi vous ne nommez pas vos paramètres "foo", mais "répétitions", "parent" et d'autres noms indiquant clairement ce qui est attendu lorsque combiné avec le nom de la fonction.
Sebastian Redl

1
Je suis d'accord avec vous sur tous les points. Le seul problème que Javascript pose vraiment avec ce modèle, c’est que vous travaillez sur des données modifiables et qu’il est par conséquent très probable que vous passiez d’un état à un autre. Il existe cependant une bibliothèque qui vous donne accès aux infrastructures de données de clojure en clair, bien que j'oublie comment elle s'appelle. Passer des arguments en tant qu'objet n'est pas inconnu non plus, jQuery effectue cette opération à plusieurs endroits, mais documente les parties de l'objet qu'ils utilisent. Personnellement, je voudrais séparer les champs UI et GameLogic, mais tout ce qui fonctionne pour vous :)
Robin Heggelund Hansen

@SebastianRedl Pour quoi suis-je censé passer parent? Est-ce que repetitionset un tableau de nombres ou de chaînes ou est-ce pas important? Ou peut-être que les répétitions sont juste un nombre représentant le nombre de répétitions que je veux? Il y a beaucoup d'apis qui prennent juste un objet options . Le monde est meilleur si vous nommez les choses correctement, mais cela ne garantit pas que vous saurez utiliser l’API, sans poser de questions.
Daniel Kaplan

8

J'ai trouvé que mon code a tendance à être structuré comme suit:

  • Les fonctions qui prennent des cartes ont tendance à être plus grandes et ont des effets secondaires.
  • Les fonctions qui prennent des arguments ont tendance à être plus petites et pures.

Je n'ai pas cherché à créer cette distinction, mais c'est souvent ainsi que cela se retrouve dans mon code. Je ne pense pas que l'utilisation d'un style annule nécessairement l'autre.

Les fonctions pures sont faciles à tester. Les cartes plus grandes avec des cartes entrent davantage dans la zone de test "d'intégration" car elles impliquent généralement plus de pièces mobiles.

En javascript, une chose qui aide beaucoup est d'utiliser quelque chose comme la bibliothèque Match de Meteor pour effectuer la validation des paramètres. Cela indique très clairement ce que la fonction attend et peut gérer les cartes de manière très propre.

Par exemple,

function foo (post) {
  check(post, {
    text: String,
    timestamp: Date,
    // Optional, but if present must be an array of strings
    tags: Match.Optional([String])
    });

  // do stuff
}

Voir http://docs.meteor.com/#match pour plus d'informations.

:: MISE À JOUR ::

L'enregistrement vidéo de "Clojure in the Large" de Clojure / West par Stuart Sierra aborde également ce sujet. Comme le PO, il contrôle les effets secondaires dans le cadre de la carte, ce qui facilite grandement les tests. Il a également publié un article dans son blog décrivant son flux de travail actuel pour Clojure qui semble pertinent.


1
Je pense que mes commentaires à @Evicatos préciseront ma position ici. Oui, je mue et les fonctions ne sont pas pures. Mais, mes fonctions sont vraiment faciles à tester, surtout après coup, pour des défauts de régression que je n’avais pas prévu de tester. La moitié du mérite revient à JS: Il est très facile de construire un "map" / objet avec uniquement les données dont j'ai besoin pour mon test. Il suffit ensuite de le transmettre et de vérifier les mutations. Les effets secondaires sont toujours représentés sur la carte, ils sont donc faciles à tester.
Daniel Kaplan

1
Je crois que l’utilisation pragmatique des deux méthodes est la solution "correcte". Si les tests sont faciles pour vous et que vous pouvez atténuer le problème méta de communication des champs obligatoires à d'autres développeurs, cela semble être une victoire. Merci pour votre question; J'ai aimé lire la discussion intéressante que vous avez commencée.
Alanning

5

L'argument principal que je peux opposer à cette pratique est qu'il est très difficile de déterminer les données dont une fonction a réellement besoin.

Cela signifie que les futurs programmeurs de la base de code devront savoir comment la fonction appelée fonctionne en interne - et tous les appels de fonctions imbriquées - pour pouvoir l'appeler.

Plus j'y pense, plus votre objet gameState a une odeur de global. Si c'est comme ça qu'on l'utilise, pourquoi le faire circuler?


1
Oui, puisque je le mute d'habitude, c'est un global. Pourquoi se donner la peine de le faire circuler? Je ne sais pas, c'est une question valable. Mais mon instinct me dit que si je cessais de le passer, mon programme deviendrait instantanément plus difficile à raisonner. Chaque fonction peut faire tout ou rien à l’état global. Dans l'état actuel des choses, vous voyez ce potentiel dans la signature de la fonction. Si vous ne pouvez pas le savoir, je ne suis pas sûr de rien de tout cela :)
Daniel Kaplan

1
BTW: re: le principal argument contre: Cela semble être vrai que ce soit en clojure ou en javascript. Mais c'est un argument précieux à faire valoir. Peut-être que les avantages énumérés l'emportent largement sur les inconvénients.
Daniel Kaplan

2
Je sais maintenant pourquoi je donner la peine qui passe autour , même si elle est une variable globale: Il permet moi d'écrire des fonctions pures. Si je change gameState = f(gameState)de mode f(), c'est beaucoup plus difficile à tester f. f()peut renvoyer une chose différente à chaque fois que je l'appelle. Mais il est facile de faire en sorte que f(gameState)la même chose soit renvoyée à chaque fois.
Daniel Kaplan

3

Il y a plus de nom qui convient à ce que vous faites que Big ball of boue . Ce que vous faites s'appelle le modèle d' objet de Dieu . Cela n’apparaît pas comme ça au premier abord, mais en Javascript il y a très peu de différence entre

update(gameState)
  ...
  gameState = handleUnitCollision(gameState)
  ...
  gameState = handleLoot(gameState)
  ...

et

{
  ...
  handleUnitCollision: function() {
    ...
  },
  ...
  handleLoot: function() {
    ...
  },
  ...
  update: function() {
    ...
    this.handleUnitCollision()
    ...
    this.handleLoot()
    ...
  },
  ...
};

Que ce soit ou non une bonne idée dépend probablement des circonstances. Mais cela va certainement dans le sens de la méthode Clojure. L'un des objectifs de Clojure est d'éliminer ce que Rich Hickey appelle "complexité incidente". La communication de plusieurs objets est certainement plus complexe qu’un seul objet. Si vous divisez la fonctionnalité en plusieurs objets, vous devez soudainement vous soucier de la communication, de la coordination et du partage des responsabilités. Ce sont des complications qui sont seulement accessoires à votre objectif initial d'écrire un programme. Vous devriez voir le discours de Rich Hickey dans Simple rendu facile . Je pense que c'est une très bonne idée.


Question connexe plus générale [ programmers.stackexchange.com/questions/260309/… modélisation des données par rapport aux classes traditionnelles)
user7610

"Dans la programmation orientée objet, un objet divin est un objet qui en sait trop ou en fait trop. L'objet divin est un exemple d'anti-motif." Donc, l'objet de Dieu n'est pas une bonne chose, pourtant votre message semble dire le contraire. C'est un peu déroutant pour moi.
Daniel Kaplan

@tieTYT vous ne faites pas de programmation orientée objet, donc c'est correct
user7610

Comment êtes-vous arrivé à cette conclusion (celle du "ça va")?
Daniel Kaplan

Le problème avec l'objet divin dans OO est que "l'objet devient tellement conscient de tout ou que tous les objets deviennent tellement dépendants de l'objet divin que lorsqu'il y a un changement ou un bogue à corriger, il devient un véritable cauchemar à implémenter." source Dans votre code, il y a un autre objet à côté de l'objet Dieu, donc la deuxième partie n'est pas un problème. En ce qui concerne la première partie, votre objet Dieu est le programme et votre programme doit être au courant de tout. Donc ça va aussi.
user7610

2

Je viens juste de faire face à ce sujet plus tôt dans la journée en jouant avec un nouveau projet. Je travaille à Clojure pour faire un jeu de poker. J'ai représenté les valeurs faciales et les costumes en tant que mots-clés et j'ai décidé de représenter une carte sous la forme d'une carte semblable à

{ :face :queen :suit :hearts }

J'aurais aussi bien pu en faire des listes ou des vecteurs des deux éléments de mots clés. Je ne sais pas si cela fait une différence entre la mémoire et les performances, je vais donc simplement utiliser des cartes pour le moment.

Dans le cas où je changerais d'avis par la suite, j'ai décidé que la plupart des parties de mon programme devaient passer par une "interface" pour accéder aux éléments d'une carte, de sorte que les détails de la mise en œuvre soient contrôlés et masqués. J'ai des fonctions

(defn face [card] (card :face))
(defn suit [card] (card :suit))

que le reste du programme utilise. Les cartes sont transmises aux fonctions sous forme de cartes, mais les fonctions utilisent une interface convenue pour accéder aux cartes et ne devraient donc pas pouvoir se gâcher.

Dans mon programme, une carte ne sera probablement jamais qu'une carte à deux valeurs. Dans la question, tout l'état du jeu est transmis sous forme de carte. L'état du jeu sera beaucoup plus compliqué qu'une seule carte, mais je ne pense pas qu'il soit fautif de recourir à une carte. Dans un langage impératif pour les objets, je pourrais tout aussi bien avoir un seul et même grand objet GameState et appeler ses méthodes, et avoir le même problème:

class State
  def complex-process()
    state = clone(this) ; or just use 'this' below if mutation is fine
    state.subprocess-one()
    state.subprocess-two()
    state.subprocess-three()
    return state

Maintenant, c'est orienté objet. Y a-t-il quelque chose de particulièrement faux avec cela? Je ne pense pas, vous déléguez simplement le travail à des fonctions qui savent comment gérer un objet State. Et que vous travailliez avec des cartes ou des objets, vous devriez vous méfier du moment de les séparer en morceaux plus petits. Je dis donc que l’utilisation de cartes convient parfaitement, à condition que vous utilisiez le même soin que celui que vous utiliseriez avec des objets.


2

D'après ce que j'ai (peu) vu, utiliser des cartes ou d'autres structures imbriquées pour créer un seul objet d'état global immuable, comme celui-ci, est assez courant dans les langages fonctionnels, du moins dans les langages purs, en particulier lorsque State Monad est utilisé comme @Ptharien'sFlame mentioend .

Voici deux obstacles à l’utilisation efficace de ce contenu que j'ai vus / lus (et d’autres réponses mentionnées ici):

  • Mutation d'une valeur (profondément) imbriquée dans l'état (immuable)
  • Cacher une majorité de l'Etat aux fonctions qui n'en ont pas besoin et leur donner juste le peu de travail dont ils ont besoin pour travailler / muter

Il existe plusieurs techniques / modèles courants qui peuvent aider à résoudre ces problèmes:

Le premier est Zippers : ils permettent de traverser et de muter au plus profond d'une hiérarchie imbriquée immuable.

Une autre est Lenses : elles vous permettent de vous focaliser sur la structure vers un emplacement spécifique et de lire / modifier la valeur à cet endroit. Vous pouvez combiner différentes lentilles pour vous concentrer sur différentes choses, un peu comme une chaîne de propriétés ajustable dans la POO (où vous pouvez substituer des variables aux noms de propriété réels!)

Prismatic a récemment publié un blog sur l'utilisation de ce type de technique, entre autres choses, en JavaScript / ClojureScript, que vous devriez vérifier. Ils utilisent les curseurs (qu'ils comparent aux fermetures à glissière) pour définir l'état de la fenêtre:

Om restaure l'encapsulation et la modularité à l'aide de curseurs. Les curseurs fournissent des fenêtres pouvant être mises à jour dans des parties particulières de l'état de l'application (un peu comme des fermetures à glissière), permettant aux composants de ne prendre en référence que les parties pertinentes de l'état global et de les mettre à jour sans contexte.

IIRC, ils abordent également l’immuabilité en JavaScript dans cet article.


L'opération de conversation mentionnée aborde également l'utilisation de la fonction update-in pour limiter la portée d'une fonction peut mettre à jour vers un sous-arbre de la carte d'état. Je pense que personne n'a encore évoqué cela.
user7610

@ user7610 Bonne prise, je ne peux pas croire que j'ai oublié de mentionner celui-là - j'aime cette fonction ( assoc-inet al). Je suppose que je viens d'avoir Haskell sur le cerveau. Je me demande si quelqu'un en a fait un portage JavaScript? Les gens ne l'ont probablement pas abordé parce que (comme moi) ils n'ont pas regardé la conversation :)
paul

@paul en un sens parce que c'est disponible dans ClojureScript, mais je ne suis pas sûr que cela compte dans votre esprit. Cela pourrait exister dans PureScript et je pense qu’il existe au moins une bibliothèque qui fournit des structures de données immuables en JavaScript. J'espère qu'au moins l'un d'entre eux les possède, sinon il serait difficile de les utiliser.
Daniel Kaplan

@tieTYT J'avais pensé à une implémentation JS native lorsque j'ai fait ce commentaire, mais vous soulignez le point intéressant ClojureScript / PureScript. Je devrais regarder dans JS immuable et voir ce qui se passe, je n'ai pas travaillé avec cela auparavant.
paul

1

Que ce soit une bonne idée ou non dépendra vraiment de ce que vous faites avec l'état à l'intérieur de ces sous-processus. Si je comprends bien l’exemple de Clojure, les dictionnaires d’État renvoyés ne sont pas les mêmes dictionnaires d’état. Ils sont des copies, éventuellement avec ajouts et modifications, que (je suppose) que Clojure est capable de créer efficacement car La nature fonctionnelle de la langue en dépend. Les dictionnaires d'état d'origine de chaque fonction ne sont en aucun cas modifiés.

Si je comprends bien, vous êtes en train de modifier les objets d'état que vous passez dans vos fonctions javascript plutôt que de retourner une copie, ce qui signifie que vous faites quelque chose de très, très différent de ce que le code Clojure fait. Comme Mike Partridge l’a souligné, il s’agit en gros d’une action globale que vous transmettez et que vous quittez explicitement de fonctions sans raison réelle. À ce stade, je pense que cela vous fait simplement penser que vous faites quelque chose que vous n'êtes pas réellement.

Si vous faites explicitement des copies de l'état, que vous le modifiez, puis que vous retournez cette copie modifiée, continuez. Je ne suis pas sûr que ce soit nécessairement le meilleur moyen d'accomplir ce que vous essayez de faire en Javascript, mais c'est probablement "en gros plan" par rapport à ce que fait cet exemple de Clojure.


1
Au final, est-ce vraiment "très, très différent"? Dans l'exemple de Clojure, il écrase son ancien État avec le nouvel État. Oui, il n’ya pas de réelle mutation, l’identité est en train de changer. Mais dans son "bon" exemple, tel qu'il est écrit, il n'a aucun moyen d'obtenir la copie qui a été transmise au sous-processus-deux. L'identité de cette valeur a été écrasée. Par conséquent, je pense que la chose qui est "très, très différente" est en réalité juste un détail d'implémentation de langage. Au moins dans le contexte de ce que vous abordez.
Daniel Kaplan

2
L'exemple Clojure a deux conséquences: 1) le premier exemple dépend des fonctions appelées dans un certain ordre, et 2) les fonctions sont pures et n'ont donc aucun effet secondaire. Comme les fonctions du deuxième exemple sont pures et partagent la même signature, vous pouvez les réorganiser sans avoir à vous soucier des dépendances masquées de l'ordre dans lequel elles ont été appelées. Si vous modifiez l'état de vos fonctions, vous n'avez pas la même garantie. La mutation d'état signifie que votre version n'est pas aussi composable, ce qui était la raison originale du dictionnaire.
Evicatos

1
Vous allez devoir me montrer un exemple car, selon mon expérience, je peux déplacer les choses à volonté et cela a très peu d'effet. Juste pour me le prouver, j'ai déplacé deux appels de sous-processus aléatoires au milieu de ma update()fonction. J'ai déplacé l'un vers le haut et l'autre vers le bas. Tous mes tests étaient toujours réussis et lorsque je jouais à mon jeu, je ne remarquais aucun effet néfaste. Je sens mes fonctions sont tout aussi composable que l'exemple Clojure. Nous jetons tous les deux nos anciennes données après chaque étape.
Daniel Kaplan

1
Si vos tests réussissent sans que vous remarquiez d'effets néfastes, cela signifie que vous ne modifiez pas actuellement un état ayant des effets secondaires inattendus ailleurs. Puisque vos fonctions ne sont pas pures, vous n'avez aucune garantie que ce sera toujours le cas. Je pense que je dois être fondamentalement incompréhensible à propos de votre mise en œuvre si vous dites que vos fonctions ne sont pas pures mais que vous jetez vos anciennes données après chaque étape.
Evicatos

1
@Evicatos - Être pur et avoir la même signature ne signifie pas que l'ordre des fonctions n'a pas d'importance. Imaginez calculer un prix avec des rabais fixes et en pourcentage appliqués. (-> 10 (- 5) (/ 2))retourne 2.5. (-> 10 (/ 2) (- 5))renvoie 0.
Zak

1

Si vous avez un objet d'état global, parfois appelé "objet divin", qui est transmis à chaque processus, vous finissez par confondre un certain nombre de facteurs, qui augmentent tous le couplage tout en diminuant la cohésion. Ces facteurs ont tous un impact négatif sur la maintenabilité à long terme.

Couplage de trampes Ceci résulte du transfert de données à travers différentes méthodes qui n'ont pas besoin de presque toutes les données, afin de les acheminer à l'endroit où elles peuvent réellement être traitées. Ce type de couplage s'apparente à l'utilisation de données globales, mais peut être plus contenu. Le couplage de tramp est l'opposé de "besoin de savoir", qui est utilisé pour localiser les effets et pour contenir les dommages qu'un code erroné peut avoir sur l'ensemble du système.

Navigation dans les données Chaque sous-processus de votre exemple doit savoir comment obtenir exactement les données dont il a besoin, et doit pouvoir le traiter et éventuellement construire un nouvel objet d'état global. C’est la conséquence logique du couplage tramp; l'intégralité du contexte d'une donnée est nécessaire pour pouvoir fonctionner sur la donnée. Encore une fois, les connaissances non locales sont une mauvaise chose.

Si vous passez dans un "zipper", un "objectif" ou un "curseur", comme décrit dans le message de @paul, c'est une chose. Vous seriez en train de contenir l'accès et d'autoriser la fermeture à glissière, etc., à contrôler la lecture et l'écriture des données.

Violation de responsabilité unique Revendiquer chacun des "sous-processus-un", "sous-processus-deux" et "sous-processus-trois" n'a qu'une seule responsabilité, à savoir produire un nouvel objet d'état global avec les bonnes valeurs, est un réductionnisme flagrant. C'est tout à la fin, n'est-ce pas?

Ce que je veux dire ici, c'est qu'avoir toutes les principales composantes de votre jeu a les mêmes responsabilités, car votre jeu va à l'encontre de l'objectif de la délégation et de l'affacturage.

Impact des systèmes

L'impact majeur de votre conception est la faible maintenabilité. Le fait que vous puissiez garder le jeu entier dans votre tête signifie que vous êtes très probablement un excellent programmeur. J'ai conçu beaucoup de choses que je pourrais garder en tête pendant tout le projet. Ce n’est cependant pas le but de l’ingénierie des systèmes. Le but est de créer un système qui fonctionne pour quelque chose de plus grand qu'une personne peut garder dans sa tête en même temps .

Ajouter un autre programmateur, ou deux, ou huit, fera que votre système se désagrégera presque immédiatement.

  1. La courbe d’apprentissage des objets divins est plate (c’est-à-dire qu’il faut beaucoup de temps pour devenir compétent en eux). Chaque programmeur supplémentaire devra apprendre tout ce que vous savez et le garder en tête. Vous ne pourrez jamais engager des programmeurs meilleurs que vous, en supposant que vous puissiez les payer suffisamment pour qu'ils souffrent en maintenant un objet divin gigantesque.
  2. L'approvisionnement de test, dans votre description, concerne uniquement la zone blanche. Vous devez connaître tous les détails de l'objet divin, ainsi que du module à tester, pour pouvoir configurer un test, l'exécuter et déterminer a) qu'il a bien agi et b) qu'il n'a effectué aucun test. de 10.000 mauvaises choses. Les chances sont grandement empilés contre vous.
  3. L'ajout d'une nouvelle fonctionnalité nécessite que vous: a) parcouriez chaque sous-processus et déterminez si la fonctionnalité affecte un code qu'il contient, et inversement, b) parcourez votre état global et concevez les ajouts, et c) effectuez chaque test unitaire et modifiez-le vérifier qu'aucune unité testée n'a affecté la nouvelle fonctionnalité de manière défavorable .

finalement

  1. Les objets divinement mutables ont été la malédiction de mon existence de programmeur, certains de mes actes personnels et d’autres dans lesquels je me suis retrouvé pris au piège.
  2. La monade d'état n'échelle pas. L'état croît de manière exponentielle, avec toutes les implications pour les tests et les opérations que cela implique. La manière dont nous contrôlons l'état dans les systèmes modernes se fait par délégation (division des responsabilités) et portée (limitation de l'accès à un sous-ensemble de l'état). L'approche "tout est une carte" est l'exact opposé de l'état de contrôle.
En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.