Zero-cost Property в С++

в 19:11, , рубрики: c++, c++11, code, example, examples, optimization, properties, property, syntax sugar, tricks

Расскажу об одном решении которое имеет больше смысла в качестве упражнения а не практической пользы. Постановка задачи звучит так: Хочу получить в C++ семантику property как в C# и без накладных расходов.

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

К слову, компиляторы Microsoft имеют способ описать property но это не является частью стандарта C++.

Сразу отмечу что property получились с значительными ограничениями и больше подходят для имитации Swizzling из GLSL. По этому буду воспроизводить маленький кусочек vec2 а именно property yx которое должно возвращать исходный вектор с свапнутыми полями. Далее vec2 буду иногда называть контейнером, как более общий случай. Когда упоминаю property, буду подразумевать поле внутри контейнера, то есть yx в конкретном примере.

Использую стандарт С++11

Желаемое поведение:

int main()
{
  vec2 a(1, 2);
  std::cout << "a = " << a.x << " " << a.y << std::endl; // a = 1 2
 
  vec2 b = a.yx;
  std::cout << "b = " << b.x << " " << b.y << std::endl; // b = 2 1

  vec2 c;
  c.yx = a;
  std::cout << "c = " << c.x << " " << c.y << std::endl; // c = 2 1

  vec2 d(3, 4);
  d.yx = d;
  std::cout << "d = " << d.x << " " << d.y << std::endl; // d = 4 3
  return 0;
}

Основной трюк заключается в том, чтобы создать такой пустой класс property, который сможет извлечь указатель на контейнер полем которого он является. Самым лаконичным способом оказалось сделать так, чтобы адрес yx совпадал с адресом vec2. В противном случае пришлось бы передавать смещение поля property относительно контейнера.

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

template <typename OWNER, 
          typename VALUE,
          VALUE (OWNER::*GETTER)() const,
          OWNER &(OWNER::*SETTER)(const VALUE &)>
class Property final
{
  friend OWNER;

 private:
  Property() = default;            // Можно создать только в OWNER
  Property(Property &&) = delete;  // Нельзя перемещать из OWNER
  Property &operator=(Property &&) = delete;

 public:
  operator VALUE() const
  {
    auto owner = reinterpret_cast<const OWNER *>(this); // <- Ключевой элемент
    return (owner->*GETTER)();
  }

  const OWNER &operator=(const VALUE &value)
  {
    auto owner = reinterpret_cast<OWNER *>(this); // <- Ключевой элемент
    return (owner->*SETTER)(value);
  }
};

О всех проблемах после кода vec2

struct vec2 final
{
  vec2() = default;
  inline vec2(float both) : x(both), y(both) {}
  inline vec2(float x, float y) : x(x), y(y) {}
  inline vec2(const vec2 &other) : x(other.x), y(other.y) {}
  inline vec2(vec2 &&other) : x(other.x), y(other.y) {}

  vec2 &operator=(const vec2 &other);
  vec2 &operator=(vec2 &&other);

 private:
  vec2 get_yx() const;
  vec2 &set_yx(const vec2 &);

 public:
  union  // <- Ключевой элемент
  {
    // Анонимная структура содержит реальные поля vec2
    struct
    {
      float x;
      float y;
    };
    // Property лежит в начале памяти vec2 благодаря union
    Property<vec2, vec2, &vec2::get_yx, &vec2::set_yx> yx;
  };
};

static_assert(std::is_standard_layout<vec2>::value,
              "The property semantics require standard layout");
static_assert(offsetof(vec2, yx) == 0,
              "The property must have zero offset");
static_assert(std::is_trivially_constructible<vec2>::value,
              "Modify the class to take into account the union");
static_assert(std::is_trivially_destructible<vec2>::value,
              "Modify the class to take into account the union");

inline vec2 &vec2::operator=(const vec2 &other)
{
  x = other.x;
  y = other.y;
  return *this;
}

