Quelle est une bonne pratique de conception pour éviter de demander un type de sous-classe?

11

J'ai lu que lorsque votre programme a besoin de savoir de quelle classe est un objet, il indique généralement un défaut de conception, donc je veux savoir quelle est la bonne pratique pour gérer cela. J'implémente une classe Shape avec différentes sous-classes héritées d'elle comme Circle, Polygon ou Rectangle et j'ai différents algorithmes pour savoir si un cercle entre en collision avec un polygone ou un rectangle. Supposons ensuite que nous ayons deux instances de Shape et que nous voulons savoir si l'une entre en collision avec l'autre, dans cette méthode, je dois déduire quel type de sous-classe est l'objet que je heurte afin de savoir quel algorithme je dois appeler, mais, c'est un mauvaise conception ou mauvaise pratique? C'est ainsi que je l'ai résolu.

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

Ceci est un mauvais modèle de conception? Comment pourrais-je résoudre ce problème sans avoir à déduire les types de sous-classe?

Alejandro
la source
1
Vous vous retrouvez que chaque instance, par exemple le cercle, connaît tous les autres types de formes. Ils sont donc tous solidement connectés d'une manière ou d'une autre. Et dès que vous ajoutez une nouvelle forme, comme Triangle, vous finissez par ajouter un support Triangles partout. Cela dépend de ce que vous voulez changer plus souvent, ajouterez-vous de nouvelles formes, cette conception est mauvaise. Parce que vous avez l'étalement de la solution - Votre support des triangles doit être ajouté partout. Au lieu de cela, vous devez extraire votre Collisiondetection dans une classe distincte, qui peut fonctionner avec tous les types et déléguer.
thepacker
OMI, cela se résume aux exigences de performance. Plus le code est spécifique, plus il peut être optimisé et plus il s'exécutera rapidement. Dans ce cas particulier (également implémenté), la vérification du type est OK car les contrôles de collision personnalisés peuvent être énormément plus rapides qu'une solution générique. Mais lorsque les performances d'exécution ne sont pas critiques, j'opterais toujours pour l'approche générale / polymorphe.
marstato
Merci à tous, dans mon cas, les performances sont critiques et je n'ajouterai pas de nouvelles formes, peut-être que je fais l'approche CollisionDetection, Cependant je devais encore connaître le type de sous-classe, si je garde une méthode "Type getType ()" dans Shape ou plutôt faire une sorte d '"instance de" avec Shape dans la classe CollisionDetection?
Alejandro
1
Il n'y a pas de procédure de collision efficace entre les Shapeobjets abstraits . Votre logique dépend des éléments internes d'autres objets, sauf si vous vérifiez la collision pour les points de délimitation bool collide(x, y)(un sous-ensemble de points de contrôle peut être un bon compromis). Sinon, vous devez vérifier le type d'une manière ou d'une autre - s'il y a vraiment besoin d'abstractions, la production de Collisiontypes (pour les objets dans la zone de l'acteur actuel) devrait être la bonne approche.
frisson

Réponses:

13

Polymorphisme

Tant que vous utilisez getType()ou quelque chose du genre, vous n'utilisez pas le polymorphisme.

Je comprends que vous ayez besoin de savoir quel type vous avez. Mais tout travail que vous voudriez faire en sachant que cela devrait vraiment être poussé dans la classe. Ensuite, vous lui dites simplement quand le faire.

Le code procédural obtient des informations puis prend des décisions. Le code orienté objet dit aux objets de faire des choses.
- Alec Sharp

Ce principe s'appelle dire, ne demandez pas . Le suivre vous aide à ne pas diffuser de détails comme le type et à créer une logique qui agit sur eux. Faire cela transforme une classe à l'envers. Il est préférable de conserver ce comportement dans la classe afin qu'il puisse changer lorsque la classe change.

Encapsulation

Vous pouvez me dire qu'aucune autre forme ne sera jamais nécessaire, mais je ne vous crois pas et vous non plus.

Un bon effet de l'encapsulation suivante est qu'il est facile d'ajouter de nouveaux types car leurs détails ne sont pas répartis dans le code où ils apparaissent ifet dans la switchlogique. Le code d'un nouveau type doit tous être au même endroit.

Un système de détection de collision de type ignorant

Permettez-moi de vous montrer comment je concevrais un système de détection de collision performant et fonctionnant avec n'importe quelle forme 2D sans se soucier du type.

entrez la description de l'image ici

Dis que tu étais censé dessiner ça. Semble simple. Ce sont tous des cercles. Il est tentant de créer une classe de cercle qui comprend les collisions. Le problème est que cela nous renvoie à une ligne de pensée qui s'effondre lorsque nous avons besoin de 1000 cercles.

Nous ne devrions pas penser aux cercles. Nous devrions penser aux pixels.

Et si je vous disais que le même code que vous utilisez pour dessiner ces gars-là est ce que vous pouvez utiliser pour détecter quand ils se touchent ou même ceux sur lesquels l'utilisateur clique.

entrez la description de l'image ici

Ici, j'ai dessiné chaque cercle avec une couleur unique (si vos yeux sont assez bons pour voir le contour noir, ignorez simplement cela). Cela signifie que chaque pixel de cette image cachée correspond à ce qui l'a attiré. Un hashmap s'en occupe bien. Vous pouvez réellement faire du polymorphisme de cette façon.

Cette image que vous n'avez jamais à montrer à l'utilisateur. Vous le créez avec le même code qui a dessiné le premier. Juste avec des couleurs différentes.

Lorsque l'utilisateur clique sur un cercle, je sais exactement quel cercle car un seul cercle est de cette couleur.

Lorsque je dessine un cercle au-dessus d'un autre, je peux rapidement lire chaque pixel que je suis sur le point d'écraser en les déversant dans un ensemble. Lorsque j'ai terminé les points de consigne pour chaque cercle avec lequel il est entré en collision, je n'ai plus qu'à les appeler une fois pour l'avertir de la collision.

Un nouveau type: les rectangles

Tout cela a été fait avec des cercles mais je vous demande: cela fonctionnerait-il différemment avec des rectangles?

Aucune connaissance du cercle ne s'est infiltrée dans le système de détection. Peu importe le rayon, la circonférence ou le point central. Il se soucie des pixels et des couleurs.

La seule partie de ce système de collision qui doit être enfoncée dans les formes individuelles est une couleur unique. En dehors de cela, les formes peuvent simplement penser à dessiner leurs formes. C'est ce qu'ils sont bons de toute façon.

Maintenant, lorsque vous écrivez la logique de collision, vous ne vous souciez pas du sous-type que vous avez. Vous lui dites d'entrer en collision et il vous indique ce qu'il a trouvé sous la forme qu'il prétend dessiner. Pas besoin de connaître le type. Et cela signifie que vous pouvez ajouter autant de sous-types que vous le souhaitez sans avoir à mettre à jour le code dans d'autres classes.

Choix d'implémentation

Vraiment, il n'a pas besoin d'être d'une couleur unique. Il pourrait s'agir de références d'objets réelles et enregistrer un niveau d'indirection. Mais ceux-ci ne seraient pas aussi beaux lorsqu'ils sont dessinés dans cette réponse.

Ceci n'est qu'un exemple d'implémentation. Il y en a certainement d'autres. Ce que cela était censé montrer, c'est que plus vous laissez ces sous-types de formes s'en tenir à leur seule responsabilité, mieux tout le système fonctionne. Il existe probablement des solutions plus rapides et moins gourmandes en mémoire, mais si elles m'obligent à diffuser les connaissances sur les sous-types, je serais réticent à les utiliser même avec les gains de performances. Je ne les utiliserais que si j'en avais clairement besoin.

Double expédition

Jusqu'à présent, j'ai complètement ignoré la double expédition . Je l'ai fait parce que je le pouvais. Tant que la logique de collision ne se soucie pas des deux types en collision, vous n'en avez pas besoin. Si vous n'en avez pas besoin, ne l'utilisez pas. Si vous pensez que vous pourriez en avoir besoin, remettez-le à plus tard. Cette attitude s'appelle YAGNI .

Si vous décidez que vous avez vraiment besoin de différents types de collisions, demandez-vous si n sous-types de forme ont vraiment besoin de n 2 types de collisions. Jusqu'à présent, j'ai travaillé très dur pour faciliter l'ajout d'un autre sous-type de forme. Je ne veux pas le gâcher avec une implémentation à double répartition qui oblige les cercles à savoir que des carrés existent.

Combien de types de collisions y a-t-il de toute façon? Un peu de spéculation (une chose dangereuse) invente des collisions élastiques (rebondissantes), inélastiques (collantes), énergétiques (explosives) et destructrices (damageuses). Il pourrait y en avoir plus mais si celui-ci est inférieur à n 2, il ne faut pas trop concevoir nos collisions.

Cela signifie que lorsque ma torpille frappe quelque chose qui accepte des dégâts, elle n'a pas à SAVOIR qu'elle a touché un vaisseau spatial. Il suffit de lui dire: "Ha ha! Vous avez subi 5 points de dégâts."

Les objets qui infligent des dégâts envoient des messages de dégâts aux objets qui acceptent les messages de dégâts. De cette façon, vous pouvez ajouter de nouvelles formes sans en parler aux autres formes. Vous finissez par vous propager autour de nouveaux types de collisions.

Le vaisseau spatial peut renvoyer à la torp "Ha ha! Vous avez pris 100 points de dégâts." ainsi que "Vous êtes maintenant collé à ma coque". Et le torp peut renvoyer "Eh bien, j'ai fini pour ainsi m'oublier".

À aucun moment ne sait exactement ce que chacun est. Ils savent juste se parler via une interface de collision.

Maintenant, bien sûr, la double répartition vous permet de contrôler les choses plus intimement que cela, mais voulez-vous vraiment cela ?

Si vous le faites, pensez au moins à faire une double répartition par le biais d'abstractions des types de collisions qu'une forme accepte et non sur la mise en œuvre réelle de la forme. En outre, le comportement de collision est quelque chose que vous pouvez injecter en tant que dépendance et déléguer à cette dépendance.

Performance

La performance est toujours critique. Mais cela ne signifie pas que c'est toujours un problème. Testez les performances. Ne vous contentez pas de spéculer. Sacrifier tout le reste au nom de la performance ne conduit généralement pas à un code de perforation de toute façon.

candied_orange
la source
Continuons cette discussion dans le chat .
candied_orange
+1 pour "Vous pouvez me dire qu'aucune autre forme ne sera jamais nécessaire mais je ne vous crois pas et vous non plus."
Tulains Córdova
Penser aux pixels ne vous mènera nulle part si ce programme ne concerne pas le dessin de formes mais des calculs purement mathématiques. Cette réponse implique que vous devez tout sacrifier à la pureté orientée objet perçue. Il contient également une contradiction: vous dites d'abord que nous devrions baser notre conception entière sur l'idée que nous pourrions avoir besoin de plus de types de formes à l'avenir, puis vous dites "YAGNI". Enfin, vous négligez que faciliter l'ajout de types signifie souvent qu'il est plus difficile d'ajouter des opérations, ce qui est mauvais si la hiérarchie des types est relativement stable mais que les opérations changent beaucoup.
Christian Hackl
7

La description du problème semble que vous devez utiliser des méthodes multimédias (alias Multiple dispatch), dans ce cas particulier - Double dispatch . La première réponse est allée longuement sur la façon de traiter de manière générique les formes en collision dans le rendu raster, mais je pense qu'OP voulait une solution "vectorielle" ou peut-être que le problème entier a été reformulé en termes de formes, qui est un exemple classique dans les explications de la POO.

Même l'article de Wikipédia cité utilise la même métaphore de collision, permettez-moi de citer (Python n'a pas de multiméthodes intégrées comme certains autres langages):

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

