Design adequado para evitar o uso de dynamic_cast?

9

Depois de fazer algumas pesquisas, não consigo encontrar um exemplo simples para resolver um problema que encontro com frequência.

Digamos que eu queira criar um pequeno aplicativo em que possa criar Squares, se Circleoutras formas, exibi-las em uma tela, modificar suas propriedades depois de selecioná-las e depois calcular todos os seus perímetros.

Eu faria a classe model como esta:

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 eu tenho mais classes de formas: triângulos, hexágonos, com cada vez que suas variáveis ​​de proprietários e getters e setters associados. Os problemas que enfrentei tinham 8 subclasses, mas pelo exemplo parei em 2)

Agora tenho um ShapeManager, instanciando e armazenando todas as formas em uma 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, tenho uma visualização com caixas de rotação para alterar cada parâmetro para cada tipo de forma. Por exemplo, quando eu seleciono um quadrado na tela, o widget de parâmetro exibe apenas Squareparâmetros relacionados (graças a AbstractShape::getType()) e propõe a alteração da largura do quadrado. Para fazer isso, preciso de uma função que permita modificar a largura ShapeManager, e é assim que eu faço:

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

Existe um design melhor que me evite usar dynamic_caste implementar um casal getter / setter ShapeManagerpara cada variável de subclasse que eu possa ter? Eu já tentei usar o modelo, mas falhei .


O problema que estou enfrentando não é realmente com formas, mas com diferentes Jobs para uma impressora 3D (ex: PrintPatternInZoneJob, TakePhotoOfZone, etc.) com AbstractJobcomo sua classe base. O método virtual é execute()e não getPerimeter(). O único momento em que preciso usar o uso concreto é preencher as informações específicas de que um trabalho precisa :

  • PrintPatternInZone precisa da lista de pontos para imprimir, da posição da zona, de alguns parâmetros de impressão, como a temperatura

  • TakePhotoOfZone precisa de qual zona levar na foto, o caminho onde a foto será salva, as dimensões, etc ...

Quando eu ligarei execute(), os Jobs usarão as informações específicas que eles têm para realizar a ação que devem executar.

O único momento em que preciso usar o tipo concreto de um trabalho é quando eu preencher ou exibir essas informações (se a TakePhotoOfZone Jobfor selecionada, um widget exibindo e modificando os parâmetros de zona, caminho e dimensões será mostrado).

Os Jobs são então colocados em uma lista de Jobs que recebem o primeiro trabalho, o executam (chamando AbstractJob::execute()), passam para o próximo, até o final da lista. (É por isso que eu uso herança).

Para armazenar os diferentes tipos de parâmetros, eu uso um JsonObject:

  • vantagens: mesma estrutura para qualquer trabalho, sem dynamic_cast ao definir ou ler parâmetros

  • problema: não é possível armazenar ponteiros (para Patternou Zone)

Você acha que existe uma maneira melhor de armazenar dados?

Então, como você armazenaria o tipo concreto doJob para usá-lo quando eu tiver que modificar os parâmetros específicos desse tipo? JobManagersó tem uma lista de AbstractJob*.

ElevenJune
fonte
5
Parece que o ShapeManager se tornará uma classe God, porque basicamente conterá todos os métodos de configuração para todos os tipos de formas.
Emerson Cardoso
Você já considerou um design de "bolsa de propriedades"? Como changeValue(int shapeIndex, PropertyKey propkey, double numericalValue)where PropertyKeypode ser uma enumeração ou string, e "Width" (que significa que a chamada ao setter atualizará o valor da width) está entre um dos valores permitidos.
Rwong
Embora algumas propriedades sejam consideradas anti-padrão OO por alguns, há situações em que a propriedade bag simplifica o design, onde todas as outras alternativas tornam as coisas mais complicadas. No entanto, para determinar se o pacote de propriedades é adequado ao seu caso de uso, são necessárias mais informações (como a codificação da GUI interage com o getter / setter).
Rwong
Eu considerei o design do conjunto de propriedades (embora não soubesse seu nome), mas com um contêiner de objeto JSON. Com certeza poderia funcionar, mas eu pensei que não era um design elegante e que poderia existir uma opção melhor. Por que é considerado um anti-padrão OO?
ElevenJune
Por exemplo, se eu quiser armazenar um ponteiro para usá-lo mais tarde, como eu faço?
precisa saber é o seguinte

