Type Loopholes: решая нерешаемое. Рефлексия времени компиляции

bb75da772915e17566ffc9cefeb9f4ae

Лупхолы — это техника, позволяющая манипулировать глобальным состоянием компилятора, добавляя значения и считывая их.

Эта техника позволяет решать многие задачи, некоторые из которых будут рассмотрены в статье:

  1. Узнать, какие параметры принимает конструктор типа

  2. Узнать, с какими шаблонными параметрами вызывался метод/функция с ADL

  3. Как сделать метапрограммирование с типами более похожим на обычный код, где есть состояние

    Пример работы лупхолов:

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

    А вот определение Injector и Getter:

    template 
    struct Getter {
      friend constexpr auto Magic(Getter);
    };
    
    template 
    struct Injector {
      friend constexpr auto Magic(Getter) {return Value;};
    };

Принцип работы лупхолов

  1. В C++ можно объявлять дружественные функции:

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

Но не всем известно, что их вместе с тем можно ещё и определять:

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

Если объявление такой функции было доступно отдельно, то порядок поиска его сущности обычен:

auto F() -> void;

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

auto main() -> int {
  F(); // Well Formed
};
  1. Также функции можно объявлять без уточнения типа возвращаемого значения, используя auto:

auto F();

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

Использовать такую функцию до уточнения этого типа запрещается.
Уточнить этот тип допустимо последующим определением, где тип выведется из ретурна:

auto F();

auto F() {
  return 42;
};

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

3. Объединение оснований первого и второго пунктов дает такой пример:

auto F();

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

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

Теперь добавляем сюда шаблоны: дружественное определение инстанцируется (будет зарегистрировано компилятором) только с инстанцированием содержащего его шаблона. То-есть:

auto F();

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

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

template struct S;

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

Проявилось то-самое состояние компиляции.

4. Теперь можно пронаблюдать это состояние программно. Для этого нужно обеспечить для него зависимость. То-есть сделать валидность вызова неразрешимым без уточнения некоторой передаваемой информации, например аргумента:

struct U {};

auto F(U);


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

Так мы исключаем возможность вычисления корректности выражения вызова в шаблоне без информации о передаваемом типе (которое иначе неизбежно бы произошло):

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

Не будь этого аргумента, kTest мог бы быть вычислен статически, т.е. requires { F (); } просто давал бы IF, поскольку с т.з. языка там написано нечто, что не сможет быть корректным никогда, на что тестировать ошибочно.
Тем не менее, этот пример все еще не даст нам пронаблюдать состояние:

static_assert(!kTest); // passes
template struct S;
static_assert(!kTest); // passes again

По причине мемоизации: kTest всегда должны называть одну и ту же сущность, т.е. она не может быть различной по определению. Будучи false в первый раз, она обязана быть false и во второй.
5. Остается лишь сделать так, чтобы одна и та же синтаксически конструкция (kTest) называла разные сущности в разных контекстах. Мы используем то, как взаимодействуют лямбды и аргументы по умолчанию. Переопределяем kTest<>:

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

Тестируем:

static_assert(!kTest); // passes
template struct S;
static_assert(kTest); // passes again

Реализация используемых функций, типов (не про лупхолы)

template 
struct Wrapper {};


template 
struct TypeList {};

template 
struct TypeList {
  using Type = T;
};


template 
consteval auto operator==(const TypeList&, const TypeList&) -> bool {
  return false;
};

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

namespace impl {

template 
struct IndexedType {};

template 
struct Caster {};

template 
struct Caster, Ts...> : IndexedType... {};

} // namespace impl

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

Интроспекция входный параметров конструктора

Мотивация

При реализации классического Dependency Injection, нам нужно узнавать, от каких компонентов зависит наш компонент. В нём они указываются в конструкторе,

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() 
              == kTypeList);

Без лупхолов, такой код на чистом C++ был бы невозможен, т.к. в C++ не получится получить указатель на конструктор, как это возможно сделать с методом. Поэтому остаётся решать такое лупхолами.

Как это работает

Идея в том, чтобы через простую рекурсию в начале узнать кол-во входных параметров конструктора, а затем передать в конструктор N объектов, которые скастуются в необходимые для конструктора типы благодаря шаблонному оператору, а в операторе они запишут это в глобальное состояние.

В начале нам нужно определить кол-во аргументов, для этого нам поможет простая структура SimpleCaster:

