Qual é uma boa prática de design para evitar perguntar a um tipo de subclasse?

11

Eu li que quando seu programa precisa saber qual classe é um objeto, geralmente indica uma falha de design, por isso quero saber qual é uma boa prática para lidar com isso. Estou implementando uma classe Shape com diferentes subclasses herdadas, como Circle, Polygon ou Rectangle e tenho algoritmos diferentes para saber se um Circle colide com um Polygon ou Rectangle. Suponhamos que temos duas instâncias de Shape e queremos saber se uma colide com a outra. Nesse método, devo inferir que tipo de subclasse é o objeto que estou colidindo para saber qual algoritmo devo chamar, mas esse é um mau design ou prática? Foi assim que resolvi.

abstract class Shape {
  ShapeType getType();
  bool collide(Shape other);
}

class Circle : Shape {
  getType() { return Type.Circle; }

  bool collide(Shape other) {
    if(other.getType() == Type.Rect) {
      collideCircleRect(this, (Rect) other);     
    } else if(other.getType() == Type.Polygon) {
      collideCirclePolygon(this, (Polygon) other);
    }
  }
}

Esse é um padrão de design ruim? Como eu poderia resolver isso sem precisar inferir os tipos de subclasse?

Alejandro
fonte
1
Você conclui que todas as Instâncias, por exemplo, Circle, conhecem todos os outros Shape-Types. Então, eles estão todos conectados de alguma forma. E assim que você adiciona uma nova forma, como Triângulo, acaba adicionando suporte a Triângulos em todos os lugares. Depende do que você deseja alterar com mais frequência. Você adicionará novas formas, esse design é ruim. Porque você tem uma expansão de solução - Seu suporte a triângulos deve ser adicionado a todos os lugares. Em vez disso, você deve extrair sua detecção de colisão em uma classe separada, que pode funcionar com todos os tipos e delegar.
thepacker
Na IMO, isso se resume aos requisitos de desempenho. Quanto mais específico o código, mais otimizado ele pode ser e mais rápido ele será executado. Nesse caso em particular (implementado também), a verificação do tipo é aceitável, porque as verificações de colisão personalizadas podem ser extremamente mais rápidas que uma solução genérica. Mas quando o desempenho do tempo de execução não é crítico, eu sempre acompanha a abordagem geral / polimórfica.
marstato
Obrigado a todos, no meu caso, o desempenho é crítico e não adicionarei novas formas, talvez eu faça a abordagem CollisionDetection. No entanto, ainda precisava saber o tipo de subclasse, devo manter um método "Type getType ()" em Shape ou, em vez disso, fazer algum tipo de "instância de" com Shape na classe CollisionDetection?
Alejandro
1
Não há procedimento de colisão eficaz entre Shapeobjetos abstratos . Sua lógica depende das partes internas de outros objetos, a menos que você esteja checando pontos de limite de colisão bool collide(x, y)(um subconjunto de pontos de controle pode ser uma boa escolha). Caso contrário, você precisará verificar o tipo de alguma forma - se houver realmente necessidade de abstrações, a produção de Collisiontipos (para objetos na área do ator atual) deve ser a abordagem correta.
shudder

Respostas:

13

Polimorfismo

Contanto que você use getType()ou algo assim, você não está usando polimorfismo.

Entendo que você precisa saber que tipo tem. Mas qualquer trabalho que você queira fazer sabendo que realmente deve ser empurrado para a classe. Então você apenas diz quando fazê-lo.

O código processual obtém informações e toma decisões. O código orientado a objetos informa aos objetos para fazerem as coisas.
- Alec Sharp

Este princípio é chamado de dizer, não pergunte . A seguir, ajuda a não espalhar detalhes como digitar e criar uma lógica que atue sobre eles. Fazer isso transforma uma classe de dentro para fora. É melhor manter esse comportamento dentro da classe para que ele possa mudar quando a classe mudar.

Encapsulamento

Você pode me dizer que nenhuma outra forma será necessária, mas eu não acredito em você e nem deveria.

Um bom efeito de seguir o encapsulamento é que é fácil adicionar novos tipos porque seus detalhes não se espalham no código em que aparecem ife na switchlógica. O código para um novo tipo deve estar em um só lugar.

Um sistema de detecção de colisão ignorante

Deixe-me mostrar como eu projetaria um sistema de detecção de colisão com desempenho e funcione com qualquer formato 2D, não se importando com o tipo.

insira a descrição da imagem aqui