Donc, la question suivante est de savoir comment obtenir la prise en charge des multiméthodes dans votre langage de programmation.

Roman Susi
la source
Oui, cas particulier de Multiple dispatch aka Multimethods, ajouté à la réponse
Roman Susi
5

Ce problème nécessite une refonte à deux niveaux.

Tout d'abord, vous devez extraire la logique de détection de la collision entre les formes hors des formes. Ainsi, vous ne violeriez pas l' OCP chaque fois que vous devez ajouter une nouvelle forme au modèle. Imaginez que vous avez déjà défini Circle, Square et Rectangle. Vous pourriez alors le faire comme ceci:

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

Ensuite, vous devez organiser la méthode appropriée à appeler en fonction de la forme qui l'appelle. Vous pouvez le faire en utilisant le polymorphisme et le modèle de visiteur . Pour ce faire, nous devons avoir en place le modèle d'objet approprié. Tout d'abord, toutes les formes doivent adhérer à la même interface:

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

Ensuite, nous devons avoir une classe de visiteurs parents:

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

}

J'utilise une classe ici au lieu de l'interface, car j'ai besoin que chaque objet visiteur ait un attribut de ShapeCollisionDetectortype.

Chaque implémentation d' IShapeinterface instanciera le visiteur approprié et appellera la Acceptméthode appropriée de l'objet avec lequel l'objet appelant interagit, comme ceci:

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

