Линейный распределитель C++ и границы контейнера

Я реализую собственный графический интерфейс/игровой движок и решил внедрить гетерогенную систему управления ресурсами. Как следствие этого, пользовательские распределители очень желательны в ряде ситуаций.

Один из распределителей, который я определил для использования, — это простой линейный распределитель. Насколько я понимаю, базовый линейный распределитель реализован примерно так (для краткости без надлежащей обработки ошибок):

struct linear_allocator
{
    using value_type = std::byte;
    using pointer = value_type*;
    using size_type = std::size_t;

    explicit linear_allocator(size_type n) noexcept
    {
        data = static_cast<pointer>(::operator new(n, std::nothrow_t{}));
    }

    ~linear_allocator() noexcept
    {
        ::operator delete(data, std::nothrow_t{});
    }

    [[nodiscard]] auto allocate(size_type n) noexcept -> pointer
    {
        auto temp = position;
        position += n;
        return temp;
    }

    auto deallocate(pointer p, size_type n) noexcept -> void
    {
        position = data;
    }

private:
    pointer data = nullptr;
    pointer position = nullptr;
};

Моя проблема с этой реализацией заключается в ее состоянии. Поскольку это чрезвычайно важный для производительности компонент моего кода, я обеспокоен тем, что это не оптимально и, вполне возможно, подвержено ошибкам.

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

Лучшее решение, на мой взгляд, состоит в том, чтобы контейнер владел информацией о состоянии и оставил выделение контейнеру. Таким образом, контейнер будет содержать всю необходимую информацию, а бухгалтерский учет не будет дублироваться. Кроме того, такое же поведение линейного распределителя может быть достигнуто, если выделение выполняется только в конструкторе контейнеров, а освобождение — только в деструкторе контейнеров. Но если это правда, кажется, что распределителю больше не нужен конструктор или деструктор, и я мог бы также использовать std::allocator. И если это так... какова цель линейного распределителя?

Кажется, я уговорил себя думать, что линейный аллокатор — это антипаттерн. На самом деле я ищу какой-то плотно упакованный гетерогенный контейнер, а линейные распределители кажутся странным слиянием концепций распределителя и контейнера. Пользовательский вектор, в котором хранится бухгалтерская книга (для доступа к индексу, если куча содержит объекты разных размеров) и куча для данных, кажется, больше соответствует тому, что требуется.

Может кто-нибудь объяснить, где должны быть границы между распределителями и контейнерами (особенно в контексте стандартной библиотеки)? Я предполагаю, что в моем обосновании есть ошибка.

Редактировать: основываясь на приведенном ниже предложении Юджина, я переписал свою схему выделения/контейнера следующим образом (обратите внимание, здесь отсутствует конструктор копирования/перемещения, назначение и т. д., которые я только что удалил на время быть... но должно быть реализовано должным образом в какой-то момент). Основной вывод из предоставленного ответа заключается в том, что конструктор и деструкторы command_vector отвечают за распределение, а класс linear_allocator теперь не имеет состояния и прост. Код примерно выглядит следующим образом (я понимаю, что могу и, вероятно, должен вызвать alloc.allocate() один раз, а не выделять здесь 3 отдельных фрагмента):

класс контейнера command_vector

template<typename key_t, typename alloc_t = linear_allocator<std::byte>>
struct command_vector
{
    using key_type = key_t;
    using value_type = std::byte;
    using pointer = value_type*;
    using size_type = std::size_t;
    using difference_type = std::ptrdiff_t;
    using allocator = alloc_t;
    using iterator = command_packet*;
    using const_iterator = const iterator;
    using reverse_iterator = std::reverse_iterator<iterator>;
    using const_reverse_iterator = std::reverse_iterator<const_iterator>;

    explicit command_vector() noexcept :
        alloc{},
        keys{ alloc.allocate(packet_count) },
        packets{ alloc.allocate(packet_count) },
        packet_pos{ packets },
        heap{ alloc.allocate(heap_size) },
        heap_pos{ heap }
    {
        
    }

    explicit command_vector(size_type max_packets, size_type max_heap) noexcept :
        alloc{},
        keys{ alloc.allocate(max_packets) },
        packets{ alloc.allocate(max_packets) },
        packet_pos{ packets },
        heap{ alloc.allocate(max_heap) },
        heap_pos{ heap }
    {

    }

