Qual è una buona pratica di progettazione per evitare di chiedere un tipo di sottoclasse?


11

Ho letto che quando il tuo programma ha bisogno di sapere che classe è un oggetto, di solito indica un difetto di progettazione, quindi voglio sapere qual è una buona pratica per gestirlo. Sto implementando una classe Shape con diverse sottoclassi ereditate da essa come Circle, Polygon o Rectangle e ho algoritmi diversi per sapere se un Circle si scontra con un Polygon o un Rectangle. Quindi supponiamo di avere due istanze di Shape e di voler sapere se una si scontra con l'altra, in quel metodo devo dedurre quale tipo di sottoclasse è l'oggetto che sto scontrando per sapere quale algoritmo dovrei chiamare ma, questo è un cattiva progettazione o pratica? Questo è il modo in cui l'ho risolto.

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

Questo è un cattivo modello di design? Come potrei risolverlo senza dover dedurre i tipi di sottoclasse?


1
Alla fine, ogni istanza, ad esempio Circle, conosce ogni altro tipo di forma. Quindi sono tutti solidi collegati in qualche modo. E non appena aggiungi una nuova forma, come Triangolo, finisci per aggiungere il supporto Triangoli ovunque. Dipende da cosa vuoi cambiare più spesso, aggiungerai nuove forme, questo design è cattivo. Perché hai una soluzione sprawl - Il tuo supporto ai triangoli deve essere aggiunto ovunque. Invece dovresti estrarre il tuo Collisiondetection in una Classe separata, che può funzionare con tutti i tipi e delegare.
thepacker il


IMO questo si riduce ai requisiti di prestazione. Più codice specifico è, più può essere ottimizzato e più veloce verrà eseguito. In questo caso particolare (implementato anche), il controllo del tipo è OK perché i controlli di collisione personalizzati possono essere incredibilmente più veloci di una soluzione generica. Ma quando le prestazioni di runtime non sono fondamentali, seguirei sempre l'approccio generale / polimorfico.
marstato,

Grazie a tutti, nel mio caso le prestazioni sono fondamentali e non aggiungerò nuove forme, forse faccio l'approccio CollisionDetection, tuttavia dovrei ancora conoscere il tipo di sottoclasse, se dovessi tenere un metodo "Type getType ()" in Shape o invece facendo una sorta di "istanza di" con Shape nella classe CollisionDetection?
Alejandro,

1
Non esiste una procedura di collisione efficace tra Shapeoggetti astratti . La tua logica dipende dagli interni di altri oggetti, a meno che tu non stia verificando la collisione per punti diversi bool collide(x, y)(un sottoinsieme di punti di controllo potrebbe essere un buon compromesso). Altrimenti devi controllare il tipo in qualche modo - se c'è davvero bisogno di astrazioni, la produzione di Collisiontipi (per oggetti all'interno dell'attuale area dell'attore) dovrebbe essere l'approccio giusto.
brivido il

Risposte:


13

Polimorfismo

Finché usi getType()o qualcosa del genere, non stai usando il polimorfismo.

Capisco che hai bisogno di sapere che tipo hai. Ma qualsiasi lavoro che vorresti fare sapendo che dovrebbe davvero essere inserito nella classe. Allora gli dici solo quando farlo.

Il codice procedurale ottiene informazioni e prende decisioni. Il codice orientato agli oggetti dice agli oggetti di fare le cose.
- Alec Sharp

Questo principio si chiama dire, non chiedere . In seguito ti aiuta a non diffondere dettagli come digitare e creare la logica che agisce su di essi. In questo modo si capovolge una lezione. È meglio mantenere quel comportamento all'interno della classe in modo che possa cambiare quando cambia la classe.

incapsulamento

Puoi dirmi che non saranno mai necessarie altre forme, ma io non ti credo e nemmeno tu dovresti.

Un buon effetto del seguente incapsulamento è che è facile aggiungere nuovi tipi perché i loro dettagli non si diffondono nel codice in cui si presentano ife nella switchlogica. Il codice per un nuovo tipo dovrebbe essere tutto in un posto.

Un tipo di sistema di rilevamento delle collisioni ignorante

Lascia che ti mostri come progetterei un sistema di rilevamento delle collisioni che è performante e funziona con qualsiasi forma 2D senza preoccuparsi del tipo.

inserisci qui la descrizione dell'immagine

Di 'che avresti dovuto disegnarlo. Sembra semplice Sono tutti cerchi. È allettante creare una classe circolare che capisca le collisioni. Il problema è che questo ci manda giù una linea di pensiero che cade a pezzi quando abbiamo bisogno di 1000 cerchi.

