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

ab6a7efadf86bdf166a756b9db449614.png

В C++ есть несколько «умных указателей» — std::unique_ptr, std::shared_ptr, std::weak_ptr. Также есть более нестандартные умные указатели, например в boost1: intrusive_ptr, local_shared_ptr.

В этой статье мы рассмотрим новый вид умного указателя, который можно назвать static_ptr. Больше всего он похож на std::unique_ptr без динамической аллокации памяти.

std: unique_ptr

std::unique_ptr2 это обертка над простым указателем T*. Наверное, все программисты на C++ использовали этот класс.

Одна из самых популярных причин использования этого указателя — динамический полиморфизм.

Если мы на этапе компиляции не «знаем», объект какого именно класса будем создавать в некой точке выполнения, то из-за этого не знаем значение, на которое надо увеличивать указатель стека, а значит такой объект на стеке создавать нельзя — можем создать его только в куче.

Пусть у нас есть виртуальный класс IEngine и его наследники TSteamEngine, TRocketEngine, TEtherEngine. Объект «какого-то наследника IEngine, известного в run-time» это чаще всего именно std::unique_ptr, в таком случае память для объекта аллоцируется в куче.

std::unique_ptr<IEngine> с объектами разного размера» />std: unique_ptr<IEngine> с объектами разного размера</p>

<h2>Аллокация маленьких объектов</h2>

<p>Аллокации в куче нужны для «больших объектов» (<code>std::vector</code> с кучей элементов, etc.), в то время как стек лучше подходит для «маленьких объектов».</p>

<p>В Linux для получения размера стека для процесса можно запустить: </p>

<pre><code>ulimit -s</code></pre>

<p>по умолчанию покажется невысокое число, на моих системах это 8192 KiB = 8 MiB. В то время как память из кучи можно хавать гигабайтами.</p>

<p>Аллокация большого количества маленьких объектов фрагментирует память и негативно отражается на кэше. Для устранения таких проблем может использоваться <strong>memory pool</strong> — есть крутая статья на эту тему3, рекомендую ее прочитать.</p>

<h2>Объекты на стеке</h2>

<p>Как можно сделать объект, аналогичный <code>std::unique_ptr</code>, но полностью стековый? </p>

<p>В C++ есть <code>std::aligned_storage</code>4, который дает сырую память на стеке, и в этой памяти при помощи конструкции placement new5 можно создать объект нужного класса <code>T</code>. Надо проконтролировать, чтобы памяти было не меньше чем <code>sizeof(T)</code>.</p>

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

<h2>sp: static_ptr<T></h2>

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

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

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

<p><img src=template struct move_constructer { static void call(T* lhs, T* rhs) { new (lhs) T(std::move(*rhs)); } }; // call `move_constructer::call(dst, src);`

Однако что делать, если класс T не имеет move-конструктора?

Есть шанс, что T имеет move-оператор присваивания, тогда надо использовать его. Если и его нет, то надо «сломать» компиляцию.

Чем новее стандарт C++, тем легче писать код для таких вещей. Получим такой код (скомпилируется в C++17):

template 
struct move_constructer {
    static void call(T* lhs, T* rhs) {
        if constexpr (std::is_move_constructible_v) {
            new (lhs) T(std::move(*rhs));
        } else if constexpr (std::is_default_constructible_v && std::is_move_assignable_v) {
            new (lhs) T();
            *lhs = std::move(*rhs);
        } else {
            [](){ static_assert(flag, "move constructor disabled"); }();
        }
    }
};

(на 10 строке слом компиляции в виде static_assert происходит с хаком8)

Однако неплохо бы еще указывать noexcept-спецификатор, когда это возможно. В C++20 получаем такой код, настолько простой, насколько возможно в данный момент:

template 
struct move_constructer {
    static void call(T* lhs, T* rhs)
        noexcept (std::is_nothrow_move_constructible_v)
        requires (std::is_move_constructible_v)
    {
        new (lhs) T(std::move(*rhs));
    }