    constexpr command_vector(key_type* keys, pointer packets, pointer heap) noexcept requires std::is_same_v<alloc_t, null_allocator<value_type>> :
        alloc{},
        keys{ keys },
        packets{ packets },
        packet_pos{ packets },
        heap{ heap },
        heap_pos{ heap }
    {
        
    }

    constexpr command_vector(const command_vector&) noexcept = delete;
    constexpr command_vector(command_vector&&) noexcept = delete;
    constexpr auto operator=(const command_vector&) noexcept -> command_vector& = delete;
    constexpr auto operator=(command_vector&&) noexcept -> command_vector& = delete;

    ~command_vector() noexcept
    {
        if constexpr (!std::is_same_v<allocator, null_allocator<value_type>>)
        {
            alloc.deallocate(packets);
            alloc.deallocate(heap);
        }
    }

    template<typename command_t>
    constexpr auto push_back(command_t&& command) noexcept -> void
    {
        *heap_pos   = command;
        *packet_pos = make_packet(std::forward<command_t>(command));

        heap_pos   += sizeof(std::decay_t<command_t>);
        ++packet_pos;
    }

    constexpr auto pop_back() noexcept -> void
    {
        --packet_pos;
        heap_pos = static_cast<pointer>(packet_pos->command);
    }

    constexpr auto clear() noexcept -> void
    {
        packet_pos = packets;
        heap_pos = heap;
    }

    constexpr auto size() noexcept -> difference_type
    {
        return packet_pos - packets;
    }

    constexpr auto begin()   -> iterator               { return packets;    }
    constexpr auto end()     -> iterator               { return packet_pos; }
    constexpr auto cbegin()  -> const_iterator         { return begin();    }
    constexpr auto cend()    -> const_iterator         { return end();      }
    constexpr auto rbegin()  -> reverse_iterator       { return end();      }
    constexpr auto rend()    -> reverse_iterator       { return begin();    }
    constexpr auto crbegin() -> const_reverse_iterator { return cend();     }
    constexpr auto crend()   -> const_reverse_iterator { return cbegin();   }

private:

    allocator alloc{};
    key_type* keys{};
    command_packet* packets{};
    command_packet* packet_pos{};
    pointer heap{};
    pointer heap_pos{};
};

класс null_allocator

template<typename val_t>
struct null_allocator
{
    using value_type = val_t;
    using pointer = value_type*;
    using size_type = std::size_t;

    [[nodiscard]] constexpr auto allocate(size_type n) noexcept -> pointer
    {
        return nullptr;
    }

    constexpr auto deallocate(pointer p, size_type n) noexcept -> void
    {
        
    }
};

класс linear_allocator

template<typename val_t>
struct linear_allocator
{
    using value_type = val_t;
    using pointer = value_type*;
    using size_type = std::size_t;

    [[nodiscard]] auto allocate(size_type n) noexcept -> pointer
    {
        return static_cast<pointer>(::operator new(n, std::nothrow_t{}));
    }

    auto deallocate(pointer p, size_type n) noexcept -> void
    {
        ::operator delete(p, std::nothrow_t{});
    }
};

person Christopher Mauer    schedule 17.12.2020    source источник
comment
Распределители стандартных библиотек, вероятно, выполняют системный вызов для каждого выделения памяти, что составляет порядка 10 000 тактов. Ваш базовый линейный распределитель, вероятно, будет компилироваться в 1 инструкцию, то есть порядка одного такта. Несколько дополнительных слов памяти, необходимых для реализации вашего распределителя, вероятно, являются достойным компромиссом, если только вы не работаете в системе с очень ограниченным объемом памяти.   -  person user2407038    schedule 17.12.2020
comment
Не удивлюсь, если то, что вы говорите, правда. Но разве ::operator new не является распределителем, выполняющим системные вызовы? Конечно, я сделал это noexcept и в целом удалил много обработки/проверки ошибок, которые, вероятно, выполняет std::allocator, но удаление состояния объекта более или менее превращает это (функционально говоря) обратно в std::allocator, правильно?   -  person Christopher Mauer    schedule 17.12.2020
comment
@ user2407038 Я не согласен. Библиотека времени выполнения C++ делает мало системных вызовов, предварительно выделяя память большими кусками. Авторы библиотек не дураки.   -  person Eugene    schedule 17.12.2020


Ответы (1)


пожалуйста, объясните, где должны быть границы между распределителями и контейнерами, которые должны быть

