solving the unsolvable. Compile-time reflection

Lupholes is a technique that allows you to manipulate the global state of the compiler by adding and reading values.

This technique allows you to solve many problems, some of which will be discussed in the article:

  1. Find out what parameters a type constructor accepts

  2. Find out with what template parameters the method/function was called with A.D.L.

  3. How to make metaprogramming with types more like regular code where there is state

    An example of how loopholes work:

    static_assert((std::ignore = Injector<0, 42>(), true));
    static_assert(Magic(Getter<0>{}) == 42);

    And here is the definition of Injector and Getter:

    template <auto I>
    struct Getter {
      friend constexpr auto Magic(Getter<I>);
    };
    
    template <auto I, auto Value>
    struct Injector {
      friend constexpr auto Magic(Getter<I>) {return Value;};
    };
How loopholes work
  1. In C++ you can declare friend functions:

struct S {
  friend auto F() -> void;
};

But not everyone knows that they can also be defined:

struct S {
  friend auto F() -> void {};
};

If the declaration of such a function was available separately, then the order of searching for its entity is usual:

auto F() -> void;

struct S {
  friend auto F() -> void {};
};

auto main() -> int {
  F(); // Well Formed
};
  1. You can also declare functions without specifying the return type using auto:

auto F();

auto main() -> int {
  F(); // Ill Formed
};

It is prohibited to use such a function until this type is specified.
This type can be clarified by a subsequent definition, where the type is derived from the return:

auto F();

auto F() {
  return 42;
};

auto main() -> int {
  F(); // Well Formed
};

3. Combining the grounds of the first and second paragraphs gives the following example:

auto F();

struct S {
  friend auto F() {
    return 42;
  };
};

auto main() -> int {
  F(); // Well Formed
};

Now we add templates here: a friendly definition is instantiated (will be registered by the compiler) only with the instantiation of the template containing it. That is:

auto F();

template <typename>
struct S {
  friend auto F() {
    return 42;
  };
};

auto A() -> void {
  F(); // Ill Formed
};

template struct S<void>;

auto B() -> void {
  F(); // Well Formed
};

The same compilation state appeared.

4. Now you can observe this state programmatically. To do this, you need to provide dependency for it. That is, make the validity of the call undecidable without specifying some transmitted information, for example an argument:

struct U {};

auto F(U);


template <typename>
struct S {
  friend auto F(U) {
    return 42;
  };
};

This eliminates the possibility of calculating the correctness of the call expression in the template without information about the type being passed (which would otherwise inevitably happen):

template <typename T>
constexpr bool kTest = requires {F(T{});};

Without this argument, kTest could have been calculated statically, i.e. requires { F(); } would simply give IF, since from the point of view. language there is something written there that can never be correct, for which it is wrong to test.
However, this example still won't let us observe the state:

static_assert(!kTest<U>); // passes
template struct S<void>;
static_assert(!kTest<U>); // passes again

Due to memoization: kTest must always name the same entity, i.e. it cannot be different by definition. Being false the first time, it must be false the second time.
5. All that remains is to make sure that the same syntactically construct (kTest) names different entities in different contexts. We use the way lambdas and default arguments interact. Redefining kTest<>:

template <typename T, auto = []{}>
constexpr bool kTest = requires { F(T{}); };

Testing:

static_assert(!kTest<U>); // passes
template struct S<void>;
static_assert(kTest<U>); // passes again
Implementation of used functions, types (not about loopholes)
template <auto I>
struct Wrapper {};


template <typename... Ts>
struct TypeList {};

template <typename T>
struct TypeList<T> {
  using Type = T;
};


template <typename... Ts, typename... TTs>
consteval auto operator==(const TypeList<Ts...>&, const TypeList<TTs...>&) -> bool {
  return false;
};

template <typename... Ts>
consteval auto operator==(const TypeList<Ts...>&, const TypeList<Ts...>&) -> bool {
  return true;
};
template <typename... Ts>
inline constexpr TypeList<Ts...> kTypeList;

namespace impl {

template <std::size_t I, typename T>
struct IndexedType {};

template <typename...>
struct Caster {};

template <std::size_t... Is, typename... Ts>
struct Caster<std::index_sequence<Is...>, Ts...> : IndexedType<Is, Ts>... {};

} // namespace impl

