问题 如何为其派生类型的每种可能组合实现基类的方法


我有以下Shape接口,它由多个其他类实现,如Rectangle,Circle,Triangle ......

interface IShape{
    bool IsColliding(IShape other);
}

IsColliding方法应该检查Shape是否与另一个相撞,而不管它们的具体类型如何。 但是,每对形状(矩形/矩形,矩形/圆形,圆形/三角形等......)都有自己的碰撞检查实现。

我正在努力为这个问题找到一个好的设计解决方案。

天真的方法是切换“其他”形状的类型以调用正确的实现:

class Rectangle : IShape{
    bool IsColliding(IShape other){
        if(other is Rectangle){
            return CollisionHandler.CheckRectangleVsRectangle(this,(Rectangle)other);
        }else if(other is Circle){
            return CollisionHandler.CheckRectangleVsCircle(this,(Circle)other);
        } else
            // etc ...
    }
}

但添加新形状意味着修改每个派生类中的方法以添加新案例。

我还想过调用一个像这样的独特的静态方法:

static bool IsColliding(IShape shapeA, IShape shapeB);

但即使它集中了所有内容,它也会使执行的类型测试数量翻倍,而且我仍然需要在每个第一级“if”中添加一个新案例。

if(shapeA is Rectangle){
    if(shapeB is Rectangle){
        // Rectangle VS Rectangle
    }else if(shapeB is Circle){
        // Rectangle VS Circle
    }else{
        // etc ...
    }
}else if(shapeA is Circle){
    if(shapeB is Rectangle){
        // Rectangle VS Circle
    }else{
        // etc ...
    }
} // etc ...

那么,它怎么能更好地设计呢?


10590
2017-09-04 14:20


起源



答案:


这是一个使用双重调度的想法(超出访问者模式的原则):

基本事实是碰撞函数是对称的。即 IsCollision(shapeA, shapeB) = IsCollision(shapeB, shapeA)。所以你不需要实现每一个 n^2 组合(n 是形状类的数量)但只有大约一半:

         circle  tri rect
circle      x     x    x
tri               x    x
rec                    x

因此,假设您有形状的顺序,每个形状都会导致与位于它们之前或相等的形状发生碰撞。

在此实现中,将特定于形状的碰撞处理分派给称为的对象 CollisionHandler。以下是接口(为简洁起见而简化):

interface IShape
{
    int CollisionPrecedence { get; }
    AbstractCollisionHandler CollisionHandler { get; }
    void Collide(AbstractCollisionHandler handler);
}

class AbstractCollisionHandler
{
    public virtual void Collides(Circle other) { throw new NotImplementedException(); }
    public virtual void Collides(Rect other) { throw new NotImplementedException(); }
}

基于这些接口,特定的形状类是:

class CircleCollisionHandler : AbstractCollisionHandler
{
    public override void Collides(Circle other)
    {
        Console.WriteLine("Collision circle-circle");
    }
}
class Circle : IShape
{
    public int CollisionPrecedence { get { return 0; } }
    public AbstractCollisionHandler CollisionHandler { get { return new CircleCollisionHandler(); } }
    public void Collide(AbstractCollisionHandler handler) { handler.Collides(this); }
}

class TriCollisionHandler : AbstractCollisionHandler
{
    public override void Collides(Circle other)
    {
        Console.WriteLine("Collision tri-circle");
    }

    public override void Collides(Tri other)
    {
        Console.WriteLine("Collision tri-tri");
    }
}

class Tri : IShape
{
    public int CollisionPrecedence { get { return 1; } }
    public AbstractCollisionHandler CollisionHandler { get { return new TriCollisionHandler(); } }
    public void Collide(AbstractCollisionHandler handler) { handler.Collides(this); }
}

调用特定碰撞函数的函数是:

static void Collides(IShape a, IShape b)
{
    if (a.CollisionPrecedence >= b.CollisionPrecedence)
        b.Collide(a.CollisionHandler);
    else
        a.Collide(b.CollisionHandler);
}

如果您现在想要实现另一种形状 Rect那么你必须做三件事:

改变 AbstractCollisionHandler 包括rect

abstract class AbstractCollisionHandler
{
    ...
    public virtual void Collides(Rect other) { throw new NotImplementedException(); }
}

