Что такое хорошая практика проектирования, чтобы не задавать тип подкласса?

11

Я читал, что когда вашей программе нужно знать, к какому классу относится объект, обычно это указывает на недостаток дизайна, поэтому я хочу знать, что такое хорошая практика для этого. Я реализую класс Shape с различными подклассами, унаследованными от него, такими как Circle, Polygon или Rectangle, и у меня есть разные алгоритмы, чтобы узнать, сталкивается ли Circle с Polygon или Rectangle. Затем предположим, что у нас есть два экземпляра Shape и мы хотим знать, сталкивается ли один с другим, в этом методе я должен определить, какой тип подкласса является объектом, с которым я сталкиваюсь, чтобы узнать, какой алгоритм я должен вызвать, но это плохой дизайн или практика? Это способ, которым я решил это.

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

Это плохой дизайн шаблона? Как я могу решить эту проблему без вывода типов подкласса?

Alejandro
источник
1
Вы заканчиваете тем, что каждый Экземпляр, например Круг, знает все другие Типы Формы. Так что они все как-то связаны между собой. И как только вы добавите новую фигуру, такую ​​как треугольник, вы в конечном итоге добавите поддержку треугольников повсюду. Это зависит от того, что вы хотите менять чаще, будете ли вы добавлять новые фигуры, этот дизайн плохой. Поскольку у вас есть разрастание решения - ваша поддержка треугольников должна быть добавлена ​​везде. Вместо этого вы должны извлечь ваше определение Collision в отдельный класс, который может работать со всеми типами и делегатами.
упаковщик
ИМО это сводится к требованиям к производительности. Чем конкретнее код, тем более он может быть оптимизирован и тем быстрее он будет работать. В этом конкретном случае (реализованном тоже) проверка типа является приемлемой, поскольку специализированные проверки столкновений могут быть чрезвычайно быстрыми, чем универсальное решение. Но когда производительность во время выполнения не критична, я бы всегда использовал общий / полиморфный подход.
Марстато
Спасибо всем, в моем случае производительность критична, и я не буду добавлять новые фигуры, может быть, я использую подход CollisionDetection, однако мне все еще нужно было знать тип подкласса, должен ли я сохранить метод «Type getType ()» в Shape или вместо этого делать какой-то "экземпляр" с Shape в классе CollisionDetection?
Алехандро
1
Там нет эффективной процедуры столкновения между абстрактными Shapeобъектами. Ваша логика зависит от внутренних объектов другого объекта, если вы не проверяете столкновение на наличие пограничных точек bool collide(x, y)(подмножество контрольных точек может быть хорошим компромиссом). В противном случае вам нужно как-то проверить тип - если действительно есть необходимость в абстракциях, тогда создание Collisionтипов (для объектов в области текущего актера) должно быть правильным подходом.
вздрогнуть

Ответы:

13

Полиморфизм

Пока вы используете getType()или что-то подобное, вы не используете полиморфизм.

Я понимаю, что тебе нужно знать, какой у тебя тип. Но любую работу, которую вы хотели бы выполнять, зная, что это действительно нужно отодвинуть в класс. Тогда просто скажи, когда это делать.

Процедурный кодекс получает информацию, затем принимает решения. Объектно-ориентированный код говорит объектам что-то делать.
- Алек Шарп

Этот принцип называется сказать, не спрашивайте . Следуя этому, вы не будете распространять детали типа текста и создавать логику, которая на них действует. Это превращает класс наизнанку. Лучше хранить это поведение внутри класса, чтобы оно могло меняться при изменении класса.

Инкапсуляция

Вы можете сказать мне, что никакие другие формы никогда не понадобятся, но я не верю вам и вам не следует.

Приятным эффектом последующей инкапсуляции является то, что легко добавлять новые типы, потому что их детали не распространяются на код, в котором они отображаются, ifи на switchлогику. Код для нового типа должен быть в одном месте.

Система обнаружения столкновений невежественного типа

Позвольте мне показать вам, как я спроектировал бы систему обнаружения столкновений, которая бы работала и работала с любой 2D-формой, не заботясь о типе.

введите описание изображения здесь

Скажи, что ты должен был это нарисовать. Кажется простым Это все круги. Соблазнительно создать класс круга, который понимает столкновения. Проблема в том, что это заставляет нас задуматься, когда нам нужно 1000 кругов.

Мы не должны думать о кругах. Мы должны думать о пикселях.

