Odpowiedni typ klucza w kontenerze asocjacyjnym

0

Cześć,

Problem:

Projekt wykorzystuje wzorzec entity component system. Mam klasę abstrakcyjną Component, którą dziedziczy każdy rodzaj komponentu oraz klasę Entity, która posiada kontener dla różnego rodzaju komponentów. Często potrzebuję pobierać dany typ komponentu lub sprawdzać, czy dany komponent obiekt Entity posiada. Zdecydowałem, że kontenerem tym będzie std::unordered_map. Potrzebowałem unikalnych identyfikatorów dla każdego typu komponentów. Stworzyłem więc osobny plik nagłówkowy, w którym zawarłem typ wyliczeniowy enum class ComponentIdentifier i jest użyty, jako klucz w kontenerze. Jeśli mam komponent klasy TransformComponent to dodałem dla tego typu identyfikator Transform, itd..

enum class ComponentIdentifier
{
	Transform
}

dodawanie, sprawdzanie, pobieranie wygląda następująco:

entity.addComponent<TransformComponent>(ComponentIdentifier::Transform);
entity.hasComponent(ComponentIdentifier::Transform);
entity.getComponent<TransformComponent>(ComponentIdentifier::Transform);

drugi sposób, jaki przyszedł mi do głowy, a o niego w głównej mierze chodzi, to wykorzystanie mechanizmu RTTI. Z jego wykorzystaniem sprawa się upraszcza. Znika potrzeba definiowania własnych unikalnych identyfikatorów tylko wykorzystuję operator typeid. Wtedy kluczem w kontenerze byłby typ std::type_index.

dodawanie, sprawdzanie, pobieranie wygląda następująco i wszystko mogę robić z poziomu samego typu:

entity.addComponent<TransformComponent>();
entity.hasComponent<TransformComponent>();
entity.getComponent<TransformComponent>();

definicje, z wykorzystaniem typeid, wyglądają mniej więcej tak (przykłady pisane z palca):

template<typename T>
void addComponent() noexcept
{
	static_assert(std::is_base_of<Component, T>(), "");
	assert(!hasComponent<T>());
	...
	mComponents[typeid(T)] = Component::Ptr{new T}; // unique_ptr
}

template<typename T>
T& getComponent() noexcept
{
	static_assert(std::is_base_of<Component, T>(), "");
	assert(hasComponent<T>());
	...
	return static_cast<T&>(*mComponents[typeid(T)]);
}

gdzieś indziej stosuję pobieranie z kontenera entity o określonych komponentach. Kod wygląda mniej więcej tak:

pool.getEntities(Matcher::allOf({typeid(TransformComponent), typeid(OtherComponentType)}));

lub wersja z typem wyliczeniowym:

pool.getEntities(Matcher::allOf({ComponentIdentifier::Transform, ComponentIdentifier::OtherComponent}));

Pytanie:

Czy w tym przypadku wykorzystanie RTTI ma sens? Czy znacznie wpłynie na wydajność, bezpieczeństwo? Zapis wydaje się prostszy, ale nie wiem, czy jest to 'ładne' rozwiązanie. Oczywiście mogę zostać przy definiowaniu własnych identyfikatorów. Natomiast jeżeli dojdzie kolejny typ komponentu będę musiał dodać nowy dla niego lecz operator typeid sam załatwi sprawę. Przy jakim wariancie Ty byś został? Proszę o opinię.

2

Rozwiązania z RTTI bym unikał (być może kompletnie błędnie), ale możesz to samo osiągnąć w czasie kompilacji dodając np. stałą wartość dla każdej klasy, np. T::component_type. Możesz to osiągnąć na co najmniej trzy sposoby:

  1. szablonując Component po ComponentIdentifier. To może być kłopotliwe jeśli chcesz trzymać Componenty w homogenicznym kontenerze,
  2. dodając na siłę do każdej klasy. W/g mnie mało czyste rozwiązanie i ciężkie do refaktorowania,
  3. dodając nową klasę w hierarchii:
template<ComponentIdentifier ID>
struct TypedComponent : Component
{
	static constexpr ComponentIdentifier component_type = ID;
};

Potem zamiast dziedziczyć po Component, dziedziczyłbyś po np. TypedComponent<ComponentIdentifier::Transform>, a funkcje obsługujące by wyglądały tak:

