¿Cuál es una buena práctica de diseño para evitar preguntar un tipo de subclase?


11

He leído que cuando su programa necesita saber de qué clase es un objeto, generalmente indica una falla de diseño, por lo que quiero saber cuál es una buena práctica para manejar esto. Estoy implementando una Forma de clase con diferentes subclases heredadas de ella como Círculo, Polígono o Rectángulo y tengo diferentes algoritmos para saber si un Círculo choca con un Polígono o un Rectángulo. Luego, supongamos que tenemos dos instancias de Shape y queremos saber si una choca con la otra, en ese método tengo que inferir qué tipo de subclase es el objeto que estoy colisionando para saber a qué algoritmo debo llamar, pero este es un mal diseño o práctica? Así lo resolví.

abstract class Shape {
  ShapeType getType();
  bool collide(Shape other);
}

class Circle : Shape {
  getType() { return Type.Circle; }

  bool collide(Shape other) {
    if(other.getType() == Type.Rect) {
      collideCircleRect(this, (Rect) other);     
    } else if(other.getType() == Type.Polygon) {
      collideCirclePolygon(this, (Polygon) other);
    }
  }
}

Este es un mal patrón de diseño? ¿Cómo podría resolver esto sin tener que inferir los tipos de subclase?


1
Al final, cada instancia, por ejemplo, Circle, conoce todos los demás tipos de formas. Entonces todos están conectados de alguna manera. Y tan pronto como agregue una nueva forma, como Triángulo, terminará agregando soporte de Triángulos en todas partes. Depende de lo que quiera cambiar con más frecuencia, si agrega nuevas formas, este diseño es malo. Debido a que tiene la expansión de la solución: su soporte de triángulos debe agregarse en todas partes. En su lugar, debe extraer su detección de colisión en una clase separada, que puede funcionar con todos los tipos y delegar.
thepacker


En mi opinión, esto se reduce a los requisitos de rendimiento. Cuanto más específico sea el código, más optimizado puede ser y más rápido se ejecutará. En este caso particular (también lo implementó), verificar el tipo está bien porque las comprobaciones de colisión personalizadas pueden ser enormemente más rápidas que una solución genérica. Pero cuando el rendimiento en tiempo de ejecución no es crítico, siempre iría con el enfoque general / polimórfico.
marstato

Gracias a todos, en mi caso, el rendimiento es crítico y no agregaré nuevas formas, tal vez haga el enfoque de Detección de colisión. Sin embargo, todavía tenía que saber el tipo de subclase, ¿debería mantener un método "Type getType ()" en Shape o, en cambio, ¿está haciendo algún tipo de "instancia de" con Shape en la clase CollisionDetection?
Alejandro

1
No existe un procedimiento efectivo de colisión entre Shapeobjetos abstractos . Su lógica depende de las partes internas de otro objeto a menos que esté verificando la colisión para detectar puntos límite bool collide(x, y)(un subconjunto de puntos de control podría ser una buena compensación). De lo contrario, debe verificar el tipo de alguna manera: si realmente hay necesidad de abstracciones, producir Collisiontipos (para objetos dentro del área del actor actual) debería ser el enfoque correcto.
estremecimiento

Respuestas:


13

Polimorfismo

Mientras lo uses getType()o algo así, no estás usando polimorfismo.

Entiendo sentir que necesitas saber qué tipo tienes. Pero cualquier trabajo que desee hacer sabiendo que realmente debe ser empujado a la clase. Entonces solo le dices cuándo hacerlo.

El código de procedimiento obtiene información y luego toma decisiones. El código orientado a objetos le dice a los objetos que hagan cosas.
- Alec Sharp

Este principio se llama decir, no preguntar . Seguirlo le ayuda a no difundir detalles como escribir y crear una lógica que actúe sobre ellos. Hacer eso convierte una clase de adentro hacia afuera. Es mejor mantener ese comportamiento dentro de la clase para que pueda cambiar cuando la clase cambie.

Encapsulación

Puedes decirme que nunca se necesitarán otras formas, pero no te creo y tampoco deberías.

Un buen efecto de seguir la encapsulación es que es fácil agregar nuevos tipos porque sus detalles no se extienden en el código donde aparecen ify en la switchlógica. El código para un nuevo tipo debería estar en un solo lugar.

Un sistema de detección de colisión tipo ignorante

Permítame mostrarle cómo diseñaría un sistema de detección de colisión que sea eficaz y que funcione con cualquier forma 2D al no importarme el tipo.

ingrese la descripción de la imagen aquí