inline vec2 &vec2::operator=(vec2 &&other)
{
  x = std::move(other.x);
  y = std::move(other.y);
  return *this;
}

inline vec2 vec2::get_yx() const { return vec2(y, x); }

inline vec2 &vec2::set_yx(const vec2 &other)
{
  if (this == &other)
  {
    std::swap(x, y);
    return *this;
  }
  x = other.y;
  y = other.x;
  return *this;
}

Для чего используется union?

Примерно все компиляторы принудительно устанавливают размер пустой структуры или класса в 1 байт. Хоть этого нет в стандарте C++ но можно например найти в стандарте GCC 6.18 Structures with No Members 

Есть еще один механизм управления выделения памяти для пустых структур с помащью аттрибут [[no_unique_address]] но при проверке с компилятором msvc пустые структуры все также выделяли дополнительный байт. Без union это привело бы к UB так как предсказать смещение property было бы затруднительно. Допустим у нас только один property. Его адрес мог бы зависеть от компилятора, битности целевой платформы, размера других полей контейнера. Все из-за выравнивания памяти. Есть вариант передавать в property смещение относительно контейнера через функцию, но об этом позже.

Безопасность

Итак. Чтобы это работало более менее безопасно нужно выполнить несколько условий. Что уже не безопасно.

  1. Property всегда должен лежать как поле в самом начале памяти контейнера.

  2. Property не может быть скопирован или перемещен. Указатель this всегда должен указывать на контейнер.

Защита которую удалось поставить:

  • Проверить что смещение peorpety относительно контейнера равно нулю можно, но только после декларации поля.

  • Достоверно проверить смещение можно только если контейнер имеет стандартный layout.

  • За счет приватного дефолтного конструктора Property, его можно создать только внутри контейнера.

  • Property не имеет конструктора перемещения. Так что он привязан к контейнеру для сохранения соответствия this у контейнера и property.

Защита которую не удалось поставить:

  • Не удалось ограничить класс Property так, чтобы его можно было использовать только как поле. То есть никто не запретит создать инстанс внутри любого метода контейнера, что приведет к UB.

  • При помощи union удалось достичь соответствие адреса контейнера и Property на разных компиляторах. Но, нет способа обязать оформлять класc именно таким образом.

Что на счет zero-cost?

При параметре оптимизации O2 компиляторы прекрасно инлайнят все вызовы Property и get/set методы. Union позволяет избежать выделение дополнительной памяти.

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

Рассматривал дизассемблированный код на примере функции:

vec2 disassembly_target(vec2 value)
{
  return value.yx;
}

Немного дизассемблированного кода для нескольких компиляторов:

MinGW clang 16.0.2 -std=c++11 -O2

disassembly_target(vec2):            # @disassembly_target(vec2)
        mov     rax, rcx
        movsd   xmm0, qword ptr [rdx]           # xmm0 = mem[0],zero
        shufps  xmm0, xmm0, 225                 # xmm0 = xmm0[1,0,2,3]
        movlps  qword ptr [rcx], xmm0
        ret

Все инлайнится. Оптимизированы не только вычисления указателя на vec2 но и сама перестановка значений. Очень хорошо.

x86–64 gcc 14.2 -std=c++11 -O2

disassembly_target(vec2):
        movq    xmm0, QWORD PTR [rsi]
        mov     rax, rdi
        shufps  xmm0, xmm0, 0xe1
        movlps  QWORD PTR [rdi], xmm0
        ret

Тоже очень хороший результат

x64 msvc v19.40 VS17.10 /std:c++17 /GR- /O2

; Function compile flags: /Ogtpy
;       COMDAT vec2 disassembly_target(vec2)
__$ReturnUdt$ = 8
value$ = 16
vec2 disassembly_target(vec2) PROC          ; disassembly_target, COMDAT
; File C:WindowsTEMPcompiler-explorer-compiler2024829-3304-1p0myd7.2grhexample.cpp
; Line 34
        mov     eax, DWORD PTR [rdx+4]
        mov     DWORD PTR [rcx], eax
        mov     eax, DWORD PTR [rdx]
        mov     DWORD PTR [rcx+4], eax