Non dovremmo pensare ai circoli. Dovremmo pensare ai pixel.

E se ti dicessi che lo stesso codice che usi per disegnare questi ragazzi è quello che puoi usare per rilevare quando si toccano o anche su quali l'utente sta facendo clic.

inserisci qui la descrizione dell'immagine

Qui ho disegnato ogni cerchio con un colore unico (se i tuoi occhi sono abbastanza buoni da vedere il contorno nero, ignoralo). Ciò significa che ogni pixel in questa immagine nascosta si ricollega a ciò che l'ha disegnato. Una hashmap si occupa di questo in modo piacevole. Puoi effettivamente fare il polimorfismo in questo modo.

Questa immagine non devi mai mostrarla all'utente. Lo crei con lo stesso codice che ha disegnato il primo. Solo con colori diversi.

Quando l'utente fa clic su un cerchio, so esattamente quale cerchio perché solo un cerchio è quel colore.

Quando disegno un cerchio sopra un altro posso leggere rapidamente ogni pixel che sto per sovrascrivere scaricandoli in un set. Quando ho finito i set point per ogni cerchia con cui si è scontrato e ora devo chiamare ciascuno solo una volta per avvisarlo della collisione.

Un nuovo tipo: rettangoli

Tutto questo è stato fatto con i cerchi ma ti chiedo: funzionerebbe in modo diverso con i rettangoli?

Nessuna conoscenza del cerchio è trapelata nel sistema di rilevamento. Non importa del raggio, della circonferenza o del punto centrale. Si preoccupa per i pixel e il colore.

L'unica parte di questo sistema di collisione che deve essere inserito nelle singole forme è un colore unico. Diverso da quello che le forme possono solo pensare a disegnare le loro forme. È quello che sono bravi in ​​ogni caso.

Ora quando scrivi la logica di collisione non ti interessa quale sottotipo hai. Gli dici di scontrarsi e ti dice cosa ha trovato sotto la forma che finge di disegnare. Non c'è bisogno di conoscere il tipo. Ciò significa che puoi aggiungere tutti i sottotipi che desideri senza dover aggiornare il codice in altre classi.

Scelte di implementazione

Davvero, non deve essere un colore unico. Potrebbe essere un vero riferimento a un oggetto e salvare un livello di riferimento indiretto. Ma quelli non sarebbero così belli se disegnati in questa risposta.

Questo è solo un esempio di implementazione. Ce ne sono sicuramente altri. Ciò che questo doveva mostrare è che più si lasciano attaccare questi sottotipi di forma con la loro unica responsabilità, meglio funziona l'intero sistema. Probabilmente ci sono soluzioni più veloci e meno dispendiose in termini di memoria, ma se mi costringono a diffondere la conoscenza dei sottotipi intorno, sarei detestabile usarli anche con i miglioramenti delle prestazioni. Non li userei se non ne avessi chiaramente bisogno.

Doppia spedizione

Fino ad ora ho completamente ignorato la doppia spedizione . L'ho fatto perché potevo. Fintanto che alla logica di collisione non importa quali due tipi si sono scontrati, non ne hai bisogno. Se non ti serve, non usarlo. Se pensi di averne bisogno, rimandaci il più a lungo possibile. Questo atteggiamento si chiama YAGNI .

Se decidi di aver davvero bisogno di diversi tipi di collisioni, chiedi a te stesso se n sottotipi di forma hanno davvero bisogno di n 2 tipi di collisioni. Finora ho lavorato molto duramente per rendere semplice l'aggiunta di un altro sottotipo di forma. Non voglio rovinarlo con un'implementazione a doppia spedizione che costringe i circoli a sapere che esistono dei quadrati.

Quanti tipi di collisioni ci sono comunque? Un po 'di speculazione (una cosa pericolosa) inventa collisioni elastiche (gonfiabili), anelastiche (appiccicose), energiche (esplosive) e distruttive (dannose). Potrebbero essercene di più, ma se questo è inferiore a n 2, non progettiamo eccessivamente le nostre collisioni.

Ciò significa che quando il mio siluro colpisce qualcosa che accetta danni non deve sapere che colpisce una nave spaziale. Deve solo dirgli "Ah ah! Hai subito 5 punti di danno".

Le cose che infliggono danni inviano messaggi di danno a cose che accettano messaggi di danno. Fatto ciò, puoi aggiungere nuove forme senza dire alle altre forme della nuova forma. Finisci solo per diffonderti attorno a nuovi tipi di collisioni.

La nave spaziale può rimandare al torp "Ah ah! Hai subito 100 punti di danno". così come "Ora sei bloccato sul mio scafo". E il torp può rispedire "Beh, ho finito per dimenticarmi di me".