Respostas:

10

Gostaria de expandir a "outra sugestão" de Emerson Cardoso, porque acredito que seja a abordagem correta no caso geral - embora você possa, é claro, encontrar outras soluções mais adequadas a qualquer problema em particular.

O problema

No seu exemplo, a AbstractShapeclasse possui um getType()método que identifica basicamente o tipo concreto. Isso geralmente é um sinal de que você não tem uma boa abstração. Afinal, o objetivo de abstrair não é ter que se preocupar com os detalhes do tipo concreto.

Além disso, caso você não esteja familiarizado com isso, você deve ler o Princípio Aberto / Fechado. Muitas vezes é explicado com um exemplo de formas, para que você se sinta em casa.

Abstrações úteis

Suponho que você tenha introduzido o AbstractShapeporque achou útil para alguma coisa. Provavelmente, alguma parte do seu aplicativo precisa conhecer o perímetro das formas, independentemente de qual seja a forma.

Este é o lugar onde a abstração faz sentido. Como este módulo não se preocupa com formas concretas, ele pode depender AbstractShapeapenas. Pelo mesmo motivo, ele não precisa do getType()método - portanto, você deve se livrar dele.

Outras partes do aplicativo funcionarão apenas com um tipo específico de forma, por exemplo Rectangle. Essas áreas não se beneficiarão de uma AbstractShapeaula, portanto você não deve usá-la lá. Para passar apenas a forma correta para essas peças, é necessário armazenar formas concretas separadamente. (Você pode armazená-los como AbstractShapeadicionalmente ou combiná-los rapidamente).

Minimizando o uso do concreto

Não há como contornar: você precisa dos tipos de concreto em alguns lugares - pelo menos durante a construção. No entanto, às vezes é melhor manter o uso de tipos de concreto limitado a algumas áreas bem definidas. Essas áreas separadas têm o único objetivo de lidar com os diferentes tipos - enquanto toda a lógica do aplicativo é mantida fora deles.

Como você consegue isso? Geralmente, introduzindo mais abstrações - o que pode ou não refletir as abstrações existentes. Por exemplo, sua GUI realmente não precisa saber com que tipo de forma está lidando. Ele só precisa saber que existe uma área na tela onde o usuário pode editar uma forma.

Assim, você define um resumo ShapeEditViewpara o qual possui RectangleEditViewe CircleEditViewimplementações que mantêm as caixas de texto reais para largura / altura ou raio.

Em uma primeira etapa, você pode criar um RectangleEditViewsempre que criar um Rectanglee depois colocá-lo em um std::map<AbstractShape*, AbstractShapeView*>. Se você preferir criar as visualizações conforme necessário, faça o seguinte:

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

De qualquer forma, o código fora dessa lógica de criação não precisará lidar com formas concretas. Como parte da destruição de uma forma, é necessário remover a fábrica, obviamente. Obviamente, este exemplo é simplificado demais, mas espero que a idéia seja clara.

Escolhendo a opção certa

Em aplicações muito simples, você pode achar que uma solução suja (fundição) oferece o melhor retorno possível.

A manutenção explícita de listas separadas para cada tipo de concreto é provavelmente o caminho a percorrer se o seu aplicativo lida principalmente com formas de concreto, mas tem algumas partes que são universais. Aqui, faz sentido abstrair apenas na medida em que a funcionalidade comum exigir.

Em geral, vale a pena percorrer todo o caminho se você tiver muita lógica que opera em formas, e o tipo exato de forma realmente é um detalhe do seu aplicativo.