Et des visiteurs spécifiques ressembleraient à ceci:

    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 cette façon, vous n'avez pas besoin de modifier les classes de formes à chaque fois que vous ajoutez une nouvelle forme, et vous n'avez pas besoin de vérifier le type de forme pour appeler la méthode de détection de collision appropriée.

Un inconvénient de cette solution est que si vous ajoutez une nouvelle forme, vous devez étendre la classe ShapeVisitor avec la méthode pour cette forme (par exemple VisitTriangle(Triangle triangle)), et par conséquent, vous devrez implémenter cette méthode dans tous les autres visiteurs. Cependant, comme il s'agit d'une extension, dans le sens où aucune méthode existante n'est modifiée, mais seulement de nouvelles sont ajoutées, cela ne viole pas OCP et la surcharge de code est minime. En outre, en utilisant la classe ShapeCollisionDetector, vous évitez la violation de SRP et vous évitez la redondance de code.

Vladimir Stokic
la source
5

Votre problème de base est que dans la plupart des langages de programmation OO modernes, la surcharge de fonctions ne fonctionne pas avec la liaison dynamique (c'est-à-dire que le type des arguments de fonction est déterminé au moment de la compilation). Vous auriez besoin d'un appel de méthode virtuel virtuel sur deux objets plutôt que sur un seul. Ces méthodes sont appelées multi-méthodes . Cependant, il existe des moyens d' émuler ce comportement dans des langages comme Java, C ++, etc. C'est là que la double répartition est très pratique.

L'idée de base est que vous utilisez le polymorphisme deux fois. Lorsque deux formes entrent en collision, vous pouvez appeler la méthode de collision correcte de l'un des objets par polymorphisme et passer l'autre objet du type de forme générique. Dans la méthode appelée, vous savez alors si cet objet est un cercle, un rectangle ou autre. Vous appelez ensuite la méthode de collision sur l'objet de forme passé et lui passez l' objet this . Ce deuxième appel trouve ensuite à nouveau le type d'objet correct par polymorphisme.

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
  }

  // ...
}