struct SimpleCaster {
  template 
  constexpr operator T&&();

  template 
  constexpr operator T&();
};

Далее используя эту структуру мы простой рекурсивной функций посмотрим кол-во аргументов. Если requires{T{(Is, SimpleCaster)…};} не сработало, то значит нужно увеличивать размер пака и так до тех пор, пока не найдёт нужный размер. 256 это верхний потолок, сколько можно будет делать аргументов. 0, 0, которое передаётся в GetArgsCount, это начальные значения, чтобы начинать оно искало с 2х аргументов и дальше, т.к. с одним аргументом оно работает только с агрегатами из-за того, что оно будет инстанцировать копи, мув конструкторы.

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

template 
consteval auto GetArgsCount() {
  return GetArgsCountImpl();
};

А теперь собственно сам класс, который будет записывать данные. Он записывает данные при вызове оператора преобразования.

template 
struct Caster {
  template >{}, TypeList{}>{}>
  constexpr operator T&&(); 

  template >{}, TypeList{}>{}>
  constexpr operator T&(); 
};

Ну и теперь нам осталось лишь вызвать конструктор с объектами типа Caster, а затем прочитать записанные данные. Внутри концепта лямбды с созданным паком Is мы добавляем данные в глобальный стейт, а в теле читаем. При этом остаётся возможность передать свой размер, если подход с GetArgsCount не справляется, или хочется ускорить компиляцию.

template ()>
consteval auto Reflect() {
  return [&](std::index_sequence) requires requires {T{Caster{}...};} {
    return TypeList>{}>{}))::Type...>{};
  }(std::make_index_sequence());
};

Что получилось

#include 

template 
struct Wrapper {};

template 
struct TypeList {};

template 
struct TypeList {
  using Type = T;
};

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

template 
consteval auto operator==(const TypeList&, const TypeList&) -> bool {
  return false;
};



template 
inline constexpr TypeList kTypeList;


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

template 
struct Injector {
  friend constexpr auto Magic(Getter) {return Value;};
};

template 
struct Caster {
  template >{}, TypeList{}>{}>
  constexpr operator T&&(); 

  template >{}, TypeList{}>{}>
  constexpr operator T&(); 
};


struct SimpleCaster {
  template 
  constexpr operator T&&();

  template 
  constexpr operator T&();
};

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

template 
consteval auto GetArgsCount() {
  return GetArgsCountImpl();
};



template ()>
consteval auto Reflect() {
  return [&](std::index_sequence) requires requires {T{Caster{}...};} {
    return TypeList>{}>{}))::Type...> {};
  }(std::make_index_sequence());
};

Constexpr счётчик

Для реализации следующего нам понадобится счётчик, только вот его реализация не так проста, как кажется на первый взгляд.

template 
struct TagWithValue {};

template 
consteval auto CounterImpl() -> std::size_t {
  if constexpr(requires{Magic(Getter{}>{});}) {
    return CounterImpl();
  };
  return (std::ignore = Injector{}, 0>{}, I);
};

Такой подход работать не будет из-за того, что компиляторы C++ могут просто не вычислять значение после следующего вызова, а просто взять старое, из-за чего он и не будет работать.

Решается это просто прокидыванием извне уникального пака Ts, который будет гарантироваться, что будет заново вычисляться значение.

template 
consteval auto CounterImpl() -> std::size_t {
  if constexpr(requires{Magic(Getter{}>{});}) {
    return CounterImpl();
  };
  return (std::ignore = Injector{}, 0>{}, I);
};

Ну и обернуть в интерфейс без I

template ()>
consteval auto Counter() -> std::size_t {
  return R;
};

Проверяем:

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

Но вот это работать не будет:

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

Но это нужно не везде, а там где такое нужно, легко достигается нужного эффекта.

Если вы инстанцируете шаблон функции Foo с параметром int, то в первый раз функция инстанцируется и как-бы добавится в конец кода, ей будет доступно всё что было до этого момента, но не то что после, а если снова попытаться её инстанцировать, то нового инстанса произведено не будет, а возьмётся тот, что уже есть выше.

Такая особенность это пример того, как имплементаця использует более широкую гарантию. Если бы шаблоны инстанцировались в разное от одного набора аргументов, то IFNDR.

