Bajo acoplamiento y estrecha cohesión

11

Por supuesto que depende de la situación. Pero cuando un objeto o sistema de palanca inferior se comunica con un sistema de nivel superior, ¿deberían preferirse las devoluciones de llamada o los eventos a mantener un puntero a un objeto de nivel superior?

Por ejemplo, tenemos una worldclase que tiene una variable miembro vector<monster> monsters. Cuando la monsterclase se comunica con el world, ¿debería preferir usar una función de devolución de llamada o debería tener un puntero a la worldclase dentro de la monsterclase?

hidayat
fuente
Aparte de la ortografía en el título de la pregunta, en realidad no está redactado en forma de pregunta. Creo que reformularlo podría ayudar a solidificar lo que está preguntando aquí, ya que no creo que pueda obtener una respuesta útil a esta pregunta en su forma actual. Y tampoco es una pregunta de diseño de juegos, es una pregunta sobre la estructura de programación (no estoy seguro de si eso significa que la etiqueta de diseño es o no apropiada, no puedo recordar dónde llegamos al 'diseño de software' y las etiquetas)
MrCranky

Respuestas:

10

Hay tres formas principales en que una clase puede hablar con otra sin estar estrechamente unida a ella:

  1. A través de una función de devolución de llamada.
  2. A través de un sistema de eventos.
  3. A través de una interfaz.

Los tres están estrechamente relacionados entre sí. Un sistema de eventos en muchos sentidos es solo una lista de devoluciones de llamada. Una devolución de llamada es más o menos una interfaz con un único método.

En C ++, rara vez uso devoluciones de llamada:

  1. C ++ no tiene un buen soporte para las devoluciones de llamada que retienen su thispuntero, por lo que es difícil usar devoluciones de llamada en código orientado a objetos.

  2. Una devolución de llamada es básicamente una interfaz de un método no extensible. Con el tiempo, encuentro que casi siempre termino necesitando más de un método para definir esa interfaz y una sola devolución de llamada rara vez es suficiente.

En este caso, probablemente haría una interfaz. En su pregunta, en realidad no explica a qué monsternecesita comunicarse realmente world. Adivinando, haría algo como:

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

La idea aquí es que solo pones IWorld(que es un nombre horrible) el mínimo Monsternecesario para acceder. Su visión del mundo debería ser lo más estrecha posible.

munificente
fuente
1
Los delegados +1 (devoluciones de llamada) generalmente se vuelven más numerosos a medida que pasa el tiempo. En mi opinión, dar una interfaz a los monstruos para que puedan llegar a las cosas es una buena manera de hacerlo.
Michael Coleman
12

No use una función de devolución de llamada para ocultar qué función está llamando. Si tuviera que poner una función de devolución de llamada, y esa función de devolución de llamada tendrá una y solo una función asignada, entonces realmente no está rompiendo el acoplamiento. Solo lo estás enmascarando con otra capa de abstracción. No está ganando nada (aparte del tiempo de compilación), pero está perdiendo claridad.

No lo llamaría exactamente una mejor práctica, pero es un patrón común tener entidades contenidas por algo para tener un puntero a su padre.

Dicho esto, podría valer la pena usar el patrón de interfaz para dar a tus monstruos un subconjunto limitado de funcionalidades que pueden invocar en el mundo.

Tétrada
fuente
+1 Darle al monstruo una forma limitada de llamar al padre es, en mi opinión, un buen medio camino.
Michael Coleman
7

En general, trato de evitar los enlaces bidireccionales, pero si debo tenerlos, me aseguro absolutamente de que haya un método para crearlos y otro para romperlos, de modo que nunca tenga inconsistencias.

