Progettazione corretta per evitare l'uso di dynamic_cast?


9

Dopo aver fatto alcune ricerche, non riesco a trovare un semplice esempio per risolvere un problema che incontro spesso.

Diciamo che voglio creare una piccola applicazione in cui posso creare Squares, Circles e altre forme, visualizzarle su uno schermo, modificare le loro proprietà dopo averle selezionate e quindi calcolare tutti i loro perimetri.

Farei la classe del modello in questo modo:

class AbstractShape
{
public :
    typedef enum{
        SQUARE = 0,
        CIRCLE,
    } SHAPE_TYPE;

    AbstractShape(SHAPE_TYPE type):m_type(type){}
    virtual ~AbstractShape();

    virtual float computePerimeter() const = 0;

    SHAPE_TYPE getType() const{return m_type;}
protected :
    const SHAPE_TYPE  m_type;
};

class Square : public AbstractShape
{
public:
    Square():AbstractShape(SQUARE){}
    ~Square();

    void setWidth(float w){m_width = w;}
    float getWidth() const{return m_width;}

    float computePerimeter() const{
        return m_width*4;
    }

private :
    float m_width;
};

class Circle : public AbstractShape
{
public:
    Circle():AbstractShape(CIRCLE){}
    ~Circle();

    void setRadius(float w){m_radius = w;}
    float getRadius() const{return m_radius;}

    float computePerimeter() const{
        return 2*M_PI*m_radius;
    }

private :
    float m_radius;
};

(Immagina di avere più classi di forme: triangoli, esagoni, con ogni volta variabili dei loro proprietari e getter e setter associati. I problemi che ho affrontato avevano 8 sottoclassi, ma per l'esempio mi sono fermato a 2)

Ora ho ShapeManagerun'istanza e l'archiviazione di tutte le forme in un array:

class ShapeManager
{
public:
    ShapeManager();
    ~ShapeManager();

    void addShape(AbstractShape* shape){
        m_shapes.push_back(shape);
    }

    float computeShapePerimeter(int shapeIndex){
        return m_shapes[shapeIndex]->computePerimeter();
    }


private :
    std::vector<AbstractShape*> m_shapes;
};

Infine, ho una vista con spinbox per modificare ogni parametro per ogni tipo di forma. Ad esempio, quando seleziono un quadrato sullo schermo, il widget parametri visualizza solo i Squareparametri correlati (grazie a AbstractShape::getType()) e propone di modificare la larghezza del quadrato. Per fare ciò ho bisogno di una funzione che mi permetta di modificare la larghezza in ShapeManager, ed è così che lo faccio:

void ShapeManager::changeSquareWidth(int shapeIndex, float width){
   Square* square = dynamic_cast<Square*>(m_shapes[shapeIndex]);
   assert(square);
   square->setWidth(width);
}

Esiste un design migliore che mi evita di usare dynamic_caste di implementare una coppia getter / setter ShapeManagerper ogni sottoclasse che posso avere? Ho già provato a utilizzare il modello ma non ci sono riuscito .


Il problema che sto affrontando non è davvero con le forme, ma con diverse Jobs per una stampante 3D (es: PrintPatternInZoneJob, TakePhotoOfZone, etc.) con AbstractJobla loro classe di base. Il metodo virtuale è execute()e non getPerimeter(). L'unica volta che ho bisogno di usare un uso concreto è riempire le informazioni specifiche di cui un lavoro ha bisogno :

  • PrintPatternInZone necessita dell'elenco dei punti da stampare, della posizione della zona, di alcuni parametri di stampa come la temperatura

  • TakePhotoOfZone ha bisogno di quale zona prendere nella foto, il percorso in cui la foto verrà salvata, le dimensioni, ecc ...

Quando chiamerò execute(), i lavori utilizzeranno le informazioni specifiche che devono realizzare per realizzare l'azione che dovrebbero fare.

L'unica volta che ho bisogno di usare il tipo concreto di un lavoro è quando riempio o visualizzo queste informazioni (se TakePhotoOfZone Jobviene selezionato un, verrà mostrato un widget che mostra e modifica i parametri di zona, percorso e dimensioni).

Gli Jobs vengono quindi inseriti in un elenco di Jobs che accettano il primo lavoro, lo eseguono (chiamando AbstractJob::execute()), il passaggio al successivo, avanti e indietro fino alla fine dell'elenco. (Ecco perché uso l'ereditarietà).

