Separate Schnittstelle für Mutationsmethoden

11

Ich habe daran gearbeitet, Code zu überarbeiten, und ich glaube, ich habe den ersten Schritt im Kaninchenbau getan. Ich schreibe das Beispiel in Java, aber ich nehme an, es könnte agnostisch sein.

Ich habe eine Schnittstelle Foodefiniert als

public interface Foo {

    int getX();

    int getY();

    int getZ();
}

Und eine Implementierung als

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;
}

Ich habe auch eine Schnittstelle MutableFoo, die passende Mutatoren bietet

/**
 * 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);
}

Es gibt einige Implementierungen davon MutableFoo(ich habe sie noch nicht implementiert). Einer von ihnen ist

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;
}

Der Grund, warum ich diese aufgeteilt habe, ist, dass es für jeden gleich wahrscheinlich ist, dass sie verwendet werden. Das heißt, es ist ebenso wahrscheinlich, dass jemand, der diese Klassen verwendet, eine unveränderliche Instanz wünscht, wie es ist, dass er eine veränderbare Instanz will.

Der primäre Anwendungsfall, den ich habe, ist eine Schnittstelle namens StatSet, die bestimmte Kampfdetails für ein Spiel darstellt (Trefferpunkte, Angriff, Verteidigung). Die "effektiven" Statistiken oder die tatsächlichen Statistiken sind jedoch ein Ergebnis der Basisstatistiken, die niemals geändert werden können, und der trainierten Statistiken, die erhöht werden können. Diese beiden sind verwandt mit

/**
 * 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);
}

Die trainierten Statistiken werden nach jedem Kampf wie erhöht

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);
}

aber sie werden nicht gleich nach dem Kampf erhöht. Wenn Sie bestimmte Gegenstände verwenden, bestimmte Taktiken anwenden und das Schlachtfeld geschickt einsetzen, können Sie unterschiedliche Erfahrungen sammeln.

Nun zu meinen Fragen:

  1. Gibt es einen Namen für die Aufteilung von Schnittstellen durch Accessoren und Mutatoren in separate Schnittstellen?
  2. Ist Splitting sie auf diese Weise der ‚richtige‘ Ansatz , wenn sie gleich wahrscheinlich sind verwendet werden, oder gibt es ein anderes, akzeptierten Muster , dass ich stattdessen verwenden sollte (z. B. , Foo foo = FooFactory.createImmutableFoo();die zurückkehren könnte DefaultFoooder DefaultMutableFooaber weil versteckt createImmutableFookehrt Foo)?
  3. Gibt es unmittelbar vorhersehbare Nachteile bei der Verwendung dieses Musters, abgesehen von der Komplikation der Schnittstellenhierarchie?

Der Grund, warum ich angefangen habe, es so zu entwerfen, ist, dass ich der Meinung bin, dass alle Implementierer einer Schnittstelle die einfachste mögliche Schnittstelle einhalten und nichts mehr bereitstellen sollten. Durch Hinzufügen von Setzern zur Benutzeroberfläche können jetzt die effektiven Statistiken unabhängig von ihren Teilen geändert werden.

Das Erstellen einer neuen Klasse für das EffectiveStatSetmacht wenig Sinn, da wir die Funktionalität in keiner Weise erweitern. Wir könnten die Implementierung ändern und EffectiveStatSeteine Mischung aus zwei verschiedenen erstellen StatSets, aber ich denke, das ist nicht die richtige Lösung.

public class EffectiveStatSet implements StatSet {

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

    public int getHitpoints() {
        return baseStats.getHitpoints() + (trainedStats.getHitpoints() / 4);
    }
}
Zymus
quelle
1
Ich denke, das Problem ist, dass Ihr Objekt veränderbar ist, selbst wenn Sie über seine "unveränderliche" Schnittstelle darauf zugreifen. Besser ein veränderliches Objekt mit Player.TrainStats ()
Ewan
@gnat Haben Sie Hinweise darauf, wo ich mehr darüber erfahren kann? Aufgrund der bearbeiteten Frage bin ich mir nicht sicher, wie oder wo ich das anwenden könnte.
Zymus
@gnat: Ihre Links (und die Links zweiten und dritten Grades) sind sehr nützlich, aber eingängige Sätze der Weisheit helfen wenig. Es zieht nur Missverständnisse und Verachtung an.
Rwong

Antworten:

6

Mir scheint, Sie haben eine Lösung, die nach einem Problem sucht.

Gibt es einen Namen für die Aufteilung von Schnittstellen durch Accessoren und Mutatoren in separate Schnittstellen?

Das mag ein wenig provozieren, aber eigentlich würde ich es "Überdesign" oder "Überkomplizieren" nennen. Indem Sie eine veränderbare und eine unveränderliche Variante derselben Klasse anbieten, bieten Sie zwei funktional äquivalente Lösungen für dasselbe Problem, die sich nur in nicht funktionalen Aspekten wie Leistungsverhalten, API und Sicherheit gegen Nebenwirkungen unterscheiden. Ich denke, das liegt daran, dass Sie Angst haben, eine Entscheidung zu treffen, die Sie bevorzugen, oder dass Sie versuchen, die "const" -Funktion von C ++ in C # zu implementieren. Ich denke, in 99% aller Fälle wird es keinen großen Unterschied machen, ob der Benutzer die veränderliche oder die unveränderliche Variante auswählt, er kann seine Probleme mit der einen oder der anderen lösen. Also "Wahrscheinlichkeit einer Klasse zu verwenden"

Die Ausnahme ist, wenn Sie eine neue Programmiersprache oder ein Mehrzweck-Framework entwerfen, das von etwa zehntausenden Programmierern oder mehr verwendet wird. Dann kann es tatsächlich besser skaliert werden, wenn Sie unveränderliche und veränderbare Varianten von Allzweckdatentypen anbieten. Aber dies ist eine Situation, in der Sie Tausende verschiedener Nutzungsszenarien haben werden - was ist wahrscheinlich nicht das Problem, mit dem Sie konfrontiert sind, denke ich?

Ist die Aufteilung auf diese Weise der „richtige“ Ansatz, wenn sie mit gleicher Wahrscheinlichkeit verwendet werden oder wenn es ein anderes, akzeptierteres Muster gibt?

Das "akzeptiertere Muster" heißt KISS - halte es einfach und dumm. Treffen Sie eine Entscheidung für oder gegen die Veränderbarkeit für die bestimmte Klasse / Schnittstelle in Ihrer Bibliothek. Wenn Ihr "StatSet" beispielsweise ein Dutzend oder mehr Attribute enthält und diese meist einzeln geändert werden, würde ich die veränderbare Variante bevorzugen und die Basisstatistiken nur nicht ändern, wenn sie nicht geändert werden sollten. Für so etwas wie eine FooKlasse mit den Attributen X, Y, Z (ein dreidimensionaler Vektor) würde ich wahrscheinlich die unveränderliche Variante bevorzugen.

Gibt es unmittelbar vorhersehbare Nachteile bei der Verwendung dieses Musters, abgesehen von der Komplikation der Schnittstellenhierarchie?

Überkomplizierte Designs erschweren das Testen, die Wartung und die Weiterentwicklung von Software.

Doc Brown
quelle
1
Ich denke du meinst "KISS - Halte es einfach, dumm"
Epicblood
2

Gibt es einen Namen für die Aufteilung von Schnittstellen durch Accessoren und Mutatoren in separate Schnittstellen?

Es könnte einen Namen dafür geben, wenn diese Trennung nützlich ist und einen Vorteil bietet , den ich nicht sehe . Wenn die Separatons keinen Nutzen bringen, ergeben die beiden anderen Fragen keinen Sinn.

Können Sie uns einen geschäftlichen Anwendungsfall nennen, bei dem die beiden separaten Schnittstellen uns Vorteile bringen, oder handelt es sich bei dieser Frage um ein akademisches Problem (YAGNI)?

Ich kann mir vorstellen, mit einem veränderlichen Einkaufswagen (Sie können mehr Artikel einlegen) einzukaufen, der zu einer Bestellung werden kann, bei der die Artikel vom Kunden nicht mehr geändert werden können. Der Auftragsstatus ist weiterhin veränderbar.

Implementierungen, die ich gesehen habe, trennen die Schnittstellen nicht

  • In Java gibt es keine Schnittstelle ReadOnlyList

Die ReadOnly-Version verwendet das "WritableInterface" und löst eine Ausnahme aus, wenn eine Schreibmethode verwendet wird

k3b
quelle
Ich habe das OP geändert, um den primären Anwendungsfall zu erläutern.
Zymus
2
"Es gibt keine Schnittstelle ReadOnlyList in Java" - ehrlich gesagt sollte es sein. Wenn Sie einen Code haben, der eine Liste <T> als Parameter akzeptiert, können Sie nicht leicht feststellen, ob er mit einer nicht änderbaren Liste funktioniert. Code, der Listen zurückgibt, es gibt keine Möglichkeit zu wissen, ob Sie sie sicher ändern können. Die einzige Möglichkeit besteht darin, sich entweder auf möglicherweise unvollständige oder ungenaue Dokumentation zu stützen oder für alle Fälle eine defensive Kopie zu erstellen. Ein schreibgeschützter Sammlungstyp würde dies viel einfacher machen.
Jules
@Jules: In der Tat scheint der Java-Weg alles oben zu sein: um sicherzustellen, dass die Dokumentation vollständig und genau ist, und um für alle Fälle eine defensive Kopie zu erstellen. Es lässt sich sicherlich auf sehr große Unternehmensprojekte skalieren.
Rwong
1

Die Trennung einer veränderlichen Erfassungsschnittstelle und einer schreibgeschützten Erfassungsschnittstelle ist ein Beispiel für das Prinzip der Schnittstellentrennung. Ich glaube nicht, dass jeder Anwendung des Prinzips spezielle Namen gegeben werden.

Beachten Sie hier einige Worte: "Nur-Lese-Vertrag" und "Sammlung".

Ein schreibgeschützter Vertrag bedeutet, dass die Klasse Ader Klasse Beinen schreibgeschützten Zugriff gewährt , jedoch nicht impliziert, dass das zugrunde liegende Sammlungsobjekt tatsächlich unveränderlich ist. Unveränderlich bedeutet, dass es sich in Zukunft niemals ändern wird, egal von welchem ​​Agenten. Ein schreibgeschützter Vertrag besagt lediglich, dass der Empfänger ihn nicht ändern darf. jemand anderes (insbesondere Klasse A) darf es ändern.

Um ein Objekt unveränderlich zu machen, muss es wirklich unveränderlich sein - es muss Versuche ablehnen, seine Daten zu ändern, unabhängig davon, welcher Agent es anfordert.

Das Muster wird höchstwahrscheinlich bei Objekten beobachtet, die eine Sammlung von Datenlisten, Sequenzen, Dateien (Streams) usw. darstellen.


Das Wort "unveränderlich" wird zur Modeerscheinung, aber das Konzept ist nicht neu. Und es gibt viele Möglichkeiten, Unveränderlichkeit zu nutzen, sowie viele Möglichkeiten, mit etwas anderem (dh seinen Konkurrenten) ein besseres Design zu erzielen.


Hier ist mein Ansatz (nicht basierend auf Unveränderlichkeit).

  1. Definieren Sie ein DTO (Datenübertragungsobjekt), auch als Wertetupel bezeichnet.
    • Diese DTO werden die drei Felder enthalten: hitpoints, attack, defense.
    • Nur Felder: öffentlich, jeder kann schreiben, kein Schutz.
    • Ein DTO muss jedoch ein Wegwerfobjekt sein: Wenn eine Klasse Aein DTO an die Klasse übergeben muss B, erstellt sie eine Kopie davon und übergibt stattdessen die Kopie. Sie Bkönnen das DTO also nach Belieben verwenden (darauf schreiben), ohne das DTO zu beeinflussen, an dem es Afesthält.
  2. Die grantExperiencein zwei Teile zu unterteilende Funktion:
    • calculateNewStats
    • increaseStats
  3. calculateNewStats nimmt die Eingaben von zwei DTO, von denen einer die Spielerstatistiken und einer die feindlichen Statistiken darstellt, und führt die Berechnungen durch.
    • Für die Eingabe sollte der Anrufer je nach Bedarf zwischen den Basis-, trainierten oder effektiven Statistiken wählen.
    • Das Ergebnis wird eine neue DTO sein , wo jedes Feld ( hitpoints, attack, defense) speichert die Menge erhöht werden für diese Fähigkeit.
    • Das neue DTO "Betrag zum Inkrementieren" betrifft nicht die Obergrenze (auferlegte maximale Obergrenze) für diese Werte.
  4. increaseStats ist eine Methode für den Spieler (nicht für das DTO), die ein DTO "Betrag zum Inkrementieren" benötigt und dieses Inkrement auf das DTO anwendet, das dem Spieler gehört und das trainierbare DTO des Spielers darstellt.
    • Wenn für diese Statistiken anwendbare Maximalwerte vorhanden sind, werden diese hier erzwungen.

Falls calculateNewStatsfestgestellt wird, dass keine Informationen von anderen Spielern oder Feinden abhängen (über die Werte im DTO mit zwei Eingaben hinaus), kann sich diese Methode möglicherweise an einer beliebigen Stelle im Projekt befinden.

Wenn calculateNewStatsfestgestellt wird, dass das Objekt vollständig von den Objekten des Spielers und des Feindes abhängig ist (dh zukünftige Objekte des Spielers und des Feindes haben möglicherweise neue Eigenschaften und calculateNewStatsmüssen aktualisiert werden, um so viel wie möglich von ihren neuen Eigenschaften zu verbrauchen), calculateNewStatsmüssen diese beiden akzeptiert werden Objekte, nicht nur das DTO. Das Berechnungsergebnis ist jedoch weiterhin das Inkrement-DTO oder ein einfaches Datenübertragungsobjekt, das die Informationen enthält, die zum Ausführen eines Inkrements / Upgrades verwendet werden.

rwong
quelle
1

Hier gibt es ein großes Problem: Nur weil eine Datenstruktur unveränderlich ist, heißt das nicht, dass wir keine modifizierten Versionen davon benötigen . Die wirkliche unveränderliche Version einer Datenstruktur bereitstellt setX, setYund setZMethoden - sie nur eine Rückkehr neue Struktur stattdessen das Objekt modifizieren Sie sie auf genannt.

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

Wie können Sie einem Teil Ihres Systems die Möglichkeit geben, ihn zu ändern und gleichzeitig andere Teile einzuschränken? Mit einem veränderlichen Objekt, das einen Verweis auf eine Instanz der unveränderlichen Struktur enthält. Grundsätzlich statt Ihrer Spielerklasse der Lage, das Statistik - Objekt zu mutieren , während jeder zweiten Klasse einen unveränderlichen Blick auf der Statistik zu geben, seine Statistiken sind unveränderlich und der Spieler ist wandelbar. Anstatt von:

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

Sie hätten:

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

Sieh den Unterschied? Das Statistikobjekt ist vollständig unveränderlich, aber die aktuellen Statistiken des Spielers sind eine veränderbare Referenz auf das unveränderliche Objekt. Sie müssen überhaupt keine zwei Schnittstellen erstellen - machen Sie einfach die Datenstruktur vollständig unveränderlich und verwalten Sie einen veränderlichen Verweis darauf, wo Sie ihn gerade zum Speichern des Status verwenden.

Dies bedeutet, dass andere Objekte in Ihrem System sich nicht auf einen Verweis auf das Statistikobjekt verlassen können. Das Statistikobjekt, das sie haben, ist nicht dasselbe Statistikobjekt, das der Spieler hat, nachdem der Spieler seine Statistiken aktualisiert hat.

Dies ist ohnehin sinnvoller, da sich konzeptionell nicht wirklich die Statistiken ändern, sondern die aktuellen Statistiken des Spielers . Nicht das Statistikobjekt. Der Verweis auf das Statistikobjekt, über das das Spielerobjekt verfügt. Andere Teile Ihres Systems, die von dieser Referenz abhängen, sollten alle explizit referenzieren player.currentStats()oder solche, anstatt das zugrunde liegende Statistikobjekt des Spielers in den Griff zu bekommen, es irgendwo zu speichern und sich darauf zu verlassen, dass es durch Mutationen aktualisiert wird.

Jack
quelle
1

Wow ... das bringt mich wirklich zurück. Ich habe die gleiche Idee ein paar Mal versucht. Ich war zu stur, um es aufzugeben, weil ich dachte, es gäbe etwas Gutes zu lernen. Ich habe andere gesehen, die dies auch im Code versucht haben. Die meisten Implementierungen, die ich gesehen habe, werden als schreibgeschützte Schnittstellen wie Ihre FooVieweroder ReadOnlyFoound die schreibgeschützte Schnittstelle als FooEditoroder WriteableFoooder in Java bezeichnet. Ich glaube, ich habe sie FooMutatoreinmal gesehen. Ich bin mir nicht sicher, ob es ein offizielles oder sogar allgemeines Vokabular gibt, um Dinge auf diese Weise zu tun.

Dies hat mir an den Orten, an denen ich es ausprobiert habe, nie etwas Nützliches gebracht. Ich würde es ganz vermeiden. Ich würde tun, was andere vorschlagen, und einen Schritt zurücktreten und überlegen, ob Sie diesen Begriff wirklich in Ihrer größeren Idee brauchen. Ich bin mir nicht sicher, ob es einen richtigen Weg gibt, da ich nie den Code behalten habe, den ich während des Versuchs erstellt habe. Jedes Mal zog ich mich nach erheblichen Anstrengungen zurück und vereinfachte die Dinge. Und damit meine ich etwas in der Art, wie andere über YAGNI, KISS und DRY gesagt haben.

Ein möglicher Nachteil: Wiederholung. Sie müssen diese Schnittstellen möglicherweise für viele Klassen erstellen, und selbst für nur eine Klasse müssen Sie jede Methode benennen und jede Signatur mindestens zweimal beschreiben. Von all den Dingen, die mich beim Codieren verbrennen, bringt es mich am meisten in Schwierigkeiten, mehrere Dateien auf diese Weise ändern zu müssen. Am Ende vergesse ich schließlich, die Änderungen an dem einen oder anderen Ort vorzunehmen. Sobald Sie eine Klasse namens Foo und Schnittstellen namens FooViewer und FooEditor haben, müssen Sie, wenn Sie entscheiden, dass es besser ist, wenn Sie sie Bar nennen, stattdessen dreimal umgestalten-> umbenennen, es sei denn, Sie haben eine wirklich erstaunlich intelligente IDE. Ich fand das sogar so, als ich einen beträchtlichen Teil des Codes von einem Codegenerator hatte.

Persönlich mag ich es nicht, Schnittstellen für Dinge zu erstellen, die auch nur eine einzige Implementierung haben. Wenn ich im Code herumreise, kann ich nicht einfach direkt zur einzigen Implementierung springen, sondern muss zur Schnittstelle und dann zur Implementierung der Schnittstelle springen oder zumindest ein paar zusätzliche Tasten drücken, um dorthin zu gelangen. Diese Dinge summieren sich.

Und dann ist da noch die Komplikation, die Sie erwähnt haben. Ich bezweifle, dass ich mit meinem Code jemals wieder diesen bestimmten Weg gehen werde. Selbst für den Ort, an dem dies am besten in meinen Gesamtplan für den Code (ein von mir erstelltes ORM) passt, habe ich dies gezogen und durch etwas ersetzt, mit dem ich leichter codieren kann.

Ich würde wirklich mehr über die von Ihnen erwähnte Komposition nachdenken. Ich bin gespannt, warum Sie das für eine schlechte Idee halten. Ich hätte erwartet, EffectiveStatskomponiert und unveränderlich zu sein, Statsund so etwas StatModifierswürde einige Sätze von StatModifiers weiter komponieren , die alles darstellen, was Statistiken ändern könnte (temporäre Effekte, Gegenstände, Position in einem Verbesserungsbereich, Müdigkeit), aber das EffectiveStatsmüssten Sie nicht verstehen, weil StatModifierswürde Verwalten Sie, was diese Dinge waren und wie viel Wirkung und welche Art sie auf welche Statistiken haben würden. DasStatModifierwäre eine Schnittstelle für die verschiedenen Dinge und könnte Dinge wie "Bin ich in der Zone", "Wann lässt die Medizin nach" usw. wissen, müsste aber nicht einmal andere Objekte über solche Dinge informieren. Es müsste nur gesagt werden, welcher Status und wie modifiziert er gerade war. Besser noch StatModifierkönnte man einfach eine Methode zur Herstellung einer neuen unveränderlichen Statsauf der Grundlage einer anderen Statsaufdecken, die anders wäre, weil sie entsprechend geändert worden wäre. Sie könnten dann so etwas tun currentStats = statModifier2.modify(statModifier1.modify(baseStats))und alles Statskann unveränderlich sein. Ich würde das nicht einmal direkt codieren, sondern wahrscheinlich alle Modifikatoren durchlaufen und sie jeweils auf das Ergebnis der vorherigen Modifikatoren anwenden, beginnend mit baseStats.

Jason Kell
quelle