A menudo, puede evitar el enlace bidireccional por completo al pasar los datos según sea necesario. Una refactorización trivial es hacer que, en lugar de que el monstruo mantenga un vínculo con el mundo, pase el mundo por referencia a los métodos de monstruos que lo necesitan. Mejor aún, es pasar solo una interfaz para las partes del mundo que el monstruo necesita estrictamente, lo que significa que el monstruo no depende de la implementación concreta del mundo. Esto corresponde al Principio de segregación de interfaz y al Principio de inversión de dependencia , pero no comienza a introducir el exceso de abstracción que a veces se puede obtener con eventos, señales + ranuras, etc.

En cierto modo, puede argumentar que usar una devolución de llamada es una mini interfaz muy especializada, y eso está bien. Debe decidir si puede lograr sus objetivos de manera más significativa a través de una colección de métodos en un objeto de interfaz o varios varios en diferentes devoluciones de llamada.

Kylotan
fuente
3

Trato de evitar que los objetos contenidos llamen a su contenedor porque encuentro que conduce a la confusión, se vuelve demasiado fácil de justificar, se usará en exceso y creará dependencias que no se pueden administrar.

En mi opinión, la solución ideal es que las clases de nivel superior sean lo suficientemente inteligentes como para gestionar las clases de nivel inferior. Por ejemplo, el mundo que sabe determinar si ocurrió una colisión entre un monstruo y un caballero sin que ninguno de los dos sepa del otro es mejor para mí que el monstruo que le pregunta al mundo si chocó con un caballero.

Otra opción en su caso, probablemente descubriría por qué la clase de monstruos necesita saber sobre la clase mundial y lo más probable es que descubra que hay algo en la clase mundial que se puede dividir en una clase propia que hace sentido para la clase de monstruos saber.

Remojar
fuente
2

Obviamente, no llegarás muy lejos sin eventos, pero antes de comenzar a escribir (y diseñar) un sistema de eventos, debes hacerte la pregunta real: ¿por qué el monstruo se comunicaría con la clase mundial? ¿Debería realmente?

Tomemos una situación "clásica", un monstruo atacando a un jugador.

Monster está atacando: el mundo puede identificar muy bien la situación en la que un héroe está al lado de un monstruo, y decirle al monstruo que ataque. Entonces la función en monstruo sería:

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

Pero el mundo (que ya conoce al monstruo) no necesita ser conocido por el monstruo. En realidad, el monstruo puede ignorar la existencia misma del mundo de clase, que probablemente sea mejor.

Lo mismo cuando el monstruo se está moviendo (dejo que los subsistemas obtengan la criatura y manejen el cálculo / intención del movimiento para ello, el monstruo es solo una bolsa de datos, pero mucha gente diría que esto no es POO real).

Mi punto es: los eventos (o la devolución de llamada) son geniales, por supuesto, pero no son la única respuesta a cada problema que enfrentarás.

Raveline
fuente
1

Siempre que puedo, trato de restringir la comunicación entre objetos a un modelo de solicitud y respuesta. Hay un orden parcial implícito en los objetos en mi programa de tal manera que entre dos objetos A y B, puede haber una forma de que A llame directa o indirectamente a un método de B o que B llame directa o indirectamente a un método de A , pero nunca es posible que A y B se llamen mutuamente los métodos de los demás. A veces, por supuesto, desea tener una comunicación hacia atrás con la persona que llama de un método. Hay un par de formas en que me gusta hacer esto, y ninguna de ellas son devoluciones de llamada.

Una forma es incluir más información en el valor de retorno de la llamada al método, lo que significa que el código del cliente decide qué hacer con él después de que el procedimiento le devuelve el control.

La otra forma es llamar a un objeto hijo mutuo. Es decir, si A llama a un método en B y B necesita comunicar cierta información a A, B llama a un método en C, donde A y B pueden llamar a C, pero C no puede llamar a A o B. El objeto A sería entonces responsable de obtener la información de C después de que B devuelva el control a A. Tenga en cuenta que esto no es fundamentalmente diferente de la primera forma que propuse. El objeto A solo puede recuperar la información de un valor de retorno; B o C. no invocan ninguno de los métodos del objeto A. Una variación de este truco es pasar C como parámetro del método, pero las restricciones en la relación de C con A y B aún se aplican.