template <std::size_t I, typename... Ts>
consteval auto Get(TypeList<Ts...>) -> decltype(
  []<typename T>(impl::IndexedType<I, T>&&) -> T {
}(impl::Caster<std::index_sequence_for<Ts...>, Ts...>{}));

Introspection of constructor input parameters

Introspection of constructor parametersMotivation

When implementing classic Dependency Injection, we need to find out which components our component depends on. In it they are indicated in the constructor,

struct SomeInterface {
  virtual auto SomeFunction() -> int = 0;
};

struct SomeInterface2 {
  virtual auto SomeFunction2() -> void = 0;
};

class SomeStruct {
public:
  SomeStruct(SomeInterface& some, SomeInterface2& other) :
                   some(some),
                   other(other) {
    this->some.SomeFunction();
  };

private:
  SomeInterface& some;
  SomeInterface2& other;
};

static_assert(Reflect<SomeStruct>() 
              == kTypeList<SomeInterface, SomeInterface2>);

Without loophols, such code in pure C++ would be impossible, because in C++ it is not possible to obtain a pointer to a constructor, as is possible with a method. Therefore, it remains to solve this with loopholes.

How it works

The idea is to first find out the number of input parameters of the constructor through simple recursion, and then pass N objects to the constructor, which will be cast into the types necessary for the constructor thanks to the template operator, and in the operator they will write this to the global state.

First, we need to determine the number of arguments; for this, a simple SimpleCaster structure will help us:

struct SimpleCaster {
  template <typename T>
  constexpr operator T&&();

  template <typename T>
  constexpr operator T&();
};

Next, using this structure, we will look at the number of arguments of a simple recursive function. If requires{T{(Is, SimpleCaster)…};} did not work, then you need to increase the pack size and so on until the required size is found. 256 is the upper ceiling for how many arguments can be made. 0, 0, which is passed to GetArgsCount, are the initial values, so it started looking from 2 arguments and further, because with one argument it only works with aggregates due to the fact that it will instantiate copy, move constructors.

template <typename T, std::size_t Max, std::size_t... Is>
consteval auto GetArgsCountImpl() {
  if constexpr(requires{T{(Is, SimpleCaster{})...};}) {
    return sizeof...(Is);
  } else {
    static_assert(sizeof...(Is) != Max, "Not found counstructor");
    return GetArgsCountImpl<T, Is..., 0>();
  };
};

template <typename T, std::size_t Max = 256>
consteval auto GetArgsCount() {
  return GetArgsCountImpl<T, Max, 0, 0>();
};

And now the actual class itself, which will record the data. It writes data when the conversion operator is called.

template <typename Main, auto I>
struct Caster {
  template <typename T, auto = Injector<TypeList<Main, Wrapper<I>>{}, TypeList<T>{}>{}>
  constexpr operator T&&(); 

  template <typename T, auto = Injector<TypeList<Main, Wrapper<I>>{}, TypeList<T>{}>{}>
  constexpr operator T&(); 
};

Well, now all we have to do is call the constructor with objects of type Caster, and then read the recorded data. Inside the lambda concept with the created Is pack, we add data to the global state and read it in the body. At the same time, it remains possible to pass your size if the GetArgsCount approach does not work, or you want to speed up compilation.

template <typename T, std::size_t I = GetArgsCount<T>()>
consteval auto Reflect() {
  return [&]<auto... Is>(std::index_sequence<Is...>) requires requires {T{Caster<T, Is>{}...};} {
    return TypeList<typename decltype(Magic(Getter<TypeList<T, Wrapper<Is>>{}>{}))::Type...>{};
  }(std::make_index_sequence<I>());
};
What happened
#include <utility>

template <auto>
struct Wrapper {};

template <typename... Ts>
struct TypeList {};

template <typename T>
struct TypeList<T> {
  using Type = T;
};

template <typename... Ts>
consteval auto operator==(const TypeList<Ts...>&, const TypeList<Ts...>&) -> bool {
  return true;
};

template <typename... Ts, typename... TTs>
consteval auto operator==(const TypeList<Ts...>&, const TypeList<TTs...>&) -> bool {
  return false;
};



template <typename... Ts>
inline constexpr TypeList<Ts...> kTypeList;


template <auto I>
struct Getter {
  friend constexpr auto Magic(Getter<I>);
};

