サブクラス型を要求しないようにするための優れた設計方法は何ですか?


11

プログラムがオブジェクトのクラスを知る必要がある場合、通常は設計上の欠陥を示しているので、これを処理するための適切なプラクティスを知りたいと読みました。Circle、Polygon、Rectangleなど、さまざまなサブクラスを継承するShapeクラスを実装しています。また、CircleがPolygonまたはRectangleと衝突するかどうかを知るためのさまざまなアルゴリズムがあります。次に、Shapeの2つのインスタンスを取得し、一方が他方と衝突するかどうかを知りたいと仮定します。そのメソッドでは、どのアルゴリズムを呼び出すべきかを知るために衝突しているオブジェクトがどのサブクラスタイプであるかを推測しますが、これは悪いデザインや練習?これが私が解決した方法です。

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

これは悪いデザインパターンですか?サブクラス型を推測せずにこれをどのように解決できますか?


1
サークルなどのすべてのインスタンスが他のすべてのシェイプタイプを知っていることになります。そのため、それらはすべて何らかの形でしっかりと接続されています。そして、三角形のような新しい形状を追加するとすぐに、どこでも三角形のサポートを追加することになります。より頻繁に変更したいものに依存します。新しいシェイプを追加しますか、このデザインは悪いです。ソリューションが無秩序に広がっているため、三角形のサポートをどこにでも追加する必要があります。代わりに、衝突検出を別のクラスに抽出する必要があります。これは、すべてのタイプとデリゲートで機能します。
ザパッカー


IMOこれはパフォーマンス要件に依存します。コードが具体的であればあるほど、コードはより最適化され、実行が速くなります。この特定の場合(実装も)、型のチェックは問題ありません。なぜなら、調整された衝突チェックは、一般的なソリューションよりも非常に高速だからです。しかし、実行時のパフォーマンスが重要でない場合は、常に一般的/多態的なアプローチを採用します。
marstato

すべてのおかげで、私の場合、パフォーマンスが重要であり、新しいシェイプを追加することはありません。CollisionDetectionアプローチを行うこともありますが、サブタイプのタイプを知る必要がありました。 CollisionDetectionクラスのShapeでシェイプまたは代わりに何らかの「インスタンス」を実行しますか?
アレハンドロ

1
抽象Shapeオブジェクト間に効果的な衝突手順はありません。境界点の衝突をチェックしていない限り、ロジックは他のオブジェクトの内部に依存しますbool collide(x, y)(制御点のサブセットは良いトレードオフかもしれません)。それ以外の場合は、何らかの方法で型をチェックする必要があります-抽象化が本当に必要な場合は、Collision(現在のアクタの領域内のオブジェクトの)型を生成するのが適切なアプローチです。
震え

回答:


13

多型

getType()あなたがそれに似たものを使用している限り、ポリモーフィズムは使用していません。

自分のタイプを知る必要があると感じています。しかし、それを知っている間にやりたいことは、実際にクラスにプッシュダウンする必要があります。次に、いつ実行するかを指示します。

手続き型コードは情報を取得してから決定を下します。オブジェクト指向コードは、オブジェクトに何かをするように指示します。
—アレック・シャープ

この原則はテルと呼ばれ、尋ねないでください。それに従うと、タイプなどの詳細を広めたり、それらに作用するロジックを作成したりするのに役立ちます。それを行うと、クラスが裏返しになります。クラスが変更されたときに変更できるように、その動作をクラス内に保持することをお勧めします。

カプセル化

他の形は必要ないだろうと言うことができますが、私はあなたを信じてはいけないし、あなたもそうすべきではありません。

カプセル化に従うことの素晴らしい効果は、新しいタイプを追加するのが簡単なことです。なぜなら、それらの詳細は、それらが現れるロジックifswitchロジックに広がらないからです。新しい型のコードはすべて1つの場所にある必要があります。

タイプ無知の衝突検出システム

タイプを気にせずに、高性能で任意の2D形状で動作する衝突検出システムをどのように設計するかを説明します。

ここに画像の説明を入力してください

あなたはそれを描くことになっていたとしましょう。簡単そうです。それはすべて円です。衝突を理解するサークルクラスを作成するのは魅力的です。問題は、これが私たちに1000の円を必要とするときにバラバラになる思考の線を下に送ることです。

サークルについて考えてはいけません。ピクセルについて考える必要があります。

これらの人を描くのに使用するのと同じコードが、彼らが触れたとき、またはユーザーがクリックしたものを検出するために使用できるものであると言ったらどうでしょう。

ここに画像の説明を入力してください

ここでは、各円を一意の色で描画しました(目が黒の輪郭を見るのに十分であれば、それを無視してください)。これは、この非表示の画像のすべてのピクセルが、描画したものにマップされることを意味します。ハッシュマップはそれをうまく処理します。この方法で実際にポリモーフィズムを行うことができます。

この画像をユーザーに表示する必要はありません。最初のコードを描いたのと同じコードで作成します。色違いです。

ユーザーが円をクリックすると、1つの円だけがその色であるため、どの円かが正確にわかります。