In nessun momento sa esattamente cosa sia ciascuno. Sanno solo come parlarsi attraverso un'interfaccia di collisione.

Ora, il doppio invio ti consente di controllare le cose più intimamente di così, ma lo vuoi davvero ?

Se lo fai, ti preghiamo almeno di pensare a fare una doppia spedizione attraverso le astrazioni di quali tipi di collisioni accetta una forma e non sull'attuazione effettiva della forma. Inoltre, il comportamento alla collisione è qualcosa che puoi iniettare come dipendenza e delegare a quella dipendenza.

Prestazione

Le prestazioni sono sempre fondamentali. Ma ciò non significa che sia sempre un problema. Test delle prestazioni. Non limitarti a speculare. Sacrificare tutto il resto in nome della performance di solito non porta comunque al codice di perforazione.



+1 per "Puoi dirmi sarà mai necessaria alcuna altre forme, ma io non ti credo e non si dovrebbe."
Tulains Córdova

Pensare ai pixel non ti porterà da nessuna parte se questo programma non tratta di disegnare forme ma di calcoli puramente matematici. Questa risposta implica che dovresti sacrificare tutto per percepire la purezza orientata agli oggetti. Contiene anche una contraddizione: prima dici che dovremmo basare il nostro intero disegno sull'idea che potremmo aver bisogno di più tipi di forme in futuro, quindi dici "YAGNI". Infine, trascuri che rendere più semplice l'aggiunta di tipi spesso significa che è più difficile aggiungere operazioni, il che è negativo se la gerarchia dei tipi è relativamente stabile ma le operazioni cambiano molto.
Christian Hackl,

7

La descrizione del problema sembra che dovresti usare Multimethods (alias Invio multiplo), in questo caso particolare - Doppio invio . La prima risposta è stata approfondita su come gestire genericamente le forme di collisione nel rendering raster, ma credo che OP volesse una soluzione "vettoriale" o forse l'intero problema è stato riformulato in termini di forme, che è un classico esempio nelle spiegazioni di OOP.

Anche l'articolo di Wikipedia citato usa la stessa metafora della collisione, lasciatemi solo citare (Python non ha metodi integrati come in altre lingue):

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

Quindi, la domanda successiva è come ottenere supporto per i metodi multipli nel tuo linguaggio di programmazione.



Sì, caso speciale di invio multiplo alias Multimethods, aggiunto alla risposta
Roman Susi,

5

Questo problema richiede una riprogettazione su due livelli.

Innanzitutto, è necessario estrarre la logica per rilevare la collisione tra le forme fuori dalle forme. Questo è così non violeresti OCP ogni volta che devi aggiungere una nuova forma al modello. Immagina di avere già definito Cerchio, Quadrato e Rettangolo. Potresti quindi farlo in questo modo:

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

Successivamente, è necessario disporre che venga chiamato il metodo appropriato in base alla forma che lo chiama. Puoi farlo usando il polimorfismo e il modello di visitatore . Per raggiungere questo obiettivo, dobbiamo disporre del modello a oggetti appropriato. Innanzitutto, tutte le forme devono aderire alla stessa interfaccia:

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

Successivamente, dobbiamo avere una classe visitatore genitore:

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

}

Sto usando una classe qui invece dell'interfaccia, perché ho bisogno che ogni oggetto visitatore abbia un attributo di ShapeCollisionDetectortipo.

Ogni implementazione IShapedell'interfaccia creerebbe un'istanza del visitatore appropriato e chiamerebbe il Acceptmetodo appropriato dell'oggetto con cui l'oggetto chiamante interagisce, in questo modo:

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

E i visitatori specifici sarebbero così:

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

In questo modo, non è necessario modificare le classi di forma ogni volta che si aggiunge una nuova forma e non è necessario verificare il tipo di forma per chiamare il metodo di rilevamento delle collisioni appropriato.

Uno svantaggio di questa soluzione è che se aggiungi una nuova forma, devi estendere la classe ShapeVisitor con il metodo per quella forma (ad esempio VisitTriangle(Triangle triangle)) e, di conseguenza, dovresti implementare quel metodo in tutti gli altri visitatori. Tuttavia, poiché si tratta di un'estensione, nel senso che non vengono modificati metodi esistenti, ma ne vengono aggiunti solo nuovi, ciò non viola OCP e l'overhead del codice è minimo. Inoltre, utilizzando la classe ShapeCollisionDetector, si evita la violazione di SRP ed si evita la ridondanza del codice.


5

