ここでクラスを分離しすぎていませんか?


8

私はリアルタイムの地形レンダリングエンジンに取り組んでいます。QuadTreeクラスとNodeクラスがあります。QuadTreeクラスは、カメラの場所に応じてツリーを展開または折りたたみます。したがって、QuadTreeがNodeオブジェクトの存続期間を担当することは理にかなっています。問題は、その存続期間に関連付ける必要があり、QuadTreeとは何の関係もない多くのデータがあることです。これらのデータは相互に関連していなくてもかまいません。私は成功せずにクラスを正しく分離するクリーンな方法を探していました。すべての変更(場合によってはマイナーな変更も)には、いくつかのファイルを介した変更が必要で、ほとんどの場合、それに関連しないファイルが必要です。ようやく機能するように見えるものがありますが、それをあまりにも切り離そうとしているので、あまりメリットがないために複雑さが増しています。これは私がそれをした方法です:

私のQuadTreeクラスは、ツリー以外のものを処理するべきではありません。しかし、ノードを作成するたびに、これらのノードにデータを関連付ける必要があります。これはQuadTreeクラスで行うためにクラスを汚染するため、QuadTreeとこれらのデータを作成することを仕事とするクラスの間で通信するためのインターフェースを追加しました。この時点で、私はそれを正しい方法で行っていると思います。疑似コード:

class QTInterface
{
    virtual void nodeCreated( Node& node ) = 0;
    virtual void nodeDestroyed( Node& node ) = 0;
};

class QuadTree
{
public:
    QuadTree( ...., QTInterface& i ) : i( i ) {}

    void update( Camera camera )
    {
        // ....
        i.nodeCreated( node );
        // ....
        i.nodeDestroyed( node );
    }

private:
    QTInterface& i;
    Node root;
};

次に、これらの各ノードにランダムデータを関連付ける必要があります。したがって、QTInterfaceを実装する私のクラスでは、まさにそれを行うマップがあります。

class Terrain : public QTInterface
{
    void nodeCreated( Node node )
    {
        Data data;
        // ... create all the data associated to this node
        map[ node ] = data
        // One more thing, The QuadTree actually needs one field of Data to continue, so I fill it there
        node.xxx = data.xxx
    }

    void nodeDestroyed( Node node )
    {
        // ... destroy all the data associated to this node
        map.erase( node );
    }

};

NodeとQuadTreeはコードの他の部分から独立しているので、そこに戻らなければならないのは、QuadTreeアルゴリズムで何かを変更する必要があるからです。

しかし、それが私の最初の問題だと思います。ほとんどの場合、最適化については気になるまで心配しませんが、クラスを適切に分離するためにこの種のオーバーヘッドを追加する必要があるのは、デザインに欠陥があるためだと思います。

これに関するもう1つの問題は、ノードに関連付けられたデータが大量のデータの袋になってしまうことです。同じ結果を得るために、痛みを抑えて、Nodeクラスをバッグとして使用することもできます。

だから、ここでいくつかの質問:

  • 複雑すぎますか?Nodeクラスを拡張して、一部のクラスで使用されるデータビーニングのバッグにすべきですか?

  • いいえの場合、私の代替品はどれくらい良いですか?もっと良い方法はありますか?

  • クラスを正しく分離する方法にはいつも苦労しています。後で使用できるようにするためのアドバイスはありますか?(たとえば、どのような質問を自分自身に尋ねる必要があるか、またはどのように処理するかなど。紙でこれについて考えることは私には非常に抽象的なようで、すぐに何かをコーディングすると、後でリファクタリングが行われます)

注:不必要な詳細で埋め尽くされた非常に長い質問を避けるために、問題をできるだけ単純化しようとしました。重要なものを省略していないことを願っています。

編集:いくつかの詳細が尋ねられました:

