Estou tentando desacoplar muito as aulas aqui?


8

Estou trabalhando em um mecanismo de renderização de terreno em tempo real. Eu tenho aulas de QuadTree e Node. A classe QuadTree expande / recolhe uma árvore, dependendo de onde a câmera está. Portanto, faz sentido que o QuadTree seja responsável pela vida útil dos objetos Node. O problema é que existem muitos dados que precisam ser associados a essa vida útil e não têm nada a ver com o QuadTree. Esses dados podem nem estar relacionados entre si. Eu tenho procurado uma maneira limpa de dissociar minhas aulas corretamente, sem sucesso. Todas as alterações (às vezes até as menores) exigiam alterações em vários arquivos, na maioria das vezes com arquivos não relacionados a ele. Finalmente, tenho algo que parece funcionar, mas acho que tenho tentado dissociá-lo demais, o que aumentou a complexidade, sem muitos benefícios. Foi assim que eu fiz:

Minha classe QuadTree não deve lidar com nada além da árvore. Mas toda vez que eu crio um nó, preciso associar dados a esses nós. Como isso poluiria a classe para fazê-lo na classe QuadTree, adicionei uma interface para se comunicar entre o QuadTree e a classe cujo trabalho é criar esses dados. Neste ponto, acho que tenho feito da maneira correta. Pseudo-código:

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

Agora preciso associar alguns dados aleatórios a cada um desses nós. Portanto, na minha classe que implementa o QTInterface, tenho um mapa fazendo exatamente isso:

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

};

O Node e o QuadTree agora são independentes de outras partes do código e, se eu tiver que voltar para lá, será apenas porque tenho que mudar alguma coisa no algoritmo QuadTree.

Mas é aí que acho que tenho meu primeiro problema. Na maioria das vezes, não me preocupo com a otimização até vê-la, mas acho que se tiver que adicionar esse tipo de sobrecarga para desacoplar minhas classes corretamente, é porque o design tem falhas.

Outro problema com isso é que os dados associados ao nó acabarão sendo um pacote de muitos dados. Para os mesmos resultados com menos dor, eu poderia ter usado a classe Node como uma sacola.

Então, várias perguntas aqui:

  • Estou complicando demais as coisas? Eu deveria apenas ter estendido a classe Node, tornando-o um conjunto de dados sendo usado por algumas classes?

  • Se não, qual é a minha alternativa? Existe uma maneira melhor?

  • Sempre tenho dificuldade em desacoplar minhas aulas corretamente. Algum conselho para dar que eu poderia usar mais tarde? (Como que perguntas eu tenho que me fazer, por exemplo, ou como você processa? Pensar nisso no papel parece muito abstrato para mim e codificar imediatamente algo resulta em refatoração posterior)

Nota: Tentei simplificar o problema o máximo possível para evitar uma pergunta muito longa, cheia de detalhes desnecessários. Espero não ter omitido os importantes.

Editar: Alguns detalhes foram solicitados:

A câmera não pode apenas escolher os nós visíveis porque isso significa que eu tenho que manter todos os nós na memória, o que não é possível porque o mecanismo deve render terrenos muito grandes com alta resolução. A profundidade da árvore seria facilmente 25+. Além disso, também é mais fácil saber quando novos nós foram criados / destruídos (basicamente, o mais fácil: se o nó não tem filhos e a profundidade não é 0, é porque o nó precisa ser criado, se o nó tiver filhos e o algoritmo pára por aí, significa que eles estavam visíveis no quadro antes, mas não agora, então tenho que excluí-los e os dados vinculados a ele).