实现冲突处理程序

class RectCollisionHandler : AbstractCollisionHandler
{
    public override void Collides(Circle other)
    {
        Console.WriteLine("Collision rect-circle");
    }

    public override void Collides(Tri other)
    {
        Console.WriteLine("Collision rect-tri");
    }

    public override void Collides(Rect other)
    {
        Console.WriteLine("Collision rect-rect");
    }
}

并实现相关的接口方法 Rect 类:

class Rect : IShape
{
    public int CollisionPrecedence { get { return 2; } }
    public AbstractCollisionHandler CollisionHandler { get { return new RectCollisionHandler(); } }
    public void Collide(AbstractCollisionHandler handler) { handler.Collides(this); }

}

就那么简单。这是一个显示被调用函数的小测试程序:

Collides(new Circle(), new Tri());
Collides(new Tri(), new Circle());
Collides(new Rect(), new Circle());

输出:

Collision tri-circle
Collision tri-circle
Collision rect-circle

5
2017-09-04 15:50



我觉得讨厌这个实现的一件事是你必须记住已经使用了什么CollisionPrecedence。它 能够 我相信会变得更好,但我认为如果你想拥有相似的性能,你必须要进行一些改进 - WorldSEnder
它也有相同的开/关问题。您需要添加新方法。 - Royal Bg
@RoyalBg是的。没有别的办法。至少这种方法只需要在一个地方添加一个方法(新类中的方法除外)。您的答案假设碰撞可能由通过界面暴露的属性决定,通常情况并非如此。从理论上讲,形状和相应的碰撞处理程序可以在同一类中实现。但它仍然需要修改抽象基类。访问者模式存在的原因是正确的(对于这些不可能坚持打开/关闭的情况)。 - Nico Schertler


答案:


这是一个使用双重调度的想法(超出访问者模式的原则):

基本事实是碰撞函数是对称的。即 IsCollision(shapeA, shapeB) = IsCollision(shapeB, shapeA)。所以你不需要实现每一个 n^2 组合(n 是形状类的数量)但只有大约一半:

         circle  tri rect
circle      x     x    x
tri               x    x
rec                    x

因此,假设您有形状的顺序,每个形状都会导致与位于它们之前或相等的形状发生碰撞。

在此实现中,将特定于形状的碰撞处理分派给称为的对象 CollisionHandler。以下是接口(为简洁起见而简化):

interface IShape
{
    int CollisionPrecedence { get; }
    AbstractCollisionHandler CollisionHandler { get; }
    void Collide(AbstractCollisionHandler handler);
}

class AbstractCollisionHandler
{
    public virtual void Collides(Circle other) { throw new NotImplementedException(); }
    public virtual void Collides(Rect other) { throw new NotImplementedException(); }
}

基于这些接口,特定的形状类是:

class CircleCollisionHandler : AbstractCollisionHandler
{
    public override void Collides(Circle other)
    {
        Console.WriteLine("Collision circle-circle");
    }
}
class Circle : IShape
{
    public int CollisionPrecedence { get { return 0; } }
    public AbstractCollisionHandler CollisionHandler { get { return new CircleCollisionHandler(); } }
    public void Collide(AbstractCollisionHandler handler) { handler.Collides(this); }
}

class TriCollisionHandler : AbstractCollisionHandler
{
    public override void Collides(Circle other)
    {
        Console.WriteLine("Collision tri-circle");
    }

    public override void Collides(Tri other)
    {
        Console.WriteLine("Collision tri-tri");
    }
}

class Tri : IShape
{
    public int CollisionPrecedence { get { return 1; } }
    public AbstractCollisionHandler CollisionHandler { get { return new TriCollisionHandler(); } }
    public void Collide(AbstractCollisionHandler handler) { handler.Collides(this); }
}

调用特定碰撞函数的函数是:

static void Collides(IShape a, IShape b)
{
    if (a.CollisionPrecedence >= b.CollisionPrecedence)
        b.Collide(a.CollisionHandler);
    else
        a.Collide(b.CollisionHandler);
}

如果您现在想要实现另一种形状 Rect那么你必须做三件事:

改变 AbstractCollisionHandler 包括rect

abstract class AbstractCollisionHandler
{
    ...
    public virtual void Collides(Rect other) { throw new NotImplementedException(); }
}

