Расскажу об одном решении которое имеет больше смысла в качестве упражнения, а не практической пользы. Постановка задачи звучит так: Хочу получить в 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 смещение относительно контейнера через функцию, но об этом позже.
Безопасность
Итак. Чтобы это работало более менее безопасно нужно выполнить несколько условий. Что уже не безопасно.
Property всегда должен лежать как поле в самом начале памяти контейнера.
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
Комментарии (7)
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, можно было сделать специализацию, дабы не писать руками возвращаемые типы при создании такового.
ImagineTables
02.10.2024 20:08К слову, компиляторы Microsoft имеют способ описать property
Как и clang с
-fdeclspec
.NeoCode
02.10.2024 20:08А в gcc свойства насколько я понимаю так и не добавили? Хотя казалось бы там больше всего языковых расширений...
sergegers
Это есть в стандарте, чтобы не испортилась адресная арифметика
artptr86
C++20 (ISO/IEC JTC1 SC22 WG21 N 4860)
sergegers
Много лишней информации и не противоречит тому, что я написал
artptr86
А почему мой комментарий должен противоречить?