template <auto I, auto Value>
struct Injector {
  friend constexpr auto Magic(Getter<I>) {return Value;};
};

template <typename Main, auto I>
struct Caster {
  template <typename T, auto = Injector<TypeList<Main, Wrapper<I>>{}, TypeList<T>{}>{}>
  constexpr operator T&&(); 

  template <typename T, auto = Injector<TypeList<Main, Wrapper<I>>{}, TypeList<T>{}>{}>
  constexpr operator T&(); 
};


struct SimpleCaster {
  template <typename T>
  constexpr operator T&&();

  template <typename T>
  constexpr operator T&();
};

template <typename T, std::size_t Max, std::size_t... Is>
consteval auto GetArgsCountImpl() {
  if constexpr(requires{T{(Is, SimpleCaster{})...};}) {
    return sizeof...(Is);
  } else {
    static_assert(sizeof...(Is) != Max, "Not found counstructor");
    return GetArgsCountImpl<T, Is..., 0>();
  };
};

template <typename T, std::size_t Max = 256>
consteval auto GetArgsCount() {
  return GetArgsCountImpl<T, Max, 0, 0>();
};



template <typename T, std::size_t I = GetArgsCount<T>()>
consteval auto Reflect() {
  return [&]<auto... Is>(std::index_sequence<Is...>) requires requires {T{Caster<T, Is>{}...};} {
    return TypeList<typename decltype(Magic(Getter<TypeList<T, Wrapper<Is>>{}>{}))::Type...> {};
  }(std::make_index_sequence<I>());
};

Constexpr counter

To implement the following, we need a counter, but its implementation is not as simple as it seems at first glance.

template <typename Tag, auto Value>
struct TagWithValue {};

template <typename Tag = void, std::size_t I = 0>
consteval auto CounterImpl() -> std::size_t {
  if constexpr(requires{Magic(Getter<TagWithValue<Tag, I>{}>{});}) {
    return CounterImpl<Tag, I + 1>();
  };
  return (std::ignore = Injector<TagWithValue<Tag, I>{}, 0>{}, I);
};

This approach will not work due to the fact that C++ compilers may simply not calculate the value after the next call, but simply take the old one, which is why it will not work.

This can be solved simply by passing a unique Ts pack from the outside, which will guarantee that the value will be recalculated.

template <typename Tag, std::size_t I = 0, typename... Ts>
consteval auto CounterImpl() -> std::size_t {
  if constexpr(requires{Magic(Getter<TagWithValue<Tag, I>{}>{});}) {
    return CounterImpl<Tag, I + 1, Ts...>();
  };
  return (std::ignore = Injector<TagWithValue<Tag, I>{}, 0>{}, I);
};

Well, wrap it in an interface without I

template <typename Tag = void, typename... Ts, auto R = CounterImpl<Tag, 0, Ts...>()>
consteval auto Counter() -> std::size_t {
  return R;
};

We check:

static_assert(Counter<void>() == 0);
static_assert(Counter<void, int>() == 1);
static_assert(Counter<void, void>() == 2);

But this won't work:

static_assert(Counter() == 0);
static_assert(Counter() == 1);
static_assert(Counter() != Counter());

But this is not necessary everywhere, and where it is needed, the desired effect is easily achieved.

If you instantiate the Foo function template with the int parameter, then the first time the function is instantiated and, as it were, added to the end of the code, everything that was before that moment will be available to it, but not so much after, and if you try to instantiate it again, then new no instance will be produced, but the one that already exists above will be taken.

This feature is an example of how the implementation uses a broader guarantee. If templates were instantiated differently from one set of arguments, then IFNDR.

It’s easier to understand this feature using the example of the simple GetUnique function (Ps: Don’t use loopholes for functions that are implemented through fold expressions, the team lead won’t thank you), which removes duplicates from the list of types

It takes advantage of the fact that if you try to instantiate an already instantiated template, nothing more will be added. This means that if we simply insert parameters from the pack into the templates, it will only record unique values.

To ensure that the function does not stop working after the first call, due to the fact that 2 values ​​will be written to the same key, we create a structure that will store the tag (the original list of types) and the index we actually need.

template <typename Tag, std::size_t Index>
struct GetUniqueKey {};