Digamos que você deveria desenhar isso. Parece simples. São todos círculos. É tentador criar uma classe de círculo que entenda colisões. O problema é que isso nos leva a uma linha de pensamento que desmorona quando precisamos de 1000 círculos.

Não deveríamos estar pensando em círculos. Deveríamos estar pensando em pixels.

E se eu disser a você que o mesmo código que você usa para desenhar esses caras é o que você pode usar para detectar quando eles tocam ou mesmo em quais deles o usuário está clicando.

insira a descrição da imagem aqui

Aqui, desenhei cada círculo com uma cor única (se seus olhos forem bons o suficiente para ver o contorno preto, apenas ignore). Isso significa que todos os pixels nesta imagem oculta são mapeados de volta para o que a atraiu. Um hashmap cuida disso muito bem. Você pode realmente fazer polimorfismo dessa maneira.

Essa imagem você nunca precisa mostrar ao usuário. Você o cria com o mesmo código que desenhou o primeiro. Apenas com cores diferentes.

Quando o usuário clica em um círculo, eu sei exatamente qual círculo, porque apenas um círculo é dessa cor.

Quando desenho um círculo em cima de outro, posso ler rapidamente todos os pixels que estou prestes a substituir, despejando-os em um conjunto. Quando terminei os pontos de ajuste para todos os círculos com os quais colidimos e agora só preciso ligar para cada um uma vez para notificá-lo da colisão.

Um novo tipo: Retângulos

Tudo isso foi feito com círculos, mas eu pergunto: seria diferente com retângulos?

Nenhum conhecimento de círculo vazou no sistema de detecção. Ele não se importa com raio, circunferência ou ponto central. Ele se importa com pixels e cores.

A única parte desse sistema de colisão que precisa ser empurrada para as formas individuais é uma cor única. Fora isso, as formas podem apenas pensar em desenhar suas formas. É no que eles são bons de qualquer maneira.

Agora, quando você escreve a lógica da colisão, não se importa com o subtipo que possui. Você diz para colidir e diz o que encontrou sob a forma que está pretendendo desenhar. Não há necessidade de saber o tipo. E isso significa que você pode adicionar quantos subtipos quiser, sem precisar atualizar o código em outras classes.

Opções de implementação

Realmente, não precisa ser de uma cor única. Pode ser referências a objetos reais e salvar um nível de indireção. Mas aqueles não pareceriam tão agradáveis ​​quando desenhados nesta resposta.

Este é apenas um exemplo de implementação. Certamente existem outros. O que isso foi feito para mostrar é que, quanto mais próximo você deixar esses subtipos de formas ficarem com sua única responsabilidade, melhor o sistema inteiro funcionará. Provavelmente, existem soluções mais rápidas e com menos uso intensivo de memória, mas se elas me forçarem a espalhar o conhecimento dos subtipos, eu seria relutante em usá-las mesmo com os ganhos de desempenho. Eu não os usaria a menos que claramente precisasse deles.

Expedição dupla

Até agora, ignorei completamente o duplo envio . Eu fiz isso porque eu podia. Contanto que a lógica da colisão não se importe com os dois tipos colididos, você não precisa dela. Se você não precisar, não use. Se você acha que pode precisar, deixe de lidar com isso o máximo que puder. Essa atitude é chamada YAGNI .

Se você realmente precisar de diferentes tipos de colisões, pergunte a si mesmo se n subtipos de forma realmente precisam de 2 tipos de colisões. Até agora, trabalhei muito para facilitar a adição de outro subtipo de forma. Não quero estragar tudo com uma implementação de despacho duplo que força os círculos a saber que existem quadrados.

De qualquer forma, quantos tipos de colisões existem? Um pouco de especulação (coisa perigosa) inventa colisões elásticas (saltitantes), inelásticas (pegajosas), enérgicas (explosivas) e destrutivas (perigosas). Pode haver mais, mas se isso for menor que n 2, não vamos exagerar nossas colisões.

Isso significa que, quando meu torpedo atinge algo que aceita danos, não precisa SABER que atingiu uma nave espacial. Só precisa dizer: "Ha ha! Você sofreu 5 pontos de dano".

Coisas que causam dano enviam mensagens de dano para coisas que aceitam mensagens de dano. Feito dessa maneira, você pode adicionar novas formas sem informar as outras formas sobre a nova forma. Você acaba se espalhando por novos tipos de colisões.

A nave espacial pode enviar de volta para a rampa "Ha ha! Você sofreu 100 pontos de dano". bem como "Agora você está preso ao meu casco". E a torp pode enviar de volta "Bem, eu terminei, então esqueça de mim".

