web-dev-qa-db-ja.com

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

私はリアルタイムの地形レンダリングエンジンに取り組んでいます。 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 )。

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

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

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

8
Aulaulz

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_tを超えない場合は、Kデータ要素を格納する展開されたノードをプールしてリンクするフリーリストポインターの実装を使用して、既存の再割り当ての必要を回避できます。メモリブロック。あなたがそのような代替案を必要とするなら、私はそれを示すことができます。

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

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

enter image description here

これは、実行していることの難しさと入力の規模を考慮すると非常に合理的です。基本的に、TerrainQuadtreeQTInterfaceにのみ依存し、クアッドツリーまたはそのノードの内部には依存しません。以前はこれがありました:

enter image description here

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

enter image description here

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

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

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

私が知っている他の言語には正確な構文がないため、C++を使用していると思います。C++では、データ構造の非常に効果的なデカップリングメカニズムは、静的多型を使用できるクラステンプレートです。 std::vector<T, Alloc>のような標準のコンテナーを検討すると、ベクターは、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には、クワッドツリーノードの内部に関する完全な情報が必要です。このような場合、ノードにデータを割り当てる必要がある唯一の理由を想定すると、その抽象的なQTInterfaceでノードに割り当てる必要があるデータを返すだけで、ツリーノードの内部への依存関係を簡単に排除できます。 。

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

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

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

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

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

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

複雑すぎますか? Nodeクラスを拡張して、一部のクラスで使用されているデータの袋にしただけですか?

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

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

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

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

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

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

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

2
user204677

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

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

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

3
Martin Maat