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