Пользовательские распределители как альтернатива вектору интеллектуальных указателей?

Этот вопрос касается владения указателями, использования указателей, интеллектуальных указателей, векторов и распределителей.

Я немного потерялся в своих мыслях об архитектуре кода. Кроме того, если на этот вопрос где-то уже есть ответ, 1. извините, но я пока не нашел удовлетворительного ответа и 2. пожалуйста, укажите мне на него.

Моя проблема заключается в следующем:

У меня есть несколько «вещей», хранящихся в векторе, и несколько «потребителей» этих «вещей». Итак, моя первая попытка была такой:

std::vector<thing> i_am_the_owner_of_things;
thing* get_thing_for_consumer() {
    // some thing-selection logic
    return &i_am_the_owner_of_things[5]; // 5 is just an example
}

...

// somewhere else in the code:
class consumer {
    consumer() {
       m_thing = get_thing_for_consumer();
    }

    thing* m_thing;
};

В моем приложении это было бы безопасно, потому что «вещи» в любом случае переживут «потребителей». Однако во время выполнения можно добавить больше «вещей», и это может стать проблемой, потому что, если std::vector<thing> i_am_the_owner_of_things; перераспределяется, все указатели thing* m_thing становятся недействительными.

Исправлением этого сценария было бы хранение уникальных указателей на «вещи» вместо «вещей» напрямую, т.е. следующим образом:

std::vector<std::unique_ptr<thing>> i_am_the_owner_of_things;
thing* get_thing_for_consumer() {
    // some thing-selection logic
    return i_am_the_owner_of_things[5].get(); // 5 is just an example
}

...

// somewhere else in the code:
class consumer {
    consumer() {
       m_thing = get_thing_for_consumer();
    }

    thing* m_thing;
};

Недостатком здесь является то, что связь памяти между «вещами» теряется. Можно ли как-то восстановить эту когерентность памяти с помощью пользовательских распределителей? Я думаю о чем-то вроде распределителя, который всегда будет выделять память, например, для 10 элементов за раз, и при необходимости добавляет больше фрагментов памяти размером с 10 элементов.

Пример:
изначально:
v = ☐☐☐☐☐☐☐☐☐☐
больше элементов:
v = ☐☐☐☐☐☐☐☐☐☐ ? ☐☐☐☐☐☐☐☐☐☐
и еще раз:
v = ☐☐☐☐☐☐☐☐☐☐ ? ☐☐☐☐☐☐☐☐☐☐ ? ☐☐☐☐☐☐☐☐☐☐

Используя такой распределитель, мне даже не пришлось бы использовать std::unique_ptrs «вещей», потому что во время перераспределения std::vector адреса памяти уже существующих элементов не изменились бы.

В качестве альтернативы я могу думать только о ссылке на «вещь» в «потребителе» через std::shared_ptr<thing> m_thing, в отличие от текущего thing* m_thing, но это кажется мне худшим подходом, потому что «вещь» не должна владеть «потребителем» и с общими указателями я бы создал совместное владение.

Итак, хорош ли подход с аллокатором? И если да, то как это можно сделать? Должен ли я реализовать распределитель самостоятельно или он уже существует?

Используют ли несколько потребителей одно и то же? Потому что если нет, то не было бы более уместно передать право собственности от вектора к потребителю?

Mike van Dyke 27.05.2019 12:43

Вы знаете максимальное количество things впереди? Если да, то вызов reserve по вектору и перераспределения элементов не будет.

Marek R 27.05.2019 12:43

Да, несколько потребителей могут использовать одну и ту же вещь. В том-то и дело, что право собственности не должно переходить к потребителю.

j00hi 27.05.2019 12:44

Я сомневаюсь, что можно дать вам достойный отзыв, не имея понятия, что такое thing и как он себя ведет.

Marek R 27.05.2019 12:45

@MarekR Да, возможно, это вариант. Но это никогда не может быть чистым решением, потому что, с одной стороны, вы хотите, чтобы эта верхняя граница была как можно более жесткой. А что, если вам в какой-то редкой ситуации понадобится больше?

