web-dev-qa-db-fra.com

"Tout est une carte", est-ce que je fais ça correctement?

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

  • Clojure est une programmation fonctionnellement idiomatique
  • La plupart des états sont immuables

J'ai pris 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-3 arguments, vous pouvez plaider pour la transformer en carte et simplement passer une carte. Il y a beaucoup d'avantages à cela:
    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 traversent simplement, ils n'interfèrent pas.
    3. Vous n'avez pas besoin de définir un schéma
  2. Contrairement à la transmission d'un objet, aucune donnée ne se cache. Mais, il fait valoir que la dissimulation de données peut causer des problèmes et est surfaite:
    1. Performance
    2. Facilité de mise en œuvre
    3. Dès que vous communiquez via le réseau ou entre les processus, vous devez de toute façon avoir les deux parties d'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 en: "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 quel est le résultat en supprimant une seule étape
    4. Chaque sous-processus pourrait ajouter plus d'informations sur l'état. Si le sous-processus un doit communiquer quelque chose au sous-processus trois, c'est aussi simple que d'ajouter une clé/valeur.
    5. Pas de passe-partout pour extraire les données dont vous avez besoin de l'état juste pour que vous puissiez les sauvegarder. Passez simplement l'état entier et laissez le sous-processus attribuer ce dont il a besoin.

Maintenant, revenons à ma situation: j'ai pris cette leçon et l'ai appliquée à mon jeu. Autrement dit, presque toutes mes fonctions de haut niveau prennent et retournent un objet gameState. 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)
  ...

Ce que je suis ici pour vous demander, c'est ai-je créé une abomination qui a perverti une idée qui n'est pratique que dans un langage de programmation fonctionnel? JavaScript n'est pas ' t idiomatiquement fonctionnel (bien qu'il puisse être écrit de cette façon) et il est vraiment 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 formulée? Il est rare que mes fonctions soient pures (je veux dire par là qu'elles modifient souvent le gameState. Je n'ai pas d'autres effets secondaires compliqués à part ça). Ces idées s'effondrent-elles si vous ne disposez pas de données immuables?

Je crains qu'un jour je ne me réveille et que je réalise que toute cette conception est une imposture et que je viens vraiment d'implémenter le anti-modèle Big Ball Of Mud .


Honnêtement, je travaille sur ce code depuis des mois et c'est génial. Je sens que j'obtiens tous les avantages qu'il prétend. Mon code est super facile pour moi pour raisonner. Mais je suis une équipe d'un homme donc j'ai la malédiction de la connaissance.

Mise à jour

J'ai codé 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 ceci d'une manière propre?" entre en jeu. Sinon, j'aurais vraiment du mal. Jusqu'à présent, je ne me bats pas du tout.

Je comprends comment une autre paire d'yeux serait nécessaire pour valider sa maintenabilité. Tout ce que je peux dire, c'est que je tiens avant tout à la maintenabilité. Je suis toujours l'évangéliste le plus fort pour le code propre, peu importe où je travaille.

Je veux répondre directement à ceux qui ont déjà une mauvaise expérience personnelle avec cette façon de coder. Je ne le savais pas alors, mais je pense que nous parlons vraiment de deux façons 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 vécu. Quand quelqu'un a une mauvaise expérience personnelle avec "Tout est une carte", il parle de la difficulté de la maintenir car:

  1. Vous ne connaissez jamais la structure de la carte requise par la fonction
  2. N'importe quelle fonction peut muter l'entrée d'une manière inattendue. Vous devez regarder partout dans 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 sûr, cette structure croît généralement avec le temps. Voilà pourquoi...

Il y a un endroit où chercher l'implémentation de référence (ie: le schéma). Cette implémentation de référence est un code utilisé par le jeu pour ne pas être obsolète.

En ce qui concerne le deuxième point, je n'ajoute/supprime 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 également 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 :)

69
Daniel Kaplan