Cependant, un gros inconvénient de cette technique est que chaque classe de la hiérarchie doit connaître tous les frères et sœurs. Cela impose une lourde charge de maintenance si une nouvelle forme est ajoutée ultérieurement.

sigy
la source
2

Ce n'est peut-être pas la meilleure façon d'aborder ce problème

La collision mathématique des formes est particulière aux combinaisons de formes. Cela signifie que le nombre de sous-routines dont vous aurez besoin est le carré du nombre de formes que votre système prend en charge. Les collisions de formes ne sont pas en fait des opérations sur des formes, mais des opérations qui prennent des formes comme paramètres.

Stratégie de surcharge de l'opérateur

Si vous ne pouvez pas simplifier le problème mathématique sous-jacent, je recommanderais l'approche de surcharge de l'opérateur. Quelque chose comme:

 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 ) { ... }
     (...)

Sur l'intialiseur statique, j'utiliserais la réflexion pour faire une carte des méthodes pour implémenter un dispather diynamique sur la méthode de collision générique (Shape s1, Shape s2). L'initialiseur statique peut également avoir une logique pour détecter les fonctions de collision manquantes et les signaler, refusant de charger la classe.

C'est un peu similaire à la surcharge de l'opérateur C ++. En C ++, la surcharge de l'opérateur est très déroutante car vous disposez d'un ensemble fixe de symboles que vous pouvez surcharger. Cependant, le concept est très intéressant et peut être reproduit avec des fonctions statiques.

La raison pour laquelle j'utiliserais cette approche est que la collision n'est pas une opération sur un objet. Une collision est une opération externe qui indique une relation entre deux objets arbitraires. De plus, l'initialiseur statique pourra vérifier si je manque une fonction de collision.

Simplifiez votre problème mathématique si possible

Comme je l'ai mentionné, le nombre de fonctions de collision est le carré du nombre de types de formes. Cela signifie que dans un système avec seulement 20 formes, vous aurez besoin de 400 routines, avec 21 formes 441 et ainsi de suite. Ce n'est pas facilement extensible.

Mais vous pouvez simplifier vos calculs . Au lieu d'étendre la fonction de collision, vous pouvez pixelliser ou trianguler chaque forme. De cette façon, le moteur de collision n'a pas besoin d'être extensible. La collision, la distance, l'intersection, la fusion et plusieurs autres fonctions seront universelles.

Trianguler

Avez-vous remarqué que la plupart des packages et jeux 3D triangulent tout? C'est l'une des formes de simplification des mathématiques. Cela s'applique également aux formes 2D. Les polys peuvent être triangulés. Les cercles et les splines peuvent être approximés en poligones.

Encore une fois ... vous aurez une seule fonction de collision. Votre classe devient alors:

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

Et vos opérations:

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

N'est-ce pas plus simple?

Pixelliser

Vous pouvez pixelliser votre forme pour avoir une seule fonction de collision.

La pixellisation peut sembler être une solution radicale, mais peut être abordable et rapide selon la précision de vos collisions de formes. S'ils n'ont pas besoin d'être précis (comme dans un jeu), vous pouvez avoir des bitmaps basse résolution. La plupart des applications n'ont pas besoin d'une précision absolue sur les mathématiques.

Les approximations peuvent être suffisantes. Le supercalculateur ANTON pour la simulation de la biologie en est un exemple. Ses calculs ignorent de nombreux effets quantiques difficiles à calculer et jusqu'à présent, les simulations effectuées sont cohérentes avec les expériences faites dans le monde réel. Les modèles d'infographie PBR utilisés dans les moteurs de jeu et les packages de rendu apportent des simplifications qui réduisent la puissance informatique nécessaire pour rendre chaque image. N'est pas réellement précis sur le plan physique, mais est suffisamment proche pour être convaincant à l'œil nu.

Lucas
la source