Per memorizzare i diversi tipi di parametri utilizzo un JsonObject:

  • vantaggi: stessa struttura per qualsiasi lavoro, nessun dynamic_cast durante l'impostazione o la lettura dei parametri

  • problema: impossibile memorizzare i puntatori (su Patterno Zone)

Credi che esista un modo migliore di archiviare i dati?

Quindi come memorizzeresti il ​​tipo concreto diJob per usarlo quando devo modificare i parametri specifici di quel tipo? JobManagerha solo un elenco di AbstractJob*.


5
Sembra che il tuo ShapeManager diventerà una classe God, perché conterrà fondamentalmente tutti i metodi setter per tutti i tipi di forme.
Emerson Cardoso,

Hai considerato un design "bag di proprietà"? Ad esempio, changeValue(int shapeIndex, PropertyKey propkey, double numericalValue)dove PropertyKeypuò essere un enum o una stringa e "Width" (che indica che la chiamata al setter aggiornerà il valore di width) è uno dei valori consentiti.
dal

Anche se la borsa della proprietà è considerata da alcuni anti-pattern OO, ci sono situazioni in cui l'utilizzo della borsa della proprietà semplifica il design, dove ogni altra alternativa renderà le cose più complicate. Tuttavia, per determinare se la borsa della proprietà è adatta al tuo caso d'uso, sono necessarie ulteriori informazioni (ad esempio come il codice GUI interagisce con il getter / setter).
rwong

Ho considerato il design della borsa delle proprietà (anche se non ne conoscevo il nome) ma con un contenitore di oggetti JSON. Poteva sicuramente funzionare, ma pensavo che non fosse un design elegante e che potesse esistere un'opzione migliore. Perché è considerato un anti-pattern OO?
ElevenJune

Ad esempio, se voglio archiviare un puntatore per usarlo in un secondo momento, come posso fare?
ElevenJune

Risposte:


10

Vorrei ampliare l '"altro suggerimento" di Emerson Cardoso perché ritengo che sia l'approccio corretto nel caso generale, anche se ovviamente è possibile trovare altre soluzioni più adatte a qualsiasi problema specifico.

Il problema

Nel tuo esempio, la AbstractShapeclasse ha un getType()metodo che identifica sostanzialmente il tipo concreto. Questo è generalmente un segno che non hai una buona astrazione. Il punto fondamentale dell'astrattismo, dopo tutto, non deve preoccuparsi dei dettagli di tipo concreto.

Inoltre, nel caso in cui non si abbia familiarità con esso, è necessario leggere il principio aperto / chiuso. Viene spesso spiegato con un esempio di forme, quindi ti sentirai come a casa.

Astrazioni utili

Presumo che tu l'abbia introdotto AbstractShapeperché l'hai trovato utile per qualcosa. Molto probabilmente, alcune parti dell'applicazione devono conoscere il perimetro delle forme, indipendentemente da quale sia la forma.

Questo è il luogo in cui l'astrazione ha un senso. Poiché questo modulo non si occupa di forme concrete, può dipendere AbstractShapesolo da. Per lo stesso motivo, non ha bisogno del getType()metodo, quindi dovresti sbarazzartene.

Altre parti dell'applicazione funzioneranno solo con un particolare tipo di forma, ad es Rectangle. Queste aree non beneficeranno di una AbstractShapelezione, quindi non dovresti usarla lì. Per passare solo la forma corretta a queste parti, è necessario conservare le forme concrete separatamente. (Puoi memorizzarli come AbstractShapeaggiuntivi o combinarli al volo).

Riduzione al minimo dell'utilizzo concreto

Non c'è modo di aggirarlo: in alcuni punti sono necessari i tipi di calcestruzzo, almeno durante la costruzione. Tuttavia, a volte è meglio limitare l'uso di tipi di calcestruzzo limitati ad alcune aree ben definite. Queste aree separate hanno il solo scopo di trattare i diversi tipi, mentre tutta la logica dell'applicazione è tenuta fuori da essi.