实现冲突处理程序

class RectCollisionHandler : AbstractCollisionHandler
{
    public override void Collides(Circle other)
    {
        Console.WriteLine("Collision rect-circle");
    }

    public override void Collides(Tri other)
    {
        Console.WriteLine("Collision rect-tri");
    }

    public override void Collides(Rect other)
    {
        Console.WriteLine("Collision rect-rect");
    }
}

并实现相关的接口方法 Rect 类:

class Rect : IShape
{
    public int CollisionPrecedence { get { return 2; } }
    public AbstractCollisionHandler CollisionHandler { get { return new RectCollisionHandler(); } }
    public void Collide(AbstractCollisionHandler handler) { handler.Collides(this); }

}

就那么简单。这是一个显示被调用函数的小测试程序:

Collides(new Circle(), new Tri());
Collides(new Tri(), new Circle());
Collides(new Rect(), new Circle());

输出:

Collision tri-circle
Collision tri-circle
Collision rect-circle

5
2017-09-04 15:50



我觉得讨厌这个实现的一件事是你必须记住已经使用了什么CollisionPrecedence。它 能够 我相信会变得更好,但我认为如果你想拥有相似的性能,你必须要进行一些改进 - WorldSEnder
它也有相同的开/关问题。您需要添加新方法。 - Royal Bg
@RoyalBg是的。没有别的办法。至少这种方法只需要在一个地方添加一个方法(新类中的方法除外)。您的答案假设碰撞可能由通过界面暴露的属性决定,通常情况并非如此。从理论上讲,形状和相应的碰撞处理程序可以在同一类中实现。但它仍然需要修改抽象基类。访问者模式存在的原因是正确的(对于这些不可能坚持打开/关闭的情况)。 - Nico Schertler


试想一下:你需要的是一个根据两个参数而变化的行为(this 和 other)。

换句话说,你需要的是 多个派遣 (或者更具体地, 双重调度)。起初,与许多其他源自C ++的“OOP”语言一样,C#旨在支持 单一调度 只有(像Java一样,不像Common Lisp,Clojure,Lua等语言,它们被设计为支持Multiple Dispatch)。

有一种经典的方法来模拟单个调度语言上的多个调度,称为 访客模式。如果你想要遵循这条道路,那就已经存在了 一个答案 这里是Stack Overflow(使用C#和访问者模式,以及与你的问题非常相似的问题),所以我不再重复了。

我可以补充的是,与Java,C#4.0+  支持多个调度...通过使用 dynamic 关键字,加上通常的方法重载。

所以我们可以这样:

public abstract class Shape
{
    private CollisionDetector detector = new CollisionDetector();

    public bool IsColliding(Shape that)
    {
        return detector.IsColliding((dynamic) this, (dynamic) that);
    }
}

public class CollisionDetector
{
    public bool IsColliding(Circle circle1, Circle circle2)
    {
        Console.WriteLine("circle x circle");
        return true;
    }

    public bool IsColliding(Circle circle, Rectangle rectangle)
    {
        Console.WriteLine("circle x rectangle");
        return true;
    }

    public bool IsColliding(Rectangle rectangle, Circle circle)
    {
        // Just reuse the previous method, it is the same logic:
        return IsColliding(circle, rectangle);
    }

    public bool IsColliding(Rectangle rectangle1, Rectangle rectangle2)
    {
        Console.WriteLine("rectangle x rectangle");
        return true;
    }
}

public class Circle : Shape { }

public class Rectangle : Shape { }

是的,这将按预期工作。运用 dynamic 会强制延迟绑定,因此在运行时会选择实际的方法调用。当然,这会产生性能成本:动态类型分辨率比静态分辨率慢得多。如果这是不可接受的,请使用我在上面引用的答案。


4
2017-09-04 16:48



我觉得很脏 dynamic 对于这种情况,特别是在性能重要的游戏逻辑中。 - Arturo Torres Sánchez
@ArturoTorresSánchez我有点理解你的观点。实际上我在答案中说过,性能成本可能很高。我只是不想重复“静态”方法,这已在别处描述过。不确定“脏”是什么意思(你会说Python和Ruby是“脏”语言,因为根据定义它们是动态的吗?)。 - rsenna
“脏”只是表达,但它引用了性能成本。虽然你可以用Python编写游戏(我不了解Ruby),但最重要的游戏开发是用更高效的语言(如C ++)完成的。 - Arturo Torres Sánchez
@ArturoTorresSánchez所以我们同意所有必需的信息已经在我的答案中:-) - rsenna
我的意思是,是的,但我从未说过你的答案是不完整的。这只是对此事的意见。 - Arturo Torres Sánchez


