web-dev-qa-db-fra.com

Une bonne conception pour éviter l'utilisation de dynamic_cast?

Après avoir fait quelques recherches, je n'arrive pas à trouver un exemple simple pour résoudre un problème que je rencontre souvent.

Disons que je veux créer une petite application où je peux créer Squares, Circles et d'autres formes, les afficher sur un écran, modifier leurs propriétés après les avoir sélectionnées, puis calculer toutes leurs périmètres.

Je ferais la classe modèle comme ceci:

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

(Imaginez que j'ai plus de classes de formes: triangles, hexagones, avec à chaque fois leurs variables proprers et getters et setters associés. Les problèmes que j'ai rencontrés avaient 8 sous-classes, mais pour l'exemple, je me suis arrêté à 2)

J'ai maintenant un ShapeManager, instanciant et stockant toutes les formes dans un tableau:

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

Enfin, j'ai une vue avec des spinbox pour changer chaque paramètre pour chaque type de forme. Par exemple, lorsque je sélectionne un carré à l'écran, le widget de paramètres affiche uniquement les paramètres liés à Square (grâce à AbstractShape::getType()) et propose de changer la largeur du carré. Pour ce faire, j'ai besoin d'une fonction me permettant de modifier la largeur dans ShapeManager, et voici comment je le fais:

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

Existe-t-il une meilleure conception m'évitant d'utiliser le dynamic_cast Et d'implémenter un couple getter/setter dans ShapeManager pour chaque variable de sous-classe que je pourrais avoir? J'ai déjà essayé d'utiliser modèle mais a échoué .


Le problème auquel je suis confronté n'est pas vraiment avec les formes mais avec différents Jobs pour une imprimante 3D (ex: PrintPatternInZoneJob, TakePhotoOfZone, etc.) avec AbstractJob comme classe de base. La méthode virtuelle est execute() et non getPerimeter(). La seule fois où j'ai besoin d'utiliser concrètement est de remplir les informations spécifiques dont un travail a besoin :

  • PrintPatternInZone a besoin de la liste des points à imprimer, de la position de la zone, de certains paramètres d'impression comme la température

  • TakePhotoOfZone a besoin de la zone à prendre en photo, du chemin où la photo sera sauvegardée, des dimensions, etc ...

Lorsque j'appellerai alors execute(), les Jobs utiliseront les informations spécifiques dont ils disposent pour réaliser l'action qu'ils sont censés faire.

La seule fois où j'ai besoin d'utiliser le type concret d'un Job est quand je remplis ou affiche ces informations (si un TakePhotoOfZoneJob est sélectionné, un widget affichant et modifiant les paramètres de zone, de chemin et de dimensions sera affiché).

Les Jobs sont ensuite placés dans une liste de Jobs qui prennent le premier job, l'exécute (en appelant AbstractJob::execute()), le passe au suivant, et ainsi de suite jusqu'à la fin de la liste. (C'est pourquoi j'utilise l'héritage).

Pour stocker les différents types de paramètres j'utilise un JsonObject:

  • avantages: même structure pour n'importe quel travail, pas de dynamic_cast lors du réglage ou de la lecture des paramètres

  • problème: impossible de stocker des pointeurs (dans Pattern ou Zone)

Pensez-vous qu'il existe une meilleure façon de stocker les données?

Ensuite comment stockeriez-vous le type concret du Job pour l'utiliser quand je dois modifier les paramètres spécifiques de ce type? JobManager n'a qu'une liste de AbstractJob*.

9
ElevenJune

Je voudrais approfondir "l'autre suggestion" d'Emerson Cardoso, car je pense que c'est la bonne approche dans le cas général - bien que vous puissiez bien sûr trouver d'autres solutions mieux adaptées à un problème particulier.

Le problème

Dans votre exemple, la classe AbstractShape possède une méthode getType() qui identifie essentiellement le type concret. C'est généralement un signe que vous n'avez pas une bonne abstraction. Après tout, l'abstrait n'a pas à se soucier des détails du type concret.

De plus, si vous ne le connaissez pas, vous devriez lire sur le principe ouvert/fermé. Il est souvent expliqué avec un exemple de formes, donc vous vous sentirez comme chez vous.

Abstractions utiles

Je suppose que vous avez introduit le AbstractShape parce que vous l'avez trouvé utile pour quelque chose. Très probablement, une partie de votre application doit connaître le périmètre des formes, quelle que soit la forme.

C'est l'endroit où l'abstraction prend tout son sens. Parce que ce module ne se préoccupe pas des formes concrètes, il peut dépendre uniquement de AbstractShape. Pour la même raison, il n'a pas besoin de la méthode getType() - vous devez donc vous en débarrasser.

D'autres parties de l'application ne fonctionneront qu'avec un type particulier de forme, par ex. Rectangle. Ces zones ne bénéficieront pas d'une classe AbstractShape, vous ne devriez donc pas l'utiliser là-bas. Afin de ne transmettre que la forme correcte à ces pièces, vous devez stocker les formes en béton séparément. (Vous pouvez également les stocker sous la forme AbstractShape ou les combiner à la volée).

Minimiser l'utilisation du béton

Il n'y a aucun moyen de contourner cela: vous avez besoin des types de béton à certains endroits - tout au moins pendant la construction. Cependant, il est parfois préférable de limiter l'utilisation de types de béton à quelques zones bien définies. Ces zones distinctes ont pour seul but de traiter les différents types - tandis que toute logique d'application est exclue.

Comment y parvenez-vous? Habituellement, en introduisant plus d'abstractions - qui peuvent ou non refléter les abstractions existantes. Par exemple, votre interface graphique n'a pas vraiment besoin de savoir de quel type de forme il s'agit. Il suffit de savoir qu'il y a une zone sur l'écran où l'utilisateur peut modifier une forme.

Vous définissez donc un ShapeEditView abstrait pour lequel vous avez des implémentations RectangleEditView et CircleEditView qui contiennent les zones de texte réelles pour largeur/hauteur ou rayon.

Dans un premier temps, vous pouvez créer un RectangleEditView chaque fois que vous créez un Rectangle, puis le placer dans un std::map<AbstractShape*, AbstractShapeView*>. Si vous préférez créer les vues selon vos besoins, vous pouvez effectuer les opérations suivantes à la place:

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

De toute façon, le code en dehors de cette logique de création n'aura pas à traiter de formes concrètes. Dans le cadre de la destruction d'une forme, vous devez évidemment supprimer l'usine. Bien sûr, cet exemple est trop simplifié, mais j'espère que l'idée est claire.

Choisir la bonne option

Dans des applications très simples, vous trouverez peut-être qu'une solution sale (coulée) vous donne le plus pour votre argent.

La gestion explicite de listes distinctes pour chaque type de béton est probablement la voie à suivre si votre application traite principalement de formes en béton, mais comporte des parties universelles. Ici, il est logique de n'abstraire que dans la mesure où la fonctionnalité commune l'exige.

Aller jusqu'au bout est généralement payant si vous avez beaucoup de logique qui opère sur les formes, et le type exact de forme est vraiment un détail pour votre application.

10
doubleYou

ne approche serait de rendre les choses plus générales afin de éviter de transtyper en types spécifiques.

Vous pouvez implémenter un getter/setter de base de propriétés de flottement " dimension " dans la classe de base, qui définit une valeur dans une carte, basée sur une clé spécifique pour le nom de la propriété. Exemple ci-dessous:

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

Ensuite, dans votre classe de gestionnaire, vous devez implémenter une seule fonction, comme ci-dessous:

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

Exemple d'utilisation dans la vue:

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

ne autre suggestion:

Étant donné que votre gestionnaire expose uniquement le setter et le calcul de périmètre (qui sont également exposés par Shape), vous pouvez simplement instancier une vue appropriée lorsque vous instanciez une classe Shape spécifique. PAR EXEMPLE:

  • Instanciez un Square et un SquareEditView;
  • Passez l'instance Square à l'objet SquareEditView;
  • (facultatif) Au lieu d'avoir un ShapeManager, dans votre vue principale, vous pouvez toujours conserver une liste de formes;
  • Dans SquareEditView, vous conservez une référence à un carré; cela éliminerait le besoin de lancer pour éditer les objets.
2
Emerson Cardoso