¿Diseño adecuado para evitar el uso de dynamic_cast?

9

Después de hacer algunas investigaciones, parece que no puedo encontrar un ejemplo simple para resolver un problema que encuentro a menudo.

Digamos que quiero crear una pequeña aplicación donde pueda crear Squares, Circles y otras formas, mostrarlas en una pantalla, modificar sus propiedades después de seleccionarlas y luego calcular todos sus perímetros.

Haría la clase de modelo así:

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

(Imagine que tengo más clases de formas: triángulos, hexágonos, cada vez con sus variables de apuntalamiento y getters y setters asociados. Los problemas que enfrenté tenían 8 subclases, pero por el ejemplo me detuve en 2)

Ahora tengo una ShapeManagerinstanciación y almacenamiento de todas las formas en una matriz:

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

Finalmente, tengo una vista con spinboxes para cambiar cada parámetro para cada tipo de forma. Por ejemplo, cuando selecciono un cuadrado en la pantalla, el widget de parámetros solo muestra Squareparámetros relacionados (gracias a AbstractShape::getType()) y propone cambiar el ancho del cuadrado. Para hacer eso, necesito una función que me permita modificar el ancho ShapeManager, y así es como lo hago:

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

¿Hay un mejor diseño que me evite usar dynamic_caste implementar un par getter / setter ShapeManagerpara cada variable de subclase que pueda tener? Ya intenté usar la plantilla pero fallé .


El problema que estoy enfrentando no es realmente con formas pero con diferentes Jobs para una impresora 3D (por ejemplo: PrintPatternInZoneJob, TakePhotoOfZone, etc.) con AbstractJobcomo su clase base. El método virtual es execute()y no getPerimeter(). El único momento en que necesito usar un uso concreto es llenar la información específica que necesita un trabajo :

  • PrintPatternInZone necesita la lista de puntos para imprimir, la posición de la zona, algunos parámetros de impresión como la temperatura

  • TakePhotoOfZone necesita qué zona tomar en la foto, la ruta donde se guardará la foto, las dimensiones, etc.

Cuando llame execute(), los Trabajos utilizarán la información específica que tienen para darse cuenta de la acción que se supone que deben hacer.

El único momento en que necesito usar el tipo concreto de un Trabajo es cuando completo o visualizo estas informaciones (si TakePhotoOfZone Jobse selecciona una, se mostrará un widget que muestra y modifica los parámetros de zona, ruta y dimensiones).

Los Jobs luego se colocan en una lista de Jobs que toman el primer trabajo, lo ejecutan (llamando AbstractJob::execute()), van al siguiente, y continúan hasta el final de la lista. (Por eso uso la herencia).

Para almacenar los diferentes tipos de parámetros utilizo a JsonObject:

  • ventajas: la misma estructura para cualquier trabajo, sin Dynamic_cast al configurar o leer parámetros

  • problema: no puede almacenar punteros (a Patterno Zone)

¿Crees que hay una mejor manera de almacenar datos?

Entonces, ¿cómo almacenarías el tipo concreto deJob usarlo cuando tengo que modificar los parámetros específicos de ese tipo? JobManagersolo tiene una lista de AbstractJob*.

Once junio
fuente
55
Parece que su ShapeManager se convertirá en una clase de Dios, porque básicamente contendrá todos los métodos de establecimiento para todo tipo de formas.
Emerson Cardoso
¿Has considerado un diseño de "bolsa de propiedades"? Por ejemplo, changeValue(int shapeIndex, PropertyKey propkey, double numericalValue)dónde PropertyKeypuede ser una enumeración o una cadena, y "Ancho" (que significa que la llamada al emisor actualizará el valor de ancho) se encuentra entre uno de los valores permitidos.
rwong
A pesar de que algunos consideran que la bolsa de propiedades es un anti-patrón OO, hay situaciones en las que el uso de la bolsa de propiedades simplifica el diseño, donde cualquier otra alternativa hará las cosas más complicadas. Sin embargo, para determinar si la bolsa de propiedades es adecuada para su caso de uso, se necesita más información (como cómo el código GUI interactúa con el captador / definidor).
rwong
Consideré el diseño de la bolsa de propiedades (aunque no sabía su nombre) pero con un contenedor de objetos JSON. Seguro que podría funcionar, pero pensé que no era un diseño elegante y que podría existir una mejor opción. ¿Por qué se considera un anti-patrón OO?
ElevenJune
Por ejemplo, si quiero almacenar un puntero para usarlo más tarde, ¿cómo lo hago?
ElevenJune