円を別の円の上に描画すると、上書きしようとしているすべてのピクセルをセットにダンプすることですばやく読み取ることができます。衝突したすべての円の設定ポイントが完了したら、衝突を通知するためにそれぞれを1回呼び出すだけで済みます。

新しいタイプ:長方形

これはすべて円を使って行われましたが、私はあなたに尋ねます:それは長方形で異なる動作をしますか?

サークルの知識は検出システムに漏れていません。半径、円周、または中心点は気にしません。ピクセルと色が重要です。

この衝突システムの個々の形状にプッシュダウンする必要がある唯一の部分は、一意の色です。それ以外は、図形は図形の描画について考えることができます。とにかく彼らが得意なものです。

衝突ロジックを記述するとき、サブタイプが何であるかは気にしません。衝突するように指示し、描画するふりをしている図形の下で見つかったものを通知します。タイプを知る必要はありません。また、他のクラスのコードを更新することなく、好きなだけサブタイプを追加できます。

実装の選択肢

本当に、それはユニークな色である必要はありません。それは実際のオブジェクト参照であり、間接的なレベルを保存する可能性があります。しかし、この答えに描かれている場合、それらは見栄えがよくありません。

これは実装の一例にすぎません。確かに他のものがあります。これが意味することは、これらの形状のサブタイプを単一の責任に近づけるほど、システム全体がうまく機能することです。より高速でメモリ集約度の低いソリューションが存在する可能性がありますが、サブタイプの知識を広めることを余儀なくされた場合、パフォーマンスが向上してもそれらを使用することを嫌います。明らかに必要でない限り、私はそれらを使用しません。

ダブルディスパッチ

これまで、二重ディスパッチを完全に無視してきました。できたからです 衝突ロジックが衝突した2つのタイプを気にしない限り、それは必要ありません。必要ない場合は、使用しないでください。あなたがそれを必要とするかもしれないと思うなら、できる限りそれを扱うことを先送りにしてください。この態度はヤグニと呼ばれます。

あなたが決める場合、あなたは本当に、n個の形状のサブタイプは本当に必要なn個ならば、あなたの自己を頼む衝突の種類を必要とする2衝突の種類。これまでのところ、別のシェイプサブタイプを簡単に追加できるように一生懸命取り組んできました。ダブルディスパッチの実装でそれを台無しにしたくはありません。

とにかく何種類の衝突がありますか?少し推測(危険なこと)は、弾性衝突(弾力性)、非弾性(粘着性)、エネルギー(爆発)、および破壊的(損傷)を生み出します。さらに多くの可能性がありますが、これがn 2未満であれば、衝突を過剰に設計しないでください。

これは、私の魚雷がダメージを受け入れる何かに当たったとき、宇宙船に当たったことを知る必要がないことを意味します。「ハハ!あなたは5ポイントのダメージを受けた」と伝えるだけです。

損傷を与えるものは、損傷メッセージを受け入れるものに損傷メッセージを送信します。そのようにして、新しい形状について他の形状に知らせることなく、新しい形状を追加できます。あなたは新しいタイプの衝突の周りに広がるだけです。

宇宙船は「ハハ!あなたは100ポイントのダメージを受けた」とトルプに送り返すことができます。「あなたは今、私の船体にこだわっています」。そして、torpは「さて、私は忘れてしまいました」と返事することができます。

それぞれが何であるかを正確に知ることはありません。彼らは、衝突インターフェイスを介して互いに対話する方法を知っています。

確かに、ダブルディスパッチを使用すると、これよりも詳細に制御できますが、本当に必要ですか?

少なくとも、実際の形状の実装ではなく、形状が受け入れる衝突の種類の抽象化を通じて二重ディスパッチを行うことを検討してください。また、衝突動作は、依存関係として注入し、その依存関係に委任できるものです。

性能

パフォーマンスは常に重要です。しかし、それが常に問題であることを意味するわけではありません。パフォーマンスをテストします。推測するだけではありません。パフォーマンスの名の下で他のすべてを犠牲にしても、通常はパフォーマンスの良いコードにはなりません。



+1のための「あなたは、他の形状は、これまで必要とされません私に言うことができますが、私はあなたを信じていないと、どちらがあなた必要があります。」
Tulainsコルドバ

このプログラムが図形を描くことではなく、純粋に数学的な計算である場合、ピクセルについて考えることはどこにも行きません。この答えは、オブジェクト指向の純粋さを認識するためにすべてを犠牲にする必要があることを意味します。また、矛盾も含まれています。最初に、将来、より多くの種類の形状が必要になる可能性があるという考えに基づいて設計全体を行う必要があると言ってから、「YAGNI」と言います。最後に、型を追加しやすくすることは、操作を追加するのが難しいことを意味することを無視します。これは、型階層が比較的安定しているが、操作が大きく変化する場合は悪いことです。
クリスチャンハックル

7

問題の説明は、マルチメソッド(別名、マルチディスパッチ)を使用する必要があるように聞こえます。この特定のケースでは、ダブルディスパッチです。最初の答えは、ラスターレンダリングで形状の衝突を一般的に処理する方法について詳細に説明しましたが、OPは「ベクター」ソリューションを望んでいたか、OOPの説明の古典的な例である問題全体が形状に関して再定式化されたと思います。