Come ci riesci? Di solito, introducendo più astrazioni - che possono o meno rispecchiare le astrazioni esistenti. Ad esempio, la tua GUI non ha davvero bisogno di sapere con che tipo di forma ha a che fare. Deve solo sapere che c'è un'area sullo schermo in cui l'utente può modificare una forma.

Quindi definisci un abstract ShapeEditViewper il quale hai RectangleEditViewe le CircleEditViewimplementazioni che contengono le caselle di testo effettive per larghezza / altezza o raggio.

In un primo momento, è possibile creare un RectangleEditViewogni volta che si crea un Rectanglee quindi lo si inserisce in un std::map<AbstractShape*, AbstractShapeView*>. Se invece preferisci creare le viste di cui hai bisogno, puoi invece procedere come segue:

std::map<AbstractShape*, std::function<AbstractShapeView*()>> viewFactories;
// ...
auto rect = new Rectangle();
// ...
auto viewFactory = [rect]() { return new RectangleEditView(rect); }
viewFactories[rect] = viewFactory;

In ogni caso, il codice al di fuori di questa logica di creazione non dovrà occuparsi di forme concrete. Come parte della distruzione di una forma, è necessario rimuovere la fabbrica, ovviamente. Naturalmente, questo esempio è troppo semplificato, ma spero che l'idea sia chiara.

Scegliere l'opzione giusta

In applicazioni molto semplici, potresti scoprire che una soluzione sporca (casting) ti dà solo il massimo del guadagno.

Mantenere esplicitamente elenchi separati per ogni tipo di calcestruzzo è probabilmente la strada da percorrere se l'applicazione si occupa principalmente di forme concrete, ma ha alcune parti che sono universali. Qui, ha senso astrarre solo nella misura in cui la funzionalità comune lo richiede.

Andare fino in fondo in genere paga se si dispone di molta logica che opera sulle forme e il tipo esatto di forma è davvero un dettaglio per la propria applicazione.


Mi piace molto la tua risposta, hai descritto perfettamente il problema. Il problema che sto affrontando non è proprio con le forme ma con diversi lavori per una stampante 3D (ad esempio: PrintPatternInZoneJob, TakePhotoOfZone, ecc.) Con AbstractJob come classe base. Il metodo virtuale è execute () e non getPerimeter (). L'unica volta che ho bisogno di usare un uso concreto è riempire le informazioni specifiche di un lavoro (elenco di punti, posizione, temperatura, ecc.) Con un widget specifico. Associare una vista a ciascun lavoro non sembra essere la cosa da fare in questo caso particolare, ma non vedo come adattare la tua visione al mio pb.
Undici giugno

Se non si desidera mantenere elenchi separati, è possibile utilizzare un ViewSelector piuttosto che un viewFactory: [rect, rectView]() { rectView.bind(rect); return rectView; }. A proposito, questo dovrebbe ovviamente essere fatto nel modulo di presentazione, ad esempio in un RectangleCreatedEventHandler.
doubleYou

3
Detto questo, cerca di non progettare troppo questo. Il vantaggio dell'astrazione deve comunque superare il costo del piumaggio aggiuntivo. A volte può essere preferibile un cast ben posizionato o una logica separata.
doubleYou

2

Un approccio sarebbe quello di rendere le cose più generali al fine di evitare il casting su tipi specifici .

È possibile implementare un getter / setter di base delle proprietà float " dimension " nella classe base, che imposta un valore in una mappa, basato su una chiave specifica per il nome della proprietà. Esempio sotto:

class AbstractShape
{
public :
    typedef enum{
        SQUARE = 0,
        CIRCLE,
    } SHAPE_TYPE;

    AbstractShape(SHAPE_TYPE type):m_type(type){}
    virtual ~AbstractShape();

    virtual float computePerimeter() const = 0;

    void setDimension(const std::string& name, float v){ m_dimensions[name] = v; }
    float getDimension() const{ return m_dimensions[name]; }