Ahora, la pregunta importante es por qué insisto en hacer las cosas de esta manera. Hay tres razones principales:

  • Mantiene mis objetos más débilmente acoplados. Mis objetos pueden encapsular otros objetos, pero nunca dependerán del contexto de la persona que llama, y ​​el contexto nunca dependerá de los objetos encapsulados.
  • Mantiene mi flujo de control fácil de razonar. Es bueno poder asumir que el único código que puede cambiar el estado interno de selfun método en ejecución es ese método y no otro. Este es el mismo tipo de razonamiento que podría conducir a colocar mutexes en objetos concurrentes.
  • Protege invariantes en los datos encapsulados de mis objetos. Los métodos públicos pueden depender de invariantes, y esos invariantes pueden violarse si un método puede llamarse externamente mientras otro ya se está ejecutando.

No estoy en contra de todos los usos de las devoluciones de llamada. De acuerdo con mi política de nunca "llamar al llamante", si un objeto A invoca un método en B y le pasa una devolución de llamada, la devolución de llamada puede no cambiar el estado interno de A, y eso incluye los objetos encapsulados por A y el objetos en el contexto de A. En otras palabras, la devolución de llamada solo puede invocar métodos en los objetos que le da B. La devolución de llamada, en efecto, está bajo las mismas restricciones que B.

Un último extremo suelto para atar es que permitiré la invocación de cualquier función pura, independientemente de este orden parcial del que he estado hablando. Las funciones puras son un poco diferentes de los métodos, ya que no pueden cambiar o depender de un estado mutable o efectos secundarios, por lo que no hay que preocuparse por cuestiones confusas.

Jake McArthur
fuente
0

¿Personalmente? Solo uso un singleton.

Sí, está bien, mal diseño, no está orientado a objetos, etc. ¿Sabes qué? No me importa . Estoy escribiendo un juego, no un escaparate tecnológico. Nadie me va a calificar en el código. El propósito es hacer un juego que sea divertido, y todo lo que se interponga en mi camino dará como resultado un juego que sea menos divertido.

¿Alguna vez tendrás dos mundos funcionando a la vez? ¡Tal vez! Quizás lo hagas. Pero a menos que pueda pensar en esa situación en este momento, probablemente no lo hará.

Entonces, mi solución: hacer un singleton mundial. Llama a funciones en él. Acabar con todo el desastre. Usted podría pasar un parámetro adicional para cada función - y no se equivoque, que es dónde lleva esto. O simplemente podrías escribir código que funcione.

Hacerlo de esta manera requiere un poco de disciplina para limpiar las cosas cuando se vuelve desordenado (eso es "cuándo", no "si"), pero no hay forma de evitar que el código se desordene, ya sea que tenga el problema de los espaguetis o los miles problema de capas de abstracción. Al menos de esta manera no estás escribiendo grandes cantidades de código innecesario.

Y si decides que ya no quieres un singleton, generalmente es bastante simple deshacerte de él. Toma algo de trabajo, toma pasar un billón de parámetros, pero esos son parámetros que necesitaría pasar de todos modos.

ZorbaTHut
fuente
2
Probablemente lo exprese un poco más sucintamente: "No tengas miedo de refactorizar".
Tetrad
¡Los solteros son malvados! Los globales son mucho mejores. Todas las virtudes singleton que enumeró son las mismas para un global. Uso punteros globales (en realidad funciones globales que devuelven referencias) a mis diversos subsistemas y los inicializo / destruyo en mi función principal. Los punteros globales evitan problemas de orden de inicialización, cuelgan singletons durante el desmontaje, construcción no trivial de singletons, etc. Repito que los singletons son malvados.
deft_code
@Tetrad, muy de acuerdo. Es una de las mejores habilidades que puedes tener.
ZorbaTHut