; Line 92
        mov     rax, rcx
; Line 93
        ret     0
vec2 disassembly_target(vec2) ENDP          ; disassembly_target

msvc не захотел работать с C++11, поставил C++17. Компилятор отработал более прямолинейно, но тоже хорошо. Все важные оптимизации на месте.

Считаю что условный zero-cost на релизе достигнуто.

Можно ли обойтись без union?

Да можно. Вот более ранний но рабочий способ описать Property который я рассматривал

class vec3
{
 public:
  vec3() = default;

  inline const vec2 get_a() const { return vec2(x, y); }

  inline const vec3 &set_a(const vec2 &v)
  {
    y = v.x;
    x = v.y;
    return *this;
  }

 public:
  struct
  {
    inline operator vec2() const
    {
      auto self = reinterpret_cast<const vec3 *>(this - offsetof(vec3, yx));
      return self->get_a();
    }

    inline const vec3 &operator=(const vec2 &v)
    {
      auto self = reinterpret_cast<vec3 *>(this - offsetof(vec3, yx));
      return self->set_a(v);
    }
  } yx;

  float x{};
  float y{};
  float z{};
};

static_assert(std::is_standard_layout<vec3>::value,
              "The property semantics require standard layout");
static_assert(offsetof(vec3, yx) == 0, 
              "The property must have zero offset");

В этом примере указатель на контейнер рассчитывается через смещение поля property внутри контейнера, что накладывает те же ограничения на layout контейнера. Также не используется union но это не означает что будет выделен лишний байт. Все зависит от того как компилятор разберется с выравниванием памяти. Но за то не так важно где именно в памяти находится property.

Поле yx не защищено, хотя это необходимо сделать, и запись станет еще более громоздкой. При этом, вариант с смещением не получится вынести в шаблон в представленном виде, так как невозможно передать в параметр шаблона смещение еще не определенного поля. Выходом может стать функция, которую можно передать как параметр, и она вернет смещение подсчитанное все тем же offsetof.

Что если передать смещение в шаблон в виде функции?

Следующий код сокращен. Имеет только геттер и никакой защиты.

template <typename OWNER,
          typename VALUE,
          VALUE (OWNER::*GETTER)() const,
          ptrdiff_t (*OFFSET)()>
class Getter final
{
 public:
  operator VALUE() const
  {
    auto owner = reinterpret_cast<const OWNER *>(this - OFFSET());
    return (owner->*GETTER)();
  }
};

struct vec4
{
  inline static constexpr ptrdiff_t offsetof_yx() { return offsetof(vec4, yx); }

  vec2 get_yx() const { return vec2(y, x); }

 public:
  float x{};
  float y{};
  float z{};
  float w{};

  Getter<vec4, vec2, &vec4::get_yx, &offsetof_yx> yx;
};

Единственным способом передать смещение внутрь шаблона который я нашел, это передать его в виде функции. Благодаря линковки удается решить проблему курицы и яйца, когда нужно в тип поля передать смещение этого же поля в контейнере который еще не определен полностью.

Выводы

Решение рабочее, но требует уделять много внимания защищенности. Не все меры защиты можно инкапсулировать, так что не может применяться свободно в клиентском коде. Этот подход можно применять внутри математических библиотек имитирующих математику из других языков. Но требует покрытие тестами. Также могут возникнуть крайние случаи которые я не смог увидеть.

Резюмируя

Zero-cost property в C++ возможны?

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

Стоит ли использовать эту технику?

Не стоит. Используйте классические геттеры и сеттеры. Прирост удобства незначителен относительно рисков допустить ошибку и снижает читаемость вашего кода.

Зачем существует эта статья?

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


P.S. Было так забавно писать все это на тему "как убрать две пустые скобки"

 a.yx() -> a.yx

Автор: TheDestr

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js