Geringe Kopplung und enger Zusammenhalt

11

Natürlich kommt es auf die Situation an. Wenn jedoch ein Objekt oder System mit niedrigerem Hebel mit einem System höherer Ebene kommuniziert, sollten Rückrufe oder Ereignisse dem Behalten eines Zeigers auf ein Objekt höherer Ebene vorgezogen werden?

Zum Beispiel haben wir eine worldKlasse, die eine Mitgliedsvariable hat vector<monster> monsters. Wenn die monsterKlasse mit dem kommuniziert world, sollte ich dann lieber eine Rückruffunktion verwenden oder einen Zeiger auf die worldKlasse innerhalb der monsterKlasse haben?

hidayat
quelle
Abgesehen von der Schreibweise im Fragentitel wird diese nicht in Form einer Frage formuliert. Ich denke, eine Umformulierung könnte helfen, das zu festigen, was Sie hier fragen, da ich nicht glaube, dass Sie eine nützliche Antwort auf diese Frage in der aktuellen Form erhalten können. Und es ist auch keine Frage zum Spieldesign, es ist eine Frage zur Programmierstruktur (nicht sicher, ob das bedeutet, dass das Design-Tag angemessen ist oder nicht, ich kann mich nicht erinnern, wo wir auf "Software-Design" und Tags gestoßen sind)
MrCranky

Antworten:

10

Es gibt drei Möglichkeiten, wie eine Klasse mit einer anderen sprechen kann, ohne eng daran gebunden zu sein:

  1. Durch eine Rückruffunktion.
  2. Durch ein Ereignissystem.
  3. Über eine Schnittstelle.

Die drei sind eng miteinander verwandt. Ein Ereignissystem ist in vielerlei Hinsicht nur eine Liste von Rückrufen. Ein Rückruf ist mehr oder weniger eine Schnittstelle mit einer einzigen Methode.

In C ++ verwende ich selten Rückrufe:

  1. C ++ bietet keine gute Unterstützung für Rückrufe, bei denen der thisZeiger beibehalten wird. Daher ist es schwierig, Rückrufe in objektorientiertem Code zu verwenden.

  2. Ein Rückruf ist im Grunde eine nicht erweiterbare Ein-Methoden-Schnittstelle. Mit der Zeit stelle ich fest, dass ich fast immer mehr als eine Methode benötige, um diese Schnittstelle zu definieren, und ein einziger Rückruf ist selten genug.

In diesem Fall würde ich wahrscheinlich eine Schnittstelle machen. In Ihrer Frage geben Sie nicht an, mit was monstertatsächlich kommuniziert werden muss world. Wenn ich eine Vermutung anstellen würde, würde ich so etwas tun:

class IWorld {
public:
  virtual Monster* getNearbyMonster(const Position & position) = 0;
  virtual Item*    getItemAt(const Position & position) = 0;
};

class Monster {
public:
  void update(IWorld * world) {
    // Do stuff...
  }
}

class World : public IWorld {
public:
  virtual Monster* getNearbyMonster(const Position & position) {
    // ...
  }

  virtual Item*    getItemAt(const Position & position) {
    // ...
  }

  // Lots of other stuff that Monster should not have access to...
}

Die Idee hier ist, dass Sie nur IWorlddas Nötigste eingeben (was ein beschissener Name ist), auf das zugegriffen werden Monstermuss. Sein Blick auf die Welt sollte so eng wie möglich sein.

großartig
quelle
1
+1 Delegierte (Rückrufe) werden im Laufe der Zeit normalerweise zahlreicher. Meiner Meinung nach ist es ein guter Weg, den Monstern eine Schnittstelle zu geben, damit sie an Sachen herankommen können.
Michael Coleman
12

Verwenden Sie keine Rückruffunktion, um zu verbergen, welche Funktion Sie aufrufen. Wenn Sie eine Rückruffunktion einfügen und dieser Rückruffunktion nur eine einzige Funktion zugewiesen ist, wird die Kopplung überhaupt nicht unterbrochen. Sie maskieren es nur mit einer anderen Abstraktionsebene. Sie gewinnen nichts (außer vielleicht Kompilierungszeit), aber Sie verlieren an Klarheit.