Что если я скажу вам, что тот же код, который вы используете для рисования этих парней, - это то, что вы можете использовать, чтобы определить, когда они касаются или даже те, на которые нажимает пользователь.

введите описание изображения здесь

Здесь я нарисовал каждый круг уникальным цветом (если ваши глаза достаточно хороши, чтобы увидеть черный контур, просто игнорируйте это). Это означает, что каждый пиксель в этом скрытом изображении соответствует тому, что нарисовало его. Хешмап позаботится об этом красиво. Вы действительно можете сделать полиморфизм таким образом.

Это изображение вам никогда не придется показывать пользователю. Вы создаете его с тем же кодом, который нарисовал первый. Просто с разными цветами.

Когда пользователь нажимает на круг, я точно знаю, какой круг, потому что этот цвет имеет только один круг.

Когда я рисую круг поверх другого, я могу быстро прочитать каждый пиксель, который я собираюсь перезаписать, поместив их в набор. Когда я закончил, установил точки для каждого круга, с которым он столкнулся, и теперь мне нужно всего лишь позвонить каждому из них, чтобы уведомить его о столкновении.

Новый тип: прямоугольники

Все это было сделано с кругами, но я спрашиваю вас: с прямоугольниками это будет работать иначе?

Никакие круговые знания не просочились в систему обнаружения. Его не волнует радиус, окружность или центральная точка. Это заботится о пикселях и цвете.

Единственная часть этой системы столкновений, которую необходимо придавить отдельным формам, - это уникальный цвет. Кроме этого формы могут просто думать о рисовании своих форм. Это то, что они хороши в любом случае.

Теперь, когда вы пишете логику столкновения, вам все равно, какой у вас подтип. Вы говорите ему, что он сталкивается, и он говорит вам, что он нашел под формой, которую притворяется рисовать. Не нужно знать тип. А это значит, что вы можете добавлять столько подтипов, сколько вам нужно, без необходимости обновлять код в других классах.

Варианты реализации

Действительно, это не обязательно должен быть уникальный цвет. Это могут быть реальные ссылки на объекты и сохранить уровень косвенности. Но те не выглядят так хорошо, когда нарисованы в этом ответе.

Это всего лишь один пример реализации. Там, безусловно, есть другие. Это должно было показать, что чем ближе вы позволяете этим подтипам формы придерживаться своей единственной ответственности, тем лучше работает вся система. Вероятно, существуют более быстрые и менее ресурсоемкие решения, но если они заставят меня распространять знания о подтипах вокруг, я бы не хотел использовать их даже при повышении производительности. Я бы не использовал их, если бы я явно не нуждался в них.

Двойная отправка

До сих пор я полностью игнорировал двойную отправку . Я сделал это, потому что мог. Пока логике столкновения не важно, какие два типа столкнулись, вам это не нужно. Если вам это не нужно, не используйте его. Если вы думаете, что вам это может понадобиться, отложите заниматься этим как можно дольше. Такое отношение называется ЯГНИ .

Если вы решили, что вам действительно нужны разные виды столкновений, спросите себя, действительно ли n подтипам формы нужно n 2 видов столкновений. До сих пор я работал очень усердно, чтобы упростить добавление другого подтипа формы. Я не хочу портить это реализацией двойной отправки, которая заставляет круги знать, что квадраты существуют.

Сколько видов столкновений существует? Немного спекулируя (опасная вещь) изобретает упругие столкновения (упругие), неупругие (липкие), энергичные (взрывные) и разрушительные (разрушительные). Их может быть больше, но если это меньше, чем n 2, давайте не будем чрезмерно проектировать наши столкновения.

Это означает, что когда моя торпеда поражает что-то, что получает урон, оно не должно ЗНАТЬ, что оно поражает космический корабль. Надо только сказать: «Ха-ха! Ты получил 5 единиц урона».

Вещи, которые наносят ущерб, отправляют сообщения об ущербе вещам, которые принимают сообщения о повреждении. Сделав это, вы можете добавлять новые фигуры, не рассказывая другим фигурам о новой фигуре. В итоге вы распространяетесь только вокруг новых типов столкновений.

Космический корабль может отправить обратно в оцепенение "Ха-ха! Вы получили 100 единиц урона". а также "Вы сейчас застряли в моем корпусе". И торп может отправить назад: «Ну, я готов, так что забудь обо мне».

