Иногда в С++ не хватает каких-то фич, которые есть в других языках. Мне, например, не хватает preperties из C#:
В них можно описать функции set/get, при этом работать с ними через поле класса, как с переменной. Очень удобно, например, когда после выставления значения нужно так же сделать какую-то работу. Например, при установке трансформации в акторе, сразу же обновить его визуальную составляющую:
class Actor { public Matrix4x4 transform { set { _transform = value; UpdateVisual(); } get { return _transform; } } private UpdateVisual() { ... } } ... actor.transform = left.transform * righ.transform * up.transform;
В С++ это приходится делать через функции, что может быть громоздкои и менее читаемо:
actor.SetTransform(left.GetTransform() * right.GetTransform() * up.GetTransform());
Под катом я расскажу как можно в С++ обернуть set/get в переменную с минимальным размером 1 байт
Наивная реализация
Итак, для начала нам нужны собственно функции Set/Get(). Повторить синтаксис C#, к сожалению, не возможно, поэтому функции Set/Get() нужно писать прямо в теле класса. В целом это даже выглядит логично и лаконично.
Допустим, у нас есть поле transform, и мы хотим сделать для него property. Напишем для него SetTransform()/GetTransform()
struct Actor { // Matrix4x4 transform; // наша будущая property void SetTransform(const Matrix4x4& mtx) { _transform = mtx; UpdateVisual(); } Matrix4x4 GetTransform() const { return _transform; } private: Matrix4x4 _transform; };
Теперь мы хотим чтобы при работе с публичным полем transform вызывались SetTransform/GetTransform() при чтении и записи значения.
Для этого заведем шаблонный класс Property<> с пачкой методов operator, чтобы имитировать поведение установки и чтения значения, а так же математических операций.
Чтобы передать setter/getter, можем воспользоваться обычными std::function<>. Да, это ресурсозатратный способ, но для понимания базовой концепции воспольуемся им:
template<typename TYPE> class Property { std::function<void(TYPE)> _setter; // Функция установки значения std::function<TYPE()> _getter; // Функция получения значения public: using valueType = TYPE; // Конструктор из setter и getter функций Property(std::function<void(TYPE)> setter, std::function<TYPE()> getter):_setter(setter), _getter(getter) {} // Получение значения valueType Get() const { return _getter(); } // Установка значения void Set(const valueType& value) { _setter(const_cast<valueType&>(value)); } // Оператор приведения к значению, для получения значения operator valueType() const { return _getter(); } // Оператор присваивания, для установки значения Property& operator=(const valueType& value) { _setter(const_cast<valueType&>(value)); return *this; } // Оператор присваивания, для установки значения из другого свойства Property& operator=(const Property& value) { _setter(value.Get()); return *this; } // Оператор равенства, для сравнения значений template<typename V, typename X = typename std::enable_if<std::is_same<V, valueType>::value && SupportsEqualOperator<valueType>::value>::type> bool operator==(const V& value) const { return _getter() == value; } // Оператор неравенства, для сравнения значений template<typename V, typename X = typename std::enable_if<std::is_same<V, valueType>::value && SupportsEqualOperator<valueType>::value>::type> bool operator!=(const V& value) const { return _getter() != value; } // Оператор сложения, для сложения значений template<typename T, typename X = typename std::enable_if<SupportsPlus<valueType>::value && std::is_same<T, valueType>::value>::type> valueType operator+(const T& value) { return _getter() + value; } // Оператор вычитания, для вычитания значений template<typename T, typename X = typename std::enable_if<SupportsMinus<valueType>::value && std::is_same<T, valueType>::value>::type> valueType operator-(const T& value) { return _getter() - value; } // Оператор деления, для деления значений template<typename T, typename X = typename std::enable_if<SupportsDivide<valueType>::value && std::is_same<T, valueType>::value>::type> valueType operator/(const T& value) { return _getter() / value; } // Оператор умножения, для умножения значений template<typename T, typename X = typename std::enable_if<SupportsMultiply<valueType>::value && std::is_same<T, valueType>::value>::type> valueType operator*(const T& value) { return _getter() * value; } // Оператор сложения с присваиванием, для сложения значений с присваиванием template<typename T, typename X = typename std::enable_if<SupportsPlus<valueType>::value && std::is_same<T, valueType>::type> Property& operator+=(const T& value) { _setter(_getter() + value); return *this; } // Оператор вычитания с присваиванием, для вычитания значений с присваиванием template<typename T, typename X = typename std::enable_if<SupportsMinus<valueType>::value && std::is_same<T, valueType>::value>::type> Property& operator-=(const T& value) { _setter(_getter() - value); return *this; } // Оператор деления с присваиванием, для деления значений с присваиванием template<typename T, typename X = typename std::enable_if<SupportsDivide<valueType>::value && std::is_same<T, valueType>::value>::type> Property& operator/=(const T& value) { _setter(_getter() / value); return *this; } // Оператор умножения с присваиванием, для умножения значений с присваиванием template<typename T, typename X = typename std::enable_if<SupportsMultiply<valueType>::value && std::is_same<T, valueType>::value>::type> Property& operator*=(const T& value) { _setter(_getter() * value); return *this; } };
Здесь стоит обратить внимание на шаблонную магию в операторах математических операций. Она нужна чтобы не определять методы для типов, не поддерживающих конкретные математические операции, иначе будет ошибка компиляции.
template<typename T, typename X = typename std::enable_if<SupportsMultiply<valueType>::value && std::is_same<T, valueType>::value>::type>
В первой части мы указываем шаблон предполагаемой переменной, с которой хотим взаимодействовать, т.к. он может отличаться от типа property
Вторая часть - магия type traits, которая говорит возможна ли эта математическая переменная между TYPE и T.
std::enable_if через SFINAE "отключает" шаблонную функцию из класса, если не удовлетворяется условие
SupportsMultiply. В данном случае - если между типами невозможна математическая операция, то шаблонный метод перегрузки математической операции не генерируется
Реализация SupportsMultiply
template<class T, class = void_t<>> struct SupportsMinus : std::false_type {}; template<class T> struct SupportsMinus<T, void_t<decltype(std::declval<T>() * std::declval<T>())>> : std::true_type {};
Вернемся к нашему классу Actor, попробуем в нем определить property:
struct Actor { Property<Matrix4x4> transform = Property<Matrix4x4>([this](Matrix4x4 x) { SetTransform(x); }, [this]() { return GetTransform(); }); // А вот и наша property void SetTransform(const Matrix4x4& mtx) { _transform = mtx; UpdateVisual(); } Matrix4x4 GetTransform() const { return _transform; } private: Matrix4x4 _transform; };
Немножко громоздко, но уже выполняет свою функцию: transform выглядит как переменная, с ней можно работать в математических операциях (которые позволяет тип Matrix4x4).
Но вещь получается довольно тяжелая из-за использования std::function<>. Во-первых, у него немаленький размер (х2 в нашем случае), во-вторых куча накладных расходов на инициализацию и вызов. Очевидно, от std::function<> надо избавляться.
Оптимизированное решение
Так как мы работаем с функциями класса, то мы можем сократить накладные расходы хранив указатель на объект (this) и пару указателей на функции setter/getter.
template<typename CLASS, typename TYPE> class Property { CLASS* _owner; // Указатель на объект TYPE(CLASS::*_getter)(); // Указатель на функцию получения значения void(CLASS::*_setter)(const TYPE&); // Указатель на функцию установки значения public: using valueType = TYPE; // Конструктор из setter и getter функций Property(CLASS* owner, TYPE(CLASS::*getter)(), void(CLASS::*setter)(const TYPE&)):_owner(owner), _getter(getter), _setter(setter) {} // Получение значения valueType Get() const { return (_owner->*_getter)(); } // Установка значения void Set(const valueType& value) { (_owner->*_setter)(const_cast<valueType&>(value)); } // Оператор приведения к значению, для получения значения operator valueType() const { return (_owner->*_getter)(); } // Оператор присваивания, для установки значения Property& operator=(const valueType& value) { (_owner->*_setter)(const_cast<valueType&>(value)); return *this; } ... остальная реализация ... };
Уже лучше, но все еще храним целых три указателя. Если таких property в классе много, а самих объектов тысячи (напр. акторы в игровом движке), может быть критично по памяти.
Оптимизируем еще
disclaimer: спорное решние, но учитывая особенности может быть как неплохой компромисс
Чтобы не хранить указатели на объект и пару setter/getter, их можно вычислять почти полностью в compile time.
Чтобы получить указатель на объект, владеющий property, можно использовать offsetof(class, field) и вычитать его из this от roperty. Идея такая: раз мы знаем указатель на самого себя, и в compile time знаем оффсет от начала класса до property, можем вычислить адрес владеющего property объекта.
_propertiesClassType * GetThis() const { return reinterpret_cast<_propertiesClassType*>( const_cast<std::byte*>(reinterpret_cast<const std::byte*>(this)) - offsetof(_propertiesClassType, NAME)); }
А для хранения указателей на setter/getter можно воспользоваться специализацией класса property для конкретной пары setter/getter. Чтобы корректно с ней работать так или иначе нужно заворачивать это все в макрос.
У себя в проекте я выбрал подход, где в макросе определяется целый класс, со специализацией:
#define PROPERTIES(CLASSNAME) \ typedef CLASSNAME _propertiesClassType #define PROPERTY(TYPE, NAME, SETTER, GETTER) \ class NAME##_PROPERTY \ { \ _propertiesClassType* GetThis() const \ { \ return reinterpret_cast<_propertiesClassType*>( \ const_cast<std::byte*>(reinterpret_cast<const std::byte*>(this)) - offsetof(_propertiesClassType, NAME)); \ } \ \ public: \ typedef TYPE valueType; \ \ NAME##_PROPERTY() {} \ \ operator valueType() const { return GetThis()->GETTER(); } \ NAME##_PROPERTY& operator=(const valueType& value) { GetThis()->SETTER(const_cast<valueType&>(value)); return *this; } \ \ NAME##_PROPERTY& operator=(const NAME##_PROPERTY& value) { GetThis()->SETTER(value.Get()); return *this; } \ \ template<typename vt, typename X = typename std::enable_if<std::is_same<vt, valueType>::value && SupportsEqualOperator<valueType>::value>::type> \ bool operator==(const vt& value) const { return Math::Equals(GetThis()->GETTER(), value); } \ \ template<typename vt, typename X = typename std::enable_if<std::is_same<vt, valueType>::value && SupportsEqualOperator<valueType>::value>::type> \ bool operator!=(const vt& value) const { return !Math::Equals(GetThis()->GETTER(), value); } \ \ template<typename T, typename X = typename std::enable_if<o2::SupportsPlus<valueType>::value && std::is_same<T, valueType>::value>::type> \ valueType operator+(const T& value) { return GetThis()->GETTER() + value; } \ \ template<typename T, typename X = typename std::enable_if<o2::SupportsMinus<valueType>::value && std::is_same<T, valueType>::value>::type> \ valueType operator-(const T& value) { return GetThis()->GETTER() - value; } \ \ template<typename T, typename X = typename std::enable_if<o2::SupportsDivide<valueType>::value && std::is_same<T, valueType>::value>::type> \ valueType operator/(const T& value) { return GetThis()->GETTER() / value; } \ \ template<typename T, typename X = typename std::enable_if<o2::SupportsMultiply<valueType>::value && std::is_same<T, valueType>::value>::type> \ valueType operator*(const T& value) { return GetThis()->GETTER() * value; } \ \ template<typename T, typename X = typename std::enable_if<o2::SupportsPlus<valueType>::value && std::is_same<T, valueType>::value>::type> \ NAME##_PROPERTY& operator+=(const T& value) { auto _this = GetThis(); _this->SETTER(_this->GETTER() + value); return *this; } \ \ template<typename T, typename X = typename std::enable_if<o2::SupportsMinus<valueType>::value && std::is_same<T, valueType>::value>::type> \ NAME##_PROPERTY& operator-=(const T& value) { auto _this = GetThis(); _this->SETTER(_this->GETTER() - value); return *this; } \ \ template<typename T, typename X = typename std::enable_if<o2::SupportsDivide<valueType>::value && std::is_same<T, valueType>::value>::type> \ NAME##_PROPERTY& operator/=(const T& value) { auto _this = GetThis(); _this->SETTER(_this->GETTER() / value); return *this; } \ \ template<typename T, typename X = typename std::enable_if<o2::SupportsMultiply<valueType>::value && std::is_same<T, valueType>::value>::type> \ NAME##_PROPERTY& operator*=(const T& value) { auto _this = GetThis(); _this->SETTER(_this->GETTER() * value); return *this; } \ \ valueType Get() const { return GetThis()->GETTER(); } \ void Set(const valueType& value) { GetThis()->SETTER(const_cast<valueType&>(value)); } \ \ PropertyValueProxy<valueType, NAME##_PROPERTY> GetValueProxy() { return PropertyValueProxy<valueType, NAME##_PROPERTY>(this); } \ \ bool IsProperty() const { return true; } \ }; \ \ NAME##_PROPERTY NAME;
Но есть вариант с менее монструозным макросом, с вынесением функционала в шаблонный класс: godbolt example (спасибо ИИ за генерацию)
Pross & Cons
Самый большой плюс - это отсутствие полей внутри класса property. В C++ нулевого размера типа не может быть, минимум 1 байт. Это как раз таки удобно, ведь на сам property иногда нужно взять указатель.
Но есть и минус - на каждую property генерируется микро-класс, специализирующийся под конкретный класс и пару setter/getter. От этого "пухнет" бинарник, а в отладке прилично накладных ресурсов.
Однако даже в отладке эти накладные затраты почти незаметны, а современные компиляторы отлично оптимизируют бинарник, ведь эти property-классы отлично оптимизируются.
Хорош подход или плох, имхо, дело вкуса и потребностей. В своем проекте я использую его, учитывая его минусы. Лично мне нра��ится более лаконичный синтаксис, а так же я извлекаю некие профиты через рефлексию.
А было бы прикольно если бы фича попала в стандарт С++ с нормальным синтаксисом и оптимизациями...
Подписывайтесь на мой уютный неформальный telegram-канал про разработки игрового движка на С++: https://t.me/o2engine