j00hi 27.05.2019 12:46

@MarekR «Вещь» живет дольше, чем «потребитель», и «вещей» может быть произвольное количество. И независимо от того, как часто перераспределяется вектор-владелец, указатели m_thing должны оставаться действительными.

j00hi 27.05.2019 12:48

Насколько большой thing? Принимает ли он обратные вызовы? Или это ведет себя как структурный тип? Общаются ли несколько потребителей с помощью thing (изменяют ли потребители thing)? Содержит ли он другие указатели?

Marek R 27.05.2019 12:48

@MarekR Вы можете предположить худшее для thing: несколько потребителей могут изменить его, и он может даже содержать другие указатели.

j00hi 27.05.2019 12:53

Можете ли вы пойти на другой подход, например: do_something_on_thing(functor, consumer)? Это вызвало бы функцию потребителя непосредственно для вещи в векторе вместо того, чтобы назначать вещь потребителю.

Mike van Dyke 27.05.2019 12:58

Если нескольким потребителям необходимо получить доступ к одному и тому же thing, разумным подходом будет использование вектора каких-либо указателей. Выделите каждый thing отдельно и передайте указатель потребителям. Указатели могут быть простыми указателями или, лучше, std::shared_ptr<>, потому что вы фактически разделяете владение имеют: вы не должны удалять thing, пока жив один из его потребителей.

cmaster - reinstate monica 27.05.2019 13:02

@MikevanDyke Это интересный момент, но consumer нужно сохранить указатель на thing, чтобы позже проверить, нужно ли ему обновлять себя на основе изменений, которые произошли с thing. (Я хотел опустить эту информацию, потому что вопрос должен быть по существу и не перегружен лишними подробностями.)

j00hi 27.05.2019 13:07

Добавляются или удаляются ли вещи только на концах владельца и никогда не добавляются и не удаляются из середины? Если это так, вы можете использовать std::deque.

Galik 27.05.2019 13:13

@ j00hi, можно ли сказать, что ваша цель похожа на шаблон локатора сервисов?

Igor G 27.05.2019 13:17
«Недостатком здесь является то, что связь памяти между «вещами» теряется». — почему это важно?
Igor G 27.05.2019 13:20

@IgorG Когерентность памяти важна (или может быть) важна, когда есть сотни вещей, и обновление должно выполняться для всех этих сотен вещей. Их размещение в одной строке (строках) кэша может значительно повысить производительность.

j00hi 27.05.2019 18:56
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
14
15
836
4
Перейти к ответу Данный вопрос помечен как решенный

Ответы 4

Ответ принят как подходящий

Если вы можете рассматривать thing как тип значения, сделайте это. Это упрощает работу, вам не нужен интеллектуальный указатель, чтобы обойти проблему недействительности указателя/ссылки. Последнее можно решить по-разному:

  • Если новые экземпляры thing вставляются через push_front и push_back во время работы программы, используйте std::deque вместо std::vector. Затем никакие указатели или ссылки на элементы в этом контейнере не становятся недействительными (хотя итераторы недействительны — спасибо @odyss-jii за указание на это). Если вы опасаетесь, что сильно полагаетесь на преимущество в производительности от полностью непрерывного расположения памяти std::vector: создайте тест и профиль.
  • Если новые экземпляры thing вставляются в середину контейнера во время программы, рассмотрите возможность использования std::list. Никакие указатели/итераторы/ссылки не становятся недействительными при вставке или удалении элементов контейнера. Итерация по std::list намного медленнее, чем по std::vector, но убедитесь, что это реальная проблема в вашем сценарии, прежде чем слишком беспокоиться об этом.

Это несколько хороших моментов для рассмотрения, спасибо! Как память управляется в std::deque? Разве это не какой-то связанный список или он хранит память непрерывно?

j00hi 27.05.2019 12:51