Exemplo de dados que precisam ser computados é a altura e as normais desses nós ( https://en.wikipedia.org/wiki/Heightmap e https://en.wikipedia.org/wiki/Normal_mapping ).

A criação desses dados envolve:

  • enviando dados do Nó calculados pelo QuadTree para uma fila de trabalho multithread
  • Depois que o mapa de altura tiver sido gerado, atualize o único campo do Nó que o QuadTree precisa para continuar o algoritmo: A altura mínima / máxima.
  • Depois disso, atualize as texturas da GPU usando o mapa de altura e o mapa normal calculados na CPU.

Mas essa é apenas a maneira de calcular os dados. Eu também posso fazer isso na GPU e exigirá etapas completamente diferentes. E é por essa razão que desejo separá-lo da classe QuadTree, porque gostaria de trocar entre os dois facilmente (para fins de teste) sem precisar refatorar todo o meu código. acoplamento de design


1
Por que você cria e destrói nós o tempo todo? A câmera não deveria simplesmente escolher quais nós da árvore deveriam ser exibidos? Além disso, você pode dar um exemplo concreto dos dados que deseja adicionar?
Nulo

Editei minha postagem para responder às suas perguntas.
Aulaulz

Respostas:


2

Para associar e desassociar dinamicamente dados em tempo real, independentemente da vida útil de um nó QT, enquanto o QT combinado com a câmera tem o conhecimento de quando os dados devem ser associados / desassociados em tempo real, isso é um pouco complicado de generalizar e acho que sua solução é na verdade não é ruim. Isso é algo difícil de projetar de uma maneira muito agradável e generalizada. tipo, "uhh ... teste bem e envie!" Ok, um pouco de piada. Vou tentar oferecer um pouco de pensamento para explorar. Uma das coisas que mais me encarou estava aqui:

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
}

Isso me diz que um nó ref / apontador não é usado apenas como chave em um contêiner associativo externo. Na verdade, você está acessando e modificando as partes internas do nó quadtree fora da própria quadtree. E deve haver uma maneira bastante fácil de pelo menos evitar isso para iniciantes. Se esse é o único lugar em que você está modificando as partes internas do nó fora do quadtree, poderá fazer isso (digamos que xxxseja um par de flutuadores):

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

Nesse ponto, o quadtree pode usar o valor de retorno dessa função para atribuir xxx. Isso já afrouxa bastante o acoplamento quando você não está mais acessando as partes internas de um nó da árvore fora da árvore.

Eliminar a necessidade de Terrainacessar os internos da quadtree eliminaria o único lugar em que você acoplaria as coisas desnecessariamente. É a única PITA real se você trocar as coisas com uma implementação de GPU, por exemplo, uma vez que a implementação da GPU pode usar um representante interno totalmente diferente para os nós.

Mas, para as suas preocupações com o desempenho, e tenho muito mais em mente do que como você consegue se desacoplar com esse tipo de coisa, sugeriria uma representação muito diferente na qual você pode transformar a associação / desassociação de dados em uma operação barata e de tempo constante. É um pouco difícil de explicar para alguém que não está acostumado a construir contêineres padrão que exigem posicionamento novo para construir elementos no lugar a partir da memória em pool, então começarei com alguns dados:

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

    ...
};

Isso é basicamente uma "lista livre indexada". Mas quando você usa esse representante para os dados associados, pode fazer algo assim:

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

Espero que tudo isso faça sentido e, naturalmente, seja um pouco mais dissociado do design original, pois não exige que os clientes tenham acesso interno aos campos dos nós da árvore (agora não é mais necessário o conhecimento dos nós, nem mesmo o uso como chaves) ) e é consideravelmente mais eficiente, pois você pode associar e desassociar dados de / para nós em tempo constante (e sem usar uma tabela de hash, o que implicaria uma constante muito maior). Espero que seus dados possam ser alinhados usando max_align_t(sem campos SIMD, por exemplo) e sejam trivialmente copiáveis, caso contrário, as coisas ficam consideravelmente mais complexas, pois precisaríamos de um alocador alinhado e poderíamos ter que rolar nosso próprio contêiner de lista gratuita. Bem, se você tem apenas tipos não trivialmente copiáveis ​​e não precisa de mais do quemax_align_t, podemos usar uma implementação de ponteiro de lista livre que agrupa e vincula nós não desenrolados que armazenam Kelementos de dados para evitar a necessidade de realocar os blocos de memória existentes. Posso mostrar que, se você precisar de uma alternativa assim.

É um pouco avançado e muito específico para C ++, considerando a idéia de alocar e liberar memória para elementos como uma tarefa separada da construção e destruição deles. Porém, se você fizer dessa maneira, Terrainabsorve as responsabilidades mínimas e não requer mais nenhum conhecimento interno da representação em árvore, nem manipula os nós opacos. No entanto, esse nível de controle de memória é normalmente o que você precisa para projetar as estruturas de dados mais eficientes.