你是对的。在你目前的方法中,你打破了 打开关闭 原理。

任务的第一部分正确完成。您正在通过为每个形状添加碰撞处理程序来决定如何处理碰撞,例如:你正在创建课程 Rectangle 等等 IsColliding 方法。

然后你需要做出另一个决定,如何应对这次碰撞。响应方需要注意它。所以就是这样 other 形状的工作,以应对这种碰撞。

我建议添加一个新方法 RespondToCollision(IShape) 在合同中。

在这种情况下,您可以创建以下(伪)方案

Collide(IShape other) {
    // do smth with other.Properties
    other.RespondToCollision(this);
}

RespondToCollision(IShape other) {
    // do smth with this.Properties<>other.Properties
}

如果两个函数的形状都没有足够的参数,则可以使用更改静态类 OneToAnotherCollisionMethod 战略课程(看看 战略模式并且也将这些策略作为参数传递。

考虑到形状是通过它们的坐标检查碰撞的事实,通过将目标侧传递到源侧而反之亦然来构建公式并不困难。


1
2017-09-04 14:35



这意味着如果添加新形状,他将不得不“教导”每个RespondToCollision实现如何处理它? - VladL
我不这么认为,这意味着只需要实现响应碰撞的新形状。哪个没关系,因为它只会添加新代码。碰撞逻辑的第一部分应该独立于什么 other 形状。而且 other 形状只是让另一半“粘合”逻辑,让我们说“建立”公式。 - Royal Bg


也许这不是最美丽的解决方案,但你可以写出接受各种形状的方法。

CollisionHandler.Check(Rectangle r = null, Circle c = null, Triangle t = null)
{
   if(r != null && c != null
   {
      return CollisionHandler.CheckRectangleVsCircle(r,c);
   }
}

1
2017-09-04 14:37



如果添加新形状,仍需要更改现有代码 - Royal Bg
@RoyalBg是的,但只是在一个地方 - VladL
是的,这与OP给出的最后一个代码相同:)他说他可以集中它,但仍然需要修改它以获得新的形状。 - Royal Bg
不,他的意思是他将代码加倍,因为形状可以作为不同的参数传递,例如,第一个=矩形,第二个=圆圈,反之亦然,所以他需要仔细检查 - VladL


我真的觉得你在这里过度工程。

您的所有形状基本上都是顶点和边的集合,甚至是圆形(只需选择满足精度需求的顶点数)。

一旦你的所有形状都是一个点和边的集合,你只需要在一个地方处理碰撞,它将对任何涉及的形状有效。

如果你的形状是凸的,你的碰撞算法可以像检查一个形状是否包含另一个形状的至少一个顶点一样简单 Contains(Point p) 可以是每个形状覆盖的虚拟方法。


1
2017-09-04 16:14



我不同意。例如。只需很少的操作就可以精确地解决圆圈碰撞问题。对曲面细分做同样的事情会降低准确性并增加运行时间。 - Nico Schertler
@NicoSchertler并且在不必要地使类型系统复杂化的程度上需要精确的精度吗?如果是,你可以用圆形做例外但仍然处理所有其他形状为一个(你总共有3个案例;圆圈,圆多边形和多边形多边形);原则仍然存在,单独处理矩形,三角形和五边形是不必要的。 - InBetween
如果你坚持这样简单的形状,那么也许吧。但是如果你引入更复杂的形状,如贝塞尔曲线边界,隐式形状,非简单多边形......如果你去3D,它会变得更糟。但是,是的,如果你只有像你所说的简单形状,那么一般的碰撞方法绝对是可能的。 - Nico Schertler
@NicoSchertler如果这是一些CAD套件或某种几何解算器我同意。如果这是,例如,游戏。我会选择tesselation和General碰撞处理程序。 OP尚未指定,所以我只是说明使这一切变得简单得多的可能性。 - InBetween