    static void call(T* lhs, T* rhs)
        noexcept (std::is_nothrow_default_constructible_v && std::is_nothrow_move_assignable_v)
        requires (!std::is_move_constructible_v && std::is_default_constructible_v && std::is_move_assignable_v)
    {
        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
void call_typed_func(void* dst, void* src) {
    Functor::call(static_cast(dst), static_cast(src));
}

template
void destruct_func(void* dst) {
    static_cast(dst)->~T();
}

И теперь мы можем для каждого типа T иметь свой экземпляр ops:

template
static constexpr ops ops_for{
    .move_construct_func = &call_typed_func>,
    .move_assign_func = &call_typed_func>,
    .destruct_func = &destruct_func,
};
using ops_ptr = const ops*;

static_ptr будет хранить внутри себя ссылку на ops_for, где T это класс объекта, который сейчас лежит в static_ptr.

Реализация: I like to move it, move it

Копировать static_ptr будет нельзя — можно только мувать в другой static_ptr. Выбор способа мува зависит от того, что за тип у объектов, которые лежат в этих двух static_ptr:

  1. Оба static_ptr пустые (dst_ops = src_ops = nullptr): ничего не делать.

  2. static_ptr содержат один и тот же тип (dst_ops = src_ops): делаем move assign и разрушаем объект в src.

  3. 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_t11:

static constexpr std::size_t align = alignof(std::max_align_t);

На моих системах это значение 16, но где-то могут быть нестандартные значения.

Кстати, память из кучи (из malloc) тоже выравнивается по максимально возможному alignment, автоматически.

Дефолтный размер буфера можно поставить в 16 байт или в sizeof(T) — что будет больше.

template
struct static_ptr_traits {
    static constexpr std::size_t buffer_size = std::max(static_cast(16), sizeof(T));
};

Понятно, что почти всегда это значение нужно будет переопределять на свою величину, чтобы помещались объекты всех классов-наследников. Желательно сделать это в виде макроса, чтобы было быстро писать. Можно сделать такой макрос для переопределения размера буфера в одном классе:

#define STATIC_PTR_BUFFER_SIZE(Tp, size)                   \
namespace sp {                                             \
    template<> struct static_ptr_traits {              \
        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 requires std::is_base_of_v \
    struct static_ptr_traits {                          \
        static constexpr std::size_t buffer_size = size;   \
    };                                                     \
}

// example:
STATIC_PTR_INHERITED_BUFFER_SIZE(IEngine, 1024)

Реализация: sp: static_ptr

Теперь можно привести реализацию самого класса. У него всего два поля — ссылка на ops и буфер для объекта:

template
requires(!std::is_void_v)
class static_ptr {
private:
    static constexpr std::size_t buffer_size = static_ptr_traits::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` 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 buf_;

    // ...

В первую очередь реализуем метод reset, который удаляет объект — этот метод часто используется:

    // destruct the underlying object
    void reset() noexcept(std::is_nothrow_destructible_v) {
        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) {
        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
    struct derived_class_check {
        static constexpr bool ok = sizeof(Derived) <= buffer_size && std::is_base_of_v;
    };

И надо объявить «друзьями» все инстанциации класса:

    // support static_ptr's conversions of different types
    template  friend class static_ptr;

Тогда два предыдущих метода можно переписать так:

    template
    static_ptr(static_ptr&& rhs)
        requires(derived_class_check::ok)
        : ops_{nullptr}
    {
        move_construct(&buf_, ops_, &rhs.buf_, rhs.ops_);
    }

    template
    static_ptr& operator=(static_ptr&& rhs)
        requires(derived_class_check::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
    Derived& emplace(Args&&... args)
        noexcept(std::is_nothrow_constructible_v)
        requires(derived_class_check::ok)
    {
        reset();
        Derived* derived = new (&buf_) Derived(std::forward(args)...);
        ops_ = &ops_for;
        return *derived;
    }

Методы-аксесоры сделаем такие же, как у std::unique_ptr:

    // accessors
    Base* get() noexcept {
        return ops_ ? reinterpret_cast(&buf_) : nullptr;
    }
    const Base* get() const noexcept {
        return ops_ ? reinterpret_cast(&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
static static_ptr make_static(Args&&... args) {
    static_ptr ptr;
    ptr.emplace(std::forward(args)...);
    return ptr;
}

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

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

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

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

Бенчмарк

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

Я рассмотрел два сценария, в каждом из них проверяется std::unique_ptr и sp::static_ptr:

  1. Создание умного указателя и вызов метода объекта.

  2. Итерирование по вектору из 128 умных указателей, у каждого вызывается метод.

В первом сценарии выигрыш у sp::static_ptr должен быть за счет отсутствия аллокации, во втором сценарии за счет локальности памяти. Хотя, конечно, понятно, что компиляторы очень умные и умеют хорошо оптимизировать «плохие» сценарии в зависимости от флагов оптимизации.

Запустим бенчмарк в сборке Debug:

***WARNING*** Library was built as DEBUG. Timings may be affected.
-------------------------------------------------------------------------------------------------
Benchmark                                                       Time             CPU   Iterations
-------------------------------------------------------------------------------------------------
BM_SingleSmartPointer>               207 ns          207 ns      3244590
BM_SingleSmartPointer>               39.1 ns         39.1 ns     17474886
BM_IteratingOverSmartPointer>       3368 ns         3367 ns       204196
BM_IteratingOverSmartPointer>        1716 ns         1716 ns       397344

В сборке Release:

-------------------------------------------------------------------------------------------------
Benchmark                                                       Time             CPU   Iterations
-------------------------------------------------------------------------------------------------
BM_SingleSmartPointer>              14.5 ns         14.5 ns     47421573
BM_SingleSmartPointer>               3.57 ns         3.57 ns    197401957
BM_IteratingOverSmartPointer>        198 ns          198 ns      3573888
BM_IteratingOverSmartPointer>         195 ns          195 ns      3627462

Таким образом, есть определенный выигрыш в перфомансе у sp::static_ptr, который представляет собой stack-only аналог std::unique_ptr.

Ссылки

  1. Boost.SmartPtr

  2. std: unique_ptr — cppreference.com

  3. C++ Memory Pool and Small Object Allocator | by Debby Nirwan

  4. std: aligned_storage — cppreference.com

  5. Placement new operator in C++ — GeeksforGeeks

  6. ceph — github.com

  7. ceph/static_ptr.h — github.com

  8. c++ — constexpr if and static_assert

  9. Objects and alignment — cppreference.com

  10. godbolt.com — выравнивание класса-наследника больше, чем у класса-предка

  11. std: max_align_t — cppreference.com

  12. Izaron/static_ptr — github.com

  13. Izaron/static_ptr, тест test_derives.cc — github.com

  14. google/benchmark — github.com

  15. Izaron/static_ptr, бенчмарк benchmark.cc — github.com

© Habrahabr.ru