Interface séparée pour les méthodes de mutation

11

J'ai travaillé sur la refactorisation de code, et je pense que j'ai peut-être fait le premier pas dans le terrier du lapin. J'écris l'exemple en Java, mais je suppose que cela pourrait être agnostique.

J'ai une interface Foodéfinie comme

public interface Foo {

    int getX();

    int getY();

    int getZ();
}

Et une mise en œuvre comme

public final class DefaultFoo implements Foo {

    public DefaultFoo(int x, int y, int z) {
        this.x = x;
        this.y = y;
        this.z = z;
    }

    public int getX() {
        return x;
    }

    public int getY() {
        return y;
    }

    public int getZ() {
        return z;
    }

    private final int x;
    private final int y;
    private final int z;
}

J'ai également une interface MutableFooqui fournit des mutateurs correspondants

/**
 * This class extends Foo, because a 'write-only' instance should not
 * be possible and a bit counter-intuitive.
 */
public interface MutableFoo extends Foo {

    void setX(int newX);

    void setY(int newY);

    void setZ(int newZ);
}

Il existe quelques implémentations MutableFooqui pourraient exister (je ne les ai pas encore implémentées). L'un d'eux est

public final class DefaultMutableFoo implements MutableFoo {

    /**
     * A DefaultMutableFoo is not conceptually constructed 
     * without all values being set.
     */
    public DefaultMutableFoo(int x, int y, int z) {
        this.x = x;
        this.y = y;
        this.z = z;
    }

    public int getX() {
        return x;
    }

    public void setX(int newX) {
        this.x = newX;
    }

    public int getY() {
        return y;
    }

    public void setY(int newY) {
        this.y = newY;
    }

    public int getZ() {
        return z;
    }

    public void setZ(int newZ) {
        this.z = newZ;
    }

    private int x;
    private int y;
    private int z;
}

La raison pour laquelle je les ai divisés est qu'il est également probable que chacun soit utilisé. Autrement dit, il est tout aussi probable que quelqu'un utilisant ces classes veuille une instance immuable, comme il le voudra une instance mutable.

Le principal cas d'utilisation que j'ai est une interface appelée StatSetqui représente certains détails de combat pour un jeu (points de vie, attaque, défense). Cependant, les statistiques "effectives", ou les statistiques réelles, sont le résultat des statistiques de base, qui ne peuvent jamais être modifiées, et des statistiques entraînées, qui peuvent être augmentées. Ces deux sont liés par

/**
 * The EffectiveStats can never be modified independently of either the baseStats
 * or trained stats. As such, this StatSet must never provide mutators.
 */
public StatSet calculateEffectiveStats() {
    int effectiveHitpoints =
        baseStats.getHitpoints() + (trainedStats.getHitpoints() / 4);
    int effectiveAttack = 
        baseStats.getAttack() + (trainedStats.getAttack() / 4);
    int effectiveDefense = 
        baseStats.getDefense() + (trainedStats.getDefense() / 4);

    return StatSetFactory.createImmutableStatSet(effectiveHitpoints, effectiveAttack, effectiveDefense);
}

les Stats formés sont augmentés après chaque bataille comme

public void grantExperience() {
    int hitpointsReward = 0;
    int attackReward = 0;
    int defenseReward = 0;

    final StatSet enemyStats = enemy.getEffectiveStats();
    final StatSet currentStats = player.getEffectiveStats();
    if (enemyStats.getHitpoints() >= currentStats.getHitpoints()) {
        hitpointsReward++;
    }
    if (enemyStats.getAttack() >= currentStats.getAttack()) {
        attackReward++;
    }
    if (enemyStats.getDefense() >= currentStats.getDefense()) {
        defenseReward++;
    }

    final MutableStatSet trainedStats = player.getTrainedStats();
    trainedStats.increaseHitpoints(hitpointsReward);
    trainedStats.increaseAttack(attackReward);
    trainedStats.increaseDefense(defenseReward);
}

mais ils ne sont pas augmentés juste après la bataille. L'utilisation de certains objets, l'emploi de certaines tactiques, une utilisation intelligente du champ de bataille peuvent tous offrir une expérience différente.

