Zero-cost Property в С++

от автора

Расскажу об одном решении которое имеет больше смысла в качестве упражнения а не практической пользы. Постановка задачи звучит так: Хочу получить в 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


ссылка на оригинал статьи https://habr.com/ru/articles/847754/


Комментарии

Добавить комментарий

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