Digamos que se supone que debes dibujar eso. Parece simple Es todo círculos. Es tentador crear una clase circular que entienda las colisiones. El problema es que esto nos envía a una línea de pensamiento que se desmorona cuando necesitamos 1000 círculos.

No deberíamos estar pensando en círculos. Deberíamos estar pensando en píxeles.

¿Qué pasaría si te dijera que el mismo código que usas para dibujar a estos tipos es el que puedes usar para detectar cuándo se tocan o incluso en cuáles está haciendo clic el usuario?

ingrese la descripción de la imagen aquí

Aquí he dibujado cada círculo con un color único (si tus ojos son lo suficientemente buenos como para ver el contorno negro, simplemente ignóralo). Esto significa que cada píxel en esta imagen oculta se asigna de nuevo a lo que lo dibujó. Un hashmap se encarga de eso muy bien. De hecho, puedes hacer polimorfismo de esta manera.

Esta imagen nunca tiene que mostrarla al usuario. Lo creas con el mismo código que dibujó el primero. Solo con diferentes colores.

Cuando el usuario hace clic en un círculo, sé exactamente qué círculo porque solo un círculo es de ese color.

Cuando dibujo un círculo encima de otro, puedo leer rápidamente cada píxel que estoy a punto de sobrescribir al volcarlos en un conjunto. Cuando termine los puntos de ajuste para cada círculo con el que colisionó y ahora solo tengo que llamar a cada uno una vez para notificarle la colisión.

Un nuevo tipo: rectángulos

Todo esto se hizo con círculos, pero le pregunto: ¿funcionaría de manera diferente con los rectángulos?

Ningún conocimiento circular se ha filtrado en el sistema de detección. No le importa el radio, la circunferencia o el punto central. Se preocupa por los píxeles y el color.

La única parte de este sistema de colisión que debe empujarse hacia las formas individuales es un color único. Aparte de eso, las formas solo pueden pensar en dibujar sus formas. Es lo que son buenos de todos modos.

Ahora, cuando escribes la lógica de colisión, no te importa qué subtipo tengas. Le dices que choque y te dice lo que encontró debajo de la forma que pretende dibujar. No es necesario saber tipo. Y eso significa que puede agregar tantos subtipos como desee sin tener que actualizar el código en otras clases.

Opciones de implementación

Realmente, no necesita ser un color único. Podrían ser referencias de objetos reales y guardar un nivel de indirección. Pero esos no se verían tan bien cuando se dibuja en esta respuesta.

Este es solo un ejemplo de implementación. Ciertamente hay otros. Lo que esto debía mostrar es que cuanto más dejes que estos subtipos de formas se adhieran a su única responsabilidad, mejor funcionará todo el sistema. Es probable que haya soluciones más rápidas y menos intensivas en memoria, pero si me obligan a difundir el conocimiento de los subtipos, no me gustaría usarlas incluso con las ganancias de rendimiento. No los usaría a menos que los necesitara claramente.

Despacho doble

Hasta ahora he ignorado por completo el doble envío . Lo hice porque pude. Mientras la lógica de colisión no le importe qué dos tipos colisionaron, no la necesita. Si no lo necesita, no lo use. Si cree que podría necesitarlo, posponga tratarlo todo el tiempo que pueda. Esta actitud se llama YAGNI .

Si decides que realmente necesitas diferentes tipos de colisiones, pregúntate si n subtipos de formas realmente necesitan n 2 tipos de colisiones. Hasta ahora he trabajado muy duro para que sea fácil agregar otro subtipo de forma. No quiero estropearlo con una implementación de doble despacho que obliga a los círculos a saber que existen cuadrados.

¿Cuántos tipos de colisiones hay de todos modos? Un poco de especulación (algo peligroso) inventa colisiones elásticas (hinchables), inelásticas (pegajosas), enérgicas (explosivas) y destructivas (dañinas). Podría haber más, pero si es menor que n 2, no sobrediseñemos nuestras colisiones.

Esto significa que cuando mi torpedo golpea algo que acepta daño, no tiene que SABER que golpeó una nave espacial. Solo tiene que decirlo: "¡Ja, ja! Recibiste 5 puntos de daño".

Las cosas que causan daño envían mensajes de daño a las cosas que aceptan mensajes de daño. Hecho de esa manera, puede agregar nuevas formas sin decirle a las otras formas sobre la nueva forma. Solo terminas extendiéndote alrededor de nuevos tipos de colisiones.