J'ai déjà pris en charge une application où "tout est une carte" auparavant. C'est une terrible idée. VEUILLEZ ne pas le faire!

Lorsque vous spécifiez les arguments passés à la fonction, cela permet de savoir très facilement les valeurs dont la fonction a besoin. Cela évite de transmettre des données superflues à la fonction qui distrait simplement le programmeur - chaque valeur transmise implique qu'elle est nécessaire, et cela oblige le programmeur prenant en charge votre code à comprendre pourquoi les données sont nécessaires.

En revanche, si vous passez tout sous forme de carte, le programmeur prenant en charge votre application devra comprendre pleinement 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 passée à la fonction actuelle afin de passer 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 - en résumant les problèmes pour que vous n'ayez pas à y penser! Imaginez maintenant 5 appels profonds et 5 appels larges 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 une douleur. Les fonctions appelées ne doivent jamais remplacer la valeur de retour de l'autre - à moins que vous ne connaissiez la fonctionnalité de tout et que la valeur de mappage d'entrée X doive être remplacée pour le prochain appel de fonction. Et la fonction actuelle doit modifier la carte pour renvoyer sa valeur, qui doit parfois écraser la valeur précédente et parfois pas.

modifier - exemple

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

L'ensemble de fonctions suivant lancerait la logique métier. Chaque fonction prendrait la carte, supprimerait certaines données, modifierait certaines données, opérerait sur les données dans la carte et mettrait le résultat dans la carte, etc. Les fonctions suivantes s'attendraient à des résultats des fonctions précédentes dans la carte. Afin de corriger un bogue dans une fonction ultérieure, vous avez dû enquêter sur toutes les fonctions antérieures ainsi que sur l'appelant pour déterminer partout 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 la façon dont la requête était exécutée. Si 'justcount' était une clé, alors la requête serait 'count select foo from bar'. L'une des fonctions précédemment appelées peut ê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 remonteraient jusqu'à l'appelant (logique métier) qui vérifierait la carte pour savoir quoi faire. Une partie de cela proviendrait d'éléments qui ont été 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ù il venait était de trouver le code qui l'ajoutait. Et l'autre emplacement qui peut également l'ajouter.

Le code était effectivement un gâchis monolithique, que vous deviez comprendre dans son intégralité pour savoir d'où venait une seule entrée sur la carte.

42
atk

Personnellement, je ne recommanderais pas ce modèle dans l'un ou l'autre paradigme. Cela facilite la rédaction initiale au détriment de la difficulté à raisonner plus tard.

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

  • Quels champs de state faut-il?
  • 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 font les objets.

28
Karl Bielefeldt

Ce que vous semblez faire, c'est effectivement une monade d'État manuelle; ce que je ferais est de construire un combinateur de liaison (simplifié) et de ré-exprimer les connexions entre vos étapes logiques en utilisant cela:

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 stateBind pour construire les différents sous-processus à partir de sous-processus et continuer dans un arbre 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 aux monades en général en JavaScript, voir cet article de blog .

12
Ptharien's Flame

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

Fogus: Donc, une fois les complexités accidentelles réduites, comment Clojure peut-il aider à résoudre le problème actuel? 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 pour la réutilisation?

Hickey: Je discuterais de OO et de la réutilisation, mais certainement, être capable de réutiliser les choses rend le problème à portée de main plus simple, car vous ne réinventez pas les roues au lieu de construire des voitures. Et Clojure étant sur la machine virtuelle Java rend beaucoup de roues — bibliothèques — disponibles. Qu'est-ce qui rend une bibliothèque réutilisable? Elle devrait faire une ou plusieurs choses bien, être relativement autosuffisante , et ne demande que peu de code client. Rien de tout cela ne tombe hors de OO, et toutes les bibliothèques Java Java répondent à ces critères, mais beaucoup le font.