doubleYou
fonte
Eu realmente gosto da sua resposta, você descreveu perfeitamente o problema. O problema que estou enfrentando não é realmente com o Shapes, mas com trabalhos diferentes para uma impressora 3D (por exemplo: PrintPatternInZoneJob, TakePhotoOfZone, etc.) com o AbstractJob como sua classe base. O método virtual é execute () e não getPerimeter (). O único momento em que preciso usar o uso concreto é preencher as informações específicas necessárias para um trabalho (lista de pontos, posição, temperatura, etc.) com um widget específico. Anexar uma visão a cada trabalho não parece ser a coisa a se fazer nesse caso específico, mas não vejo como adaptar sua visão ao meu pb.
precisa saber é o seguinte
Se você não quiser manter listas separadas, você pode usar um ViewSelector em vez de um viewFactory: [rect, rectView]() { rectView.bind(rect); return rectView; }. A propósito, é claro que isso deve ser feito no módulo de apresentação, por exemplo, em um RectangleCreatedEventHandler.
usar o seguinte comando
3
Dito isto, tente não projetar demais isso. O benefício da abstração ainda deve compensar o custo da plumagem adicional. Às vezes, um elenco bem posicionado ou lógica separada pode ser preferível.
usar o seguinte comando
2

Uma abordagem seria tornar as coisas mais gerais , a fim de evitar a transmissão para tipos específicos .

Você pode implementar um getter / setter básico de propriedades flutuantes de " dimensão " na classe base, que define um valor em um mapa, com base em uma chave específica para o nome da propriedade. Exemplo abaixo:

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

Então, na sua classe de gerente, você precisa implementar apenas uma função, como abaixo:

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

Exemplo de uso na Visualização:

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

Outra sugestão:

Como o seu gerente expõe apenas o levantador e o cálculo do perímetro (que também são expostos pelo Shape), você pode simplesmente instanciar uma Visualização adequada ao instanciar uma classe Shape específica. POR EXEMPLO:

  • Instanciar um Square e um SquareEditView;
  • Passe a instância Square para o objeto SquareEditView;
  • (opcional) Em vez de ter um ShapeManager, na visualização principal, você ainda pode manter uma lista de Formas;
  • No SquareEditView, você mantém uma referência a um Square; isso eliminaria a necessidade de conversão para editar os objetos.
Emerson Cardoso
fonte
Eu gosto da primeira sugestão e já pensei nisso, mas é bastante limitante se você deseja armazenar variáveis ​​diferentes (float, ponteiros, matrizes). Para a segunda sugestão, se o quadrado já estiver instanciado (cliquei na vista) como sei que é um objeto Square * ? a lista que armazena as formas retorna um AbstractShape * .
precisa saber é o seguinte
@ElevenJune - sim, todas as sugestões têm suas desvantagens; para o primeiro, seria necessário implementar algo mais complexo, em vez de um mapa simples, se você quiser mais tipos de propriedades. A segunda sugestão muda como você armazena as formas; você armazena a forma base na lista, mas ao mesmo tempo precisa fornecer a referência da forma específica à Visualização. Talvez você possa fornecer mais detalhes sobre o seu cenário, para que possamos avaliar se essas abordagens são melhores do que simplesmente executar um dynamic_cast.
Emerson Cardoso
@ElevenJune - o objetivo de ter o objeto view é para que sua GUI não precise saber que está trabalhando com uma classe do tipo Square. O objeto de exibição fornece o necessário para "exibir" o objeto (o que você definir) e internamente ele sabe que está usando uma instância de uma classe Square. A GUI interage apenas com a instância do SquareView. Assim, você não pode clicar em uma classe 'Quadrada'. Você só pode clicar em uma classe SquareView. Alterando parâmetros na SquareView irá atualizar a classe Praça subjacente ....
Dunk
... Essa abordagem pode muito bem permitir que você se livre da sua classe ShapeManager. Isso certamente simplificará seu design. Eu sempre digo que se você chama uma classe de gerente, assume que é um projeto ruim e descobre outra coisa. As classes de gerente são ruins por uma infinidade de razões, principalmente o problema da classe de Deus e o fato de que ninguém sabe o que a classe realmente faz, pode e não pode fazer porque os gerentes podem fazer qualquer coisa tangencialmente relacionada ao que estão gerenciando. Você pode apostar que os desenvolvedores que o seguem tirarão proveito disso, levando à típica bola grande de barro.
Dunk
11
... você já encontrou esse problema. Por que diabos faria sentido para um gerente ser aquele que muda as dimensões de uma forma? Por que um gerente calcularia o perímetro de uma forma? Caso você não tenha entendido, eu gosto da "Outra sugestão".
Dunk