>
>
>
Концепция умного указателя static_ptr&l…

Гость
Статей: 23

Концепция умного указателя static_ptr<T> в C++

В 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>'

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).

Таким образом, за счёт микроскопического оверхеда (несколько незанятых байтов) на стеке можно создавать объекты произвольного класса.

'sp::static_ptr<T>'

Имея намерение сделать stack-only аналог std::unique_ptr<T>, я решил поискать уже готовые реализации, потому что идея, казалось бы, лежит на поверхности.

Придумав такие слова как stack_ptrstatic_ptr и пр. и поискав их на GitHub, я нашёл вменяемую реализацию в проекте ceph, в ceph/static_ptr.h и увидел там некоторые полезные идеи. Впрочем, в проекте этот класс используется мало где, и в реализации есть ряд существенных промахов.

Реализация может выглядеть так – есть сам буфер для объекта (в виде std::aligned_storage); и какие-то данные, которые позволяют правильно рулить объектом: например, вызывать деструктор именно того типа, который сейчас содержится в static_ptr.

Рисунок 2. sp::static_ptr<IEngine> с объектами разного размера (буфер на 32 байта)

Реализация: насколько сложен '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 происходит с хаком)

Однако неплохо бы еще указывать 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;
  }
}

Реализация: размер буфера и выравнивание

Сейчас надо решить, какой будет дефолтный размер буфера и какое будет выравнивание потому, что 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)

Реализация: '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;
}

Реализация доступна на GitHub!

Как пользоваться sp::static_ptr<T>?

Это просто! Я сделал юнит-тесты, которые показывают лайфтайм объектов, живущих внутри static_ptr.

В тесте можно посмотреть типичные сценарии работы со static_ptr и то, что происходит с объектами внутри них.

Бенчмарк

Для бенчмарков я использовал библиотеку google/benchmark. Код для этого есть в репозитории.

Я рассмотрел два сценария, в каждом из них проверяется 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
--------------------------------------------------------------------------------
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.