A idéia fundamental é que você tenha o cliente usando a passagem em árvore no tamanho do tipo dos dados que deseja associar / desassociar em tempo real para o quadtree ctor. Em seguida, o quadtree tem a responsabilidade de alocar e liberar memória usando esse tamanho de tipo. Em seguida, ele passa a responsabilidade de construir e destruir os dados para o cliente usando QTInterfaceum despacho dinâmico. A única responsabilidade, portanto, fora da árvore que ainda está relacionada à árvore, é construir e destruir elementos da memória que o quadtree aloca e desaloca. Nesse ponto, as dependências se tornam assim:

insira a descrição da imagem aqui

O que é bastante razoável, considerando a dificuldade do que você está fazendo e a escala das entradas. Basicamente, o seu Terrainentão depende apenas de Quadtreee QTInterface, e não mais das partes internas do quadtree ou de seus nós. Anteriormente, você tinha isso:

insira a descrição da imagem aqui

E, é claro, um problema gritante com isso, especialmente se você está pensando em experimentar implementações de GPU, é essa dependência de Terrainpara Node, pois uma implementação de GPU provavelmente desejaria usar um representante de nó muito diferente. Obviamente, se você quiser usar o SOLID hardcore, faça algo assim:

insira a descrição da imagem aqui

... junto com possivelmente uma fábrica. Mas o IMO é um exagero total (no mínimo, INodeé um exagero total do IMO) e não seria muito útil em um caso tão granular quanto uma função quadtree se cada um exigisse um envio dinâmico.

Sempre tenho dificuldade em desacoplar minhas aulas corretamente. Algum conselho para dar que eu poderia usar mais tarde? (Como que perguntas eu tenho que me fazer, por exemplo, ou como você processa? Pensar nisso no papel parece muito abstrato para mim e codificar imediatamente algo resulta em refatoração posterior)

De maneira geral e grosseira, a dissociação geralmente se resume a limitar a quantidade de informações que uma determinada classe ou função requer sobre outra coisa para fazer suas coisas.

Suponho que você esteja usando C ++, já que nenhuma outra linguagem que conheço tem essa sintaxe exata e, em C ++, um mecanismo de dissociação muito eficaz para estruturas de dados são modelos de classe com polimorfismo estático, se você puder usá-los. Se você considerar os contêineres padrão std::vector<T, Alloc>, o vetor não será acoplado ao que você especificar para o que quer que Tseja. Requer apenas que Tsatisfaçam alguns requisitos básicos da interface, como é construtível para cópia e possui um construtor padrão para o construtor de preenchimento e o redimensionamento de preenchimento. E isso nunca exigirá mudanças como resultado da Tmudança.

Portanto, vinculando-o ao acima, ele permite que a estrutura de dados seja implementada usando o conhecimento mínimo absoluto do que ela contém, e que a desacopla na medida em que nem sequer precisa de nenhuma informação de tipo com antecedência (o avanço aqui é falando em termos de dependências / acoplamento de código, não informações em tempo de compilação) sobre o que Té.

A segunda maneira mais prática de minimizar a quantidade de informações necessárias é usar o polimorfismo dinâmico. Por exemplo, se você deseja implementar uma estrutura de dados razoavelmente generalizada que minimize o conhecimento do que ele armazena, poderá capturar os requisitos de interface para o que ele armazena em uma ou mais interfaces:

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

Mas de qualquer forma, tudo se resume a minimizar a quantidade de informações necessárias antecipadamente, codificando para interfaces em vez de para detalhes concretos. Aqui, a única coisa importante que você está fazendo que parece exigir muito mais informações do que o necessário é que você Terrainprecisa ter informações completas sobre os componentes internos de um nó Quadtree, por exemplo, nesse caso, supondo que o único motivo necessário é: Para atribuir uma parte de dados a um nó, podemos facilmente eliminar essa dependência aos internos de um nó da árvore, retornando apenas os dados que devem ser atribuídos ao nó nesse resumo QTInterface.