Respuestas:

10

Me gustaría ampliar la "otra sugerencia" de Emerson Cardoso porque creo que es el enfoque correcto en el caso general, aunque por supuesto puede encontrar otras soluciones más adecuadas para cualquier problema en particular.

El problema

En su ejemplo, la AbstractShapeclase tiene un getType()método que básicamente identifica el tipo concreto. Esto generalmente es una señal de que no tienes una buena abstracción. El punto principal de la abstracción, después de todo, no es tener que preocuparse por los detalles del tipo concreto.

Además, en caso de que no esté familiarizado con él, debe leer sobre el Principio Abierto / Cerrado. A menudo se explica con un ejemplo de formas, para que te sientas como en casa.

Abstracciones útiles

Supongo que ha introducido el AbstractShapeporque lo encontró útil para algo. Lo más probable es que alguna parte de su aplicación necesite conocer el perímetro de las formas, independientemente de cuál sea la forma.

Este es el lugar donde la abstracción tiene sentido. Debido a que este módulo no se ocupa de formas concretas, solo puede depender de él AbstractShape. Por la misma razón, no necesita el getType()método, por lo que debe deshacerse de él.

Otras partes de la aplicación solo funcionarán con un tipo particular de forma, por ejemplo Rectangle. Esas áreas no se beneficiarán de una AbstractShapeclase, por lo que no debe usarla allí. Para pasar solo la forma correcta a estas partes, debe almacenar las formas de concreto por separado. (Puede almacenarlos AbstractShapeadicionalmente o combinarlos sobre la marcha).

Minimizando el uso de concreto

No hay forma de evitarlo: necesita los tipos de concreto en algunos lugares, al menos durante la construcción. Sin embargo, a veces es mejor mantener el uso de tipos de concreto limitados a unas pocas áreas bien definidas. Estas áreas separadas tienen el único propósito de tratar con los diferentes tipos, mientras que toda la lógica de la aplicación se mantiene fuera de ellos.

¿Cómo lo logras? Por lo general, al introducir más abstracciones, que pueden o no reflejar las abstracciones existentes. Por ejemplo, su GUI realmente no necesita saber con qué tipo de forma se trata. Solo necesita saber que hay un área en la pantalla donde el usuario puede editar una forma.

Por lo tanto, define un resumen ShapeEditViewpara el que tiene RectangleEditViewe CircleEditViewimplementaciones que contienen los cuadros de texto reales para ancho / alto o radio.

En un primer paso, puede crear un RectangleEditViewcada vez que cree un Rectangley luego ponerlo en a std::map<AbstractShape*, AbstractShapeView*>. Si prefiere crear las vistas a medida que las necesita, puede hacer lo siguiente en su lugar:

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

De cualquier manera, el código fuera de esta lógica de creación no tendrá que lidiar con formas concretas. Como parte de la destrucción de una forma, obviamente debes eliminar la fábrica. Por supuesto, este ejemplo está demasiado simplificado, pero espero que la idea sea clara.

Elegir la opción correcta

En aplicaciones muy simples, es posible que una solución sucia (fundición) solo le brinde el máximo rendimiento.

El mantenimiento explícito de listas separadas para cada tipo de concreto es probablemente el camino a seguir si su aplicación trata principalmente con formas de concreto, pero tiene algunas partes que son universales. Aquí, tiene sentido abstraer solo en la medida en que lo requiera la funcionalidad común.

Ir todo el camino generalmente paga si tiene mucha lógica que opera en formas, y el tipo exacto de forma realmente es un detalle para su aplicación.