Maintenant pour mes questions:

  1. Existe-t-il un nom pour diviser les interfaces par les accesseurs et les mutateurs en interfaces distinctes?
  2. Est-ce que les séparer de cette manière est la «bonne» approche s'ils sont également susceptibles d'être utilisés, ou existe-t-il un modèle différent et plus accepté que je devrais utiliser à la place (par exemple, Foo foo = FooFactory.createImmutableFoo();qui pourrait revenir DefaultFooou qui DefaultMutableFooest caché parce qu'il createImmutableFoorevient Foo)?
  3. Y a-t-il des inconvénients immédiatement prévisibles à utiliser ce modèle, sauf pour compliquer la hiérarchie de l'interface?

La raison pour laquelle j'ai commencé à la concevoir de cette façon est parce que je pense que tous les implémenteurs d'une interface devraient adhérer à l'interface la plus simple possible et ne rien fournir de plus. En ajoutant des setters à l'interface, les statistiques effectives peuvent désormais être modifiées indépendamment de ses parties.

Créer une nouvelle classe pour le EffectiveStatSetn'a pas beaucoup de sens car nous n'étendons pas la fonctionnalité en aucune façon. Nous pourrions changer la mise en œuvre, et faire EffectiveStatSetun composite de deux différents StatSets, mais je pense que ce n'est pas la bonne solution;

public class EffectiveStatSet implements StatSet {

    public EffectiveStatSet(StatSet baseStats, StatSet trainedStats) {
        // ...
    }

    public int getHitpoints() {
        return baseStats.getHitpoints() + (trainedStats.getHitpoints() / 4);
    }
}
Zymus
la source
1
Je pense que le problème est que votre objet est modifiable même si vous y accédez via son interface "immuable". Mieux vaut avoir un objet mutable avec Player.TrainStats ()
Ewan
@gnat avez-vous des références où je peux en savoir plus à ce sujet? Sur la base de la question modifiée, je ne sais pas comment ni où je pourrais l'appliquer.
Zymus
@gnat: vos liens (et ses liens au deuxième et au troisième degré) sont très utiles, mais les phrases de sagesse accrocheuses ne sont d'aucune utilité. Il n'attire que les malentendus et le mépris.
rwong

Réponses:

6

Il me semble que vous avez une solution à la recherche d'un problème.

Existe-t-il un nom pour diviser les interfaces par les accesseurs et les mutateurs en interfaces distinctes?

Cela peut être un peu provocant, mais en fait, je l'appellerais «choses trop conçues» ou «choses trop compliquées». En offrant une variante mutable et immuable de la même classe, vous offrez deux solutions fonctionnellement équivalentes pour le même problème, qui ne diffèrent que par des aspects non fonctionnels comme le comportement de performance, l'API et la sécurité contre les effets secondaires. Je suppose que c'est parce que vous avez peur de prendre une décision que vous préférez, ou parce que vous essayez d'implémenter la fonctionnalité "const" de C ++ en C #. Je suppose que dans 99% des cas, cela ne fera pas une grande différence si l'utilisateur choisit la variante mutable ou immuable, il peut résoudre ses problèmes avec l'un ou l'autre. Ainsi, "probabilité d'une classe à utiliser"

L'exception est lorsque vous concevez un nouveau langage de programmation ou un cadre polyvalent qui sera utilisé par une dizaine de milliers de programmeurs ou plus. Ensuite, il peut effectivement évoluer mieux lorsque vous proposez des variantes immuables et modifiables de types de données à usage général. Mais c'est une situation où vous aurez des milliers de scénarios d'utilisation différents - ce qui n'est probablement pas le problème auquel vous êtes confronté, je suppose?

Est-ce que les diviser de cette manière est la «bonne» approche s'ils sont également susceptibles d'être utilisés ou existe-t-il un modèle différent et plus accepté

Le "modèle plus accepté" est appelé KISS - restez simple et stupide. Prenez une décision pour ou contre la mutabilité de la classe / interface spécifique dans votre bibliothèque. Par exemple, si votre "StatSet" a une douzaine d'attributs ou plus, et qu'ils sont pour la plupart modifiés individuellement, je préférerais la variante mutable et je ne modifierais pas les statistiques de base là où elles ne devraient pas être modifiées. Pour quelque chose comme une Fooclasse avec des attributs X, Y, Z (un vecteur tridimensionnel), je préférerais probablement la variante immuable.

Y a-t-il des inconvénients immédiatement prévisibles à utiliser ce modèle, sauf pour compliquer la hiérarchie de l'interface?

Les conceptions trop complexes rendent les logiciels plus difficiles à tester, plus difficiles à maintenir, plus difficiles à faire évoluer.

Doc Brown
la source
1
Je pense que vous voulez dire "KISS - Keep It Simple, Stupid"
Epicblood
2

Existe-t-il un nom pour diviser les interfaces par les accesseurs et les mutateurs en interfaces distinctes?

Il pourrait y avoir un nom pour cela si cette séparation est utile et fournit un avantage que je ne vois pas . Si les séparations n'apportent aucun avantage, les deux autres questions n'ont aucun sens.

Pouvez-vous nous dire tout cas d'utilisation commerciale où les deux interfaces séparées nous offrent des avantages ou cette question est-elle un problème académique (YAGNI)?

Je pense à une boutique avec un chariot modifiable (vous pouvez y mettre plus d'articles) qui peut devenir une commande où les articles ne peuvent plus être modifiés par le client. Le statut de la commande est toujours modifiable.

Les implémentations que j'ai vues ne séparent pas les interfaces

  • il n'y a pas d'interface ReadOnlyList en java

La version ReadOnly utilise le "WritableInterface" et lève une exception si une méthode d'écriture est utilisée

k3b
la source
J'ai modifié l'OP pour expliquer le cas d'utilisation principal.
Zymus
2
"il n'y a pas d'interface ReadOnlyList en java" - franchement, il devrait y en avoir. Si vous avez un morceau de code qui accepte un List <T> comme paramètre, vous ne pouvez pas facilement dire s'il fonctionnera avec une liste non modifiable. Code qui renvoie des listes, il n'y a aucun moyen de savoir si vous pouvez les modifier en toute sécurité. La seule option consiste soit à s'appuyer sur une documentation potentiellement incomplète ou inexacte, soit à faire une copie défensive au cas où. Un type de collection en lecture seule rendrait cela beaucoup plus simple.
Jules
@Jules: en effet, la méthode Java semble être tout ce qui précède: pour s'assurer que la documentation est complète et précise, ainsi que pour faire une copie défensive au cas où. Il s'adapte sûrement aux très grands projets d'entreprise.
rwong
1

La séparation d'une interface de collecte mutable et d'une interface de collecte en lecture seule est un exemple de principe de ségrégation d'interface. Je ne pense pas qu'il y ait de noms spéciaux pour chaque application de principe.

Notez quelques mots ici: "contrat en lecture seule" et "collecte".

Un contrat en lecture seule signifie que la classe Adonne à la classe Bun accès en lecture seule, mais n'implique pas que l'objet de collection sous-jacent est réellement immuable. Immuable signifie qu'il ne changera jamais à l'avenir, quel que soit l'agent. Un contrat en lecture seule indique uniquement que le destinataire n'est pas autorisé à le modifier; quelqu'un d'autre (en particulier, la classe A) est autorisé à le modifier.

Pour rendre un objet immuable, il doit être vraiment immuable - il doit refuser les tentatives de modification de ses données quel que soit l'agent qui le demande.

Le motif est très probablement observé sur des objets qui représentent une collection de données - listes, séquences, fichiers (flux), etc.


Le mot «immuable» devient une mode, mais le concept n'est pas nouveau. Et il existe de nombreuses façons d'utiliser l'immuabilité, ainsi que de nombreuses façons d'obtenir une meilleure conception en utilisant autre chose (c'est-à-dire ses concurrents).


Voici mon approche (non basée sur l'immuabilité).

  1. Définissez un DTO (objet de transfert de données), également appelé tuple de valeur.
    • Ce DTO contiendra les trois champs: hitpoints, attack, defense.
    • Juste des champs: public, tout le monde peut écrire, aucune protection.
    • Cependant, un DTO doit être un objet jetable: si la classe Adoit passer un DTO à la classe B, il en fait une copie et passe la copie à la place. Ainsi, vous Bpouvez utiliser le DTO comme bon vous semble (en y écrivant), sans affecter le DTO qui s'y accroche A.
  2. La grantExperiencefonction à décomposer en deux:
    • calculateNewStats
    • increaseStats
  3. calculateNewStats prendra les entrées de deux DTO, l'un représentant les statistiques du joueur et l'autre représentant les statistiques de l'ennemi, et effectuera les calculs.
    • Pour l'entrée, l'appelant doit choisir parmi les statistiques de base, formées ou efficaces, selon vos besoins.
    • Le résultat sera une nouvelle DTO où chaque champ ( hitpoints, attack, defense) stocke le montant à incrémentée pour cette capacité.
    • Le nouveau DTO "montant à incrémenter" ne concerne pas la limite supérieure (plafond maximum imposé) pour ces valeurs.
  4. increaseStats est une méthode sur le joueur (pas sur le DTO) qui prend un DTO "montant à incrémenter", et applique cet incrément sur le DTO qui appartient au joueur et représente le DTO entraînable du joueur.
    • S'il existe des valeurs maximales applicables pour ces statistiques, elles sont appliquées ici.

Dans le cas où il calculateNewStatss'avère ne dépendre d'aucun autre joueur ou information ennemie (au-delà des valeurs dans les deux entrées DTO), cette méthode peut potentiellement être localisée n'importe où dans le projet.

S'il calculateNewStatss'avère que le produit dépend entièrement du joueur et des objets ennemis (c'est-à-dire que les futurs joueurs et objets ennemis peuvent avoir de nouvelles propriétés et calculateNewStatsdoivent être mis à jour pour consommer autant de leurs nouvelles propriétés que possible), ils calculateNewStatsdoivent alors accepter ces deux objets, pas seulement le DTO. Cependant, son résultat de calcul sera toujours l'incrément DTO, ou tout autre objet de transfert de données simple qui transporte les informations utilisées pour effectuer une incrémentation / mise à niveau.

rwong
la source
1

Il y a un gros problème ici: ce n'est pas parce qu'une structure de données est immuable que nous n'en avons pas besoin de versions modifiées . La vraie la version immuable d'une structure de données ne fournit setX, setYet des setZméthodes - ils reviennent juste une nouvelle structure de lieu de modifier l'objet que vous les ai appelés.

// Mutates mutableFoo
mutableFoo.setX(...)
// Creates a new updated immutableFoo, existing immutableFoo is unchanged
newFoo = immutableFoo.setX(...)

Alors, comment donnez-vous à une partie de votre système la possibilité de la changer tout en restreignant d'autres parties? Avec un objet mutable contenant une référence à une instance de la structure immuable. Fondamentalement, au lieu que votre classe de joueur puisse muter son objet de statistiques tout en donnant à chaque autre classe une vue immuable de ses statistiques, ses statistiques sont immuables et le joueur est mutable. Au lieu de:

// Stats are mutable, mutates self.stats
self.stats.setX(...)

Vous auriez:

// Stats are immutable, mutates self, setX() returns new updated stats
self.stats = self.stats.setX(...)

Regarde la différence? L'objet stats est complètement immuable, mais les statistiques actuelles du joueur sont une référence mutable à l'objet immuable. Pas besoin de créer deux interfaces - il suffit de rendre la structure de données complètement immuable et de gérer une référence mutable à l'endroit où vous l'utilisez pour stocker l'état.

Cela signifie que les autres objets de votre système ne peuvent pas s'appuyer sur une référence à l'objet stats - l'objet stats qu'ils ont ne sera pas le même objet stats que le joueur a une fois que le joueur a mis à jour leurs statistiques.

Cela a plus de sens de toute façon, car conceptuellement, ce ne sont pas vraiment les statistiques qui changent, ce sont les statistiques actuelles du joueur . Pas l'objet statistiques. La référence à l'objet stats de l'objet joueur. Ainsi, d'autres parties de votre système en fonction de cette référence devraient toutes faire explicitement référence player.currentStats()ou certaines autres au lieu de saisir l'objet de statistiques sous-jacent du joueur, de le stocker quelque part et de compter sur sa mise à jour via des mutations.

Jack
la source
1

Wow ... cela me ramène vraiment. J'ai essayé plusieurs fois cette même idée. J'étais trop têtu pour y renoncer parce que je pensais qu'il y aurait quelque chose de bon à apprendre. J'ai vu d'autres essayer cela dans le code aussi. La plupart des implémentations que j'ai vues appelaient les interfaces en lecture seule comme la vôtre FooViewerou ReadOnlyFooet l'interface en écriture seule la FooEditorou WriteableFooou en Java, je pense avoir vu FooMutatorune fois. Je ne suis pas sûr qu'il existe un vocabulaire officiel ou même commun pour faire les choses de cette façon.

Cela n'a jamais rien fait d'utile pour moi dans les endroits où je l'ai essayé. Je l'éviterais complètement. Je ferais ce que d'autres suggèrent et je prendrais du recul et je me demanderais si vous avez vraiment besoin de cette notion dans votre idée plus large. Je ne suis pas sûr qu'il existe une bonne façon de le faire, car je n'ai jamais gardé le code que j'ai produit en essayant. Chaque fois, j'ai reculé après des efforts considérables et des choses simplifiées. Et par cela, je veux dire quelque chose dans le sens de ce que les autres ont dit à propos de YAGNI et KISS et DRY.

Un inconvénient possible: la répétition. Vous devrez créer ces interfaces pour de nombreuses classes potentiellement et même pour une seule classe, vous devrez nommer chaque méthode et décrire chaque signature au moins deux fois. De toutes les choses qui me brûlent dans le codage, devoir changer plusieurs fichiers de cette manière me pose le plus de problèmes. Je finis par oublier de faire les changements à un endroit ou à un autre. Une fois que vous avez une classe appelée Foo et des interfaces appelées FooViewer et FooEditor, si vous décidez que ce serait mieux si vous l'appeliez Bar à la place, vous devez refactoriser-> renommer trois fois sauf si vous avez un IDE vraiment incroyablement intelligent. J'ai même trouvé que c'était le cas lorsque j'avais une quantité considérable de code provenant d'un générateur de code.

Personnellement, je n'aime pas créer des interfaces pour des choses qui n'ont qu'une seule implémentation. Cela signifie que lorsque je voyage dans le code, je ne peux pas simplement passer directement à la seule implémentation, je dois passer à l'interface, puis à l'implémentation de l'interface ou au moins appuyer sur quelques touches supplémentaires pour y arriver. Ces choses s'additionnent.

Et puis il y a la complication que vous avez mentionnée. Je doute que je recommencerai jamais avec ce code. Même pour l'endroit où cela correspondait le mieux à mon plan global pour le code (un ORM que j'ai construit), je l'ai retiré et remplacé par quelque chose de plus facile à coder.

Je réfléchirais vraiment à la composition que vous avez mentionnée. Je suis curieux de savoir pourquoi vous pensez que ce serait une mauvaise idée. Je me serais attendu à avoir de la EffectiveStatscomposition et immuable Statset quelque chose comme ça StatModifiersqui composerait davantage un ensemble de StatModifiers qui représentent tout ce qui pourrait modifier les statistiques (effets temporaires, objets, emplacement dans une zone d'amélioration, fatigue) mais que vous EffectiveStatsn'auriez pas besoin de comprendre parce StatModifiersque gérer ce que ces choses étaient et quel effet et quel genre elles auraient sur quelles statistiques. leStatModifierserait une interface pour les différentes choses et pourrait savoir des choses comme "suis-je dans la zone", "quand le médicament disparaît-il", etc ... mais n'aurait même pas besoin de faire savoir à d'autres objets de telles choses. Il lui suffirait de dire quelle statistique et comment elle était modifiée en ce moment. Mieux encore StatModifierpourrait simplement exposer une méthode de production d'une nouvelle immuable Statsbasée sur une autre Statsqui serait différente car elle a été modifiée de manière appropriée. Vous pourriez alors faire quelque chose comme currentStats = statModifier2.modify(statModifier1.modify(baseStats))et tout cela Statspeut être immuable. Je ne coderais même pas cela directement, je parcourrais probablement tous les modificateurs et les appliquerais au résultat des modificateurs précédents commençant par baseStats.

Jason Kell
la source