Portanto, se eu quiser desacoplar algo, concentro-me apenas no que ele precisa fazer e crie uma interface para ele (explícita usando herança ou implícita usando polimorfismo estático e digitação de pato). E você já fez isso, em certa medida, a partir do próprio quadtree, QTInterfacepara permitir que o cliente substituísse suas funções por um subtipo e fornecer os detalhes concretos necessários para o quadtree fazer suas coisas. O único lugar em que acho que você ficou aquém é que o cliente ainda requer acesso aos internos do quadtree. Você pode evitar isso aumentando o que QTInterfacefaz, que é exatamente o que sugeri quando o fiz retornar um valor a ser atribuído anode.xxxna própria implementação quadtree. Portanto, é apenas uma questão de tornar as coisas mais abstratas e as interfaces mais completas, para que as coisas não exijam informações desnecessárias uma sobre a outra.

E, evitando essas informações desnecessárias ( Terraintendo que saber sobre os Quadtreenós internos), agora você fica mais livre para trocar a Quadtreeimplementação por GPU, por exemplo, sem alterar a Terrainimplementação também. O que as coisas não sabem um sobre o outro é livre para mudar sem se afetar. Se você realmente deseja trocar as implementações de quadtree da GPU pelas da CPU, pode ir um pouco para a rota do SOLID acima comIQuadtree(tornando o próprio quadtree abstrato). Isso vem com um hit de despacho dinâmico que pode ficar um pouco caro com a profundidade da árvore e os tamanhos de entrada dos quais você está falando. Caso contrário, pelo menos ele precisará de muito menos alterações no código se as coisas que usam o quadtree não precisarem saber sobre sua representação interna do nó para funcionar. Você pode trocar um pelo outro apenas atualizando uma única linha de código por um typedef, por exemplo, mesmo se você não usar uma interface abstrata ( IQuadtree).

Mas é aí que acho que tenho meu primeiro problema. Na maioria das vezes, não me preocupo com a otimização até vê-la, mas acho que se tiver que adicionar esse tipo de sobrecarga para desacoplar minhas classes corretamente, é porque o design tem falhas.

Não necessariamente. A dissociação geralmente implica mudar uma dependência do concreto para o abstrato. As abstrações tendem a implicar uma penalidade no tempo de execução, a menos que o compilador esteja gerando código no tempo de compilação para basicamente eliminar o custo da abstração no tempo de execução. Em troca, você tem muito mais espaço para fazer alterações sem afetar outras coisas, mas isso geralmente extrai algum tipo de penalidade no desempenho, a menos que você esteja usando a geração de código.

Agora você pode acabar com a necessidade de uma estrutura de dados associativa não trivial (mapa / dicionário, por exemplo) para associar dados a nós (ou qualquer outra coisa) em tempo real. No caso acima, acabei de fazer os nós armazenarem diretamente um índice nos dados que são alocados / liberados em tempo real. Fazer esse tipo de coisa não está muito relacionado ao estudo de como dissociar as coisas de maneira eficaz, mas como utilizar layouts de memória para estruturas de dados de forma eficaz (mais no domínio da otimização pura).

Princípios e desempenho efetivos de SE estão em desacordo entre si em níveis suficientemente baixos. Geralmente, a dissociação separa os layouts de memória para os campos comumente acessados ​​juntos, pode envolver mais alocações de heap, pode envolver mais expedição dinâmica, etc. Torna-se rapidamente trivializada à medida que você trabalha em direção a códigos de nível superior (por exemplo: operações aplicadas a imagens inteiras, não por operações de pixel ao fazer o loop através de pixels individuais), mas possui um custo que varia de trivial a grave, dependendo de quanto esses custos são incorridos no seu código mais crítico e em loop, executando o trabalho mais leve em cada iteração.

Estou complicando demais as coisas? Eu deveria apenas ter estendido a classe Node, tornando-o um conjunto de dados sendo usado por algumas classes?

Pessoalmente, não acho que seja tão ruim se você não estiver tentando generalizar demais sua estrutura de dados, apenas usando-a em um contexto muito limitado, e estiver lidando com um contexto extremamente crítico de desempenho para um tipo de problema que você possui já foi abordado antes. Nesse caso, você transformaria seu quadtree em um detalhe de implementação de seu terreno, por exemplo, em vez de algo a ser amplamente e publicamente usado, de maneira semelhante alguém pode transformar um octree em um detalhe de implementação de seu mecanismo físico, deixando de distinguir o idéia de "interface pública" de "internos". Manter invariantes relacionados ao índice espacial torna-se uma responsabilidade da classe, usando-o como um detalhe de implementação privado.