Проще понять такую особенность можно на примере простой функции GetUnique (Ps: Не используйте лупхолы для таких функций, что реализуются через fold expressions, тимлид вам спасибо не скажет), которая убирает дубликаты из списка типов

Она использует как раз тот факт, что если попытаться инстанцировать уже инстанцированный шаблон, ничего более добавляться не будет. А значит, если мы просто будем просовывать в шаблоны параметры из пака, то оно будет записывать только уникальные значения.

Для того, чтобы функция не перестала работать после первого вызова, от того, что на одинаковый ключ будет записано 2 значения, мы создаём структуру, которая будет хранить тег (оригинальный список типов) и собственно нужный нам индекс.

template 
struct GetUniqueKey {};

Далее нужно реализовать саму функцию, для этого в начале нам нужно добавить в глобальный стейт нужные нам значения:

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

Ну и затем получить пак индексов для нашего нового пака типов, размер которого мы получаем от Counter с тем же тегом, но без параметров в Ts, а они там всегда были, поэтому мы гарантируем, что это будет новый инстанс и счётчик инкрементируется, а потому мы можем просто взять значение счётчика.

return [](std::index_sequence) {
}(std::make_index_sequence>()>());

А затем мы просто читаем данные с помощью этого пака индексов, вот что получилось:

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

Интроспекция тел функций

Мотивация

Такое может пригодится при использовании паттерна Service Locator в Dependency Injection. С помощью такого можно определять зависимости классов, что пригодится для раннего диагностирования кольцевых зависимостей, применения топологической сорировки, или же при реализации параллельной инициализации компонентов.

В случае с использованием Stackfull корутин, можно легко подождать инициализации зависимого класса прямо в методе GetComponent, но со Stackless из C++20, конструктор не может быть корутиной и приходится ждать заранее, но на тот момент нам были неизвестны зависимости класса.

Решить это можно следующими путями:

  • Использовать кодогенерацию и парсеры C++(LLVM), но есть минусы:
    — Усложняется сборка
    — Сложно реализовывать, если в команде нет людей, что работали с этими вещами

  • Не интроспектировать и вынести информацию из тела

Но с лупхолами есть ещё один способ, хоть и ограниченный. Про ограничения:

  • Всё что можно интроспектировать должно использовать входные шаблонные параметры и чтобы от них зависило, какая функция будет вызвана (ADL, методы)

  • Функция должна быть объявлена как constexpr (При этом мочь обязательно выполниться в constexpr не обязательно)

  • Можно инстроспектировать только то, что работает через шаблоны

Вот пример как это будет работать с лупхолами:

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

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

static_assert((Inject(), 1));

static_assert(std::same_as()), TypeList>);

static_assert(std::same_as()), TypeList>>);

Вот что получится с Foo, если не использовать лупхолы, а вынести из класса:

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

Минусы такого подхода очевидны: очень легок ошибиться. Человек может добавить вызов в конструктор, забыв про kMethodData. Проверка же подобного будет только на рантайме, а об ошибках хотелось бы знать до запуска рантайма. Это лишний бойлерплейт, чего можно избежать.

Как это работает

Работает оно следующим образом: мы подменяем то что ожидала получить функция своим объектом. Этот объект будет записывать информацию, которая появляется при вызове метода (T и Args…), чтобы затем её считать и выцепить из неё нужное.

Для того чтобы сохранять информацию, нам понадобится ключ, с помощью которого мы будем сохранять значения

template 
struct InfoKey {};

Затем реализация самого класса, который будет отвечать за отправку данных в глобальное состояние. Благодаря лямбде и той её особенности, что её тип всегда уникален, можно сделать, чтобы оно записывало даже тогда, когда типы не уникальны.

Тут мы читаем эти T и Args…, а затем через счётчик смотрим, сколько данных уже было записано и куда нужно записать следующие, для этого мы передаём его тип (как тег) и считанные данные, чтобы было новое инстанцирование. А затем на это место через InfoKey мы записываем объединённые данные в виде T и Args… как список типов.

template 
struct InfoInjector;

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

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

Затем нам нужно собственно инстанцировать конструктор с нашим объектом, который запишет информацию

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

template 
consteval auto Ignore() {};

template 
consteval auto Inject() {
  Ignore{}>)>();
};

