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

в 19:18, , рубрики: c++, memory allocation, smart pointers, unique_ptr, Программирование, умный указатель
Концепция умного указателя static_ptr<T> в C++ - 1

В 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>, в таком случае память для объекта аллоцируется в куче.

Концепция умного указателя static ptr&lt;T&gt; в C++ с объектами разного размера" title="std::unique_ptr<IEngine> с объектами разного размера" width="262" height="302" data-src="https://habrastorage.org/getpro/habr/upload_files/964/c4a/a5d/964c4aa5d32725dc0e2aca93548b4348.png"/>
std::unique_ptr<IEngine> с объектами разного размера

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

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

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

ulimit -s

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

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

Объекты на стеке

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

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

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

sp::static_ptr<T>

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

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

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

Концепция умного указателя static ptr&lt;T&gt; в C++ с объектами разного размера (буфер на 32 байта)" title="sp::static_ptr<IEngine> с объектами разного размера (буфер на 32 байта)" width="387" height="203" data-src="https://habrastorage.org/getpro/habr/upload_files/28c/9af/42d/28c9af42d324c26c4e839d1a322bf6ac.png"/>
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 происходит с хаком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:

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

Ссылки

  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

Автор: Евгений

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js