Ich würde es nicht gerade als Best Practice bezeichnen, aber es ist ein gängiges Muster, Entitäten zu haben, die in etwas enthalten sind, um einen Zeiger auf das übergeordnete Element zu haben.

Abgesehen davon könnte es sich lohnen, das Schnittstellenmuster zu verwenden, um Ihren Monstern eine begrenzte Teilmenge an Funktionen zu geben, die sie auf der Welt aufrufen können.

Tetrad
quelle
+1 Meiner Meinung nach ist es ein guter Mittelweg, dem Monster eine eingeschränkte Möglichkeit zu geben, die Eltern anzurufen.
Michael Coleman
7

Im Allgemeinen versuche ich, bidirektionale Links zu vermeiden, aber wenn ich sie haben muss, stelle ich absolut sicher, dass es eine Methode gibt, um sie zu erstellen und eine, um sie zu brechen, damit es nie zu Inkonsistenzen kommt.

Oft können Sie die bidirektionale Verknüpfung vollständig vermeiden, indem Sie bei Bedarf Daten übergeben. Ein triviales Refactoring besteht darin, es so zu gestalten, dass das Monster keine Verbindung zur Welt hat, sondern die Welt anhand der Monstermethoden weitergibt, die es benötigen. Noch besser ist es, nur eine Schnittstelle für die Teile der Welt zu übergeben, die das Monster unbedingt benötigt, was bedeutet, dass sich das Monster nicht auf die konkrete Implementierung der Welt verlässt. Dies entspricht dem Prinzip der Schnittstellentrennung und dem Prinzip der Abhängigkeitsinversion , führt jedoch nicht zu der Einführung der überschüssigen Abstraktion, die manchmal mit Ereignissen, Signalen + Slots usw. erzielt werden kann.

In gewisser Weise können Sie argumentieren, dass die Verwendung eines Rückrufs eine sehr spezialisierte Mini-Schnittstelle ist, und das ist in Ordnung. Sie müssen entscheiden, ob Sie Ihre Ziele mit einer Sammlung von Methoden in einem Schnittstellenobjekt oder mehreren sortierten Methoden in verschiedenen Rückrufen sinnvoller erreichen können.

Kylotan
quelle
3

Ich versuche zu vermeiden, dass enthaltene Objekte ihren Container aufrufen, weil ich finde, dass dies zu Verwirrung führt, zu einfach zu rechtfertigen ist, überbeansprucht wird und Abhängigkeiten entstehen, die nicht verwaltet werden können.

Meiner Meinung nach besteht die ideale Lösung darin, dass die Klassen höherer Ebenen intelligent genug sind, um die Klassen niedrigerer Ebenen zu verwalten. Zum Beispiel ist die Welt, die weiß, ob es zu einer Kollision zwischen einem Monster und einem Ritter gekommen ist, ohne dass einer von beiden etwas über den anderen weiß, auch besser als das Monster, das die Welt fragt, ob es mit einem Ritter kollidiert.

Eine andere Option in Ihrem Fall, ich würde wahrscheinlich herausfinden, warum die Monsterklasse etwas über die Weltklasse wissen muss, und Sie werden höchstwahrscheinlich feststellen, dass es etwas in der Weltklasse gibt, das in eine eigene Klasse aufgeteilt werden kann, die es macht Sinn für die Monsterklasse zu wissen.

Dunk
quelle
2

Ohne Ereignisse werden Sie natürlich nicht sehr weit kommen, aber bevor Sie überhaupt anfangen, ein Ereignissystem zu schreiben (und zu entwerfen), sollten Sie sich die eigentliche Frage stellen: Warum sollte das Monster mit der Weltklasse kommunizieren? Sollte es wirklich?

