Иногда в С++ не хватает каких-то фич, которые есть в других языках. Мне, например, не хватает 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