I need a way to set up a compile-time "registry" that maps registered types to incrementing int
IDs and back again.
I would prefer it to be implemented such that, when used, the library to use it would look something like this:
#define component(T) /* ?? */
template <int I> using registry_t = /* ?? */;
component(some_type) {
// some_type gets mapped to ID 0
};
namespace my_namespace {
component(some_type) {
// my_namespace::some_type gets mapped to ID 1
};
}
static_assert(some_type::id() == 0, "First registered component gets ID 0");
static_assert(my_namespace::some_type::id() == 1, "Second should get ID 1");
static_assert(std::is_same_v<registry_t<0>, some_type>, "Should match");
static_assert(std::is_same_v<registry_t<1>, my_namespace::some_type>, "Should match");
I'm using falemagn's constexpr
counter library to implement a mapping of arbitrary types to incrementing IDs. It works by assigning an incrementing number each time a counter's next
method is called with a unique type; e.g.:
struct __context;
static constexpr fameta::counter<__context> component_counter;
struct struct1;
static_assert(component_counter.next<struct1>() == 0, "Starts at 0");
struct helloworld;
static_assert(component_counter.next<helloworld>() == 1, "Increments to 1");
struct foobar;
static_assert(component_counter.next<foobar>() == 2, "Increments to 2");
Attempted Solution 1 - Template Metaprogramming
The normal way I would imagine one would go about implementing this would be through template specializations:
template <int I> struct registry;
template <int I> using registry_t = typename registry<I>::type;
struct some_type {};
template <> struct registry<0> { using type = ::some_type };
Problem is that this would force the component
keyword to only be usable in the global namespace (or a parent of whichever namespace the registry
is defined in), as you can't specialize a template outside of one of its parent namespaces without a compiler error.
Attempted Solution 2 - Overloading
One can also easily generate an arbitrary number of unique types by an int
using a template
class:
template <int> class unique final {};
I figured I could use this to abuse function overloading with the following hypothetical logic:
- Declare a templated function that will never be called which serves as the registry -- it will take a
unique<I>
as a parameter and return aT
- When registering a component, implicitly specialize the template via a
static_assert
:
template <class T>
static constexpr T _registry(unique<component_counter.next<T>()>) noexcept;
template <int I>
using registry_t = std::remove_pointer_t<decltype(_registry(_unique<I>{}))
template <class T>
struct component {
static_assert(std::is_same_v<decltype(_registry<T>({})), T>);
static constexpr int id() noexcept { return component_counter.next<T>(); }
};
The theory goes that "calling" this function with a unique<0>
would yield the same output as when component
is inherited from for the first time, as the static_assert
would start the component_counter
at 0, thus creating a templated function with a unique<0>
as its parameter.
In practice, this fails because registry_t
is unable to deduce the T
from the template (below is the output from gcc 15.1 via godbolt):
<source>: In substitution of 'template<int I> using ecs::registry_t = std::remove_pointer_t<decltype (ecs::_registry(ecs::unique<I>{}))> [with int I = 0]':
<source>:136:47: required from here
136 | static_assert(std::is_same_v<ecs::registry_t<0>, test>, "");
| ^
<source>:123:64: error: no matching function for call to '_registry(ecs::unique<0>)'
123 | using registry_t = std::remove_pointer_t<decltype(_registry(unique<I>{}))>;
| ~~~~~~~~~^~~~~~~~~~~~~
<source>:123:64: note: there is 1 candidate
<source>:121:24: note: candidate 1: 'template<class T> constexpr T ecs::_registry(unique<component_counter.next<T>()>)'
121 | static constexpr T _registry(unique<component_counter.next<T>()>) noexcept;
| ^~~~~~~~~
<source>:121:24: note: template argument deduction/substitution failed:
<source>:123:64: note: couldn't deduce template parameter 'T'
123 | using registry_t = std::remove_pointer_t<decltype(_registry(unique<I>{}))>;
| ~~~~~~~~~^~~~~~~~~~~~~
<source>:136:54: error: template argument 1 is invalid
136 | static_assert(std::is_same_v<ecs::registry_t<0>, test>, "");
| ^
Compiler returned: 1
(also it seems as though this solution is incompatible with MSVC from limited testing)
Attempted Solution 3 - Argument-Dependent Lookup with Friend Overload
(tried after first posting this circa 7:00pm GMT, 23-Jul-2025)
The constexpr
counter exploits how friend
function declarations & definitions insert functions into the parent namespace to implement itself. After the first two attempts failed and after finding out how it worked, I figured I might as well try it out. We can use a constexpr
function that is never intended to be executed regularly (here called "registry_lookup
"):
template <class T>
struct component {
friend constexpr T registry_lookup(unique<component_counter.next<T>()>) { return T{}; } // Use a friend function for ADL-based lookup
static constexpr int id() noexcept { return component_counter.next<T>(); }
};
...and then use ADL to try and retrieve the return value from said function:
template<int I>
using registry_t = std::remove_cv_t<std::remove_reference_t<decltype(registry_lookup(unique<I>{}))>>;
This still fails with a compilation error (below is output from gcc 15.1 via godbolt):
<source>: In substitution of 'template<int I> using ecs::registry_t = std::remove_cv_t<typename std::remove_reference<decltype (registry_lookup(ecs::unique<I>{}))>::type> [with int I = 0]':
<source>:134:45: required from here
134 | static_assert(std::is_same<ecs::registry_t<0>, test>::value, "");
| ^
<source>:121:89: error: 'registry_lookup' was not declared in this scope
121 | using registry_t = std::remove_cv_t<std::remove_reference_t<decltype(registry_lookup(unique<I>{}))>>;
| ~~~~~~~~~~~~~~~^~~~~~~~~~~~~
<source>:134:52: error: template argument 1 is invalid
134 | static_assert(std::is_same<ecs::registry_t<0>, test>::value, "");
| ^
Compiler returned: 1
Attempted Solution 4 - Downcasting (credit to @VincentSaulue-Laborde
Mateusz Pusz had created a downcasting facility to facilitate shorter error messages in their physical units library.
Adapting this to the entity registration system, it does work for the most part. Though it is only compatible with C++20 and later, most people will probably be using those language versions with my library anyway so it's not too big of a loss.
Unfortunately, while it works on all GCC and MSVC versions which fully support C++20 concepts and loosened language restrictions regarding friend declaraitons, any version of Clang older than the latest version 20 fails to retrieve the correct type, instead retrieving the base type intended to facilitate the downcasting in the first place (see here on godbolt).
EXPECTED OUTPUT:
4test
1
OUTPUT FROM CLANG 19 & BELOW:
N3ecs15_component_baseILi0EEE
0
Other Ideas
At this point, the only other way I can see the component
macro being convenient while allowing for initially statically-allocated tables for static components and allowing namespacing while the macro is forced to be used on global scope is to allow it take multiple parameters for the different namespaces:
component(mynamespace, mycomponent) {
// ...
};
// defines mynamespace::mycomponent
I imagined that I'd have to do this anyway, and I know the necessary C preprocessor tricks to get it to work, but it doesn't mean I'll like it, as I'd nonetheless prefer a more elegant and "normal" looking solution to the problem at hand.
Full Code
Here's a full listing of the relevant library code for this project and intended uses of the definitions for reference:
#include <cstdint>
#include <type_traits>
namespace ecs {
# if __cplusplus >= 201403L && defined __GNUC__ && !defined __clang__
# define ECS_FRIEND_RETURN_TYPE auto
# else
# define ECS_FRIEND_RETURN_TYPE bool
# endif
namespace {
/// Adapted from https://github.com/falemagn/fameta-counter
template <typename Context, int Start = 0, int Step = 1>
struct counter {
template <typename Unique>
static constexpr int next() {
return next<Unique>(0)*Step+Start;
}
template <typename Unique>
static constexpr int current() {
return current<Unique>(0)*Step+Start;
}
private:
template <int I>
struct slot {
# ifdef __INTEL_COMPILER
# pragma warning push
# pragma warning disable 1624
# elif defined __clang__
# pragma GCC diagnostic push
# pragma GCC diagnostic ignored "-Wundefined-internal"
# elif defined __GNUC__
# pragma GCC diagnostic push
# pragma GCC diagnostic ignored "-Wnon-template-friend"
# pragma GCC diagnostic ignored "-Wunused-function"
# if __GNUC__ >= 16
# pragma GCC diagnostic ignored "-Wsfinae-incomplete"
# endif
# endif
friend constexpr ECS_FRIEND_RETURN_TYPE slot_allocated(slot<I>) noexcept;
# ifdef __INTEL_COMPILER
# pragma warning pop
# elif defined __clang__ || defined __GNUC__
# pragma GCC diagnostic pop
# endif
};
template <int I>
struct allocate_slot {
friend constexpr ECS_FRIEND_RETURN_TYPE slot_allocated(slot<I>) noexcept { return true; }
enum { value = I };
};
# if (__cpp_if_constexpr + 0) >= 201606L
template <typename Unique, int I = 0, int P = 0, bool = slot_allocated(slot<I + (1<<P)-1>())>
static constexpr int next(int) {
return next<Unique, I, P+1>(0);
}
template <typename Unique, int I = 0, int P = 0>
static constexpr int next(double) {
if constexpr (P == 0)
return allocate_slot<I>::value;
else
return next<Unique, I+(1<<(P-1)), 0>(0);
}
// If slot_allocated(slot<I>) has NOT been defined, then SFINAE will keep this function out of the overload set...
template <typename Unique, int I = 0, int P = 0, bool = slot_allocated(slot<I + (1<<P)-1>())>
static constexpr int current(int) {
return current<Unique, I, P+1>(0);
}
// ...And this function will be used, instead, which will return the current counter, or assert in case next() hasn't been called yet.
template <typename Unique, int I = 0, int P = 0>
static constexpr int current(double) {
static_assert(I != 0 || P != 0, "You must invoke next() first");
if constexpr (P == 0)
return I-1;
else
return current<Unique, I+(1<<(P-1)), 0>(0);
}
# else
// If slot_allocated(slot<I>) has NOT been defined, then SFINAE will keep this function out of the overload set...
template <typename Unique, int I = 0, bool = slot_allocated(slot<I>())>
static constexpr int next(int) {
return next<Unique, I+1>(0);
}
// ...And this function will be used, instead, which will define slot_allocated(slot<I>) via allocate_slot<I>.
template <typename Unique, int I = 0>
static constexpr int next(double) {
return allocate_slot<I>::value;
}
// If slot_allocated(slot<I>) has NOT been defined, then SFINAE will keep this function out of the overload set...
template <typename Unique, int I = Start, bool = slot_allocated(slot<I>())>
static constexpr int current(int)
{
return current<Unique, I+1>(0);
}
// ...And this function will be used, instead, which will return the current counter, or assert in case next() hasn't been called yet.
template <typename Unique, int I = Start>
static constexpr int current(double) {
static_assert(I != 0, "You must invoke next() first");
return I-1;
}
# endif
};
}
struct component_base {};
static constexpr counter<component_base> component_counter;
template <class T>
struct component : public component_base {
static constexpr component_id id() noexcept { return static_cast<component_id>(component_counter.next<T>()); }
};
template <int I>
using registry_t = /* ??? */;
}
#define component(T) struct T : public ::ecs::component<T>
Component
<->int
bidirectional mapping from any namespace by using the downcasting facility (credit to Mateusz Pusz for inventing that). Quick & dirty proof of concept. (Digression: your overall goal/design seems like a recipe for disaster, notably because of the risk of One-Definition-Rule violation)