te doblo
fuente
Realmente me gusta tu respuesta, describiste perfectamente el problema. El problema al que me enfrento no es realmente con formas sino con diferentes trabajos para una impresora 3D (por ejemplo: PrintPatternInZoneJob, TakePhotoOfZone, etc.) con AbstractJob como su clase base. El método virtual es execute () y no getPerimeter (). El único momento en que necesito usar el uso concreto es llenar la información específica que necesita un trabajo (lista de puntos, posición, temperatura, etc.) con un widget específico. Adjuntar una vista a cada trabajo no parece ser lo que hay que hacer en este caso en particular, pero no veo cómo adaptar su visión a mi pb.
ElevenJune
Si no desea mantener listas separadas, se puede utilizar un ViewSelector en lugar de un viewFactory: [rect, rectView]() { rectView.bind(rect); return rectView; }. Por cierto, esto debería hacerse, por supuesto, en el módulo de presentación, por ejemplo, en un RectangleCreatedEventHandler.
doubleYou
3
Dicho esto, trata de no manipular demasiado esto. El beneficio de la abstracción aún debe superar el costo de la caída adicional. A veces un molde bien colocado, o una lógica separada puede ser preferible.
doubleYou
2

Un enfoque sería hacer que las cosas sean más generales para evitar la conversión a tipos específicos .

Podría implementar un getter / setter básico de propiedades flotantes de " dimensión " en la clase base, que establece un valor en un mapa, basado en una clave específica para el nombre de la propiedad. Ejemplo a continuación:

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

Luego, en su clase de administrador, debe implementar solo una función, como a continuación:

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

Ejemplo de uso dentro de la 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);

Otra sugerencia:

Dado que su gerente solo expone el setter y el cálculo del perímetro (que también están expuestos por Shape), puede simplemente crear una Vista adecuada cuando crea una instancia de una clase de Shape específica. P.EJ:

  • Instanciar un Square y un SquareEditView;
  • Pase la instancia de Square al objeto SquareEditView;
  • (opcional) En lugar de tener un ShapeManager, en su vista principal aún podría mantener una lista de Formas;
  • Dentro de SquareEditView, mantiene una referencia a un cuadrado; esto eliminaría la necesidad de emitir para editar los objetos.
Emerson Cardoso
fuente
Me gusta la primera sugerencia y ya lo he pensado, pero es bastante limitante si desea almacenar diferentes variables (flotante, punteros, matrices). Para la segunda sugerencia, si el cuadrado ya está instanciado (hice clic en él en la vista), ¿cómo sé que es un objeto Square * ? la lista que almacena las formas devuelve un AbstractShape * .
ElevenJune
@ElevenJune: sí, todas las sugerencias tienen sus inconvenientes; para el primero necesitaría implementar algo más complejo en lugar de un mapa simple si desea más tipos de propiedades. La segunda sugerencia cambia la forma en que almacena las formas; almacena la forma base en la lista, pero al mismo tiempo debe proporcionar la referencia de la forma específica a la Vista. Tal vez podría proporcionar más detalles acerca de su escenario, para que podamos evaluar si estos enfoques son mejores que simplemente realizar una transmisión dinámica.
Emerson Cardoso
@ElevenJune: el objetivo de tener el objeto de vista es que su GUI no necesita saber que está funcionando con una clase de tipo Square. El objeto de vista proporciona lo que es necesario para "ver" el objeto (lo que sea que defina que sea) e internamente sabe que está usando una instancia de una clase Square. La GUI solo interactúa con la instancia de SquareView. Por lo tanto, no puede hacer clic en una clase 'Cuadrado'. Solo puede hacer clic en una clase de SquareView. Cambiar los parámetros en SquareView actualizará la clase Square subyacente ...
Dunk
... Este enfoque podría permitirte deshacerte de tu clase ShapeManager. Esto seguramente simplificará su diseño. Siempre digo que si llamas gerente a una clase, asumes que es un mal diseño y descubres otra cosa. Las clases de gerentes son malas por una miríada de razones, especialmente el problema de la clase de dios y el hecho de que nadie sabe lo que la clase realmente hace, puede o no puede hacer porque los gerentes pueden hacer cualquier cosa, incluso de manera tangencial en relación con lo que están administrando. Puedes apostar que los desarrolladores que te sigan aprovecharán eso que lleva a la típica gran bola de barro.
Dunk
1
... ya te has encontrado con ese problema. ¿Por qué demonios tendría sentido que un gerente sea el que cambie las dimensiones de una forma? ¿Por qué un gerente calcularía el perímetro de una forma? En caso de que no lo haya resuelto, me gusta la "Otra sugerencia".
Dunk