Темы



Концепция умного указателя 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 loading= с объектами разного размера» />std: unique_ptr с объектами разного размера

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

Аллокации в куче нужны для «больших объектов» (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

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

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

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

sp::static_ptr<IEngine loading= с объектами разного размера (буфер на 32 байта)» />sp: static_ptr с объектами разного размера (буфер на 32 байта)

Реализация: насколько сложен move?

Здесь я опишу пошаговую реализацию и множество подводных камней, которые могут всплыть.

Сам класс static_ptr я решил поместить внутри namespace sp (от static pointer).

Реализации контейнеров, умных указателей, и прочих вещей — это вообще одни из самых сложных программ на C++, потому что приходится задумываться над вещами, про которые в нормальных проектах не подозревают.

Допустим, мы хотим вызвать move-конструктор из одного участка памяти в другой. Можно написать так:

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