Next, we need to implement the function itself; to do this, we first need to add the values ​​we need to the global state:

   ([]{
      constexpr auto I = Counter<TypeList<Ts...>, Ts>();
      std::ignore = Injector<GetUniqueKey<TypeList<Ts...>, I>{}, TypeList<Ts>{}>{};
    }(), ...);

Well, then get a pack of indexes for our new pack of types, the size of which we get from Counter with the same tag, but without parameters in Ts, and they have always been there, so we guarantee that this will be a new instance and the counter is incremented, and therefore we can just take the counter value.

return []<std::size_t... Is>(std::index_sequence<Is...>) {
}(std::make_index_sequence<Counter<TypeList<Ts...>>()>());

And then we just read the data using this index pack, this is what we got:

template <typename... Ts>
consteval auto GetUnique(TypeList<Ts...>) {
  ([]{
    constexpr auto I = Counter<TypeList<Ts...>, Ts>();
    std::ignore = Injector<GetUniqueKey<TypeList<Ts...>, I>{}, TypeList<Ts>{}>{};
  }(), ...);
  return []<std::size_t... Is>(std::index_sequence<Is...>) {
    return TypeList<typename decltype(Magic(Getter<GetUniqueKey<TypeList<Ts...>, Is>{}>{}))::Type...>{};
  }(std::make_index_sequence<Counter<TypeList<Ts...>>()>());
};

Introspection of bodies of functions

Motivation

This can be useful when using the Service Locator pattern in Dependency Injection. Using this, you can determine class dependencies, which is useful for early diagnosis of ring dependencies, the use of topological sorting, or when implementing parallel initialization of components.

In the case of using Stackfull coroutines, you can easily wait for the dependent class to be initialized directly in the GetComponent method, but with Stackless from C++20, the constructor cannot be a coroutine and you have to wait in advance, but at that time we did not know the class dependencies.

This can be solved in the following ways:

But with loopholes there is another way, albeit a limited one. About restrictions:

Here's an example of how it would work with loopholes:

struct SomeImplA {
  template <typename T, typename... Args>
  auto Method(Args...) {}; // Интерфейс
};

struct Foo {
  constexpr Foo(auto& a) {
    a.template Method<int>(42);
    std::println("");
  };
};

static_assert((Inject<Foo>(), 1));

static_assert(std::same_as<decltype(GetTFromMethod<Foo>()), TypeList<int>>);

static_assert(std::same_as<decltype(GetArgsFromMethod<Foo>()), TypeList<TypeList<int>>>);

Here's what you get with Foo if you don't use loopholes and take them out of the classroom:

struct Foo {
  static constexpr auto kMethodData = kTypeList<TypeList<int, int>>;
  constexpr Foo(auto& a) {
    a.template Method<int>(42);
    std::println("");
  };
};

The disadvantages of this approach are obvious: it is very easy to make mistakes. A person can add a call to the constructor, forgetting about kMethodData. Checking for something like this will only happen at runtime, and I would like to know about errors before starting runtime. This is an extra boiler plate, which can be avoided.

How it works

It works as follows: we replace what the function expected to receive with our object. This object will record the information that appears when the method is called (T and Args…), in order to then read it and extract what is needed from it.

In order to save information, we need a key with which we will save values

template <typename Current, std::size_t I>
struct InfoKey {};

Then the implementation of the class itself, which will be responsible for sending data to the global state. Thanks to the lambda and its peculiarity that its type is always unique, you can make it write even when the types are not unique.

Here we read these T and Args…, and then through the counter we look at how much data has already been written and where the next ones need to be written, for this we pass its type (as a tag) and the read data so that there is a new instantiation. And then to this place, through InfoKey, we write the combined data in the form of T and Args… as a list of types.

template <typename Current, bool GetUnique = true>
struct InfoInjector;

template <typename Current>
struct InfoInjector<Current, true> {
  template <
    typename T,
    typename... Args,
    std::size_t I = Counter<Current, T, Args...>(),     //                T, Args... должно быть 
    auto = Injector<InfoKey<Current, I>{}, TypeList<T, Args...>{}>{}  //  уникально, иначе не запишет
  >
  static auto Method(Args...) -> void;
};

