Para asociar y disociar dinámicamente datos sobre la marcha, independientemente de la vida útil de un nodo QT, mientras que el QT combinado con la cámara tiene conocimiento de cuándo los datos deben asociarse / desasociarse sobre la marcha, es un poco difícil de generalizar y creo que su solución es En realidad no está mal. Es algo difícil de diseñar de una manera muy agradable y generalizada. como, "uhh ... ¡pruébalo bien y envíalo!" De acuerdo, un poco de broma. Trataré de ofrecer un poco de pensamiento para explorar. Una de las cosas que más me fulminó fue aquí:
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
}
Esto me dice que una referencia / puntero de nodo no solo se usa como clave en un contenedor asociativo externo. En realidad, está accediendo y modificando componentes internos del nodo quadtree fuera del quadtree mismo. Y debería haber una manera bastante fácil de evitar al menos eso para empezar. Si ese es el único lugar donde está modificando los componentes internos del nodo fuera del quadtree, entonces podría hacer esto (digamos que xxx
es un par de flotadores):
std::pair<float, float> nodeCreated(const Node& node)
{
Data data;
...
map[&node] = data;
...
return data.xxx;
}
En ese momento, el quadtree puede usar el valor de retorno de esta función para asignar xxx
. Eso ya afloja bastante el acoplamiento cuando ya no estás accediendo a las partes internas de un nodo de árbol fuera del árbol.
Eliminar la necesidad de Terrain
acceder a los componentes internos del árbol cuádruple en realidad eliminaría el único lugar donde está acoplando cosas innecesariamente. Es la única PITA real si cambia las cosas con una implementación de GPU, por ejemplo, ya que la implementación de GPU podría usar un representante interno totalmente diferente para los nodos.
Pero para sus inquietudes de rendimiento, y tengo muchas más ideas sobre cómo lograr el máximo desacoplamiento con este tipo de cosas, sugeriría una representación muy diferente en la que puede convertir la asociación / disociación de datos en una operación barata de tiempo constante. Es un poco difícil de explicar a alguien que no está acostumbrado a construir contenedores estándar que requieren una ubicación nueva para construir elementos en su lugar desde la memoria agrupada, así que comenzaré con algunos datos:
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;
}
...
};
Eso es básicamente una "lista libre indexada". Pero cuando usa este representante para los datos asociados, puede hacer algo como esto:
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();
}
};
Con suerte, todo esto tiene sentido, y naturalmente está un poco más desacoplado de su diseño original, ya que no requiere que los clientes tengan acceso interno a los campos de nodo de árbol (ahora ya no requiere conocimiento de nodos, ni siquiera para usarlo como claves ), y es considerablemente más eficiente ya que puede asociar y disociar datos a / desde nodos en tiempo constante (y sin usar una tabla hash, lo que implicaría una constante mucho mayor). Espero que sus datos se puedan alinear usando max_align_t
(sin campos SIMD, por ejemplo) y sean copiables trivialmente, de lo contrario las cosas se vuelven considerablemente más complejas ya que necesitaríamos un asignador alineado y podríamos tener que rodar nuestro propio contenedor de listas libres. Bueno, si solo tiene tipos copiables de forma no trivial y no necesita más demax_align_t
, podemos usar una implementación de puntero de lista libre que agrupa y vincula nodos desenrollados que almacenan K
elementos de datos para evitar la necesidad de reasignar bloques de memoria existentes. Puedo mostrar eso si necesitas una alternativa así.
Es un poco avanzado y muy específico de C ++, considerando la idea de asignar y liberar memoria para elementos como una tarea separada de construirlos y destruirlos. Pero si lo hace de esta manera, Terrain
absorbe las responsabilidades mínimas y ya no requiere ningún conocimiento interno de la representación del árbol, ni siquiera maneja los nodos opacos. Sin embargo, este nivel de control de memoria suele ser lo que necesita si desea diseñar las estructuras de datos más eficientes.
La idea fundamental es que el cliente utiliza el pase de árbol en el tamaño de tipo de los datos que desea asociar / desasociar sobre la marcha al quadtree ctor. Entonces el quadtree tiene la responsabilidad de asignar y liberar memoria usando ese tamaño de letra. Luego, pasa la responsabilidad de construir y destruir los datos al cliente mediante QTInterface
un despacho dinámico. La única responsabilidad, por lo tanto, fuera del árbol que todavía está relacionado con el árbol, es construir y destruir elementos de la memoria que el quadtree se asigna y desasigna. En ese punto, las dependencias se vuelven así:
Lo cual es muy razonable teniendo en cuenta la dificultad de lo que está haciendo y la escala de las entradas. Básicamente, su Terrain
entonces solo depende de Quadtree
y QTInterface
, y ya no son las partes internas del quadtree o sus nodos. Anteriormente tenías esto:
Y, por supuesto, un problema evidente con eso, especialmente si está considerando probar implementaciones de GPU, es esa dependencia de Terrain
a Node
, ya que una implementación de GPU probablemente querría usar un representante de nodo muy diferente. Por supuesto, si quieres ser SÓLIDO, harías algo como esto:
... junto con posiblemente una fábrica. Pero la IMO es una exageración total (al menos INode
es una exageración total de la IMO) y no sería muy útil en un caso tan granular como una función quadtree si cada una requiere un despacho dinámico.
Siempre tengo dificultades para desacoplar mis clases correctamente. ¿Algún consejo para dar que pueda usar más tarde? (Por ejemplo, ¿qué preguntas tengo que hacerme o cómo se procesa? Pensar en esto en papel me parece muy abstracto e inmediatamente codificar algo da como resultado una refactorización posterior)
En términos generales y crudos, el desacoplamiento a menudo se reduce a limitar la cantidad de información que una clase o función particular requiere sobre otra cosa para hacer lo suyo.
Supongo que está usando C ++ ya que ningún otro lenguaje que conozco tiene esa sintaxis exacta, y en C ++ un mecanismo de desacoplamiento muy efectivo para las estructuras de datos son las plantillas de clase con polimorfismo estático si puede usarlas. Si considera los contenedores estándar como std::vector<T, Alloc>
, vector no está acoplado a lo que especifique para T
nada. Solo requiere que T
satisfaga algunos requisitos básicos de la interfaz, como que es construible por copia y tiene un constructor predeterminado para el constructor de relleno y el cambio de tamaño de relleno. Y nunca requerirá cambios como resultado de los T
cambios.
Entonces, al vincularlo con lo anterior, permite que la estructura de datos se implemente utilizando el conocimiento mínimo absoluto de lo que contiene, y eso lo desacopla en la medida en que ni siquiera necesita ningún tipo de información por adelantado (avance aquí es hablando en términos de dependencias / acoplamiento de código, no información en tiempo de compilación) sobre lo que T
es.
La segunda forma más práctica de minimizar la cantidad de información requerida es usar polimorfismo dinámico. Por ejemplo, si desea implementar una estructura de datos razonablemente generalizada que minimice el conocimiento de lo que almacena, puede capturar los requisitos de interfaz para lo que almacena en una o más interfaces:
// Contains all the functions (pure virtual) required of the elements
// stored in the container.
class IElement {...};
Pero de cualquier manera se reduce a minimizar la cantidad de información que necesita de antemano mediante la codificación de interfaces en lugar de detalles concretos. Aquí, lo único que está haciendo que parece requerir mucha más información de la requerida es que Terrain
debe tener información completa sobre las partes internas de un nodo Quadtree, por ejemplo, en tal caso, suponiendo que la única razón por la que necesita es para asignar una pieza de datos a un nodo, podemos eliminar fácilmente esa dependencia de los componentes internos de un nodo de árbol simplemente devolviendo los datos que deberían asignarse al nodo en ese resumen QTInterface
.
Entonces, si quiero desacoplar algo, solo me concentro en lo que necesita para hacer las cosas y encuentro una interfaz para ello (ya sea explícita usando la herencia o implícita usando el polimorfismo estático y la escritura de pato). Y ya lo hizo, en cierta medida, desde el propio árbol de árbol usando QTInterface
para permitir que el cliente anule sus funciones con un subtipo y proporcione los detalles concretos necesarios para que el árbol de árbol haga lo suyo. El único lugar donde creo que te quedaste corto es que el cliente aún requiere acceso a las partes internas del quadtree. Puede evitar eso aumentando lo QTInterface
que hace, que es precisamente lo que sugerí cuando hice que devolviera un valor para asignarnode.xxx
en la implementación quadtree en sí. Por lo tanto, solo se trata de hacer las cosas más abstractas y las interfaces más completas para que las cosas no requieran información innecesaria el uno del otro.
Y al evitar esa información innecesaria ( Terrain
tener que saber sobre las Quadtree
partes internas de los nodos), ahora es más libre de intercambiar Quadtree
con una implementación de GPU, por ejemplo, sin cambiar la Terrain
implementación también. Lo que las cosas no se conocen es libre de cambiar sin afectarse. Si realmente desea cambiar las implementaciones de GPU quadtree de las CPU, puede ir un poco hacia la ruta SÓLIDA anterior conIQuadtree
(haciendo que el quadtree sea abstracto). Eso viene con un golpe de despacho dinámico que puede ser un poco costoso con la profundidad del árbol y los tamaños de entrada de los que está hablando. Si no, al menos requiere muchos menos cambios en el código si las cosas que usan el quadtree no tienen que saber acerca de su representación de nodo interno para funcionar. Es posible que pueda intercambiar uno con el otro simplemente actualizando una sola línea de código para typedef
, por ejemplo, incluso si no utiliza una interfaz abstracta ( IQuadtree
).
Pero ahí es donde creo que tengo mi primer problema. La mayoría de las veces no me preocupo por la optimización hasta que la veo, pero creo que si tengo que agregar este tipo de sobrecarga para desacoplar mis clases correctamente, es porque el diseño tiene fallas.
No necesariamente. El desacoplamiento a menudo implica desplazar una dependencia desde lo concreto a lo abstracto. Las abstracciones tienden a implicar una penalización en tiempo de ejecución a menos que el compilador esté generando código en tiempo de compilación para eliminar básicamente el costo de abstracción en tiempo de ejecución. A cambio, obtienes mucho más espacio para respirar para hacer cambios sin afectar otras cosas, pero eso a menudo extrae algún tipo de penalización de rendimiento a menos que estés usando la generación de código.
Ahora puede eliminar la necesidad de una estructura de datos asociativa no trivial (mapa / diccionario, es decir) para asociar datos a nodos (o cualquier otra cosa) sobre la marcha. En el caso anterior, acabo de hacer que los nodos almacenen directamente un índice de los datos que se asignan / liberan sobre la marcha. Hacer este tipo de cosas no está tan relacionado con el estudio de cómo desacoplar las cosas de manera efectiva sino con la manera de utilizar los diseños de memoria para las estructuras de datos de manera efectiva (más en el ámbito de la optimización pura).
Los principios y el desempeño de SE efectivos están en desacuerdo entre sí a niveles suficientemente bajos. A menudo, el desacoplamiento dividirá los diseños de memoria para los campos a los que se accede comúnmente juntos, puede implicar más asignaciones de almacenamiento dinámico, puede implicar un despacho más dinámico, etc. Se trivializa rápidamente a medida que trabaja hacia un código de nivel superior (por ejemplo: operaciones que se aplican a imágenes completas, no por -operaciones de píxeles cuando se realiza un bucle a través de píxeles individuales), pero tiene un costo que varía de trivial a severo dependiendo de cuánto se incurre en esos costos en su código de bucle más crítico que realiza el trabajo más ligero en cada iteración.
¿Estoy complicando demasiado las cosas? ¿Debería haber extendido la clase Node, convirtiéndola en una bolsa de datos que algunas clases utilizan?
Personalmente, no creo que sea tan malo si no está tratando de generalizar demasiado su estructura de datos, solo usándola en un contexto muy limitado, y está lidiando con un contexto extremadamente crítico para un tipo de problema que no tiene Abordado antes. En ese caso, convertiría su quadtree en un detalle de implementación de su terreno, por ejemplo, en lugar de algo que se use ampliamente y públicamente, de manera similar, alguien podría convertir un octree en un detalle de implementación de su motor de física al no distinguir más idea de "interfaz pública" de "elementos internos". Mantener invariantes relacionados con el índice espacial se convierte en una responsabilidad de la clase que lo usa como un detalle de implementación privado.
Diseñar una abstracción efectiva (interfaz, es decir) en un contexto crítico para el rendimiento a menudo requiere que comprenda a fondo la mayor parte del problema y una solución muy efectiva por adelantado. En realidad, puede convertirse en una medida contraproducente para tratar de generalizar y abstraer la solución al mismo tiempo que intenta descubrir el diseño efectivo en múltiples iteraciones. Una de las razones es que los contextos críticos de rendimiento requieren representaciones de datos y patrones de acceso muy eficientes. Las abstracciones ponen una barrera entre el código que desea acceder a los datos: una barrera que es útil si desea que los datos sean libres de cambiar sin afectar dicho código, pero un obstáculo si está tratando simultáneamente de encontrar la forma más efectiva de representar y acceder a dichos datos en primer lugar.
Pero si lo hace de esta manera, nuevamente me equivocaría al convertir el quadtree en un detalle de implementación privado de sus terrenos, no algo que se generalice y use fuera de sus implementaciones. Y tendría que renunciar a la idea de poder cambiar tan fácilmente las implementaciones de GPU de las implementaciones de CPU, ya que eso normalmente requeriría una abstracción que funcione para ambos y que no dependa directamente de los detalles concretos (como las repeticiones de nodo) de cualquiera de las.
El punto de desacoplamiento
Pero tal vez en algunos casos esto incluso sea aceptable para cosas más utilizadas públicamente. Antes de que la gente piense que estoy diciendo tonterías locas, considere las interfaces de imagen. ¿Cuántos de ellos serían suficientes para un procesador de video que necesita aplicar filtros de imagen en el video en tiempo real si la imagen no expone sus elementos internos (acceso directo a su matriz subyacente de píxeles en un formato de píxel específico)? No hay ninguno que yo sepa sobre el uso de algo como un resumen / virtual getPixel
aquí ysetPixel
allí mientras realiza conversiones de formato de píxel por píxel. Por lo tanto, en contextos suficientemente críticos para el rendimiento donde tiene que acceder a cosas en un nivel muy granular (por píxel, por nodo, etc.), a veces puede que tenga que exponer las partes internas de la estructura subyacente. Pero inevitablemente, como resultado, tendrá que acoplar las cosas estrechamente, y no será fácil cambiar la representación subyacente de las imágenes (cambio en el formato de la imagen, por ejemplo), por así decirlo, sin afectar todo el acceso a sus píxeles subyacentes. Pero podría haber menos razones para cambiar en ese caso, ya que en realidad podría ser más fácil estabilizar la representación de datos que la interfaz abstracta. Un procesador de video podría decidirse por la idea de usar formatos de píxeles RGBA de 32 bits y esa decisión de diseño podría ser inmutable en los años venideros.
Idealmente, desea que las dependencias fluyan hacia la estabilidad (cosas que no cambian) porque cambiar algo que tiene muchas dependencias multiplica su costo con el número de dependencias. Eso puede o no ser abstracciones en todos los casos. Por supuesto, eso ignora los beneficios de ocultar información para mantener invariantes, pero desde el punto de vista del acoplamiento, el punto principal del desacoplamiento es hacer que las cosas sean menos costosas de cambiar. Eso significa redirigir las dependencias de cosas que podrían cambiar a cosas que no cambiarán, y eso no ayuda en lo más mínimo si sus interfaces abstractas son las partes que cambian más rápidamente de su estructura de datos.
Si desea al menos mejorar ligeramente desde una perspectiva de acoplamiento, separe las partes de su nodo a las que los clientes necesitan acceder lejos de las partes que no lo hacen. Supongo que los clientes al menos no tienen que actualizar los enlaces del nodo, por ejemplo, por lo que no hay necesidad de exponer los enlaces. Al menos, debería ser capaz de obtener un agregado de valor que esté separado de la totalidad de lo que los nodos representan para que los clientes puedan acceder / modificar, como NodeValue
.