[Из песочницы] Как вместить property в один байт?
Вступление
Многие языки программирования имеют такой инструмент, как properties: C#, Python, Kotlin, Ruby и т.д. Этот инструмент позволяет вызывать какой-то метод класса при обращении к его «полю». В стандартном C++ их нет если хотите узнать, как можно их реализовать, прошу под кат.
Некоторые моменты…
- Я не Bjarne Stroustrup, поэтому могу ошибаться насчёт внутреннего устройства чего-либо, буду рад поправкам в комментариях.
- В этой статье показаны только идеи реализации Property. Для разных ситуаций подходят разные варианты, в конце статьи нет готовой библиотеки или заголовочного файла.
Методы
Всем известна реализация с помощью методов get_x
и set_x
.
class Complicated {
private:
int x;
public:
int get_x() {
std::cout << "x getter called" << std::endl;
return x;
}
int set_x(int v) {
x = v;
std::cout << "x setter called" << std::endl;
return x;
}
};
Она является самым очевидным решением, к тому же в рантайме не хранятся никакие «лишние» переменные (кроме поля x
, оно называется backing field, необязательно и не лишнее), самый главный её минус в том, что выражения, которые логически значат c.x = (c.x * c.x) - 2 * (c.x = c.x / (4 + c.x))
(конкретно в данном примере смысла мало), превращаются в c.set_x((c.get_x() * c.get_x()) - 2 * c.set_x(c.get_x() / (4 + c.get_x())))
. А я хочу, чтобы выражение в коде выглядело так же, как у меня в голове.
Вы можете как угодно кастомизировать код: добавить где-то inline
или поменять возвращаемый тип на void
, убрать backing field или один из методов, в конце концов приписать const
и volatile
, — это не влияет на рассуждения. Множество вызовов функций для такого простого арифметического выражения выглядит по крайней мере некрасиво.
Операторы
В C++, как и в большинстве других языков, можно перегрузить операторы (+, -, *, /, %, …). Но чтобы это сделать, нужен объект-обёртка.
class Complicated {
public:
class __property {
private:
int val;
public:
operator int() { // get
std::cout << "x getter called" << std::endl;
return val;
}
int operator=(int v) { // set
val = v;
std::cout << "x setter called" << std::endl;
return val;
}
} x;
};
Теперь c.x = (c.x * c.x) - 2 * (c.x = c.x / (4 + c.x))
выглядит по-человечески. А вдруг нам требуется иметь доступ к другим полям Complicated
?
class Complicated {
public:
Axis a;
class __property {
public:
operator int() { // get
std::cout << "x getter called" << std::endl;
return a.get_x(); // ??? никакого 'a' внутри __property нет
}
int operator=(int v) { // set
std::cout << "x setter called" << std::endl;
return a.set_x(v); // ??? никакого 'a' внутри __property нет
}
} x;
};
Так как операторы перегружаются внутри Complicated::__property
, то и this там имеет тип Complicated::__property const*
. Другими словами, в выражении c.x = 2
объекту x
вообще ничего не известно о объекте c
. Тем не менее, если реализация геттера и сеттера не требует ничего от Complicated
, этот вариант вполне логичен.
- Axis — некоторый объект, осуществляющий, например, физику на оси.
- Можно сделать
__property
анонимным классом. - Если property без backing field, объект x будет занимать один байт, а не 0. Тут достаточно понятно описано, почему. Из-за выравнивания эта цифра может увеличиваться. Так что если вам очень важен каждый байт памяти, вам остаётся использовать только первый вариант: отдельный класс
__property
необходим для перегрузки операторов.
Сохранение this
Предыдущий пример требует доступа к Complicated
. Так же сама терминология property подразумевает, что get_x
и set_x
будут определены как методы Complicated
. А чтобы вызвать метод внутри Complicated
, __property
должен знать this оттуда.
Этот способ тоже достаточно очевидный, но не самый лучший. Просто храним указатели на всё, что нравится: метод-геттер, метод-сеттер, this внешнего класса и так далее. Я видел такие реализации и не понимаю, почему люди считают их приемлемыми. Размер property возрастает до 32 (64) битов, а то и больше, причём указатель получается на память, которая очень близко к this у property (почти сам на себя указывает, ниже будет объяснено, почему). Вот мой минималистичный вариант, он весьма уместно использует ссылку вместо указателя.
class Complicated {
private:
Axis a;
public:
int get_x() {
std::cout << "x getter called" << std::endl;
return a.get_x();
}
int set_x(int v) {
std::cout << "x setter called" << std::endl;
return a.set_x(v);
}
class __property {
private:
Complicated& self;
public:
__property(Complicated& s): self(s) {}
inline operator int() { // get
return self.get_x();
}
inline int operator=(int v) { // set
return self.set_x(v);
}
} x;
Complicated(): x { *this } {}
};
Этот подход можно назвать улучшенным вариантом первого: он полностью содержит Методы. Как видно, функционал определен в Complicated
, а __property
приобрело более менее абстрактный вид. Тем не менее, эта реализация мне не нравится из-за её цены в рантайме и необходимости вписывать в конструктор инициализацию property.
- Можно убрать inline, я его добавил потому, что, если компилятор вставит вызовы функции вместо операторов, я достигну своей главной цели — нативности.
- Почему-то я подозреваю, что property в C# (а то и во всём .NET) и/или Qt так и реализованы, по крайней мере скриптовые языки точно не скупятся на огромное количество указателей под капотом.
Получение this
Поле x
не должно существовать вне объекта Complicated
, а если класс-обёртка будет ещё и анонимным, то каждый x
почти гарантированно будет находиться в каком-то объекте Complicated
. Значит, можно относительно безопасно получить this из внешнего класса, вычтя из указателя на x
его отступ относительно начала Complicated
.
class Complicated {
private:
Axis a;
public:
int get_x() { // get
std::cout << "x getter called" << std::endl;
return a.get_x();
}
int set_x(int v) { // set
std::cout << "x setter called" << std::endl;
return a.set_x(v);
}
class __property {
private:
inline Complicated* get_this() {
return reinterpret_cast(reinterpret_cast(this) - offsetof(Complicated, x));
}
public:
inline operator int() {
return get_this()->get_x();
}
inline int operator=(int v) {
return get_this()->set_x(v);
}
} x;
};
Тут __property
тоже имеет абстрактный характер, следовательно можно будет его обобщить при надобности. Единственный недостаток — offsetof для сложных (не-POD, отсюда и Complicated) типов неприменим, gcc об этом предупреждает (в отличие от MSVC, который, видимо, вставляет в offsetof что нужно).
Поэтому придётся обернуть __property в простую структуру (PropertyHandler
), к которой offsetof применим, а потом привести this из PropertyHandler
к this из Complicated
с помощью static_cast (если Complicated
унаследуется от PropertyHandler
), который правильно посчитает все отступы.
Конечный вариант
template struct PropertyHandler {
struct Property {
private:
inline const T* get_this() const {
return static_cast(
reinterpret_cast(
reinterpret_cast(this) - offsetof(PropertyHandler, x)
)
);
}
inline T* get_this() {
return static_cast(
reinterpret_cast(
reinterpret_cast(this) - offsetof(PropertyHandler, x)
)
);
}
public:
inline int operator=(int v) {
return get_this()->set_x(v);
}
inline operator int() {
return get_this()->get_x();
}
} x;
};
class Complicated: PropertyHandler {
private:
Axis a;
public:
int get_x() {
std::cout << "x getter called" << std::endl;
return a.get_x();
}
int set_x(int v) {
std::cout << "x setter called" << std::endl;
return a.set_x(v);
}
};
Как видно, мне уже пришлось завести шаблон, чтобы можно было выполнить static_cast, однако обобщить определение Property для очень удобного использования не получается: только совсем костыльнообразно с макросами (имя property не поддаётся кастомизации в Complicated).
Такая реализация без backing field занимает всего один неиспользуемый байт (без учёта выравнивания)! А работает так же, как реализация с указателями. С backing field она не займёт ни единого «лишнего» байта, что ещё нужно для счастья?
Главный минус этого подхода — кривой исходный код, но я считаю, что тот синтаксический сахар, который он приносит стоит затраченных на него усилий.
- Богатство C++ позволяет переопределить по-своему другие операторы (присваивания, бинарных операций, и т.д.), поэтому такую property в отдельных случаях имеет смысл реализовывать под себя, ведь какое-то ключевое слово или два амперсанда (не забывайте перегружать операторы для rvalue, если используются большие объекты) в правильном месте способны значительно улучшить скорость программы. Также открываются новые горизонты отладки…
- Можно наслаждаться лучшими модификаторами доступа, чем в C#! Если хорошо подумать и поставить правильные ключевые слова в нужные места, конечно.
- Property могут сделать какие-то api приятнее, например,
size()
у контейнеров в STL может таким образом превратиться вsize
(конкретно в этом примере имеет смысл брать одну из первых реализаций, а не последнюю — самую навороченную), или те жеbegin
сend
'ом…