引用されたウィキペディアの記事でさえ、同じ衝突のメタファーを使用しています。引用させてください(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

この問題には、2つのレベルでの再設計が必要です。

最初に、形状から形状間の衝突を検出するためのロジックを抽出する必要があります。これは、モデルに新しい形状を追加する必要があるたびに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。これは、各ビジターオブジェクトにtypeの属性が必要だからです。

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に違反せず、コードのオーバーヘッドは最小限です。また、classを使用するShapeCollisionDetectorことにより、SRPの違反を回避し、コードの冗長性を回避します。


5

あなたの基本的な問題は、ほとんどの最新のオブジェクト指向プログラミング言語では、関数のオーバーロードは動的バインディングでは機能しないことです(つまり、関数引数のタイプはコンパイル時に決定されます)。必要なのは、1つだけではなく2つのオブジェクトで仮想的な仮想メソッド呼び出しです。このようなメソッドはマルチメソッドと呼ばれます。ただし、Java、C ++などの言語でこの動作エミュレートする方法があります。これは、ダブルディスパッチが非常に便利な場所です。

基本的な考え方は、ポリモーフィズムを2回使用することです。2つの形状が衝突する場合、ポリモーフィズムを介してオブジェクトの1つの正しい衝突メソッドを呼び出し、一般的な形状タイプの他のオブジェクトを渡すことができます。呼び出されたメソッドで、このオブジェクトが円、長方形、その他のいずれであるかがわかります。次に、渡されたシェイプオブジェクトでコリジョンメソッドを呼び出し、thisオブジェクトを渡します。この2番目の呼び出しは、ポリモーフィズムを介して正しいオブジェクトタイプを再度見つけます。

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
  }

  // ...
}

ただし、この手法の大きな欠点は、階層内の各クラスがすべての兄弟について知る必要があることです。これにより、後で新しい形状が追加された場合、メンテナンスの負担が大きくなります。


2

たぶん、これはこの問題に取り組む最良の方法ではありません

数学衝突の形状の衝突は、形状の組み合わせに特有です。つまり、必要なサブルーチンの数は、システムがサポートする形状の数の2乗になります。形状の衝突は、実際には形状に対する操作ではなく、形状をパラメーターとしてとる操作です。

オペレーターの過負荷戦略

基礎となる数学の問題を単純化できない場合は、演算子のオーバーロードアプローチをお勧めします。何かのようなもの:

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

静的イニシャライザでは、リフレクションを使用してメソッドのマップを作成し、一般的なcollision(Shape s1、Shape s2)メソッドに動的なディパッサを実装します。静的イニシャライザには、クラスのロードを拒否して、欠落している衝突関数を検出して報告するロジックを含めることもできます。

これは、C ++演算子のオーバーロードに似ています。C ++では、オーバーロードできるシンボルの固定セットがあるため、オーバーロードは非常に複雑です。ただし、この概念は非常に興味深いものであり、静的関数を使用して複製できます。

このアプローチを使用する理由は、衝突はオブジェクトに対する操作ではないためです。衝突は、2つの任意のオブジェクトに関する何らかの関係を示す外部操作です。また、静的イニシャライザは、衝突関数を逃したかどうかを確認できます。

可能であれば数学の問題を簡素化する

前述したように、衝突関数の数は、形状タイプの数の2乗です。これは、20個のシェイプのみのシステムでは、21個のシェイプ441など、400のルーチンが必要であることを意味します。これは簡単に拡張できません。

しかし、あなたの数学を簡素化することができます。衝突機能を拡張する代わりに、すべての形状をラスタライズまたは三角形分割できます。そうすれば、衝突エンジンは拡張可能である必要はありません。衝突、距離、交差点、結合、その他のいくつかの機能は普遍的です。

三角測量

ほとんどの3Dパッケージとゲームがすべてを三角測量することに気づきましたか?これは、数学を単純化する形式の1つです。これは2D形状にも適用されます。ポリゴンは三角形化できます。円とスプラインは、ポリゴンに近似できます。

繰り返しますが、単一の衝突関数があります。クラスは次のようになります。

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

そしてあなたの操作:

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

もっと簡単ですか?

ラスタライズ

形状をラスタライズして、単一の衝突機能を持たせることができます。

ラスタライズは根本的な解決策のように思えるかもしれませんが、形状の衝突の精度に応じて、手頃な価格で高速な場合があります。(ゲームのように)正確である必要がない場合は、低解像度のビットマップがあります。ほとんどのアプリケーションでは、数学の絶対精度は必要ありません。

近似で十分な場合があります。生物学シミュレーション用のANTONスーパーコンピューターはその一例です。その計算は、計算が困難な多くの量子効果を破棄し、これまでに行われたシミュレーションは、実世界で行われた実験と一致しています。ゲームエンジンおよびレンダリングパッケージで使用されるPBRコンピューターグラフィックスモデルは、各フレームのレンダリングに必要なコンピューターの電力を削減する簡素化を行います。実際には物理的に正確ではありませんが、肉眼で納得させるのに十分近いです。

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.