Вы не должны начинать свой дизайн с распределителей — это детали реализации. Начните с контейнеров. Выберите контейнер с нужным вам интерфейсом, низкой алгоритмической сложностью ваших наиболее частых операций, гарантиями инвалидации итераторов, гарантиями безопасности исключений...

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

Изменить. Контейнеры содержат все состояния, связанные с вашей проблемой. Распределители обычно не имеют состояния — аллокаторы с состоянием даже не были стандартными до C++11. Распределители с отслеживанием состояния предназначены для выделения памяти из разных пулов памяти, и все состояние связано с пулами.

У вашего linear_allocator проблемы. Во-первых, deallocate() должен быть пустым — вся память должна быть освобождена в его dtor. Во-вторых, он не обрабатывает копирование - и это действительно сложная часть - я не уверен, что такое правильная семантика. Несмотря на то, что я опытный программист на C++, я бы не рискнул сам разрабатывать этот распределитель.

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

Если вам нужен контейнер разнородных данных, то это звучит правильно. Например, вам может понадобиться std::vector<std::unique_ptr<BaseClass>>. В качестве альтернативы вы можете рассмотреть std::vector<std::variant<..>>. И, конечно же, вам может понадобиться контейнер, отличный от std::vector<>.

person Eugene    schedule 17.12.2020
comment
Это вообще очень хороший совет. Я начал свой дизайн с самих данных. Я физически нарисовал, как должна выглядеть память, и смог найти некоторые тесты, сделанные другими. Я совершенно уверен, что мне нужно выделить этот конкретный кусок памяти заранее. Но я согласен, что если бы я начал с контейнера, я бы, вероятно, увидел бы проблему сразу. Поскольку это менеджер ресурсов в основе приложения, он будет содержать все необходимое для рисования элементов на экране и, следовательно, имеет жесткое ограничение по времени. - person Christopher Mauer; 17.12.2020
comment
Мне очень нравится этот ответ, но я не решаюсь отметить его как принятый по следующей причине: он не различает роль контейнера и роль распределителя с точки зрения состояния. Я также все еще не понимаю варианты использования линейного распределителя (то, что я считаю хорошо известным типом распределителя). Если распределитель свободен и не принадлежит контейнеру, то линейный шаблон распределителя будет иметь для меня больше смысла. Или, если память глобальная/статическая и используется несколькими контейнерами, это имеет смысл. Но это не то, как я обычно вижу, что это используется. - person Christopher Mauer; 17.12.2020
comment
Я добавил ответ на это в своем ответе, хотя он все еще может вас не удовлетворить ... Если вам нравится ответ, но вы чувствуете, что не можете его принять, вы можете проголосовать за него. - person Eugene; 17.12.2020
comment
На самом деле, я думаю, что ваше редактирование прояснило некоторые вещи. Я не знал, что в С++ не было распределителей с сохранением состояния до С++ 11 (распределители для меня новы (без каламбура)), поскольку мне не разрешено использовать динамическое распределение на наших встроенных платформах на работе. Для своей реализации я создал собственный векторный контейнер и сделал этот распределитель без состояния. Контейнер вызывает только выделение в конструкторе и освобождение в деструкторе. Состояние принадлежит контейнеру, как вы предложили. Я добавлю свою реализацию для тех, кто может найти ее полезной. - person Christopher Mauer; 17.12.2020
comment
Хотя я рад, что вы приняли ответ, я не смог понять, чем ваш собственный вектор и его распределитель лучше стандартных. Если вы хотите, чтобы кто-то посмотрел ваш исходный код, вы можете разместить его на сайте Stack Exchange — Code Review и объяснить его назначение. - person Eugene; 17.12.2020
comment
Честно говоря, это часть моего замешательства. Почти в каждом сценарии, который я вижу, std::allocator и std::pmr удовлетворяют необходимым требованиям. std::allocator можно использовать для создания арен, пулов и многого другого. И std::pmr может принимать источники памяти, не являющиеся владельцами. Так что в этом смысле, чем больше я копаюсь в этом, тем меньше мне кажутся пользовательские аллокаторы. Тем не менее, я думаю, вы дали мне достаточно ясности, чтобы я снова начал двигаться. В этом конкретном случае я, вероятно, перейду к модели, которая выделяет однократно, с несколькими контейнерами, которые используют эту область памяти. - person Christopher Mauer; 18.12.2020