После того как мы добавили информацию в глобальное состояние, нам нужно его прочитать, читаем T, а для этого мы создаём пак из индексов, которые соответсвуют тем, которые использовались для записи данных, кол-во их мы получаем через Counter с тегом. Это значение, которое мы тут получили, будет такое же и в другой функции, вне зависимости от порядка вызова

  [](std::index_sequence){
  }(std::make_index_sequence()>());

Далее же нам остаётся просто получить данные из глобального состояния, а затем достать первый элемент из тайплиста, т.к. именно он отвечает за нужный нам T.

template 
consteval auto GetTFromMethod() {
  return [](std::index_sequence){
     return kTypeList(Magic(Getter{}>{})))...>;
  }(std::make_index_sequence()>());
};

Принцип работы GetArgsFromMethod аналогичен, только он берёт не T, а то, что отвечает за Args — всё что после T, для этого мы выкидываем T и берём всё остальное.

template 
consteval auto DropHead(TypeList) -> TypeList {
  return {};
};

template 
consteval auto GetArgsFromMethod() {
  return [](std::index_sequence) {
    return TypeList{}>{})))...>{};
  }(std::make_index_sequence()>());
};

Что получилось

#include 

template 
struct TypeList {};

template 
inline constexpr TypeList kTypeList;

namespace impl {

template 
struct IndexedType {};

template 
struct Caster {};

template 
struct Caster, Ts...> : IndexedType... {};



} // namespace impl

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


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

template 
struct Injector {
  friend constexpr auto Magic(Getter) {return Value;};
};

template 
struct TagWithValue {};

template 
consteval auto CounterImpl() -> std::size_t {
  if constexpr(requires{Magic(Getter{}>{});}) {
    return CounterImpl();
  };
  return (std::ignore = Injector{}, 0>{}, I);
};

template ()>
consteval auto Counter() -> std::size_t {
  return R;
};

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

template 
consteval auto Ignore() {};

template 
struct InfoKey {};


template 
struct InfoInjector;

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

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

template 
consteval auto Inject() {
  Ignore{}>)>();
};

template 
consteval auto GetTFromMethod() {
  return [](std::index_sequence){
     return TypeList(Magic(Getter{}>{})))...>{};
  }(std::make_index_sequence()>());
};

template 
consteval auto DropHead(TypeList) -> TypeList {
  return {};
};

template 
consteval auto GetArgsFromMethod() {
  return [](std::index_sequence) {
    return TypeList{}>{})))...>{};
  }(std::make_index_sequence()>());
};

О том, как метапрограммирование сделать более похожим на рантайм код

Мотивация

constexpr auto array = std::array{kTypeId, kTypeId} | std::views::reverse;

static_assert(std::is_same_v, void>);

Так же мы можем использовать любые функции, которые созданы для обычных диапазонов, да и в целом писать обычный код, который будет работать с типами. (Хотя сама я предпочитаю функциональную парадигму для такого перекладывания чего-либо, а в следствии и отсутствие стейта)


Реализация подобного очень проста — мы при добавлении типа присваиваем ему уникальный Id через счётчик, а через Id становится можно получить обратно информацию о типе:

struct Types {};


template 
struct MetaInfoKey {};


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

template 
inline constexpr std::size_t kTypeId = MetaInfo::kTypeId;

template 
using GetMetaInfo = MetaInfo{}>{}))::Type>;

template 
using GetType = GetMetaInfo::Type;

Что получилось

#include 

template 
struct TypeList {};

template 
struct TypeList {
  using Type = T;
};

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

template 
struct Injector {
  friend constexpr auto Magic(Getter) {return Value;};
};

template 
struct TagWithValue {};

template 
consteval auto CounterImpl() -> std::size_t {
  if constexpr(requires{Magic(Getter{}>{});}) {
    return CounterImpl();
  };
  return (std::ignore = Injector{}, 0>{}, I);
};

template ()>
consteval auto Counter() -> std::size_t {
  return R;
};

struct Types {};


template 
struct MetaInfoKey {};


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

template 
inline constexpr std::size_t kTypeId = MetaInfo::kTypeId;

template 
using GetMetaInfo = MetaInfo{}>{}))::Type>;

template 
using GetType = GetMetaInfo::Type;

Заключение

На самом деле, это далеко не всё, что можно сказать про лупхолы, но то, что показывает главное и основное.

Код из статьи на годболте

Статья по этой теме, достойная внимания: «Неконстантные константные выражения»

Habrahabr.ru прочитано 1639 раз