La nave espacial puede enviar de vuelta al torp "¡Ja, ja! Recibiste 100 puntos de daño". así como "Ahora estás atascado en mi casco". Y el torp puede devolver "Bueno, ya terminé, así que olvídate de mí".

En ningún momento tampoco sabe exactamente qué es cada uno. Simplemente saben cómo comunicarse entre sí a través de una interfaz de colisión.

Ahora claro, el despacho doble te permite controlar las cosas más íntimamente que esto, pero ¿realmente quieres eso ?

Si lo hace, al menos piense en hacer un despacho doble a través de abstracciones de qué tipo de colisiones acepta una forma y no en la implementación real de la forma. Además, el comportamiento de colisión es algo que puede inyectarse como una dependencia y delegar a esa dependencia.

Actuación

El rendimiento siempre es crítico. Pero eso no significa que siempre sea un problema. Prueba de rendimiento. No solo especules. Sacrificar todo lo demás en nombre del rendimiento generalmente no conduce a un código de rendimiento de todos modos.



+1 para "Puedes decirme que nunca se necesitarán otras formas, pero no te creo y tampoco deberías".
Tulains Córdova

Pensar en píxeles no lo llevará a ninguna parte si este programa no se trata de dibujar formas sino de cálculos puramente matemáticos. Esta respuesta implica que debes sacrificar todo para percibir la pureza orientada a objetos. También contiene una contradicción: primero dice que debemos basar todo nuestro diseño en la idea de que podríamos necesitar más tipos de formas en el futuro, luego dice "YAGNI". Finalmente, descuida que hacer que sea más fácil agregar tipos a menudo significa que es más difícil agregar operaciones, lo cual es malo si la jerarquía de tipos es relativamente estable pero las operaciones cambian mucho.
Christian Hackl

7

La descripción del problema parece que debería usar métodos múltiples (también conocido como envío múltiple), en este caso particular: envío doble . La primera respuesta fue extensa sobre cómo lidiar genéricamente con formas colisionantes en el renderizado ráster, pero creo que OP quería una solución "vectorial" o tal vez todo el problema se ha reformulado en términos de Formas, que es un ejemplo clásico en las explicaciones de OOP.

Incluso el artículo de wikipedia citado utiliza la misma metáfora de colisión, permítanme citarlo (Python no tiene métodos múltiples integrados como algunos otros lenguajes):

@multimethod(Asteroid, Asteroid)
def collide(a, b):
    """Behavior when asteroid hits asteroid"""
    # ...define new behavior...
@multimethod(Asteroid, Spaceship)
def collide(a, b):
    """Behavior when asteroid hits spaceship"""
    # ...define new behavior...
# ... define other multimethod rules ...

Entonces, la siguiente pregunta es cómo obtener soporte para métodos múltiples en su lenguaje de programación.



Sí, se agregó a la respuesta un caso especial de Despacho múltiple, también conocido como Multimetodos
Roman Susi

5

Este problema requiere un rediseño en dos niveles.

Primero, necesita extraer la lógica para detectar la colisión entre las formas fuera de las formas. Esto es para no violar OCP cada vez que necesite agregar una nueva forma al modelo. Imagina que ya tienes un círculo, un cuadrado y un rectángulo definidos. Entonces podrías hacerlo así:

class ShapeCollisionDetector
{
    public void DetectCollisionCircleCircle(Circle firstCircle, Circle secondCircle)
    { 
        //Code that detects collision between two circles
    }

    public void DetectCollisionCircleSquare(Circle circle, Square square)
    {
        //Code that detects collision between circle and square
    }

    public void DetectCollisionCircleRectangle(Circle circle, Rectangle rectangle)
    {
        //Code that detects collision between circle and rectangle
    }

    public void DetectCollisionSquareSquare(Square firstSquare, Square secondSquare)
    {
        //Code that detects collision between two squares
    }

    public void DetectCollisionSquareRectangle(Square square, Rectangle rectangle)
    {
        //Code that detects collision between square and rectangle
    }

    public void DetectCollisionRectangleRectangle(Rectangle firstRectangle, Rectangle secondRectangle)
    { 
        //Code that detects collision between two rectangles
    }
}

A continuación, debe hacer arreglos para que se llame al método apropiado según la forma que lo llame. Puede hacerlo utilizando el polimorfismo y el patrón de visitante . Para lograr esto, debemos tener el modelo de objeto apropiado en su lugar. Primero, todas las formas deben adherirse a la misma interfaz:

    interface IShape
{
    void DetectCollision(IShape shape);
    void Accept (ShapeVisitor visitor);
}