template<typename T>
void addComponent() noexcept
{
    static_assert(std::is_base_of<Component, T>(), "");
    assert(!hasComponent<T>());
    ...
    mComponents[T::component_type] = Component::Ptr{new T}; // unique_ptr
}
 
template<typename T>
T& getComponent() noexcept
{
    static_assert(std::is_base_of<Component, T>(), "");
    assert(hasComponent<T>());
    ...
    return static_cast<T&>(*mComponents[T::component_type]);
}
1

@EvilOne przejrzyj implementację entityx

0

@kq, dziękuję bardzo za Twoje sugestie rozwiązań. Ten trzeci sposób jest naprawdę niezły i to właśnie z niego raczej skorzystam - takich sposobów właśnie szukałem, by funkcje składowe tj.: addComponent, getComponent, hasComponent klasy Entity, wywoływać bez konieczności podawania stałej oraz miałem małą nadzieję całkowitego wyeliminowania tych stałych, ale cóż.. choć przyszedł mi do głowy pewien sposób eliminujący stałe, by generować identyfikator dla typów wykorzystując magię szablonów:

namespace
{
unsigned int getUniqueIdentifier()
{
	static unsigned int uniqueIdentifier{0u};

	return uniqueIdentifier++;
}

template<typename T>
unsigned int getUniqueIdentifierType()
{
	static unsigned int uniqueIdentifierType{getUniqueIdentifier()};

	return uniqueIdentifierType;
}
}
...

// addComponent<T>
mComponents[getUniqueIdentifierType<T>()] = Component::Ptr{new T};

wydaje mi się sprytnym sposobem, lecz mam co do niego wątpliwości i ze stałymi raczej będzie bezpieczniej. Ciekawi mnie jeszcze ten:

kq napisał(a):

szablonując Component po ComponentIdentifier. To może być kłopotliwe jeśli chcesz trzymać Componenty w homogenicznym kontenerze

Mógłbyś rozwinąć? Tak na pierwszy rzut oka, jeśli uczynię klasę Component szablonową to raczej proste dodawanie klas pochodnych przez new do kontenera odpada.. i ten 'homogenicznym' to znaczy obiektów tego samego typu? (wybacz, ale pierwsze raz spotkałem się z takim określeniem).

Anyway - raz jeszcze dzięki za pomocną odpowiedź. : )

@Satirev - dzięki za wskazówkę! Przejrzę się. : >

1

Tak, homogeniczny¹ w tym kontekście oznacza ten sam typ. Jeśli Component by był szablonem to miałbyś vector<Component<co?>>/map<Foo, Component<co?>> - niemożliwe jeśli te typy by miały być różne. Musiałbyś użyć Boost.Variant albo Boost.Any lub odpowiedników, co oczywiście mija się z celem.

Jeśli chodzi o Twój pomysł: bardzo fajnie, o ile nie masz zamiaru w przyszłości serializować w żaden sposób swoich komponentów. Nie masz (prawdopodobnie) żadnego wpływu na kolejność wywołań funkcji, więc id mogą być różne dla różnych komponentów pomiędzy wywołaniami programu - a już szczególnie jeśli dodasz nowy typ. Tak więc: jeśli serializacja nie jest istotna i zakładasz, że nigdy jej nie będzie - lub zakładasz, że jednak będziesz w stanie zagwarantować stałą kolejność nadawania id kompoenentom to rozwiązanie jest jak najbardziej poprawne. Aha, to samo tyczy się logowania. Jeśli uważasz, że np. component id: 10 w logach może oznaczać różne komponenty (lub, jak wyżej, jesteś w stanie zagwarantować, że będzie to zawsze ten sam), to ok.

Podsumowując: "to zależy". W mojej opinii przynajmniej logowanie, jeśli nie (de-)serializacja, jest dostatecznym powodem, żeby gwarantować stałość id komponentów, a jeśli chcesz gwarantować stałość to jednak enumem łatwiej niż trzymać w ryzach wywołania funkcji działającej na globalnych (aka statycznych) danych.

¹ to w sumie kalka językowa z homogeneous/heterogeneous, ale mamy w Polsce serek homogenizowany, czyli z jedną kulturą bakterii ;)

1 użytkowników online, w tym zalogowanych: 0, gości: 1