    SHAPE_TYPE getType() const{return m_type;}

protected :
    const SHAPE_TYPE  m_type;
    std::map<std::string, float> m_dimensions;
};

Quindi, nella tua classe manager devi implementare solo una funzione, come di seguito:

void ShapeManager::changeShapeDimension(const int shapeIndex, const std::string& dimension, float value){
   m_shapes[shapeIndex]->setDimension(name, value);
}

Esempio di utilizzo all'interno della vista:

ShapeManager shapeManager;

shapeManager.addShape(new Circle());
shapeManager.changeShapeDimension(0, "RADIUS", 5.678f);
float circlePerimeter = shapeManager.computeShapePerimeter(0);

shapeManager.addShape(new Square());
shapeManager.changeShapeDimension(1, "WIDTH", 2.345f);
float squarePerimeter = shapeManager.computeShapePerimeter(1);

Un altro suggerimento:

Poiché il tuo manager espone solo il setter e il calcolo perimetrale (che sono esposti anche da Shape), puoi semplicemente creare un'istanza di una vista corretta quando crei un'istanza di una classe Shape specifica. PER ESEMPIO:

  • Crea un'istanza di Square e SquareEditView;
  • Passa l'istanza Square all'oggetto SquareEditView;
  • (facoltativo) Invece di avere un ShapeManager, nella vista principale potresti comunque mantenere un elenco di forme;
  • All'interno di SquareEditView, si mantiene un riferimento a un quadrato; questo eliminerebbe la necessità di eseguire il casting per la modifica degli oggetti.

Mi piace il primo suggerimento e ci ho già pensato, ma è abbastanza limitante se si desidera memorizzare variabili diverse (float, puntatori, array). Per il secondo suggerimento, se il quadrato è già istanziato (ho cliccato su di esso nella vista) come faccio a sapere che è un oggetto Square * ? l'elenco che memorizza le forme restituisce un AbstractShape * .
ElevenJune

@ElevenJune - sì, tutti i suggerimenti hanno i loro svantaggi; per la prima dovresti implementare qualcosa di più complesso piuttosto che una semplice mappa se vuoi più tipi di proprietà. Il secondo suggerimento cambia il modo in cui memorizzi le forme; memorizzi la forma base nell'elenco, ma allo stesso tempo devi fornire il riferimento della forma specifica alla vista. Forse potresti fornire maggiori dettagli sul tuo scenario, in modo da poter valutare se questi approcci sono migliori della semplice esecuzione di un Dynamic_cast.
Emerson Cardoso,

@ElevenJune - il punto fondamentale di avere l'oggetto view è quindi la tua GUI non deve sapere che sta funzionando con una classe di tipo Square. L'oggetto view fornisce ciò che è necessario per "visualizzare" l'oggetto (qualunque cosa tu lo definisca) e internamente sa che sta usando un'istanza di una classe Square. La GUI interagisce solo con l'istanza di SquareView. Pertanto, non è possibile fare clic su una classe "Square". Puoi solo fare clic su una classe SquareView. La modifica dei parametri su SquareView aggiornerà la classe Square sottostante ....
Dunk

... Questo approccio potrebbe benissimo farti sbarazzare della tua classe ShapeManager. Ciò semplificherà quasi sicuramente il tuo design. Dico sempre che se chiami una classe un Manager, presumi che sia un cattivo design e capisca qualcos'altro. Le classi del manager sono sbagliate per una miriade di ragioni, in particolare il problema della classe divina e il fatto che nessuno sa cosa faccia effettivamente la classe, può fare e non può fare perché i manager possono fare qualsiasi cosa anche tangenzialmente correlata a ciò che stanno gestendo. Puoi scommettere che gli sviluppatori che ti seguono trarranno vantaggio da quello che porta alla tipica grande palla di fango.
Dunk il

1
... hai già riscontrato questo problema. Perché mai avrebbe senso per un manager essere quello che cambia le dimensioni di una forma? Perché un manager dovrebbe calcolare il perimetro di una forma? Nel caso in cui non l'avessi capito, mi piace "Un altro suggerimento".
Dunk il
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.