4

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 a T
  • 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>
7
  • 1
    I fear what you trying to do is not going to be very usable, because all component definitions must appear before your constexpr registry is defined. Thus, it won't be a traditional library that you include in some future code and then define components later. Commented Jul 23 at 18:21
  • @CygnusX1 I've tried doing it manually (i.e. manually assigning component IDs and the int->type mapping for static components) -- it should work just fine as a library, as the intended use is for it to be used as both an ECS in it of itself and in combination with game's statically declared/builtin components when writing a plugin, for example. Commented Jul 23 at 18:26
  • 1
    You might get some inspiration from Austin Morlan's Simple ECS implementation. Commented Jul 23 at 18:44
  • @Eljay I appreciate the enthusiasm, but that's not really relevant to what I'm attempting to do in this question. Commented Jul 23 at 21:48
  • 1
    You should be able to build a compile-time 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) Commented Jul 24 at 11:14

0

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.