Ни в коем случае не знает точно, что каждый из них. Они просто знают, как разговаривать друг с другом через интерфейс столкновения.

Конечно, двойная диспетчеризация позволяет вам контролировать вещи более тщательно, чем это, но вы действительно этого хотите ?

Если вы, пожалуйста, хотя бы подумайте о том, чтобы выполнить двойную диспетчеризацию через абстракции того, какие виды столкновений принимает фигура, а не о фактической реализации фигуры. Кроме того, поведение при столкновении - это то, что вы можете внедрить в качестве зависимости и делегировать этой зависимости.

Производительность

Производительность всегда важна. Но это не значит, что это всегда проблема. Тест производительности. Не просто спекулировать. Пожертвование всем остальным во имя исполнения обычно не приводит к коду исполнения.

candied_orange
источник
+1 за «Вы можете сказать мне, что никакие другие фигуры никогда не понадобятся, но я вам не верю и вам не следует»
Тулаинс Кордова
Размышления о пикселях никуда не денутся, если эта программа не о рисовании фигур, а о чисто математических вычислениях. Этот ответ подразумевает, что вы должны пожертвовать всем ради воспринимаемой объектно-ориентированной чистоты. Это также содержит противоречие: сначала вы говорите, что мы должны основывать весь наш дизайн на идее, что в будущем нам может понадобиться больше типов фигур, затем вы говорите «ЯГНИ». Наконец, вы пренебрегаете тем, что упрощение добавления типов часто означает, что сложнее добавлять операции, что плохо, если иерархия типов относительно стабильна, но операции сильно меняются.
Кристиан Хакл
7

Описание проблемы звучит так, как будто вы должны использовать Multimethods (иначе Multiple dispatch), в данном конкретном случае - Double dispatch . В первом ответе подробно говорилось о том, как в целом иметь дело со встречными фигурами при растровом рендеринге, но я считаю, что OP хотел «векторное» решение, или, возможно, вся проблема была переформулирована в терминах Shapes, что является классическим примером в объяснениях ООП.

Даже в цитируемой статье в википедии используется та же самая метафора о столкновениях, позвольте мне привести только цитату (Python не имеет встроенных мультиметодов, как некоторые другие языки):

@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 ...

Итак, следующий вопрос - как получить поддержку мультиметодов в вашем языке программирования.

Роман Суси
источник
Да, особый случай множественной отправки, также называемый мультиметодами, добавлен к ответу
Роман Суси,
5

Эта проблема требует редизайна на двух уровнях.

Во-первых, вам нужно извлечь логику для обнаружения столкновения между фигурами из фигур. Это сделано для того, чтобы вы не нарушали OCP каждый раз, когда вам нужно добавить новую фигуру в модель. Представьте, что вы уже определили круг, квадрат и прямоугольник. Затем вы можете сделать это так:

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

Затем вы должны организовать вызов соответствующего метода в зависимости от формы, которая его вызывает. Вы можете сделать это, используя полиморфизм и шаблон посетителя . Чтобы достичь этого, у нас должна быть соответствующая объектная модель. Во-первых, все фигуры должны соответствовать одному интерфейсу:

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

Далее у нас должен быть родительский класс посетителя:

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

}

Я использую класс вместо интерфейса, потому что мне нужно, чтобы каждый объект посетителя имел атрибут ShapeCollisionDetectorтипа.

Каждая реализация IShapeинтерфейса будет создавать экземпляр соответствующего посетителя и вызывать соответствующий Acceptметод объекта, с которым взаимодействует вызывающий объект, например:

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

И конкретные посетители будут выглядеть так:

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

Таким образом, вам не нужно менять классы фигур каждый раз, когда вы добавляете новую форму, и вам не нужно проверять тип формы, чтобы вызвать соответствующий метод обнаружения столкновений.

Недостаток этого решения заключается в том, что если вы добавляете новую фигуру, вам необходимо расширить класс ShapeVisitor с помощью метода для этой фигуры (например VisitTriangle(Triangle triangle)), и, следовательно, вам придется реализовать этот метод для всех других посетителей. Однако, поскольку это расширение, в том смысле, что никакие существующие методы не изменены, а добавлены только новые, это не нарушает OCP , а накладные расходы кода минимальны. Кроме того, используя класс ShapeCollisionDetector, вы избегаете нарушения SRP и избегаете избыточности кода.

Владимир Стокич
источник
5