Он хранит память в непрерывных кусках. Это делает его своего рода гибридом между std::list и std::vector. Посмотрите эта тема для получения дополнительной информации о std::deque.

lubgr 27.05.2019 12:54

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

darune 27.05.2019 12:55

Такое поведение std::deque на самом деле именно то, что я искал, когда спрашивал о пользовательских распределителях в своем вопросе. Я оставлю вопрос открытым еще немного, надеясь, что кто-нибудь может указать мне дополнительную информацию о пользовательских распределителях для этого случая, потому что я просил об этом в заголовке моего вопроса. В противном случае большое спасибо. Информация в этом ответе и его комментариях очень краткая и полезная.

j00hi 27.05.2019 13:03

@ j00hi вы также можете проверить «круговой буфер» (не в стандартном формате) - в зависимости от потребностей.

darune 27.05.2019 13:10

Придирка, но разве не так, что итераторы на самом деле недействительны для std::deque::push_back и std::deque::push_front, но не ссылки на фактические элементы? Стоит упомянуть, чтобы кто-то не хранил итератор, ожидая, что он останется действительным после вставки сзади.

odyss-jii 27.05.2019 14:36

@odyss-jii Вы правы, спасибо за отличный совет. Я поправлю ответ.

lubgr 27.05.2019 14:38

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

Сказав это, вот моя рекомендация:

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

Таким образом, ваш потребитель будет хранить идентификатор и получать указатель на данные из «магазина» по запросу. Это также дает вам контроль над всеми «извлечениями», чтобы вы могли отслеживать их, применять меры безопасности и т. д.

void consumer::foo() {
    thing *t = m_thing_store.get(m_thing_id);
    if (t) {
        // do something with t
    }
}

Или более продвинутая альтернатива для синхронизации в многопоточном сценарии:

void consumer::foo() {
    reference<thing> t = m_thing_store.get(m_thing_id);
    if (!t.empty()) {
        // do something with t
    }
}

Где reference будет неким потокобезопасным «слабым указателем» RAII.

Есть несколько способов реализовать это. Вы можете использовать хеш-таблицу с открытой адресацией и использовать идентификатор в качестве ключа; это даст вам время доступа примерно O (1), если вы правильно его сбалансируете.

Другой альтернативой (в лучшем случае O (1), в худшем случае O (N)) является использование «ссылочной» структуры с 32-битным идентификатором и 32-битным индексом (такой же размер, как 64-битный указатель) -- индекс служит своего рода кешем. Когда вы выбираете, вы сначала пробуете индекс, если элемент в индексе имеет ожидаемый идентификатор, вы сделали. В противном случае вы получаете «промах кеша» и выполняете линейное сканирование хранилища, чтобы найти элемент на основе идентификатора, а затем сохраняете последнее известное значение индекса в своей ссылке.

Доступ к «вещи» по идентификатору приносит некоторые новые проблемы: что, если данный идентификатор повторно используется другой вещью (как в проблеме ABA), что, если потребителю нужен RAII, но «вещь» не наступает время уничтожения, производительность этого метода выборки по идентификатору важна?

Igor G 27.05.2019 13:15

@IgorG правда, но для них есть достойные проверенные в боях значения по умолчанию. Для идентификатора используйте постоянно возрастающую последовательность + заблокированный приращение (lock xadd) через std::atomic. Что касается владения thing: с этим решением потребитель может никогда не владеть thing, им владеет магазин. Таким образом, ни один потребитель не может предполагать, что thing будет существовать в любое время, его всегда нужно проверять. Это также то, что гарантирует безопасность памяти, но вы должны проектировать на основе этого принципа. Производительность выборки по идентификатору, вероятно, будет важна. Если все сделано правильно, напр. хэш-таблица с открытой адресацией, это будет очень быстро.

odyss-jii 27.05.2019 13:21

