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

9

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 des 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 tous 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 Squareparamètres liés (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 en 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_castet d'implémenter un couple getter / setter ShapeManagerpour chaque variable de sous-classe que je pourrais avoir? J'ai déjà essayé d'utiliser le modèle mais j'ai échoué .


Le problème que je suis confronté est pas vraiment avec des formes mais avec différentes Jobs pour une imprimante 3D (ex: PrintPatternInZoneJob, TakePhotoOfZone, etc.) avec AbstractJobcomme leur 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 quelle zone à prendre en photo, le chemin où la photo sera enregistrée, les dimensions, etc ...

Lorsque j'appellerai ensuite 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 a TakePhotoOfZone Jobest sélectionné, un widget affichant et modifiant les paramètres de zone, de chemin et de dimensions sera affiché).

Les Jobs sont ensuite mis 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 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 (vers Patternou Zone)

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

Alors, comment stockeriez-vous le type concret duJob pour l'utiliser lorsque je dois modifier les paramètres spécifiques de ce type? JobManagern'a qu'une liste de AbstractJob*.

ElevenJune
la source
5
On dirait que votre ShapeManager deviendra une classe God, car il contiendra essentiellement toutes les méthodes de définition pour tous les types de formes.
Emerson Cardoso
Avez-vous envisagé une conception de "sac de propriété"? Tels que changeValue(int shapeIndex, PropertyKey propkey, double numericalValue)PropertyKeypeut être une énumération ou une chaîne, et "Largeur" ​​(ce qui signifie que l'appel au setter mettra à jour la valeur de largeur) est parmi l'une des valeurs autorisées.
rwong
Même si le sac de propriété est considéré comme anti-motif OO par certains, il existe des situations où l'utilisation du sac de propriété simplifie la conception, où toute autre alternative rendra les choses plus compliquées. Cependant, pour déterminer si le sac de propriétés convient à votre cas d'utilisation, plus d'informations sont nécessaires (telles que la façon dont le code GUI interagit avec le getter / setter).
rwong
J'ai considéré la conception du sac de propriété (même si je ne connaissais pas son nom) mais avec un conteneur d'objet JSON. Cela pourrait certainement fonctionner, mais je pensais que ce n'était pas un design élégant et qu'une meilleure option pouvait exister. Pourquoi est-il considéré comme un anti-motif OO?
ElevenJune
Par exemple, si je veux stocker un pointeur pour l'utiliser plus tard, comment faire?
ElevenJune

Réponses:

10

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 AbstractShapeclasse a une getType()méthode 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 AbstractShapecar 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 ne peut dépendre AbstractShapeque de lui. Pour la même raison, il n'a pas besoin de la getType()méthode - vous devez donc vous en débarrasser.

D'autres parties de l'application ne fonctionneront qu'avec un type particulier de forme, par exemple Rectangle. Ces zones ne bénéficieront pas d'une AbstractShapeclasse, 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 les stocker en AbstractShapeplus 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 gardée à l'écart.

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 résumé ShapeEditViewpour lequel vous disposez RectangleEditViewet des CircleEditViewimplémentations qui contiennent les zones de texte réelles pour la largeur / hauteur ou le rayon.

Dans un premier temps, vous pouvez créer un RectangleEditViewchaque 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.

doubleYou
la source
J'aime beaucoup ta réponse, tu as parfaitement décrit le problème. Le problème auquel je suis confronté n'est pas vraiment avec les formes, mais avec différents travaux 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 (liste des points, position, température, etc.) avec un widget spécifique. Attacher une vue à chaque travail ne semble pas être la chose à faire dans ce cas particulier mais je ne vois pas comment adapter votre vision à mon pb.
ElevenJune
Si vous ne souhaitez pas conserver des listes distinctes, vous pouvez utiliser un viewSelector plutôt qu'un viewFactory: [rect, rectView]() { rectView.bind(rect); return rectView; }. Soit dit en passant, cela devrait bien sûr être fait dans le module de présentation, par exemple dans un RectangleCreatedEventHandler.
doubleYou
3
Cela étant dit, essayez de ne pas trop l'ingénier. Le bénéfice de l'abstraction doit encore l'emporter sur le coût de la plomberie supplémentaire. Parfois, une distribution bien placée ou une logique séparée peut être préférable.
doubleYou
2

Une approche consisterait à rendre les choses plus générales afin d' éviter de les transtyper en types spécifiques .

Vous pouvez implémenter un getter / setter de base de propriétés flottantes " 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);

Une 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'occurrence 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.
Emerson Cardoso
la source
J'aime la première suggestion et j'y ai déjà pensé, mais elle est assez limitative si vous souhaitez stocker différentes variables (float, pointeurs, tableaux). Pour la deuxième suggestion, si le carré est déjà instancié (j'ai cliqué dessus sur la vue) comment puis-je savoir que c'est un objet Square * ? la liste stockant les formes renvoie une forme abstraite * .
ElevenJune
@ElevenJune - oui toutes les suggestions ont leurs inconvénients; pour le premier, vous devrez implémenter quelque chose de plus complexe plutôt qu'une simple carte si vous voulez plus de types de propriétés. La deuxième suggestion change la façon dont vous stockez les formes; vous stockez la forme de base dans la liste, mais en même temps, vous devez fournir la référence de la forme spécifique à la vue. Peut-être pourriez-vous fournir plus de détails sur votre scénario, afin que nous puissions évaluer si ces approches sont meilleures que simplement effectuer un dynamic_cast.
Emerson Cardoso
@ElevenJune - l'intérêt d'avoir l'objet view est que votre interface graphique n'a pas besoin de savoir qu'elle fonctionne avec une classe de type Square. L'objet view fournit ce qui est nécessaire pour "visualiser" l'objet (quel que soit ce que vous définissez) et, en interne, il sait qu'il utilise une instance d'une classe Square. L'interface graphique interagit uniquement avec l'instance SquareView. Ainsi, vous ne pouvez pas cliquer sur une classe 'Square'. Vous ne pouvez cliquer que sur une classe SquareView. La modification des paramètres sur le SquareView mettra à jour la classe Square sous-jacente ....
Dunk
... Cette approche pourrait très bien vous permettre de vous débarrasser de votre classe ShapeManager. Cela simplifiera presque certainement votre conception. Je dis toujours que si vous appelez une classe un gestionnaire, puis supposez que c'est une mauvaise conception et trouvez autre chose. Les classes de gestionnaire sont mauvaises pour une multitude de raisons, notamment le problème de la classe de dieu et le fait que personne ne sait ce que la classe fait, peut faire et ne peut pas faire parce que les gestionnaires peuvent faire quoi que ce soit même tangentiellement lié à ce qu'ils gèrent. Vous pouvez parier que les développeurs qui vous suivent profiteront de cela menant à la grosse boule de boue typique.
Dunk
1
... vous avez déjà rencontré ce problème. Pourquoi diable aurait-il un sens pour un gestionnaire d'être celui qui change les dimensions d'une forme? Pourquoi un gestionnaire calculerait-il le périmètre d'une forme? Au cas où vous ne l'auriez pas compris, j'aime la "Autre suggestion".
Dunk