A continuación, debemos tener una clase de padres visitantes:

    abstract class ShapeVisitor
{
    protected ShapeCollisionDetector collisionDetector = new ShapeCollisionDetector();

    abstract public void VisitCircle (Circle circle);

    abstract public void VisitSquare(Square square);

    abstract public void VisitRectangle(Rectangle rectangle);

}

Estoy usando una clase aquí en lugar de la interfaz, porque necesito que cada objeto visitante tenga un atributo de ShapeCollisionDetectortipo.

Cada implementación de IShapeinterfaz instanciaría al visitante apropiado y llamaría al Acceptmétodo apropiado del objeto con el que interactúa el objeto que llama, de esta manera:

    class Circle : IShape
{
    public void DetectCollision(IShape shape)
    {
        CircleVisitor visitor = new CircleVisitor(this);
        shape.Accept(visitor);
    }

    public void Accept(ShapeVisitor visitor)
    {
        visitor.VisitCircle(this);
    }
}

    class Rectangle : IShape
{
    public void DetectCollision(IShape shape)
    {
        RectangleVisitor visitor = new RectangleVisitor(this);
        shape.Accept(visitor);
    }

    public void Accept(ShapeVisitor visitor)
    {
        visitor.VisitRectangle(this);
    }
}

Y los visitantes específicos se verían así:

    class CircleVisitor : ShapeVisitor
{
    private Circle Circle { get; set; }

    public CircleVisitor(Circle circle)
    {
        this.Circle = circle;
    }

    public override void VisitCircle(Circle circle)
    {
        collisionDetector.DetectCollisionCircleCircle(Circle, circle);
    }

    public override void VisitSquare(Square square)
    {
        collisionDetector.DetectCollisionCircleSquare(Circle, square);
    }

    public override void VisitRectangle(Rectangle rectangle)
    {
        collisionDetector.DetectCollisionCircleRectangle(Circle, rectangle);
    }
}

    class RectangleVisitor : ShapeVisitor
{
    private Rectangle Rectangle { get; set; }

    public RectangleVisitor(Rectangle rectangle)
    {
        this.Rectangle = rectangle;
    }

    public override void VisitCircle(Circle circle)
    {
        collisionDetector.DetectCollisionCircleRectangle(circle, Rectangle);
    }

    public override void VisitSquare(Square square)
    {
        collisionDetector.DetectCollisionSquareRectangle(square, Rectangle);
    }

    public override void VisitRectangle(Rectangle rectangle)
    {
        collisionDetector.DetectCollisionRectangleRectangle(Rectangle, rectangle);
    }
}

De esta manera, no necesita cambiar las clases de forma cada vez que agrega una nueva forma, y ​​no necesita verificar el tipo de forma para llamar al método de detección de colisión apropiado.

Un inconveniente de esta solución es que si agrega una nueva forma, debe extender la clase ShapeVisitor con el método para esa forma (por ejemplo VisitTriangle(Triangle triangle)) y, en consecuencia, deberá implementar ese método en todos los demás visitantes. Sin embargo, dado que esta es una extensión, en el sentido de que no se cambian los métodos existentes, sino que solo se agregan otros nuevos, esto no viola el OCP y la sobrecarga del código es mínima. Además, al usar la clase ShapeCollisionDetector, evita la violación de SRP y evita la redundancia de código.


5

Su problema básico es que en la mayoría de los lenguajes de programación OO modernos, la sobrecarga de funciones no funciona con enlace dinámico (es decir, el tipo de argumentos de función se determina en tiempo de compilación). Lo que necesitaría es una llamada a método virtual que sea virtual en dos objetos en lugar de solo uno. Dichos métodos se llaman métodos múltiples . Sin embargo, hay formas de emular este comportamiento en lenguajes como Java, C ++, etc. Aquí es donde el despacho doble es muy útil.

La idea básica es utilizar el polimorfismo dos veces. Cuando dos formas chocan, puede llamar al método de colisión correcto de uno de los objetos a través del polimorfismo y pasar el otro objeto del tipo de forma genérica. En el método llamado, entonces sabe si este objeto es un círculo, un rectángulo o lo que sea. Luego se llama al método de colisión en el objeto de forma pasado y se le pasa este objeto. Esta segunda llamada vuelve a encontrar el tipo de objeto correcto a través del polimorfismo.

abstract class Shape {
  bool collide(Shape other);
  bool collide(Rect other);
  bool collide(Circle other);
}

class Circle : Shape {

  bool collide(Shape other) {
    return other.collide(this);
  }

  bool collide(Rect other) {
    // algorithm to detect collision between Circle and Rect
  }