Il problema di base è che nella maggior parte dei moderni linguaggi di programmazione OO il sovraccarico delle funzioni non funziona con l'associazione dinamica (ovvero il tipo di argomenti della funzione viene determinato al momento della compilazione). Ciò di cui avresti bisogno è una chiamata di metodo virtuale virtuale su due oggetti anziché su uno solo. Tali metodi sono chiamati multi-metodi . Tuttavia, ci sono modi per emulare questo comportamento in linguaggi come Java, C ++, ecc. È qui che il doppio dispaccio è molto utile.

L'idea di base è che usi il polimorfismo due volte. Quando due forme si scontrano, è possibile chiamare il metodo di collisione corretto di uno degli oggetti attraverso il polimorfismo e passare l'altro oggetto del tipo di forma generico. Nel metodo chiamato allora sai se questo oggetto è un cerchio, un rettangolo o altro. Quindi si chiama il metodo di collisione sull'oggetto forma passato e lo si passa a questo oggetto. Questa seconda chiamata trova di nuovo il tipo di oggetto corretto attraverso il 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
  }

  // ...
}

Un grande svantaggio di questa tecnica, tuttavia, è che ogni classe della gerarchia deve conoscere tutti i fratelli. Ciò comporta un elevato onere di manutenzione se una nuova forma viene aggiunta in un secondo momento.


2

Forse questo non è il modo migliore per affrontare questo problema

La collisione tra forme matematiche è particolare delle combinazioni di forme. Ciò significa che il numero di sotto-routine di cui avrai bisogno è il quadrato del numero di forme supportate dal tuo sistema. Le collisioni di forme non sono in realtà operazioni su forme, ma operazioni che prendono forme come parametri.

Strategia di sovraccarico dell'operatore

Se non è possibile semplificare il problema matematico sottostante, consiglierei l'approccio di sovraccarico dell'operatore. Qualcosa di simile a:

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

Sull'intializzatore statico userei la riflessione per fare una mappa dei metodi per implementare un dispatch diinamico sul metodo di collisione generica (Shape s1, Shape s2). L'intializzatore statico può anche avere una logica per rilevare le funzioni di collisione mancanti e segnalarle, rifiutando di caricare la classe.

Questo è simile al sovraccarico dell'operatore C ++. In C ++ il sovraccarico dell'operatore è molto confuso perché hai un set fisso di simboli che puoi sovraccaricare. Tuttavia, il concetto è molto interessante e può essere replicato con funzioni statiche.

Il motivo per cui vorrei usare questo approccio è che la collisione non è un'operazione su un oggetto. Una collisione è un'operazione esterna che dice una relazione su due oggetti arbitrari. Inoltre, l'inizializzatore statico sarà in grado di verificare se mi manca qualche funzione di collisione.

Semplifica il tuo problema di matematica, se possibile

Come ho già detto, il numero di funzioni di collisione è il quadrato del numero di tipi di forma. Ciò significa che in un sistema con solo 20 forme avrai bisogno di 400 routine, con 21 forme 441 e così via. Questo non è facilmente estensibile.

Ma puoi semplificare la tua matematica . Invece di estendere la funzione di collisione è possibile rasterizzare o triangolare ogni forma. In questo modo il motore di collisione non deve essere estensibile. Collisione, Distanza, Intersezione, Fusione e molte altre funzioni saranno universali.

Triangola

Hai notato che la maggior parte dei pacchetti e giochi 3d triangolano tutto? Questa è una delle forme di semplificazione della matematica. Questo vale anche per le forme 2D. I polys possono essere triangolati. I cerchi e le spline possono essere approssimati ai poligoni.

Ancora una volta ... avrai una singola funzione di collisione. La tua classe diventa quindi:

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

E le tue operazioni:

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

Più semplice no?

Rasterizza

Puoi rasterizzare la tua forma per avere un'unica funzione di collisione.

La rasterizzazione può sembrare una soluzione radicale, ma può essere conveniente e veloce a seconda di quanto devono essere precise le collisioni della forma. Se non hanno bisogno di essere precisi (come in un gioco), potresti avere bitmap a bassa risoluzione. La maggior parte delle applicazioni non richiede una precisione assoluta in matematica.

Le approssimazioni possono essere abbastanza buone. Il supercomputer ANTON per la simulazione della biologia è un esempio. La sua matematica scarta molti effetti quantici che sono difficili da calcolare e finora le simulazioni fatte sono coerenti con gli esperimenti fatti nel mondo reale. I modelli di grafica computerizzata PBR utilizzati nei motori di gioco e nei pacchetti di rendering rendono semplificazioni che riducono la potenza del computer necessaria per eseguire il rendering di ciascun fotogramma. In realtà non è fisicamente preciso ma è abbastanza vicino da essere convincente ad occhio nudo.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.