カメラは可視ノードのみを選択することはできません。これは、すべてのノードをメモリに保持する必要があることを意味します。これは、エンジンが非常に大きな地形を高解像度でレンダリングすることになっているため不可能です。木の深さは簡単に25以上になります。それ以外は、新しいノードがいつ作成/破棄されたかを知るのも簡単です(基本的に同じくらい簡単です:ノードに子がなく、深さが0でない場合、ノードを作成する必要があるため、ノードが子とアルゴリズムはそこで停止します。つまり、以前はフレームに表示されていましたが、現在は表示されていないので、削除する必要があります。

計算が必要なデータの例は、これらのノードの高さと法線です(https://en.wikipedia.org/wiki/Heightmapおよびhttps://en.wikipedia.org/wiki/Normal_mapping)。

これらのデータの作成には以下が含まれます。

  • QuadTreeによって計算されたノードデータをマルチスレッドのワークキューに送信する
  • ハイトマップが生成されたら、QuadTreeがアルゴリズムを続行するために必要なノードの唯一のフィールドである最小/最大高さを更新します。
  • その後、CPUで計算された高さマップと法線マップを使用してGPUテクスチャを更新します。

しかし、これは彼のデータ計算方法にすぎません。GPUでも実行できますが、まったく異なる手順が必要になります。そして、それがQuadTreeクラスから分離したい理由です。すべてのコードをリファクタリングする必要なく、2つを簡単に(テスト目的で)交換したいからです。設計カップリング


1
なぜ常にノードを作成して破棄するのですか?カメラは、ツリーのどのノードを表示するかを選択するだけではいけませんか?また、追加したいデータの具体例を教えてもらえますか?
null

私はあなたの質問に答えるために私の投稿を編集しました。
Aulaulz

回答:


2

QTとカメラを組み合わせたQTがデータをオンザフライで関連付け/関連付け解除するタイミングを認識しているときに、QTノードの存続時間とは無関係にデータを動的に関連付けたり関連付けを解除したりする場合、一般化するのは少し難しいので、ソリューションは実際には悪くない。これは、非常に優れた一般的な方法で設計するのは難しいことです。、のような「うう....うまくそれをテストし、それを出荷!」冗談です。探求する一連の考えを提供するように努めます。私に最もはっきりと光ったことの1つはここにありました:

void nodeCreated(Node& node)
{
    ...
    // One more thing, The QuadTree actually needs one field of 
    // Data to continue, so I fill it there
    node.xxx = data.xxx
}

これは、ノードの参照/ポインタが外部の連想コンテナへのキーとして使用されていないことを示しています。実際には、クワッドツリー自体の外部にあるクワッドツリーノードの内部にアクセスして変更しています。そして、少なくとも初心者のためにそれを避けるためのかなり簡単な方法があるはずです。それがクワッドツリーの外側のノードの内部を変更している唯一の場所である場合、これを行うことができる可能性があります(xxxフロートのペアであるとしましょう):

std::pair<float, float> nodeCreated(const Node& node)
{
    Data data;
    ...
    map[&node] = data;
    ...
    return data.xxx;
}

その時点で、クワッドツリーはこの関数の戻り値を使用して割り当てることができますxxx。ツリーの外側にあるツリーノードの内部にアクセスしなくなった場合、これはすでにカップリングをかなり緩めています。

Terrainクワッドツリー内部にアクセスする必要性をなくすことで、不必要に物事を結合している唯一の場所が実際になくなります。たとえば、GPU実装がノードに対してまったく異なる内部表現を使用する可能性があるため、GPU実装と物事を交換する場合、これが唯一の本当のPITAです。

しかし、パフォーマンスの懸念のために、この種のことでデカップリングを最大限に達成する方法よりも多くの考えがあるので、実際には、データの関連付け/関連付けの解除を安価な一定時間の操作に変えることができる非常に異なる表現を提案します。プールされたメモリから要素を構築するために新しい配置を必要とする標準のコンテナの構築に慣れていない誰かに説明するのは少し難しいので、いくつかのデータから始めましょう。

struct Node
{
    ....
    // Stores an index to the data being associated on the fly
    // or -1 if there's no data associated to the node.
    int32_t data;
};

class Quadtree
{
private:
    // Stores all the data being associated on the fly.
    std::vector<char> data;

    // Stores the size of the data being associated on the fly.
    int32_t type_size;

    // Stores an index to the first free index of data
    // to reclaim or -1 if the free list is empty.
    int32_t free_index;

    ...

public:
    // Creates a quadtree with the specified type size for the
    // data associated and disassociated on the fly.
    explicit Quadtree(int32_t itype_size): type_size(itype_size), free_data(-1)
    {
        // Make sure our data type size is at least the size of an integer
        // as required for the free list.
        if (type_size < sizeof(int32_t))
            type_size = sizeof(int32_t);
    }

    // Inserts a buffer to store a data element and returns an index
    // to that.
    int32_t alloc_data()
    {
        int32_t index = free_index;
        if (free_index != -1)
        {
            // If a free index is available, pop it off the
            // free list (stack) and return that.
            void* mem = data.data() + index * type_size;
            free_index = *static_cast<int*>mem;
        }
        else
        {
            // Otherwise insert the buffer for the data
            // and return an index to that.
            index = data.size() / type_size;
            data.resize(data.size() + type_size);
        }
        return index;
    }

    // Frees the memory for the nth data element. 
    void free_data(int32_t n)
    {
        // Push the nth index to the free list to make
        // it available for use in subsequent insertions.
        void* mem = data.data() + n * type_size;
        *static_cast<int*>(mem) = free_index;
        free_index = n;
    }

    ...
};

これは基本的に「インデックス付きの無料リスト」です。ただし、関連データにこの担当者を使用すると、次のようなことができます。

class QTInterface
{
    virtual std::pair<float, float> createData(void* mem) = 0;
    virtual void destroyData(void* mem) = 0;
};

void Quadtree::update(Camera camera)
{
    ...
    node.data = alloc_data();
    node.xxx = i.createData(data.data() + node.data * type_size);
    ...
    i.destroyData(data.data() + node.data * type_size);
    free_data(node.data);
    node.data = -1;
    ...
}

class Terrain : public QTInterface
{
    // Note that we don't even need access to nodes anymore,
    // not even as keys to use. We've completely decoupled
    // terrains from tree internals.
    std::pair<float, float> createData(void* mem) override
    {
        // Construct the data (placement new) using the memory
        // allocated by the tree.
        Data* data = new(mem) Data(...);

        // Return data to assign to node.xxx.
        return data->xxx;
    }

    void destroyData(void* mem) override
    {
        // Destroy the data.
        static_cast<Data*>(mem)->~Data();
    }
};

うまくいけば、これはすべて理にかなっており、クライアントがツリーノードフィールドに内部アクセスする必要がないため、当然、元の設計から少し分離されています(ノードとしての知識さえも必要とせず、キーとして使用することすらありません) )、そしてノードとの間でデータを一定時間で関連付けたり関連付けを解除したりできるため(はるかに大きな定数を意味するハッシュテーブルを使用しなくても)、かなり効率的です。あなたのデータはmax_align_t(SIMDフィールドなしなど)を使用して整列でき、簡単にコピーできることを願っています。そうでない場合、整列されたアロケーターが必要であり、独自のフリーリストコンテナーをロールする必要があるため、状況はかなり複雑になります。さて、あなたが重要なコピー可能なタイプを持っているだけで、それ以上必要としない場合max_align_tK既存のメモリブロックを再割り当てする必要を回避するために、データ要素をそれぞれ格納する展開されたノードをプールおよびリンクするフリーリストポインタ実装を使用できます。あなたがそのような代替案を必要とするなら、私はそれを示すことができます。

要素のメモリの割り当てと解放を、要素の構築と破棄とは別のタスクとして行うという考えを考えると、これは少し高度で非常にC ++固有です。しかし、この方法でこれを行うとTerrain、最小限の責任を吸収し、ツリー表現の内部知識を一切必要とせず、不透明なノードを処理することすらありません。しかし、通常、このレベルのメモリ制御は、最も効率的なデータ構造を設計する場合に必要です。

基本的な考え方は、クライアントがクワッドツリーctorにオンザフライで関連付け/関連付けを解除したいデータのタイプサイズでツリーパスを使用しているということです。次に、クワッドツリーは、その型サイズを使用してメモリを割り当て、解放する責任があります。次にQTInterface、動的ディスパッチを使用してクライアントにデータを構築および破棄する責任を引き継ぎます。したがって、まだツリーに関連しているツリー外での唯一の責任は、クワッドツリーが自身に割り当てたり割り当てを解除したりするメモリから要素を構築および破棄することです。その時点で、依存関係は次のようになります。

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

これは、実行していることの難しさと入力の規模を考えると非常に合理的です。基本的にはあなたがTerrainのみに依存していないQuadtreeQTInterfaceし、四分木またはそのノードの最早内部。以前はこれがありました:

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

そしてもちろん、特にGPU実装を試すことを検討している場合、GPU実装は非常に異なるノードの担当者を使用する可能性が高いTerrainためNode、それに対する重大な問題はto からの依存関係です。もちろん、ハードコアSOLIDに移行したい場合は、次のようにします。

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

...おそらく工場と一緒に。しかし、IMOは完全なオーバーキル(少なくとも完全なオーバーINodeキルIMOです)であり、それぞれが動的ディスパッチを必要とする場合、クワッドツリー関数ほど細かいケースではあまり役に立ちません。

クラスを正しく分離する方法にはいつも苦労しています。後で使用できるようにするためのアドバイスはありますか?(たとえば、どのような質問を自分で自問しなければならないか、またはどのように処理しますか?紙でこれについて考えることは私には非常に抽象的なようで、すぐに何かをコーディングすると、後でリファクタリングが行われます)

大まかに言って、デカップリングは、特定のクラスまたは関数が他のことを実行するために必要とする情報の量を制限することになります。

私が知っている他の言語には正確な構文がないため、C ++を使用していると思います。C++では、データ構造の非常に効果的な分離メカニズムは、それらを使用できる場合は静的なポリモーフィズムを持つクラステンプレートです。のような標準のコンテナを考えるとstd::vector<T, Alloc>、vectorは、何を指定してTも何にも結合されません。Tコピー構築可能で、fillコンストラクターとfillのサイズ変更用のデフォルトコンストラクターがあるなど、いくつかの基本的なインターフェイス要件を満たす必要があります。変更の結果、T変更が必要になることは決してありません。

したがって、これを上記に関連付けると、データ構造に含まれるものの最小限の最小限の知識を使用してデータ構造を実装できるようになり、事前に型情報さえ必要としない程度にデータ構造が分離されます(ここでの詳細はコンパイル時の情報ではなく、コードの依存関係/カップリングの観点から説明します)T

必要な情報の量を最小限に抑えるための2番目に実用的な方法は、動的ポリモーフィズムを使用することです。たとえば、何が格納されているかについての知識を最小限に抑える合理化された一般化されたデータ構造を実装したい場合、1つ以上のインターフェースに何を格納するかについてのインターフェース要件を取得できます。

// Contains all the functions (pure virtual) required of the elements 
// stored in the container.
class IElement {...};

しかしどちらにしても、具体的な詳細ではなくインターフェイスにコーディングすることで、事前に必要な情報の量を最小限に抑えることができます。ここで、あなたがしている唯一の大きなことは、必要以上に多くの情報を必要とするように見えることです。Terrainたとえば、クワッドツリーノードの内部に関する完全な情報を持っている必要があります。たとえば、そのような場合、必要な唯一の理由はノードにデータを割り当てるには、そのabstractでノードに割り当てる必要があるデータを返すだけで、ツリーノードの内部への依存関係を簡単に排除できますQTInterface

したがって、何かを分離したい場合は、それを実行するために必要なことに焦点を当て、そのためのインターフェースを考え出します(継承を使用して明示的に、または静的ポリモーフィズムとダックタイピングを使用して暗黙的に)。そしてQTInterface、クライアントがサブタイプでその機能をオーバーライドして、クワッドツリーがその機能を実行するために必要な具体的な詳細を提供できるようにするために、クワッドツリー自体からある程度それをすでに実行しました。私があなたが不十分だと思う唯一の場所は、クライアントがまだクアッドツリーの内部にアクセスする必要があるということです。QTInterface割り当てられる値を返すようにしたときに私が提案したことを正確に行うことにより、それを回避することができますnode.xxx四分木実装自体で。つまり、物事がお互いに不必要な情報を必要としないように、物事をより抽象化し、インターフェースをより完全にすることだけです。

そして、その不要な情報(ノードの内部Terrainについて知る必要がある)を回避することで、たとえば、実装も変更することなく、GPU実装とQuadtreeより自由に交換できます。お互いについて知らないことは、お互いに影響を与えることなく自由に変更できます。CPUからGPUクワッドツリー実装を本当に交換したい場合は、上記のSOLIDルートに少し進むことができます。QuadtreeTerrainIQuadtree(quadtree自体を抽象化します)。これには動的なディスパッチヒットが付属しているため、ツリーの深さと入力サイズについては少し高価になる可能性があります。そうでない場合、少なくとも、クワッドツリーを使用するものがその内部ノード表現について機能する必要がない場合、コードの変更ははるかに少なくて済みます。typedefたとえば、抽象インターフェース(IQuadtree)を使用しない場合でも、の1行のコードを更新するだけで、一方を他方と交換できる場合があります。

しかし、それが私の最初の問題だと思います。ほとんどの場合、最適化については気になるまで心配しませんが、クラスを適切に分離するためにこの種のオーバーヘッドを追加する必要があるのは、デザインに欠陥があるためだと思います。

必ずしも。デカップリングは、依存関係を具体的なものから抽象的なものへとシフトすることを意味します。抽象化は、コンパイラがコンパイル時にコードを生成して基本的に実行時の抽象化コストを排除しない限り、実行時のペナルティを示唆する傾向があります。引き換えに、他のことに影響を与えずに変更を加えるためのはるかに余裕ができますが、コード生成を使用している場合を除き、多くの場合、何らかのパフォーマンスのペナルティを引き出します。

これで、データをその場でノード(または他の何か)に関連付けるために、自明ではない連想データ構造(マップ/辞書など)の必要性をなくすことができます。上記のケースでは、ノードに直接オンザフライで割り当て/解放されるデータへのインデックスを保存させました。これらの種類のことを行うことは、物事を効果的に分離する方法を研究することと、データ構造のメモリレイアウトを効果的に使用する方法(それほど純粋な最適化レルムでは)を研究することとはあまり関係ありません。

効果的なSEの原則とパフォーマンスは、十分に低いレベルで互いに矛盾しています。多くの場合、デカップリングは、一緒にアクセスされるフィールドのメモリレイアウトを分割し、より多くのヒープ割り当てを含み、より動的なディスパッチを含む場合があります。より高いレベルのコード(たとえば、画像全体に適用される操作ではなく、 -個々のピクセルをループするときのピクセル操作)、ただし、各反復で最も軽い処理を実行する最も重要なループ状のコードで発生するコストに応じて、取るに足らないものから深刻なものまでさまざまなコストがかかります。

複雑すぎますか?Nodeクラスを拡張して、一部のクラスで使用されるデータビーニングのバッグにすべきですか?

私が個人的には、データ構造を一般化しすぎず、非常に限られたコンテキストでのみ使用し、ある種の問題でパフォーマンスが非常に重要なコンテキストを処理している場合は、それほど悪くないと思います前に取り組みません。その場合、クアドツリーをテレインの実装の詳細に変換します。たとえば、広く一般に使用されるものではなく、同じように誰かがオクツリーを物理エンジンの実装の詳細に変換します。 「内部」からの「パブリックインターフェース」のアイデア。空間インデックスに関連する不変条件を維持することは、それをプライベート実装の詳細として使用するクラスの責任になります。

パフォーマンスが重要なコンテキストで効果的な抽象化(インターフェースなど)を設計するには、多くの場合、問題の大部分とそれに対する非常に効果的な解決策を事前に完全に理解する必要があります。実際には、ソリューションを一般化および抽象化しようとすると同時に、複数の反復にわたる効果的な設計を理解しようとする逆効果的な対策になる可能性があります。その理由の1つは、パフォーマンスが重要なコンテキストには非常に効率的なデータ表現とアクセスパターンが必要であることです。抽象化は、データにアクセスしたいコードの間に障壁を置きます。そのようなコードに影響を与えずにデータを自由に変更したい場合に役立つ障壁ですが、同時に最も効果的な表現方法を見つけようとしている場合には障害となります。そもそもそのようなデータにアクセスします。

しかし、この方法でこれを行うと、クワッドツリーをテレインのプライベートな実装の詳細に変換するのではなく、一般化してそれらの実装の外で使用するものではありません。また、CPU実装からGPU実装を非常に簡単にスワップアウトできるという考えは、通常、具体的な詳細(ノードの担当者など)に直接依存するのではなく、両方に対して機能する抽象化を考え出す必要があるため、無視する必要があります。いずれかの。

デカップリングのポイント

しかし、場合によっては、これはより公的に使用されるものについては許容できるかもしれません。人々が私がクレイジーなナンセンスを噴出していると思う前に、画像インターフェースを検討してください。画像がその内部(特定のピクセル形式でその下にあるピクセルの配列への直接アクセス)を公開しない場合、リアルタイムでビデオに画像フィルターを適用する必要のあるビデオプロセッサには、それらの数で十分でしょうか?getPixelここで抽象/仮想のようなものを使用することを知っているものはありません。setPixelピクセル単位でピクセル形式の変換を実行している間。そのため、非常に細かいレベル(ピクセルごと、ノードごとなど)でアクセスする必要がある十分にパフォーマンスが重要なコンテキストでは、基盤となる構造の内部を公開する必要がある場合があります。しかし、必然的に結果として物事を密に結合する必要があります。そうすると、基礎となるピクセルにアクセスするすべてのものに影響を与えることなく、いわば、画像の基礎となる表現を変更する(たとえば、画像形式を変更する)ことは容易ではありません。ただし、実際には抽象インターフェースよりもデータ表現を安定させる方が簡単な場合があるため、その場合は変更する理由が少なくなる可能性があります。ビデオプロセッサは、32ビットRGBAピクセル形式を使用するという考えに落ち着く可能性があり、その設計決定は今後何年も変わらない可能性があります。

多くの依存関係を持つものを変更すると、依存関係の数とコストが増大するため、理想的には、依存関係を安定性に向けて流す(変更しない)それはすべての場合において抽象である場合とそうでない場合があります。もちろん、それは不変条件を維持する上で情報を隠すことの利点を無視していますが、カップリングの観点から見ると、デカップリングの主なポイントは変更にかかる費用を少なくすることです。つまり、変更される可能性のあるものから変更されないものに依存関係をリダイレクトすることを意味します。抽象インターフェースがデータ構造の中で最も急速に変化する部分である場合、それは少しでも役に立ちません。

少なくともカップリングの観点から少し改善したい場合は、クライアントがアクセスする必要のあるノードパーツを、そうでないパーツから分離します。たとえば、クライアントは少なくともノードのリンクを更新する必要がないため、リンクを公開する必要はありません。少なくとも、クライアントがアクセス/変更するためにノードが表すものの全体とは別の値の集合を考え出すことができるはずNodeValueです。


3

行の間を読んでいると、ツリービューに集中しすぎているようです。あなたの考えは「私はオブジェクトをアタッチするノードを持つこのツリーを持っています、そしてツリーはオブジェクトに何をすべきかを伝えなければなりません」のようです。それは逆になります。結局のところ、ツリーは(問題ドメイン)オブジェクトをたどるビューにすぎません。オブジェクトには、ツリー(ノード)の知識/トレースがあってはなりません。オブジェクトを読み取り、それに応じて表示するのはビューです。

ツリーがサブスクライブできるオブジェクトにいくつかのイベントを実装すると、ノードを折りたたむ、展開する、作成する、または削除するタイミングがわかります。

そのため、ツリーがモデルに従うようにします。

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