Мне нравится этот ответ (и я не понимаю, почему за него проголосовали), потому что он предлагает другой, но жизнеспособный подход к проблеме. Разве это не именно тот подход, который делают API, такие как OpenGL или Vulkan, при обращении к ресурсам? Я имею в виду, что я не знаю, как они справляются с этим внутри, но я могу представить, как они справятся с этим, как предложено в этом ответе, поскольку они всегда возвращают последовательные числа для дескрипторов, которые указывают на такие ресурсы, как текстуры или буферы графического процессора. Эти номера также называются «именами» ресурса.

j00hi 27.05.2019 18:53

Лучшим подходом IMO было бы создание нового контейнера, который будет вести себя безопасно.

Плюсы:

  • изменение будет сделано на отдельном уровне абстракции
  • изменения в старом коде будут минимальными (просто замените std::vector новым контейнером).
  • это будет "чистый код" способ сделать это

Минусы:

  • может показаться, что есть еще немного работы

В другом ответе предлагается использовать std::list, который выполнит эту работу, но с большим количеством выделений и более медленным произвольным доступом. Так что IMO лучше составить собственный контейнер из пары std::vectors.

Таким образом, это может начать выглядеть примерно так (минимальный пример):

template<typename T>
class cluster_vector
{
public:
    static const constexpr cluster_size = 16;

    cluster_vector() {
       clusters.reserve(1024);
       add_cluster();
    }

    ...

    size_t size() const {
       if (clusters.empty()) return 0;
       return (clusters.size() - 1) * cluster_size + clusters.back().size();
    }

    T& operator[](size_t index) {
        thowIfIndexToBig(index);
        return clusters[index / cluster_size][index % cluster_size];
    }

    void push_back(T&& x) {
        if_last_is_full_add_cluster();
        clusters.back().push_back(std::forward<T>(x));
    }

private:
    void thowIfIndexToBig(size_t index) const {
        if (index >= size()) {
            throw std::out_of_range("cluster_vector out of range");
        }
    }

    void add_cluster() {
       clusters.push_back({});
       clusters.back().reserve(cluster_size);
    }

    void if_last_is_full_add_cluster() {
       if (clusters.back().size() == cluster_size) {
           add_cluster();
       }
    }

private:
    std::vector<std::vector<T>> clusters;
}

Таким образом, вы предоставите контейнер, который не будет перераспределять элементы. Он не измеряет то, что делает Т.

Downvote: предложение «свернуть самостоятельно» (когда существуют стандартные решения)

darune 27.05.2019 13:38

ты имеешь в виду std::list? Это не так std::list.

Marek R 27.05.2019 13:41

[A shared pointer] seems like the worst approach to me, because a "thing" shall not own a "consumer" and with shared pointers I would create shared ownership.

И что? Может быть, код немного менее самодокументирован, но он решит все ваши проблемы. (Кстати, вы путаете вещи, используя слово «потребитель», которое в традиционной парадигме производителя/потребителя было бы берет на себя ответственность.)

Кроме того, возврат необработанного указателя в вашем текущем коде уже полностью неоднозначен в отношении владения. В общем, я бы сказал, что хорошей практикой является по возможности избегать необработанных указателей (например, вам не нужно вызывать delete). Я бы вернул ссылку, если вы пойдете с unique_ptr

std::vector<std::unique_ptr<thing>> i_am_the_owner_of_things;
thing& get_thing_for_consumer() {
    // some thing-selection logic
    return *i_am_the_owner_of_things[5]; // 5 is just an example
}

Нет, общие указатели предназначены для выражения права собственности. И «потребитель» в моем примере НЕ должен получать право собственности на «вещь», как я ясно заявил. Цитируя Херба Саттера в его замечательном выступлении Вернуться к истокам! Основы современного стиля C++: Необработанные указатели, не являющиеся владельцами, по-прежнему хороши.

j00hi 28.05.2019 15:01

«избегайте необработанных указателей» - это миф. Следует избегать необработанных указателей владения. Тогда также нет двусмысленности, необработанные указатели не владеют вещами.

463035818_is_not_a_number 28.05.2019 20:07

Другие вопросы по теме