Lorsque nous descendons au niveau de l'algorithme, je pense que OO peut sérieusement contrecarrer la réutilisation. En particulier, l'utilisation d'objets pour représenter de simples données d'information est presque criminelle dans sa génération de -les micro-langages d'information, c'est-à-dire les méthodes de classe, contre des méthodes beaucoup plus puissantes, déclaratives et génériques comme l'algèbre relationnelle. Inventer une classe avec sa propre interface pour contenir une information, c'est comme inventer un nouveau langage pour écrire chaque nouvelle. Ceci est anti-réutilisation et, je pense, se traduit par 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 dans différents 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. Le fait d'avoir un ensemble de fonctions ouvert et large sur un ensemble ouvert et petit d'abstractions extensibles est la clé de la réutilisation algorithmique et de l'interopérabilité des bibliothèques. La grande majorité des fonctions Clojure sont définies en fonction de ces abstractions, et les auteurs de bibliothèques conçoivent également leurs formats d'entrée et de sortie en fonction d'eux, réalisant une interopérabilité énorme entre les bibliothèques développées indépendamment. C'est en contraste frappant avec les DOM et d'autres choses de ce genre 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, comme dans Java.io.

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

Tout au long de ce livre, j'opterai pour l'utilisation de types de données minimaux pour représenter les abstractions, des ensembles aux arbres en passant par les tableaux. En JavaScript, cependant, bien que ses types d'objets soient extrêmement puissants, les outils fournis pour travailler avec eux ne sont pas entièrement fonctionnels. Au lieu de cela, le modèle d'utilisation plus large associé aux objets JavaScript consiste à attacher des méthodes à des fins de répartition polymorphe. Heureusement, vous pouvez également afficher un objet JavaScript sans nom (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 Livre ou une instance d'un type Employé sont setTitle ou getSSN, alors nous avons verrouillé nos données dans des micro-langues par élément d'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 sans la machinerie prototype, sont des véhicules idéaux pour la modélisation de données associatives, 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 mappes de données soient rares dans JavaScript lui-même, heureusement Underscore fournit une multitude d'opérations utiles. Les fonctions les plus simples à saisir sont _.keys, _.values ​​et _.pluck. Les valeurs _.keys et _.values ​​sont nommées en fonction de leur fonctionnalité, qui consiste à prendre un objet et à renvoyer un tableau de ses clés ou valeurs ...

11
pooya72

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 fait de très bons points et j'aimerais les aborder. Je veux d'abord dire que ses points sont excellents.

Puisqu'il a mentionné que ce n'est pas un bon modèle même dans la programmation fonctionnelle, je considérerai JavaScript et/ou Clojure dans mes réponses. Une similitude extrêmement importante entre ces deux langues est qu'elles sont typées dynamiquement. Je serais plus d'accord avec ses points si je l'implémentais dans un langage typé comme Java ou Haskell. Mais, je vais considérer l'alternative à "Tout est une carte" "modèle pour être un OOP design en JavaScript et pas dans un langage typé statiquement (j'espère que je ne configure pas d'argument paille en faisant cela, s'il vous plaît faites le moi savoir).

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

  • De quels champs d'État a-t-il besoin?

  • Quels champs modifie-t-il?

  • Quels champs sont inchangés?

Dans une langue typée dynamiquement, 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 le savez-vous? La seule façon que je sache est de

  1. lire la documentation
  2. regardez le corps de la fonction
  3. regardez les tests
  4. devinez et exécutez le programme pour voir s'il fonctionne.

Je ne pense pas que le modèle "Tout est une carte" fasse une différence ici. Ce sont encore les seuls moyens que je connais pour répondre à ces questions.

Gardez également à l'esprit qu'en JavaScript et dans la plupart des langages de programmation impératifs, tout function peut exiger, modifier et ignorer tout état auquel il peut accéder et la signature ne fait aucune différence: la fonction/méthode pourrait faire quelque chose avec l'état global ou avec un singleton. Signatures souvent mensonge.

Je n'essaie pas de mettre en place une fausse dichotomie entre "Tout est une carte" et mal conç OO code. J'essaie juste de souligner que d'avoir les signatures qui prennent des paramètres à grain moins/plus fin/grossier ne garantissent pas que vous savez isoler, configurer et appeler une fonction.

Mais, si vous me permettez d'utiliser cette fausse dichotomie: Comparé à l'écriture de JavaScript de la manière traditionnelle OOP, "Tout est une carte" semble mieux. Dans la traditionnelle OOP façon, la fonction peut exiger, modifier ou ignorer l'état que vous passez o l'état que vous ne passez pas. Avec ce modèle "Tout est une carte", vous avez seulement besoin, modifier ou ignorer l'état que vous transmettez.

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

Dans mon code, oui. Voir mon deuxième commentaire à la réponse de @ Evicatos. Peut-être que c'est seulement parce que je fais un jeu, je ne peux pas le dire. Dans un jeu qui met à jour 60 fois par seconde, peu importe si dead guys drop loot puis good guys pick up loot ou vice versa. Chaque fonction fait toujours exactement ce qu'elle est censée faire, quel que soit l'ordre dans lequel elle est exécutée. Les mêmes données y sont simplement introduites lors d'un autre appel update si vous échangez la commande. Si tu as good guys pick up loot puis dead guys drop loot, les gentils ramasseront le butin dans le prochain update et ce n'est pas grave. Un humain ne pourra pas remarquer la différence.

C'est du moins mon expérience générale. Je me sens vraiment vulnérable en l'admettant publiquement. Peut-être que considérer cela comme correct est une très, très mauvaise chose à faire. Faites-moi savoir si j'ai fait une terrible erreur ici. Mais, si je l'ai, il est extrêmement facile de réorganiser les fonctions afin que l'ordre soit dead guys drop loot puis good guys pick up loot encore. Cela prendra moins de temps que le temps qu'il a fallu pour écrire ce paragraphe: P

Peut-être pensez-vous que "les gars morts devraient laisser tomber le butin en premier. Il serait préférable que votre code applique cet ordre". Mais pourquoi les ennemis devraient-ils abandonner le butin avant de pouvoir récupérer le butin? Pour moi, cela n'a pas de sens. Peut-être que le butin a été abandonné il y a 100 updates. Il n'est pas nécessaire de vérifier si un méchant arbitraire doit ramasser du butin déjà sur le terrain. 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 la POO traditionnelle, la façon naturelle et naïve de penser est de faire du dead guys drop loot retourne un Loot objet que je dois passer dans le good guys pick up loot. Je ne pourrais pas réorganiser ces opérations car la première renvoie l'entrée de la seconde.

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

Les objets ont un état et il est idiomatique de muter l'état, ce qui fait disparaître son histoire ... à moins que vous n'écriviez manuellement du code pour en garder la trace. De quelle manière le suivi de l'état "fait-il"?

De plus, les avantages de l'immuabilité diminuent à mesure que vos objets immuables grossissent.

C'est vrai, comme je l'ai dit, "Il est rare qu'une de mes fonctions soit pure". Ils toujours seulement opèrent sur leurs paramètres, mais ils mutent leurs paramètres. C'est un compromis que je pensais devoir faire lors de l'application de ce modèle à JavaScript.

9
Daniel Kaplan

J'ai trouvé que mon code a tendance à se structurer 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 sont pures.

Je n'avais pas l'intention de 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 unitaire. Les plus grands avec des cartes pénètrent davantage dans la zone de test "d'intégration" car ils 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. Il indique très clairement ce que la fonction attend et peut gérer les cartes de manière assez 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 en savoir plus.

:: MISE À JOUR ::

L'enregistrement vidéo de Clojure/West par Stuart Sierra "Clojure in the Large" aborde également ce sujet. Comme l'OP, il contrôle les effets secondaires dans le cadre de la carte afin que les tests deviennent beaucoup plus faciles. Il a également un article de blog décrivant son flux de travail Clojure actuel qui semble pertinent.

8
alanning

L'argument principal auquel je peux penser contre cette pratique est qu'il est très difficile de dire de quelles données 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 tout appel de fonction imbriqué - pour l'appeler.

Plus j'y pense, plus votre objet gameState sent comme un global. Si c'est ainsi qu'il est utilisé, pourquoi le faire circuler?

5
Mike Partridge

Il y a un nom plus approprié pour ce que vous faites que grosse boule de boue . Ce que vous faites s'appelle le modèle objet Die . Cela ne semble pas de cette façon à première vue, 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 c'est certainement conforme à la méthode Clojure. Un des objectifs de Clojure est de supprimer ce que Rich Hickey appelle "complexité incidente". Plusieurs objets communicants sont certainement plus complexes qu'un seul objet. Si vous divisez une fonctionnalité en plusieurs objets, vous devez soudain vous soucier de la communication et de la coordination et du partage des responsabilités. Ce sont des complications qui ne sont qu'accessoires à votre objectif initial d'écrire un programme. Vous devriez voir le discours de Rich Hickey Simple made easy . Je pense que c'est une très bonne idée.

3
user7610

D'après ce que (peu) j'ai vu, l'utilisation de cartes ou d'autres structures imbriquées pour créer un seul objet d'état immuable global comme celui-ci est assez courante dans les langages fonctionnels, au moins les purs, en particulier lorsque vous utilisez la monade d'état comme @ Ptharien'sFlame mentioend .

Deux obstacles à l'utilisation efficace de ce que j'ai vu/lu (et d'autres réponses ici ont mentionné) sont:

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

Il existe différentes techniques/modèles communs qui peuvent aider à atténuer ces problèmes:

La première est fermetures à glissière : celles-ci permettent de traverser et de muter un état profond dans une hiérarchie imbriquée immuable.

Un autre est Objectifs : ceux-ci vous permettent de vous concentrer dans la structure sur un emplacement spécifique et de lire/muter la valeur là-bas. 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 OOP (où vous pouvez remplacer les variables par des noms de propriétés réels!)

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

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 les fermetures à glissière), permettant aux composants de prendre des références uniquement aux parties pertinentes de l'état global et de les mettre à jour sans contexte.

IIRC, ils abordent également l'immuabilité en JavaScript dans ce post.

2
paul

J'ai juste fait face à ce sujet un peu 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 comme mots clés, et j'ai décidé de représenter une carte comme une carte comme

{ :face :queen :suit :hearts }

J'aurais tout 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 de mémoire/performances, donc je vais juste avec des cartes pour l'instant.

Au cas où je changerais d'avis plus tard, j'ai décidé que la plupart des parties de mon programme devraient passer par une "interface" pour accéder aux morceaux d'une carte, afin que les détails de l'implémentation soient contrôlés et caché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 être en mesure de gâcher.

Dans mon programme, une carte ne sera probablement jamais qu'une carte à 2 valeurs. Dans la question, l'état du jeu entier 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 y ait de faute à soulever à propos de l'utilisation d'une carte. Dans un langage impératif d'objet, je pourrais tout aussi bien avoir un seul gros 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 mal à 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 devez vous méfier du moment de le diviser en morceaux plus petits. Je dis donc que l'utilisation de cartes est parfaitement bien, tant que vous utilisez le même soin que vous utiliseriez avec des objets.

2
xen

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 que les dictionnaires d'état transmis. Ce sont des copies, éventuellement avec des ajouts et des modifications, que (je suppose) Clojure est capable de créer efficacement parce que le 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 modifiant les objets d'état que vous passez dans vos fonctions javascript plutôt que de renvoyer une copie, ce qui signifie que vous faites quelque chose de très, très, différent de ce que fait le code Clojure . Comme l'a souligné Mike Partridge, il s'agit essentiellement d'un global que vous passez et renvoyez explicitement des fonctions sans raison réelle. À ce stade, je pense que cela vous fait simplement penser vous faites quelque chose que vous n'êtes pas réellement.

Si vous effectuez réellement des copies explicites de l'état, le modifiez, puis renvoyez cette copie modifiée, continuez. Je ne suis pas sûr que ce soit nécessairement la meilleure façon d'accomplir ce que vous essayez de faire en Javascript, mais c'est probablement "proche" de ce que fait l'exemple Clojure.

1
Evicatos

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.

Tramp Coupling Cela résulte du passage de données à travers diverses méthodes qui n'ont pas besoin de presque toutes les données, afin de les amener à l'endroit qui peut réellement les traiter. Ce type de couplage est similaire à l'utilisation de données globales, mais peut être plus contenu. Le couplage de tramp est l'opposé du "besoin de savoir", qui est utilisé pour localiser les effets et pour contenir les dommages qu'une partie de code erronée peut avoir sur l'ensemble du système.

Navigation des données Chaque sous-processus de votre exemple doit savoir comment obtenir exactement les données dont il a besoin, et il doit pouvoir les traiter et peut-être construire un nouvel objet d'état global. C'est la conséquence logique du couplage vagabond; le contexte entier d'une donnée est nécessaire pour opérer sur la donnée. Encore une fois, les connaissances non locales sont une mauvaise chose.

Si vous passiez dans une "fermeture éclair", un "objectif" ou un "curseur", comme décrit dans le post de @paul, c'est une chose. Vous devez contenir l'accès et permettre à la fermeture à glissière, etc., de contrôler la lecture et l'écriture des données.

Violation de responsabilité unique Revendiquer que 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 dedans , est un réductionnisme flagrant. Ce ne sont que des morceaux à la fin, n'est-ce pas?

Mon point ici est que le fait d'avoir toutes les principales composantes de votre jeu a les mêmes responsabilités que votre jeu va à l'encontre de l'objectif de délégation et d'affacturage.

Impact des systèmes

L'impact majeur de votre conception est sa faible maintenabilité. Le fait que vous puissiez garder le jeu entier dans votre tête indique que vous êtes très probablement un excellent programmeur. Il y a beaucoup de choses que j'ai conçues que je pourrais garder dans ma tête pendant tout le projet. Ce n'est pas le but de l'ingénierie des systèmes. Le but est de faire un système qui peut fonctionner pour quelque chose de plus grand qu'une personne peut garder dans sa tête en même temps.

L'ajout d'un autre programmeur, ou deux, ou huit, entraînera la chute de votre système 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 dans sa tête. Vous ne pourrez embaucher que des programmeurs meilleurs que vous, en supposant que vous puissiez les payer suffisamment pour souffrir en maintenant un énorme objet divin.
  2. Le provisionnement des tests, dans votre description, est une boîte blanche uniquement. Vous devez connaître tous les détails de l'objet divin, ainsi que le module sous test, afin de mettre en place un test, de l'exécuter et de déterminer que a) il a fait la bonne chose, et b) qu'il n'a fait aucune de 10 000 mauvaises choses. Les chances sont fortement contre vous.
  3. L'ajout d'une nouvelle fonctionnalité nécessite que vous a) parcouriez chaque sous-processus et déterminiez si la fonctionnalité affecte un code et vice versa, b) parcourez votre état global et concevez les ajouts, et c) parcourez chaque unité testez-le et modifiez-le pour vérifier qu'aucune unité testée n'a affecté la nouvelle fonctionnalité.

Enfin

  1. Les objets divins mutables ont été la malédiction de mon existence de programmation, certains de ma propre initiative et d'autres dans lesquels j'ai été piégé.
  2. La monade d'État n'est pas à l'échelle. L'état croît de façon exponentielle, avec toutes les implications pour les tests et les opérations que cela implique. La façon dont nous contrôlons l'état dans les systèmes modernes est la délégation (partage des responsabilités) et la portée (restreindre l'accès à seulement un sous-ensemble de l'État). L'approche "tout est une carte" est exactement le contraire de l'état de contrôle.
1
BobDalgleish