Ваша основная проблема заключается в том, что в большинстве современных языков программирования ОО перегрузка функций не работает с динамическим связыванием (то есть тип аргументов функции определяется во время компиляции). Вам потребуется вызов виртуального метода, который является виртуальным для двух объектов, а не только для одного. Такие методы называются мульти-методами . Однако есть способы эмулировать это поведение в таких языках, как Java, C ++ и т. Д. Именно здесь двойная диспетчеризация оказывается очень полезной.

Основная идея заключается в том, что вы используете полиморфизм дважды. Когда две фигуры сталкиваются, вы можете вызвать правильный метод столкновения одного из объектов посредством полиморфизма и передать другой объект универсального типа фигуры. В вызываемом методе вы узнаете, является ли этот объект кругом, прямоугольником или чем-то еще. Затем вы вызываете метод столкновения для переданного объекта формы и передаете ему объект this . Этот второй вызов затем снова находит правильный тип объекта посредством полиморфизма.

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
  }

  // ...
}

Однако большим недостатком этого метода является то, что каждый класс в иерархии должен знать обо всех братьях и сестрах. Это создает большую нагрузку на обслуживание, если новая форма добавляется позже.

sigy
источник
2

Может быть, это не лучший способ подойти к этой проблеме

Математическое столкновение формы behing является определенным для комбинаций формы. Это означает, что количество необходимых вам подпрограмм является квадратом числа фигур, поддерживаемых системой. Столкновения с фигурами - это не операции с фигурами, а операции, принимающие фигуры в качестве параметров.

Стратегия перегрузки оператора

Если вы не можете упростить основную математическую задачу, я бы порекомендовал подход перегрузки оператора. Что-то типа:

 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 ) { ... }
     (...)

На статическом инициализаторе я бы использовал отражение, чтобы составить карту методов для реализации динамического диспетчера по методу общего столкновения (Shape s1, Shape s2). Статический инициализатор также может иметь логику, чтобы обнаруживать отсутствующие функции столкновения и сообщать о них, отказываясь загружать класс.

Это похоже на перегрузку оператора C ++. В C ++ оператор перегрузки очень запутан, потому что у вас есть фиксированный набор символов, которые вы можете перегрузить. Тем не менее, концепция очень интересна и может быть воспроизведена с помощью статических функций.

Причина, по которой я бы использовал этот подход, состоит в том, что столкновение не является операцией над объектом. Столкновение - это внешняя операция, которая говорит о некотором отношении двух произвольных объектов. Кроме того, статический инициализатор сможет проверить, не пропущена ли какая-либо функция столкновения.

Упростите вашу математическую задачу, если это возможно

Как я уже говорил, количество функций столкновения является квадратом числа типов фигур. Это означает, что в системе только с 20 фигурами вам потребуется 400 процедур, с 21 формой 441 и так далее. Это не просто расширяемо.

Но вы можете упростить свою математику . Вместо расширения функции столкновения вы можете растеризовать или триангулировать каждую фигуру. Таким образом, двигатель столкновения не должен быть расширяемым. Столкновение, Расстояние, Пересечение, Слияние и некоторые другие функции будут универсальными.

Triangulate

Вы заметили, что большинство 3d-пакетов и игр триангулируют все? Это одна из форм упрощения математики. Это относится и к 2D фигурам. Полис можно триангулировать. Круги и сплайны могут быть приближены к полигонам.

Опять же ... у вас будет одна функция столкновения. Ваш класс стал тогда:

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

И ваши операции:

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

Проще это не так?

Rasterize

Вы можете растеризовать свою форму, чтобы иметь единственную функцию столкновения.

Растеризация может показаться радикальным решением, но она может быть доступной и быстрой в зависимости от того, насколько точными должны быть ваши столкновения с фигурами. Если они не должны быть точными (как в игре), у вас могут быть изображения с низким разрешением. Большинство приложений не нуждается в абсолютной точности по математике.

Аппроксимации могут быть достаточно хорошими. Суперкомпьютер ANTON для моделирования биологии является примером. Его математика отбрасывает множество квантовых эффектов, которые трудно рассчитать, и до сих пор проведенное моделирование соответствовало экспериментам, проведенным в реальном мире. Модели компьютерной графики PBR, используемые в игровых движках и пакетах рендеринга, делают упрощения, которые уменьшают мощность компьютера, необходимую для рендеринга каждого кадра. На самом деле физически не точен, но достаточно близок, чтобы убедить его невооруженным глазом.

Лукас
источник