Вебинар: Использование статических анализаторов кода при разработке безопасного ПО - 19.12
В C++ есть несколько "умных указателей" – 'std::unique_ptr', 'std::shared_ptr', 'std::weak_ptr'.
Также есть более нестандартные умные указатели, например в boost: intrusive_ptr, local_shared_ptr.
Мы опубликовали и перевели эту статью с разрешения правообладателя. Автор статьи – Евгений Шульгин, (email – izaronplatz@gmail.com). Оригинал опубликован на сайте Habr.
В этой статье мы рассмотрим новый вид умного указателя, который можно назвать static_ptr. Больше всего он похож на std::unique_ptr без динамической аллокации памяти.
std::unique_ptr<T> — это обёртка над простым указателем T*. Наверное, все программисты на C++ использовали этот класс.
Одна из самых популярных причин использования этого указателя – динамический полиморфизм.
Если мы на этапе компиляции не "знаем", объект какого именно класса будем создавать в некой точке выполнения, то из-за этого не знаем значение, на которое надо увеличивать указатель стека, а значит такой объект на стеке создавать нельзя – можем создать его только в куче.
Пусть у нас есть полиморфный класс IEngine и его наследники TSteamEngine, TRocketEngine, TEtherEngine. Объект "какого-то наследника IEngine, известного в runtime" – это чаще всего именно std::unique_ptr<IEngine>, в таком случае память для объекта аллоцируется в куче.
Рисунок 1. std::unique_ptr<IEngine> с объектами разного размера
Аллокации в куче нужны для "больших объектов" (std::vector с кучей элементов, etc.), в то время как стек лучше подходит для "маленьких объектов".
В Linux для получения размера стека для процесса можно запустить:
ulimit -s
По умолчанию покажется невысокое число, на моих системах это 8192 KiB = 8 MiB. В то время как память из кучи можно хавать гигабайтами.
Аллокация большого количества маленьких объектов фрагментирует память и негативно отражается на кэше процессора.
Как можно сделать объект, аналогичный std::unique_ptr, но полностью стековый?
В C++ есть std::aligned_storage, который даёт сырую память на стеке, и в этой памяти при помощи конструкции placement new можно создать объект нужного класса T. Надо проконтролировать, чтобы памяти было не меньше, чем sizeof(T).
Таким образом, за счёт микроскопического оверхеда (несколько незанятых байтов) на стеке можно создавать объекты произвольного класса.
Имея намерение сделать stack-only аналог std::unique_ptr<T>, я решил поискать уже готовые реализации, потому что идея, казалось бы, лежит на поверхности.
Придумав такие слова как stack_ptr, static_ptr и пр. и поискав их на GitHub, я нашёл вменяемую реализацию в проекте ceph, в ceph/static_ptr.h и увидел там некоторые полезные идеи. Впрочем, в проекте этот класс используется мало где, и в реализации есть ряд существенных промахов.
Реализация может выглядеть так – есть сам буфер для объекта (в виде std::aligned_storage); и какие-то данные, которые позволяют правильно рулить объектом: например, вызывать деструктор именно того типа, который сейчас содержится в static_ptr.
Рисунок 2. sp::static_ptr<IEngine> с объектами разного размера (буфер на 32 байта)
Здесь я опишу пошаговую реализацию и множество подводных камней, которые могут всплыть.
Сам класс 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 происходит с хаком)
Однако неплохо бы еще указывать 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).
Хотя в 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.
Копировать static_ptr будет нельзя – можно только мувать в другой static_ptr. Выбор способа мува зависит от того, что за тип у объектов, которые лежат в этих двух static_ptr:
Получится такой метод:
// 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;
}
}
Сейчас надо решить, какой будет дефолтный размер буфера и какое будет выравнивание потому, что std::aligned_storage требует знать эти два значения.
Понятно, что выравнивание класса-наследника может превышать выравнивание класса-предка. Поэтому выравнивание должно быть максимально возможным, которое только бывает. В этом нам поможет тип std::max_align_t:
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)
Теперь можно привести реализацию самого класса. У него всего два поля – ссылка на 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;
}
Реализация доступна на GitHub!
Это просто! Я сделал юнит-тесты, которые показывают лайфтайм объектов, живущих внутри static_ptr.
В тесте можно посмотреть типичные сценарии работы со static_ptr и то, что происходит с объектами внутри них.
Для бенчмарков я использовал библиотеку google/benchmark. Код для этого есть в репозитории.
Я рассмотрел два сценария, в каждом из них проверяется std::unique_ptr и sp::static_ptr:
В первом сценарии выигрыш у sp::static_ptr должен быть за счёт отсутствия аллокации, во втором сценарии за счёт локальности памяти. Хотя, конечно, понятно, что компиляторы очень умные и умеют хорошо оптимизировать "плохие" сценарии в зависимости от флагов оптимизации.
Запустим бенчмарк в сборке Debug:
***WARNING*** Library was built as DEBUG. Timings may be affected.
--------------------------------------------------------------------------------
Benchmark Time CPU Iterations
--------------------------------------------------------------------------------
SingleUniquePointer 207 ns 207 ns 3244590
SingleStaticPointer 39.1 ns 39.1 ns 17474886
IteratingOverUniquePointers 3368 ns 3367 ns 204196
IteratingOverStaticPointers 1716 ns 1716 ns 397344
--------------------------------------------------------------------------------
В сборке Release:
--------------------------------------------------------------------------------
Benchmark Time CPU Iterations
--------------------------------------------------------------------------------
SingleUniquePointer 14.5 ns 14.5 ns 47421573
SingleStaticPointer 3.57 ns 3.57 ns 197401957
IteratingOverUniquePointers 198 ns 198 ns 3573888
IteratingOverStaticPointers 195 ns 195 ns 3627462
--------------------------------------------------------------------------------
Таким образом, есть определенный выигрыш в перфомансе у sp::static_ptr, который представляет собой stack-only аналог std::unique_ptr.
0