Para projetar uma abstração efetiva (interface, por exemplo) em um contexto crítico de desempenho, muitas vezes é necessário que você compreenda completamente a maior parte do problema e uma solução muito eficaz para isso com antecedência. Na verdade, ele pode se transformar em uma medida contraproducente para tentar generalizar e abstrair a solução, ao mesmo tempo em que tenta descobrir o design eficaz em várias iterações. Uma das razões é que os contextos críticos de desempenho exigem representações de dados e padrões de acesso muito eficientes. As abstrações colocam uma barreira entre o código que deseja acessar os dados: uma barreira que é útil se você deseja que os dados sejam livres para serem alterados sem afetar esse código, mas um obstáculo se você estiver simultaneamente tentando descobrir a maneira mais eficaz de representar e acesse esses dados em primeiro lugar.

Mas, se você fizer dessa maneira, novamente eu errei ao transformar o quadtree em um detalhe de implementação particular de seus terrenos, não em algo a ser generalizado e usado fora de suas implementações. E você teria que renunciar à ideia de poder trocar tão facilmente implementações de GPU de implementações de CPU, pois isso normalmente exigiria uma abstração que funcione para ambos e não diretamente, dependendo dos detalhes concretos (como representantes de nós) de qualquer um.

O ponto de dissociação

Mas talvez em alguns casos isso possa até ser aceitável para coisas mais usadas publicamente. Antes que as pessoas pensem que estou falando bobagens loucas, considere interfaces de imagem. Quantos deles seriam suficientes para um processador de vídeo que precisa aplicar filtros de imagem no vídeo em tempo real se a imagem não expuser seus componentes internos (acesso direto à sua matriz subjacente de pixels em um formato de pixel específico)? Não tenho conhecimento de usar algo como um abstrato / virtual getPixelaqui esetPixelenquanto faz conversões de formato de pixel por pixel. Portanto, em contextos suficientemente críticos para o desempenho, nos quais é necessário acessar as coisas em um nível muito granular (por pixel, por nó, etc.), às vezes é necessário expor os elementos internos da estrutura subjacente. Mas, inevitavelmente, você terá que unir as coisas firmemente como resultado, e não será fácil alterar a representação subjacente das imagens (alteração no formato da imagem, por exemplo), por assim dizer, sem afetar tudo que acessa seus pixels subjacentes. Mas pode haver menos razões para mudar nesse caso, pois pode ser mais fácil estabilizar a representação de dados do que a interface abstrata. Um processador de vídeo pode ter a idéia de usar os formatos de pixel RGBA de 32 bits e essa decisão de design pode ser imutável nos próximos anos.

Idealmente, você deseja que as dependências fluam em direção à estabilidade (coisas imutáveis) porque alterar algo que possui muitas dependências multiplica em custo com o número de dependências. Isso pode ou não ser abstrações em todos os casos. É claro que isso está ignorando os benefícios da informação oculta na manutenção de invariantes, mas do ponto de vista do acoplamento, o ponto principal da dissociação é tornar as coisas menos caras para mudar. Isso significa redirecionar dependências de coisas que podem mudar para coisas que não vão mudar, e isso não ajuda em nada se suas interfaces abstratas forem as partes de mudança mais rápida da sua estrutura de dados.

Se você quiser pelo menos melhorar isso um pouco da perspectiva do acoplamento, separe as partes do nó que os clientes precisam acessar e não as que não. Estou assumindo que os clientes pelo menos não precisam atualizar os links do nó, por exemplo, então não há necessidade de expor os links. Você deve pelo menos conseguir criar algum valor agregado separado da totalidade do que os nós representam para os clientes acessarem / modificarem, como NodeValue.


3

Lendo nas entrelinhas, parece que você está muito focado na visualização em árvore. Sua idéia parece ser "Eu tenho essa árvore, com nós, aos quais anexo objetos e a árvore precisa dizer aos objetos o que fazer". Deve ser o contrário, no entanto. Afinal, a árvore é apenas uma visão que deve seguir seus objetos (domínio do problema). Os objetos não devem ter conhecimento / traços da árvore (nós). É a visão que lê os objetos e se apresenta de acordo.

Você pode implementar alguns eventos em seus objetos nos quais a árvore pode se inscrever, para que ele saiba quando recolher, expandir, criar ou excluir um nó.

Então, deixe a árvore seguir o seu modelo.

Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.