В C++ есть несколько "умных указателей" - std::unique_ptr
, std::shared_ptr
, std::weak_ptr
. Также есть более нестандартные умные указатели, например в boost1: intrusive_ptr
, local_shared_ptr
.
В этой статье мы рассмотрим новый вид умного указателя, который можно назвать static_ptr
. Больше всего он похож на std::unique_ptr
без динамической аллокации памяти.
std::unique_ptr<T>
std::unique_ptr<T>
2 это обертка над простым указателем T*
. Наверное, все программисты на C++ использовали этот класс.
Одна из самых популярных причин использования этого указателя - динамический полиморфизм.
Если мы на этапе компиляции не "знаем", объект какого именно класса будем создавать в некой точке выполнения, то из-за этого не знаем значение, на которое надо увеличивать указатель стека, а значит такой объект на стеке создавать нельзя - можем создать его только в куче.
Пусть у нас есть виртуальный класс IEngine
и его наследники TSteamEngine
, TRocketEngine
, TEtherEngine
. Объект "какого-то наследника IEngine
, известного в run-time" это чаще всего именно std::unique_ptr<IEngine>
, в таком случае память для объекта аллоцируется в куче.
Аллокация маленьких объектов
Аллокации в куче нужны для "больших объектов" (std::vector
с кучей элементов, etc.), в то время как стек лучше подходит для "маленьких объектов".
В Linux для получения размера стека для процесса можно запустить:
ulimit -s
по умолчанию покажется невысокое число, на моих системах это 8192 KiB = 8 MiB. В то время как память из кучи можно хавать гигабайтами.
Аллокация большого количества маленьких объектов фрагментирует память и негативно отражается на кэше. Для устранения таких проблем может использоваться memory pool - есть крутая статья на эту тему3, рекомендую ее прочитать.
Объекты на стеке
Как можно сделать объект, аналогичный std::unique_ptr
, но полностью стековый?
В C++ есть std::aligned_storage
4, который дает сырую память на стеке, и в этой памяти при помощи конструкции placement new5 можно создать объект нужного класса T
. Надо проконтролировать, чтобы памяти было не меньше чем sizeof(T)
.
Таким образом за счет микроскопического оверхеда (несколько незанятых байтов) на стеке можно создавать объекты произвольного класса.
sp::static_ptr<T>
Имея намерение сделать stack-only аналог std::unique_ptr<T>
, я решил поискать уже готовые реализации, потому что идея, казалось бы, лежит на поверхности.
Придумав такие слова как stack_ptr
, static_ptr
и пр., и поискав их на GitHub, я нашел вменяемую реализацию в проекте ceph6, в ceph/static_ptr.h
7 и увидел там некоторые полезные идеи. Впрочем, в проекте этот класс используется мало где, и в реализации есть ряд существенных промахов.
Реализация может выглядеть так - есть сам буфер для объекта (в виде std::aligned_storage
); и какие-то данные, которые позволяют правильно рулить объектом: например, вызывать деструктор именно того типа, который сейчас содержится в static_ptr
.
Реализация: насколько сложен move?
Здесь я опишу пошаговую реализацию и множество подводных камней, которые могут всплыть.
Сам класс static_ptr
я решил поместить внутри namespace sp
(от static pointer).
Реализации контейнеров, умных указателей, и прочих вещей - это вообще одни из самых сложных программ на C++, потому что приходится задумываться над вещами, про которые в нормальных проектах не подозревают.
Допустим, мы хотим вызвать move-конструктор из одного участка памяти в другой. Можно написать так:
template <typename T>
struct move_constructer {
static void call(T* lhs, T* rhs) {
new (lhs) T(std::move(*rhs));
}
};
// call `move_constructer<T>::call(dst, src);`
Однако что делать, если класс T
не имеет move-конструктора?
Есть шанс, что T
имеет move-оператор присваивания, тогда надо использовать его. Если и его нет, то надо "сломать" компиляцию.
Чем новее стандарт C++, тем легче писать код для таких вещей. Получим такой код (скомпилируется в C++17):
template <typename T>
struct move_constructer {
static void call(T* lhs, T* rhs) {
if constexpr (std::is_move_constructible_v<T>) {
new (lhs) T(std::move(*rhs));
} else if constexpr (std::is_default_constructible_v<T> && std::is_move_assignable_v<T>) {
new (lhs) T();
*lhs = std::move(*rhs);
} else {
[]<bool flag = false>(){ static_assert(flag, "move constructor disabled"); }();
}
}
};
(на 10 строке слом компиляции в виде static_assert
происходит с хаком8)
Однако неплохо бы еще указывать noexcept
-спецификатор, когда это возможно. В C++20 получаем такой код, настолько простой, насколько возможно в данный момент:
template <typename T>
struct move_constructer {
static void call(T* lhs, T* rhs)
noexcept (std::is_nothrow_move_constructible_v<T>)
requires (std::is_move_constructible_v<T>)
{
new (lhs) T(std::move(*rhs));
}
static void call(T* lhs, T* rhs)
noexcept (std::is_nothrow_default_constructible_v<T> && std::is_nothrow_move_assignable_v<T>)
requires (!std::is_move_constructible_v<T> && std::is_default_constructible_v<T> && std::is_move_assignable_v<T>)
{
new (lhs) T();
*lhs = std::move(*rhs);
}
};
Аналогичным образом с разбором кейсов можно сделать структуру move_assigner
. Можно было бы еще сделать copy_constructer
и copy_assigner
, но в нашей реализации они не нужны. В static_ptr
будут удалены copy constructor и copy assignment operator (как и в unique_ptr
).
Реализация: std::type_info на коленке
Хотя в static_ptr
может лежать любой объект, нам все равно нужно как-то "знать" о том, что за тип там лежит. Например, чтобы мы могли вызывать деструктор именно этого объекта, и делать прочие вещи.
После нескольких попыток я выработал такой вариант - нужна структура ops
:
struct ops {
using binary_func = void(*)(void* dst, void* src);
using unary_func = void(*)(void* dst);
binary_func move_construct_func;
binary_func move_assign_func;
unary_func destruct_func;
};
И пара вспомогательных функций для перевода void*
в T*
...
template<typename T, typename Functor>
void call_typed_func(void* dst, void* src) {
Functor::call(static_cast<T*>(dst), static_cast<T*>(src));
}
template<typename T>
void destruct_func(void* dst) {
static_cast<T*>(dst)->~T();
}
И теперь мы можем для каждого типа T
иметь свой экземпляр ops
:
template<typename T>
static constexpr ops ops_for{
.move_construct_func = &call_typed_func<T, move_constructer<T>>,
.move_assign_func = &call_typed_func<T, move_assigner<T>>,
.destruct_func = &destruct_func<T>,
};
using ops_ptr = const ops*;
static_ptr
будет хранить внутри себя ссылку на ops_for<T>
, где T
это класс объекта, который сейчас лежит в static_ptr
.
Реализация: I like to move it, move it
Копировать static_ptr
будет нельзя - можно только мувать в другой static_ptr
. Выбор способа мува зависит от того, что за тип у объектов, которые лежат в этих двух static_ptr
:
-
Оба
static_ptr
пустые (dst_ops = src_ops = nullptr
): ничего не делать. -
static_ptr
содержат один и тот же тип (dst_ops = src_ops
): делаем move assign и разрушаем объект вsrc
. -
static_ptr
содержат разные типы (dst_ops != src_ops
): разрушаем объект вdst
, делаем move construct, разрушаем объект вsrc
, делаем присваиваниеdst_ops = src_ops
.
Получится такой метод:
// moving objects using ops
static void move_construct(void* dst_buf, ops_ptr& dst_ops,
void* src_buf, ops_ptr& src_ops) {
if (!src_ops && !dst_ops) {
// both object are nullptr_t, do nothing
return;
} else if (src_ops == dst_ops) {
// objects have the same type, make move
(*src_ops->move_assign_func)(dst_buf, src_buf);
(*src_ops->destruct_func)(src_buf);
src_ops = nullptr;
} else {
// objects have different type
// delete the old object
if (dst_ops) {
(*dst_ops->destruct_func)(dst_buf);
dst_ops = nullptr;
}
// construct the new object
if (src_ops) {
(*src_ops->move_construct_func)(dst_buf, src_buf);
(*src_ops->destruct_func)(src_buf);
}
dst_ops = src_ops;
src_ops = nullptr;
}
}
Реализация: размер буфера и выравнивание
Сейчас надо решить, какой будет дефолтный размер буфера и какое будет выравнивание9, потому что std::aligned_storage
требует знать эти два значения.
Понятно, что выравнивание класса-наследника может превышать выравнивание класса-предка10. Поэтому выравнивание должно быть максимально возможным, которое только бывает. В этом нам поможет тип std::max_align_t
11:
static constexpr std::size_t align = alignof(std::max_align_t);
На моих системах это значение 16, но где-то могут быть нестандартные значения.
Кстати, память из кучи (из malloc
) тоже выравнивается по максимально возможному alignment, автоматически.
Дефолтный размер буфера можно поставить в 16 байт или в sizeof(T)
- что будет больше.
template<typename T>
struct static_ptr_traits {
static constexpr std::size_t buffer_size = std::max(static_cast<std::size_t>(16), sizeof(T));
};
Понятно, что почти всегда это значение нужно будет переопределять на свою величину, чтобы помещались объекты всех классов-наследников. Желательно сделать это в виде макроса, чтобы было быстро писать. Можно сделать такой макрос для переопределения размера буфера в одном классе:
#define STATIC_PTR_BUFFER_SIZE(Tp, size)
namespace sp {
template<> struct static_ptr_traits<Tp> {
static constexpr std::size_t buffer_size = size;
};
}
// example:
STATIC_PTR_BUFFER_SIZE(IEngine, 1024)
Однако этого недостаточно, чтобы выбранный размер "наследовался" всеми классами-наследниками нужного. Для этого можно сделать еще один макрос с использованием std::is_base
:
#define STATIC_PTR_INHERITED_BUFFER_SIZE(Tp, size)
namespace sp {
template<typename T> requires std::is_base_of_v<Tp, T>
struct static_ptr_traits<T> {
static constexpr std::size_t buffer_size = size;
};
}
// example:
STATIC_PTR_INHERITED_BUFFER_SIZE(IEngine, 1024)
Реализация: sp::static_ptr<T>
Теперь можно привести реализацию самого класса. У него всего два поля - ссылка на ops
и буфер для объекта:
template<typename Base>
requires(!std::is_void_v<Base>)
class static_ptr {
private:
static constexpr std::size_t buffer_size = static_ptr_traits<Base>::buffer_size;
static constexpr std::size_t align = alignof(std::max_align_t);
// Struct for calling object's operators
// equals to `nullptr` when `buf_` contains no object
// equals to `ops_for<T>` when `buf_` contains a `T` object
ops_ptr ops_;
// Storage for underlying `T` object
// this is mutable so that `operator*` and `get()` can
// be marked const
mutable std::aligned_storage_t<buffer_size, align> buf_;
// ...
В первую очередь реализуем метод reset
, который удаляет объект - этот метод часто используется:
// destruct the underlying object
void reset() noexcept(std::is_nothrow_destructible_v<Base>) {
if (ops_) {
(ops_->destruct_func)(&buf_);
ops_ = nullptr;
}
}
Реализуем базовые конструкторы по аналогии с std::unique_ptr
:
// operators, ctors, dtor
static_ptr() noexcept : ops_{nullptr} {}
static_ptr(std::nullptr_t) noexcept : ops_{nullptr} {}
static_ptr& operator=(std::nullptr_t) noexcept(std::is_nothrow_destructible_v<Base>) {
reset();
return *this;
}
Теперь можно реализовать move constructor и move assignment operator. Чтобы принимался тот же тип, надо сделать так:
static_ptr(static_ptr&& rhs) : ops_{nullptr} {
move_construct(&buf_, ops_, &rhs.buf_, rhs.ops_);
}
static_ptr& operator=(static_ptr&& rhs) {
move_construct(&buf_, ops_, &rhs.buf_, rhs.ops_);
return *this;
}
Однако лучше, если мы сможем принимать static_ptr
для других типов. Другой тип должен влезать в буфер и быть наследником текущего типа:
template<typename Derived>
struct derived_class_check {
static constexpr bool ok = sizeof(Derived) <= buffer_size && std::is_base_of_v<Base, Derived>;
};
И надо объявить "друзьями" все инстанциации класса:
// support static_ptr's conversions of different types
template <typename T> friend class static_ptr;
Тогда два предыдущих метода можно переписать так:
template<typename Derived = Base>
static_ptr(static_ptr<Derived>&& rhs)
requires(derived_class_check<Derived>::ok)
: ops_{nullptr}
{
move_construct(&buf_, ops_, &rhs.buf_, rhs.ops_);
}
template<typename Derived = Base>
static_ptr& operator=(static_ptr<Derived>&& rhs)
requires(derived_class_check<Derived>::ok)
{
move_construct(&buf_, ops_, &rhs.buf_, rhs.ops_);
return *this;
}
Копирование запрещено:
static_ptr(const static_ptr&) = delete;
static_ptr& operator=(const static_ptr&) = delete;
Деструктор разрушает объект в буфере:
~static_ptr() {
reset();
}
Для создания объекта в буфере сделаем метод emplace
. Старый объект удалится (если он есть), в буфере создастся новый, и обновится указатель на ops
.
// in-place (re)initialization
template<typename Derived = Base, typename ...Args>
Derived& emplace(Args&&... args)
noexcept(std::is_nothrow_constructible_v<Derived, Args...>)
requires(derived_class_check<Derived>::ok)
{
reset();
Derived* derived = new (&buf_) Derived(std::forward<Args>(args)...);
ops_ = &ops_for<Derived>;
return *derived;
}
Методы-аксесоры сделаем такие же, как у std::unique_ptr
:
// accessors
Base* get() noexcept {
return ops_ ? reinterpret_cast<Base*>(&buf_) : nullptr;
}
const Base* get() const noexcept {
return ops_ ? reinterpret_cast<const Base*>(&buf_) : nullptr;
}
Base& operator*() noexcept { return *get(); }
const Base& operator*() const noexcept { return *get(); }
Base* operator&() noexcept { return get(); }
const Base* operator&() const noexcept { return get(); }
Base* operator->() noexcept { return get(); }
const Base* operator->() const noexcept { return get(); }
operator bool() const noexcept { return ops_; }
По аналогии с std::make_unique
и std::make_shared
, сделаем метод sp::make_static
:
template<typename T, class ...Args>
static static_ptr<T> make_static(Args&&... args) {
static_ptr<T> ptr;
ptr.emplace(std::forward<Args>(args)...);
return ptr;
}
Реализация доступна на GitHub12!
Как пользоваться sp::static_ptr<T>?
Это просто! Я сделал юнит-тесты, которые показывают лайфтайм объектов, живущих внутри static_ptr
13.
В тесте можно посмотреть типичные сценарии работы со static_ptr
и то, что происходит с объектами внутри них.
Бенчмарк
Для бенчмарков я использовал библиотеку google/benchmark
14. Код для этого есть в репозитории15.
Я рассмотрел два сценария, в каждом из них проверяется std::unique_ptr
и sp::static_ptr
:
-
Создание умного указателя и вызов метода объекта.
-
Итерирование по вектору из 128 умных указателей, у каждого вызывается метод.
В первом сценарии выигрыш у sp::static_ptr
должен быть за счет отсутствия аллокации, во втором сценарии за счет локальности памяти. Хотя, конечно, понятно, что компиляторы очень умные и умеют хорошо оптимизировать "плохие" сценарии в зависимости от флагов оптимизации.
Запустим бенчмарк в сборке Debug:
***WARNING*** Library was built as DEBUG. Timings may be affected.
-------------------------------------------------------------------------------------------------
Benchmark Time CPU Iterations
-------------------------------------------------------------------------------------------------
BM_SingleSmartPointer<std::unique_ptr<IEngine>> 207 ns 207 ns 3244590
BM_SingleSmartPointer<sp::static_ptr<IEngine>> 39.1 ns 39.1 ns 17474886
BM_IteratingOverSmartPointer<std::unique_ptr<IEngine>> 3368 ns 3367 ns 204196
BM_IteratingOverSmartPointer<sp::static_ptr<IEngine>> 1716 ns 1716 ns 397344
В сборке Release:
-------------------------------------------------------------------------------------------------
Benchmark Time CPU Iterations
-------------------------------------------------------------------------------------------------
BM_SingleSmartPointer<std::unique_ptr<IEngine>> 14.5 ns 14.5 ns 47421573
BM_SingleSmartPointer<sp::static_ptr<IEngine>> 3.57 ns 3.57 ns 197401957
BM_IteratingOverSmartPointer<std::unique_ptr<IEngine>> 198 ns 198 ns 3573888
BM_IteratingOverSmartPointer<sp::static_ptr<IEngine>> 195 ns 195 ns 3627462
Таким образом, есть определенный выигрыш в перфомансе у sp::static_ptr
, который представляет собой stack-only аналог std::unique_ptr
.
Ссылки
-
C++ Memory Pool and Small Object Allocator | by Debby Nirwan
-
godbolt.com - выравнивание класса-наследника больше, чем у класса-предка
Автор: Евгений