Nehmen wir eine "klassische" Situation, ein Monster, das einen Spieler angreift.

Monster greift an: Die Welt kann die Situation, in der sich ein Held neben einem Monster befindet, sehr gut identifizieren und dem Monster sagen, dass es angreifen soll. Die Funktion in Monster wäre also:

void Monster::attack(LivingCreature l)
{
  // Call to combat system
}

Aber die Welt (die das Monster bereits kennt) muss vom Monster nicht erkannt werden. Tatsächlich kann das Monster die Existenz der Klassenwelt ignorieren, was wahrscheinlich besser ist.

Das Gleiche gilt, wenn sich ein Monster bewegt (ich lasse Subsysteme die Kreatur holen und die Bewegungsberechnung / -absicht dafür übernehmen, wobei das Monster nur eine Tasche mit Daten ist, aber viele Leute würden sagen, dass dies keine echte OOP ist).

Mein Punkt ist: Ereignisse (oder Rückrufe) sind natürlich großartig, aber sie sind nicht die einzige Antwort auf jedes Problem, mit dem Sie konfrontiert werden.

Raveline
quelle
1

Wann immer ich kann, versuche ich, die Kommunikation zwischen Objekten auf ein Anforderungs- und Antwortmodell zu beschränken. Es gibt eine implizite Teilreihenfolge für die Objekte in meinem Programm, so dass zwischen zwei beliebigen Objekten A und B eine Möglichkeit für A besteht, eine Methode von B direkt oder indirekt aufzurufen, oder für B, eine Methode von A direkt oder indirekt aufzurufen , aber es ist A und B niemals möglich, sich gegenseitig die Methoden aufzurufen. Manchmal möchten Sie natürlich eine Rückwärtskommunikation mit dem Aufrufer einer Methode haben. Es gibt ein paar Möglichkeiten, wie ich das gerne mache, und keine davon ist ein Rückruf.

Eine Möglichkeit besteht darin, mehr Informationen in den Rückgabewert des Methodenaufrufs aufzunehmen. Dies bedeutet, dass der Clientcode entscheiden kann, was damit geschehen soll, nachdem die Prozedur die Kontrolle an ihn zurückgegeben hat.

Die andere Möglichkeit besteht darin, ein gemeinsames untergeordnetes Objekt aufzurufen. Das heißt, wenn A eine Methode für B aufruft und B einige Informationen an A übermitteln muss, ruft B eine Methode für C auf, wobei A und B beide C aufrufen können, C jedoch A oder B nicht aufrufen kann. Objekt A wäre dann verantwortlich dafür, dass die Informationen von C abgerufen werden, nachdem B die Kontrolle an A zurückgegeben hat. Beachten Sie, dass dies nicht wirklich grundlegend anders ist als der erste von mir vorgeschlagene Weg. Objekt A kann die Informationen immer noch nur von einem Rückgabewert abrufen. Keine der Methoden von Objekt A wird von B oder C aufgerufen. Eine Variation dieses Tricks besteht darin, C als Parameter an die Methode zu übergeben, aber die Einschränkungen für die Beziehung von C zu A und B gelten weiterhin.

Die wichtige Frage ist nun, warum ich darauf bestehe, die Dinge so zu machen. Es gibt drei Hauptgründe:

  • Es hält meine Objekte lockerer gekoppelt. Meine Objekte können andere Objekte kapseln, aber sie hängen niemals vom Kontext des Aufrufers ab, und der Kontext hängt niemals von den gekapselten Objekten ab.
  • Es hält meinen Kontrollfluss leicht zu überlegen. Es ist schön anzunehmen, dass der einzige Code, der den internen Status selfwährend der Ausführung einer Methode ändern kann , diese eine und keine andere Methode ist. Dies ist die gleiche Art von Argumentation, die dazu führen könnte, dass Mutexe auf gleichzeitige Objekte gesetzt werden.
  • Es schützt Invarianten auf den gekapselten Daten meiner Objekte. Öffentliche Methoden dürfen von Invarianten abhängen, und diese Invarianten können verletzt werden, wenn eine Methode extern aufgerufen werden kann, während eine andere bereits ausgeführt wird.

