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

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

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

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

В статье используются грязные хаки и работоспособность не гарантирована стандартом.

Использую стандарт С++11 то есть C++17 если делать мероприятие чуть более легальным с std::launder

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

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 = std::launder(reinterpret_cast<const OWNER *>(this)); // <- Ключевой элемент
    return (owner->*GETTER)();
  }

  const OWNER &operator=(const VALUE &value)
  {
    auto owner = std::launder(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++ (ISO/IEC JTC1 SC22 WG21 N 4860) но можно например найти в стандарте GCC 6.18 Structures with No Members 

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

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

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

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

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

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

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

  • Достоверно проверить смещение можно только если контейнер имеет стандартный 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++17 -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++17 -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:\Windows\TEMP\compiler-explorer-compiler2024829-3304-1p0myd7.2grh\example.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

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

Считаю что условный 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 = std::launder(reinterpret_cast<const vec3 *>(this - offsetof(vec3, yx)));
      return self->get_a();
    }

    inline const vec3 &operator=(const vec2 &v)
    {
      auto self = std::launder(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 = std::launder(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

Благодарю коментаторов: artptr86 sha512sum

Комментарии (7)


  1. sergegers
    02.10.2024 20:08

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

    Это есть в стандарте, чтобы не испортилась адресная арифметика


    1. artptr86
      02.10.2024 20:08
      +1

      C++20 (ISO/IEC JTC1 SC22 WG21 N 4860)

      6.7.2 Object model

      1. An object has nonzero size if it
        — is not a potentially-overlapping subobject, or
        — is not of class type, or
        — is of a class type with virtual member functions or virtual base classes, or
        — has subobjects of nonzero size or bit-fields of nonzero length.
        Otherwise, if the object is a base class subobject of a standard-layout class type with no non-static data members, it has zero size. Otherwise, the circumstances under which the object has zero size are implementation-defined. Unless it is a bit-field (11.4.9), an object with nonzero size shall occupy one or more bytes of storage, including every byte that is occupied in full or in part by any of its subobjects. An object of trivially copyable or standard-layout type (6.8) shall occupy contiguous bytes of storage.


      1. sergegers
        02.10.2024 20:08

        Много лишней информации и не противоречит тому, что я написал


        1. artptr86
          02.10.2024 20:08
          +3

          А почему мой комментарий должен противоречить?


  1. sha512sum
    02.10.2024 20:08
    +11

    Данная статья содержит фрагменты кода с UB.

    1. Тут:
    operator VALUE() const { auto owner = reinterpret_cast<const OWNER *>(this); // <- Ключевой элемент return (owner->*GETTER)(); }
    reintepret_cast в данном контексте не меняет то, на какой объект указывает указатель(https://eel.is/c++draft/expr.reinterpret.cast#7). И в связи с этим, указатель продолжает указывтаь на Property. Чтение же объекта из указателя другого типа нелегально(Ссылка на стандарт: https://eel.is/c++draft/basic.lval#11). Исправить ситуацию в данном случае, можно было бы при помощи std::launder(Конечно же с исправлением остальных UB). В коде в целом везде где reinterpret_cast используется, он используется неправильно.


    2. Вариант с union, а не с атрибутом на [[no_unique_address]], так-же будет с UB. В связи с тем, что https://eel.is/c++draft/basic.life#7.2 требует, чтобы указатель указывал на объект для вызова нестатических методов. https://eel.is/c++draft/class.mem#general-5.sentence-2 Тут же указывается, что любая функция в классе - нестатический метод. https://eel.is/c++draft/basic.life#1 Согласно же этому, в union на момент использования, нет объекта.


    3. auto owner = reinterpret_cast<const OWNER *>(this - OFFSET());
    Тут так-же UB. Нет никаких правил в стандарте, которые бы разрешали бы тут pointer arithmetic, а по умолчанию она нелегальна.

    По итогу остаётся то, что единственное, что тут можно сделать, это атрибут [[no_unique_address]], реинтерпрет каст + std::launder. Так и это достаточно ограниченно будет, но сделать через if constexpr другое, не такое Zero Overhead поведение для msvc в целом можно.

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

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


  1. ImagineTables
    02.10.2024 20:08

    К слову, компиляторы Microsoft имеют способ описать property

    Как и clang с -fdeclspec.


    1. NeoCode
      02.10.2024 20:08

      А в gcc свойства насколько я понимаю так и не добавили? Хотя казалось бы там больше всего языковых расширений...