template <typename Current>
struct InfoInjector<Current, false> {
  template <
    typename T,
    typename... Args,
    auto f = []{},
    std::size_t I = Counter<Current, decltype(f)>(), 
    auto = Injector<InfoKey<Current, I>{}, TypeList<T, Args...>{}>{}  // запишет всё
  >
  static auto Method(Args...) -> void;
};

Then we need to actually instantiate the constructor with our object, which will write the information

template <typename T, auto... Args>
inline constexpr auto Use() {
  std::ignore = T{Args...};
};

template <typename...>
consteval auto Ignore() {};

template <typename T, bool GetUnique = true>
consteval auto Inject() {
  Ignore<decltype(Use<T, InfoInjector<T, GetUnique>{}>)>();
};

After we have added information to the global state, we need to read it, read T, and for this we create a pack of indexes that correspond to those that were used to record the data, we receive the number of them through Counter with the tag. This value that we got here will be the same in another function, regardless of the order of the call

  []<std::size_t... Is>(std::index_sequence<Is...>){
  }(std::make_index_sequence<Counter<T>()>());

Next, we just have to get the data from the global state, and then get the first element from the typelist, because it is he who is responsible for the T we need.

template <typename T>
consteval auto GetTFromMethod() {
  return []<std::size_t... Is>(std::index_sequence<Is...>){
     return kTypeList<decltype(Get<0>(Magic(Getter<InfoKey<T, Is>{}>{})))...>;
  }(std::make_index_sequence<Counter<T>()>());
};

The principle of operation of GetArgsFromMethod is similar, only it takes not T, but what is responsible for Args – everything after T, for this we throw out T and take everything else.

template <typename T, typename... Ts>
consteval auto DropHead(TypeList<T, Ts...>) -> TypeList<Ts...> {
  return {};
};

template <typename T>
consteval auto GetArgsFromMethod() {
  return []<std::size_t... Is>(std::index_sequence<Is...>) {
    return TypeList<decltype(DropHead(Magic(Getter<InfoKey<T, Is>{}>{})))...>{};
  }(std::make_index_sequence<Counter<T>()>());
};
What happened
#include <tuple>

template <typename... Ts>
struct TypeList {};

namespace impl {

template <std::size_t I, typename T>
struct IndexedType {};

template <typename...>
struct Caster {};

template <std::size_t... Is, typename... Ts>
struct Caster<std::index_sequence<Is...>, Ts...> : IndexedType<Is, Ts>... {};



} // namespace impl

template <std::size_t I, typename... Ts>
consteval auto Get(TypeList<Ts...>) -> decltype(
  []<typename T>(impl::IndexedType<I, T>&&) -> T {
}(impl::Caster<std::index_sequence_for<Ts...>, Ts...>{}));


template <auto I>
struct Getter {
  friend constexpr auto Magic(Getter<I>);
};

template <auto I, auto Value>
struct Injector {
  friend constexpr auto Magic(Getter<I>) {return Value;};
};

template <typename Tag, auto Value>
struct TagWithValue {};

template <typename Tag, std::size_t I = 0, typename... Ts>
consteval auto CounterImpl() -> std::size_t {
  if constexpr(requires{Magic(Getter<TagWithValue<Tag, I>{}>{});}) {
    return CounterImpl<Tag, I + 1, Ts...>();
  };
  return (std::ignore = Injector<TagWithValue<Tag, I>{}, 0>{}, I);
};

template <typename Tag = void, typename... Ts, auto R = CounterImpl<Tag, 0, Ts...>()>
consteval auto Counter() -> std::size_t {
  return R;
};

template <typename T, auto... Args>
inline constexpr auto Use() {
  std::ignore = T{Args...};
};

template <typename...>
consteval auto Ignore() {};

template <typename Current, std::size_t I>
struct InfoKey {};


template <typename Current, bool GetUnique = true>
struct InfoInjector;

template <typename Current>
struct InfoInjector<Current, true> {
  template <
    typename T,
    typename... Args,
    std::size_t I = Counter<Current, T, Args...>(),     //                T, Args... должно быть 
    auto = Injector<InfoKey<Current, I>{}, TypeList<T, Args...>{}>{}  //  уникально, иначе не запишет
  >
  static auto Method(Args...) -> void;
};