Em nenhum momento os dois sabem exatamente o que são. Eles apenas sabem como se comunicar através de uma interface de colisão.

Agora, com certeza, o envio duplo permite controlar as coisas mais intimamente do que isso, mas você realmente quer isso ?

Se você, pelo menos, pense em fazer o envio duplo através de abstrações de quais tipos de colisões uma forma aceita e não na implementação real da forma. Além disso, o comportamento de colisão é algo que você pode injetar como dependência e delegar a essa dependência.

atuação

O desempenho é sempre crítico. Mas isso não significa que é sempre um problema. Teste de desempenho. Não basta especular. Sacrificar tudo o mais em nome do desempenho geralmente não leva ao código de execução de qualquer maneira.

candied_orange
fonte
Vamos continuar esta discussão no chat .
Candied_orange 26/12/16
+1 para "Você pode me dizer que nenhuma outra forma será necessária, mas eu não acredito em você e você também não deveria."
Tulains Córdova
Pensar em pixels não o levará a lugar algum, se este programa não se trata de desenhar formas, mas de cálculos puramente matemáticos. Essa resposta implica que você deve sacrificar tudo pela pureza orientada a objetos percebida. Também contém uma contradição: primeiro você diz que devemos basear todo o nosso projeto na ideia de que precisaremos de mais tipos de formas no futuro, depois diz "YAGNI". Por fim, você negligencia que facilitar a adição de tipos geralmente significa que é mais difícil adicionar operações, o que é ruim se a hierarquia de tipos for relativamente estável, mas as operações mudarem muito.
Christian Hackl
7

A descrição do problema parece que você deve usar os métodos multimídia (também conhecidos como despacho múltiplo), neste caso específico - despacho duplo . A primeira resposta foi longa sobre como lidar genericamente com formas em colisão na renderização de varredura, mas acredito que o OP queria uma solução "vetorial" ou talvez todo o problema tenha sido reformulado em termos de Shapes, que é um exemplo clássico das explicações de OOP.

Mesmo o artigo da wikipedia citado usa a mesma metáfora de colisão, deixe-me citar (o Python não possui métodos múltiplos, como em outras línguas):

@multimethod(Asteroid, Asteroid)
def collide(a, b):
    """Behavior when asteroid hits asteroid"""
    # ...define new behavior...
@multimethod(Asteroid, Spaceship)
def collide(a, b):
    """Behavior when asteroid hits spaceship"""
    # ...define new behavior...
# ... define other multimethod rules ...

Portanto, a próxima pergunta é como obter suporte para vários métodos na sua linguagem de programação.

Roman Susi
fonte
Sim, caso especial de despacho múltiplo, também conhecido como multimétodos, adicionado à resposta #
6707 Roman Susi
5

Esse problema requer reprojeto em dois níveis.

Primeiro, você precisa extrair a lógica para detectar a colisão entre as formas. Isso significa que você não violaria o OCP toda vez que precisar adicionar uma nova forma ao modelo. Imagine que você já tenha Círculo, Quadrado e Retângulo definidos. Você poderia fazer assim:

class ShapeCollisionDetector
{
    public void DetectCollisionCircleCircle(Circle firstCircle, Circle secondCircle)
    { 
        //Code that detects collision between two circles
    }

    public void DetectCollisionCircleSquare(Circle circle, Square square)
    {
        //Code that detects collision between circle and square
    }

    public void DetectCollisionCircleRectangle(Circle circle, Rectangle rectangle)
    {
        //Code that detects collision between circle and rectangle
    }

    public void DetectCollisionSquareSquare(Square firstSquare, Square secondSquare)
    {
        //Code that detects collision between two squares
    }

    public void DetectCollisionSquareRectangle(Square square, Rectangle rectangle)
    {
        //Code that detects collision between square and rectangle
    }

    public void DetectCollisionRectangleRectangle(Rectangle firstRectangle, Rectangle secondRectangle)
    { 
        //Code that detects collision between two rectangles
    }
}

Em seguida, você deve providenciar para que o método apropriado seja chamado, dependendo da forma que o chama. Você pode fazer isso usando polimorfismo e padrão de visitante . Para conseguir isso, precisamos ter o modelo de objeto apropriado em vigor. Primeiro, todas as formas devem aderir à mesma interface:

    interface IShape
{
    void DetectCollision(IShape shape);
    void Accept (ShapeVisitor visitor);
}

Em seguida, precisamos ter uma classe de visitante pai:

    abstract class ShapeVisitor
{
    protected ShapeCollisionDetector collisionDetector = new ShapeCollisionDetector();

    abstract public void VisitCircle (Circle circle);

    abstract public void VisitSquare(Square square);

    abstract public void VisitRectangle(Rectangle rectangle);

}

Estou usando uma classe aqui em vez da interface, porque preciso que cada objeto de visitante tenha um atributo do ShapeCollisionDetectortipo

Toda implementação de IShapeinterface instanciaria o visitante apropriado e chamaria o Acceptmétodo apropriado do objeto com o qual o objeto de chamada interage, assim:

    class Circle : IShape
{
    public void DetectCollision(IShape shape)
    {
        CircleVisitor visitor = new CircleVisitor(this);
        shape.Accept(visitor);
    }

    public void Accept(ShapeVisitor visitor)
    {
        visitor.VisitCircle(this);
    }
}

    class Rectangle : IShape
{
    public void DetectCollision(IShape shape)
    {
        RectangleVisitor visitor = new RectangleVisitor(this);
        shape.Accept(visitor);
    }

    public void Accept(ShapeVisitor visitor)
    {
        visitor.VisitRectangle(this);
    }
}

E visitantes específicos ficariam assim:

    class CircleVisitor : ShapeVisitor
{
    private Circle Circle { get; set; }

    public CircleVisitor(Circle circle)
    {
        this.Circle = circle;
    }

    public override void VisitCircle(Circle circle)
    {
        collisionDetector.DetectCollisionCircleCircle(Circle, circle);
    }

    public override void VisitSquare(Square square)
    {
        collisionDetector.DetectCollisionCircleSquare(Circle, square);
    }

    public override void VisitRectangle(Rectangle rectangle)
    {
        collisionDetector.DetectCollisionCircleRectangle(Circle, rectangle);
    }
}

    class RectangleVisitor : ShapeVisitor
{
    private Rectangle Rectangle { get; set; }

    public RectangleVisitor(Rectangle rectangle)
    {
        this.Rectangle = rectangle;
    }

    public override void VisitCircle(Circle circle)
    {
        collisionDetector.DetectCollisionCircleRectangle(circle, Rectangle);
    }

    public override void VisitSquare(Square square)
    {
        collisionDetector.DetectCollisionSquareRectangle(square, Rectangle);
    }

    public override void VisitRectangle(Rectangle rectangle)
    {
        collisionDetector.DetectCollisionRectangleRectangle(Rectangle, rectangle);
    }
}

Dessa forma, você não precisa alterar as classes de forma toda vez que adicionar uma nova forma e não precisa verificar o tipo de forma para chamar o método de detecção de colisão apropriado.

Uma desvantagem desta solução é que, se você adicionar uma nova forma, precisará estender a classe ShapeVisitor com o método dessa forma (por exemplo VisitTriangle(Triangle triangle)) e, consequentemente, precisará implementar esse método em todos os outros visitantes. No entanto, como essa é uma extensão, no sentido de que nenhum método existente é alterado, mas apenas o novo é adicionado, isso não viola o OCP e a sobrecarga de código é mínima. Além disso, usando a classe ShapeCollisionDetector, você evita a violação do SRP e a redundância de código.

Vladimir Stokic
fonte
5

Seu problema básico é que, na maioria das linguagens de programação OO modernas, a sobrecarga de funções não funciona com ligação dinâmica (ou seja, o tipo de argumento da função é determinado em tempo de compilação). O que você precisaria é de uma chamada de método virtual virtual em dois objetos em vez de apenas um. Tais métodos são chamados de multi-métodos . No entanto, existem maneiras de emular esse comportamento em linguagens como Java, C ++, etc. É aqui que o despacho duplo é muito útil.

A idéia básica é que você use o polimorfismo duas vezes. Quando duas formas colidem, você pode chamar o método de colisão correto de um dos objetos através do polimorfismo e passar o outro objeto do tipo genérico de forma. No método chamado você, então saber se a esse objeto é um círculo, retângulo ou o que quer. Em seguida, você chama o método de colisão no objeto de forma passado e passa a este objeto. Essa segunda chamada encontra novamente o tipo de objeto correto através do polimorfismo.

abstract class Shape {
  bool collide(Shape other);
  bool collide(Rect other);
  bool collide(Circle other);
}

class Circle : Shape {

  bool collide(Shape other) {
    return other.collide(this);
  }

  bool collide(Rect other) {
    // algorithm to detect collision between Circle and Rect
  }

  // ...
}

class Rect : Shape {

  bool collide(Shape other) {
    return other.collide(this);
  }

  bool collide(Circle other) {
    // algorithm to detect collision between Circle and Rect
  }

  // ...
}

Uma grande desvantagem dessa técnica, no entanto, é que cada classe na hierarquia precisa conhecer todos os irmãos. Isso gera uma alta carga de manutenção se uma nova forma for adicionada posteriormente.

sigy
fonte
2

Talvez essa não seja a melhor maneira de abordar esse problema

A colisão de formas do behing matemático é específica das combinações de formas. Significando que o número de sub-rotinas necessárias será o quadrado do número de formas suportadas pelo sistema. As colisões de formas não são realmente operações sobre formas, mas operações que assumem formas como parâmetros.

Estratégia de sobrecarga do operador

Se você não puder simplificar o problema matemático subjacente, recomendo a abordagem de sobrecarga do operador. Algo como:

 public final class ShapeOp 
 {
     static { ... }

     public static boolean collision( Shape s1, Shape s2 )  { ... }
     public static boolean collision( Point p1, Point p2 ) { ... }
     public static boolean collision( Point p1, Square s1 ) { ... }
     public static boolean collision( Point p1, Circle c1 ) { ... }
     public static boolean collision( Point p1, Line l1 ) { ... }
     public static boolean collision( Square s1, Point p2 ) { ... }
     public static boolean collision( Square s1, Square s2 ) { ... }
     public static boolean collision( Square s1, Circle c1 ) { ... }
     public static boolean collision( Square s1, Line l1 ) { ... }
     (...)

No inicializador estático, usaria a reflexão para fazer um mapa dos métodos para implementar um dissipador di-dinâmico no método genérico de colisão (Shape s1, Shape s2). O intializer estático também pode ter uma lógica para detectar funções de colisão ausentes e relatá-las, recusando-se a carregar a classe.

É semelhante à sobrecarga do operador C ++. No C ++, a sobrecarga do operador é muito confusa porque você tem um conjunto fixo de símbolos que pode sobrecarregar. No entanto, o conceito é muito interessante e pode ser replicado com funções estáticas.

A razão pela qual eu usaria essa abordagem é que a colisão não é uma operação sobre um objeto. Uma colisão é uma operação externa que diz alguma relação sobre dois objetos arbitrários. Além disso, o inicializador estático poderá verificar se eu perdi alguma função de colisão.

Simplifique seu problema de matemática, se possível

Como mencionei, o número de funções de colisão é o quadrado do número de tipos de formas. Isso significa que em um sistema com apenas 20 formas você precisará de 400 rotinas, com 21 formas 441 e assim por diante. Isso não é facilmente extensível.

Mas você pode simplificar sua matemática . Em vez de estender a função de colisão, você pode rasterizar ou triangular todas as formas. Dessa forma, o mecanismo de colisão não precisa ser extensível. Colisão, Distância, Interseção, Mesclagem e várias outras funções serão universais.

Triangulate

Você notou a maioria dos pacotes e jogos em 3D triangular tudo? Essa é uma das formas de simplificar a matemática. Isso se aplica também às formas 2D. Polys podem ser triangulados. Círculos e splines podem ser aproximados a polígonos.

Mais uma vez ... você terá uma única função de colisão. Sua classe se torna então:

public class Shape 
{
    public Triangle[] triangulate();
}

E suas operações:

public final class ShapeOp
{
    public static boolean collision( Triangle[] shape1, Triangle[] shape2 )
}

Mais simples, não é?

Rasterizar

Você pode rasterizar sua forma para ter uma única função de colisão.

A rasterização pode parecer uma solução radical, mas pode ser acessível e rápida, dependendo da precisão das colisões de forma. Se eles não precisarem ser precisos (como em um jogo), você poderá ter bitmaps de baixa resolução. A maioria dos aplicativos não precisa de precisão absoluta na matemática.

Aproximações podem ser boas o suficiente. O supercomputador ANTON para simulação de biologia é um exemplo. Sua matemática descarta muitos efeitos quânticos que são difíceis de calcular e até agora as simulações feitas são consistentes com os experimentos feitos no mundo real. Os modelos de gráficos de computador PBR usados ​​em mecanismos de jogos e pacotes de renderização simplificam a redução da potência do computador necessária para renderizar cada quadro. Na verdade, não é fisicamente preciso, mas está perto o suficiente para ser convincente a olho nu.

Lucas
fonte