  // ...
}

class Rect : Shape {

  bool collide(Shape other) {
    return other.collide(this);
  }

  bool collide(Circle other) {
    // algorithm to detect collision between Circle and Rect
  }

  // ...
}

Sin embargo, un gran inconveniente de esta técnica es que cada clase en la jerarquía tiene que saber acerca de todos los hermanos. Esto supone una gran carga de mantenimiento si se agrega una nueva forma más adelante.


2

Quizás esta no sea la mejor manera de abordar este problema

La colisión matemática de la forma de behing es particular de las combinaciones de formas. Lo que significa que el número de subrutinas que necesitará es el cuadrado del número de formas que admite su sistema. Las colisiones de formas no son realmente operaciones en formas, sino operaciones que toman formas como parámetros.

Estrategia de sobrecarga del operador

Si no puede simplificar el problema matemático subyacente, recomendaría el enfoque de sobrecarga del operador. Algo como:

 public final class ShapeOp 
 {
     static { ... }

     public static boolean collision( Shape s1, Shape s2 )  { ... }
     public static boolean collision( Point p1, Point p2 ) { ... }
     public static boolean collision( Point p1, Square s1 ) { ... }
     public static boolean collision( Point p1, Circle c1 ) { ... }
     public static boolean collision( Point p1, Line l1 ) { ... }
     public static boolean collision( Square s1, Point p2 ) { ... }
     public static boolean collision( Square s1, Square s2 ) { ... }
     public static boolean collision( Square s1, Circle c1 ) { ... }
     public static boolean collision( Square s1, Line l1 ) { ... }
     (...)

En el intializador estático, usaría la reflexión para hacer un mapa de los métodos para implementar un disparador dinámico en el método de colisión genérica (Forma s1, Forma s2). El intializador estático también puede tener una lógica para detectar funciones de colisión faltantes e informarlas, negándose a cargar la clase.

Esto es similar a la sobrecarga del operador C ++. En C ++, la sobrecarga del operador es muy confusa porque tiene un conjunto fijo de símbolos que puede sobrecargar. Sin embargo, el concepto es muy interesante y puede replicarse con funciones estáticas.

La razón por la que usaría este enfoque es que la colisión no es una operación sobre un objeto. Una colisión es una operación externa que dice alguna relación sobre dos objetos arbitrarios. Además, el inicializador estático podrá verificar si pierdo alguna función de colisión.

Simplifica tu problema matemático si es posible

Como mencioné, el número de funciones de colisión es el cuadrado del número de tipos de formas. Esto significa que en un sistema con solo 20 formas necesitará 400 rutinas, con 21 formas 441 y así sucesivamente. Esto no es fácilmente extensible.

Pero puedes simplificar tus matemáticas . En lugar de extender la función de colisión, puede rasterizar o triangular cada forma. De esa forma, el motor de colisión no necesita ser extensible. Colisión, Distancia, Intersección, Fusión y varias otras funciones serán universales.

Triangular

¿Notaste que la mayoría de los paquetes y juegos en 3D triangulan todo? Esa es una de las formas de simplificar las matemáticas. Esto también se aplica a las formas 2D. Polys se puede triangular. Los círculos y las estrías se pueden aproximar a los polígonos.

Nuevamente ... tendrá una sola función de colisión. Tu clase se convierte entonces en:

public class Shape 
{
    public Triangle[] triangulate();
}

Y tus operaciones:

public final class ShapeOp
{
    public static boolean collision( Triangle[] shape1, Triangle[] shape2 )
}

¿Más simple no es?

Rasterizar

Puede rasterizar su forma para tener una sola función de colisión.

La rasterización puede parecer una solución radical, pero puede ser asequible y rápida dependiendo de cuán precisas sean las colisiones de forma. Si no necesitan ser precisos (como en un juego), es posible que tenga mapas de bits de baja resolución. La mayoría de las aplicaciones no necesita precisión absoluta en las matemáticas.

Las aproximaciones pueden ser lo suficientemente buenas. La supercomputadora ANTON para simulación de biología es un ejemplo. Sus matemáticas descartan muchos efectos cuánticos que son difíciles de calcular y hasta ahora las simulaciones realizadas son consistentes con los experimentos realizados en el mundo real. Los modelos de gráficos por computadora PBR utilizados en motores de juegos y paquetes de renderizado hacen simplificaciones que reducen la potencia de la computadora necesaria para renderizar cada cuadro. En realidad no es físicamente exacto, pero está lo suficientemente cerca como para convencer a simple vista.

Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.