Ich bin nicht gegen alle Verwendungszwecke von Rückrufen. In Übereinstimmung mit meiner Richtlinie, niemals den Aufrufer aufzurufen, ändert der Rückruf möglicherweise nicht den internen Status von A, wenn ein Objekt A eine Methode für B aufruft und einen Rückruf an B übergibt, und dies schließt die von A und dem gekapselten Objekte ein Objekte im Kontext von A. Mit anderen Worten, der Rückruf kann nur Methoden für Objekte aufrufen, die ihm von B gegeben wurden. Der Rückruf unterliegt praktisch denselben Einschränkungen wie B.

Ein letztes loses Ende ist, dass ich den Aufruf jeder reinen Funktion erlauben werde, unabhängig von dieser Teilreihenfolge, über die ich gesprochen habe. Reine Funktionen unterscheiden sich ein wenig von Methoden darin, dass sie sich nicht ändern können oder von veränderlichen Zuständen oder Nebenwirkungen abhängen. Sie müssen sich also keine Sorgen machen, dass sie die Dinge verwirren.

Jake McArthur
quelle
0

Persönlich? Ich benutze nur einen Singleton.

Ja, okay, schlechtes Design, nicht objektorientiert usw. Weißt du was? Es ist mir egal . Ich schreibe ein Spiel, kein Technologie-Schaufenster. Niemand wird mich nach dem Code bewerten. Der Zweck ist es, ein Spiel zu machen, das Spaß macht, und alles, was mir im Weg steht, wird zu einem Spiel führen, das weniger Spaß macht.

Wirst du jemals zwei Welten gleichzeitig haben? Könnte sein! Vielleicht wirst du. Aber wenn Sie jetzt nicht an diese Situation denken können, werden Sie es wahrscheinlich nicht tun.

Also meine Lösung: Machen Sie einen World Singleton. Funktionen darauf aufrufen. Sei fertig mit dem ganzen Durcheinander. Du könntest jeder einzelnen Funktion einen zusätzlichen Parameter übergeben - und keinen Fehler machen, das führt dahin. Oder Sie könnten einfach Code schreiben, der funktioniert.

Um dies zu tun, ist ein wenig Disziplin erforderlich, um die Dinge zu bereinigen, wenn es chaotisch wird (das ist "wann", nicht "wenn"), aber es gibt keine Möglichkeit, zu verhindern, dass Code chaotisch wird - entweder haben Sie das Spaghetti-Problem oder die Tausenden -Layer-of-Abstraction-Problem. Zumindest schreiben Sie auf diese Weise keine großen Mengen an unnötigem Code.

Und wenn Sie sich entscheiden, keinen Singleton mehr zu wollen, ist es normalerweise ziemlich einfach, ihn loszuwerden. Nimmt einige Arbeit in Anspruch, erfordert das Weitergeben einer Milliarde Parameter, aber diese Parameter müssten Sie trotzdem weitergeben.

ZorbaTHut
quelle
2
Ich würde es wahrscheinlich etwas prägnanter formulieren: "Hab keine Angst vor dem Refactor".
Tetrad
Singletons sind böse! Globals sind viel besser. Alle von Ihnen aufgelisteten Singleton-Tugenden sind für eine globale Gruppe gleich. Ich verwende globale Zeiger (eigentlich globale Funktionen, die Referenzen zurückgeben) auf meine verschiedenen Subsysteme und initialisiere / zerstöre sie in meiner Hauptfunktion. Globale Zeiger vermeiden Probleme mit der Initialisierungsreihenfolge, baumelnde Singletons beim Abreißen, nicht triviale Konstruktion von Singletons usw. Ich wiederhole, Singletons sind böse.
Deft_code
@ Tetrad, sehr einverstanden. Es ist eine der besten Fähigkeiten, die Sie haben können.
ZorbaTHut