template <typename Current>
struct InfoInjector<Current, false> {
  template <
    typename T,
    typename... Args,
    auto f = []{},
    std::size_t I = Counter<Current, decltype(f)>(), 
    auto = Injector<InfoKey<Current, I>{}, TypeList<T, Args...>{}>{}  // запишет всё
  >
  static auto Method(Args...) -> void;
};

template <typename T, bool GetUnique = true>
consteval auto Inject() {
  Ignore<decltype(Use<T, InfoInjector<T, GetUnique>{}>)>();
};

template <typename T, typename... Ts>
consteval auto GetTFromMethod() {
  return []<std::size_t... Is>(std::index_sequence<Is...>){
     return TypeList<decltype(Get<0>(Magic(Getter<InfoKey<T, Is>{}>{})))...>{};
  }(std::make_index_sequence<Counter<T>()>());
};

template <typename T, typename... Ts>
consteval auto DropHead(TypeList<T, Ts...>) -> TypeList<Ts...> {
  return {};
};

template <typename T>
consteval auto GetArgsFromMethod() {
  return []<std::size_t... Is>(std::index_sequence<Is...>) {
    return TypeList<decltype(DropHead(Magic(Getter<InfoKey<T, Is>{}>{})))...>{};
  }(std::make_index_sequence<Counter<T>()>());
};

How to make metaprogramming more like runtime code

Motivation

constexpr auto array = std::array{kTypeId<int>, kTypeId<void>} | std::views::reverse;

static_assert(std::is_same_v<GetType<array[0]>, void>);

We can also use any functions that are created for regular ranges, and in general write regular code that will work with types. (Although I myself prefer a functional paradigm for such shifting of something, and as a result, the absence of a state)

The implementation of this is very simple – when adding a type, we assign it a unique Id through a counter, and through Id it becomes possible to get back information about the type:

struct Types {};


template <std::size_t Id>
struct MetaInfoKey {};


template <typename T>
struct MetaInfo {
  static constexpr std::size_t kTypeId = Counter<Types, T>();
  using Type = T;
private: 
  static constexpr auto _ = Injector<MetaInfoKey<kTypeId>{}, TypeList<T>{}>{};
};

template <typename T>
inline constexpr std::size_t kTypeId = MetaInfo<T>::kTypeId;

template <std::size_t Id>
using GetMetaInfo = MetaInfo<typename decltype(Magic(Getter<MetaInfoKey<Id>{}>{}))::Type>;

template <std::size_t Id>
using GetType = GetMetaInfo<Id>::Type;
What happened
#include <tuple>

template <typename... Ts>
struct TypeList {};

template <typename T>
struct TypeList<T> {
  using Type = T;
};

template <auto I>
struct Getter {
  friend constexpr auto Magic(Getter<I>);
};

template <auto I, auto Value>
struct Injector {
  friend constexpr auto Magic(Getter<I>) {return Value;};
};

template <typename Tag, auto Value>
struct TagWithValue {};

template <typename Tag, std::size_t I = 0, typename... Ts>
consteval auto CounterImpl() -> std::size_t {
  if constexpr(requires{Magic(Getter<TagWithValue<Tag, I>{}>{});}) {
    return CounterImpl<Tag, I + 1, Ts...>();
  };
  return (std::ignore = Injector<TagWithValue<Tag, I>{}, 0>{}, I);
};

template <typename Tag = void, typename... Ts, auto R = CounterImpl<Tag, 0, Ts...>()>
consteval auto Counter() -> std::size_t {
  return R;
};

struct Types {};


template <std::size_t Id>
struct MetaInfoKey {};


template <typename T>
struct MetaInfo {
  static constexpr std::size_t kTypeId = Counter<Types, T>();
  using Type = T;
private: 
  static constexpr auto _ = Injector<MetaInfoKey<kTypeId>{}, TypeList<T>{}>{};
};

template <typename T>
inline constexpr std::size_t kTypeId = MetaInfo<T>::kTypeId;

template <std::size_t Id>
using GetMetaInfo = MetaInfo<typename decltype(Magic(Getter<MetaInfoKey<Id>{}>{}))::Type>;

template <std::size_t Id>
using GetType = GetMetaInfo<Id>::Type;

Conclusion

In fact, this is not all that can be said about loopholes, but what shows the main thing.

Code from article on godbolt

An article on this topic that is worthy of attention: “Non-const constant expressions”

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *