【问题标题】:Custom allocators as alternatives to vector of smart pointers?自定义分配器作为智能指针向量的替代品?
【发布时间】:2019-10-12 22:20:33
【问题描述】:

这个问题是关于拥有指针、使用指针、智能指针、向量和分配器。

我对代码架构的想法有点迷茫。此外,如果这个问题在某个地方已经有了答案,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&lt;thing&gt; 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&lt;thing&gt; m_thing 引用“消费者”中的“事物”,而不是当前的 thing* m_thing,但这对我来说似乎是最糟糕的方法,因为“事物”应该不拥有“消费者”并且使用共享指针我将创建共享所有权。

那么,分配器方法是一种好方法吗?如果是这样,怎么办?我必须自己实现分配器还是现有的分配器?

【问题讨论】:

  • 多个消费者使用相同的东西吗?因为如果不是,将所有权从载体转移到消费者不是更合适吗?
  • 你知道things的最大数量吗?如果是,则在向量上调用reserve,并且不会重新分配元素。
  • 是的,多个消费者可以使用同一个东西。这就是重点,所有权不得转移给消费者。
  • @MarekR 是的,这可能是一个选择。但它永远不会是一个干净的解决方案,因为一方面,您希望这个上限尽可能紧。如果您在极少数情况下需要更多,该怎么办?
  • “这里的缺点是“事物”之间的内存一致性丢失了。” - 为什么它很重要?

标签: c++ c++11 shared-ptr unique-ptr allocator


【解决方案1】:

[共享指针]对我来说似乎是最糟糕的方法,因为“事物”不应拥有“消费者”,而使用共享指针我将创建共享所有权。

那又怎样?也许代码的自文档化程度较低,但它会解决您的所有问题。 (顺便说一句,您使用“消费者”这个词来混淆事物,在传统的生产者/消费者范式中 会取得所有权。)

此外,在当前代码中返回原始指针对于所有权已经完全模棱两可。一般来说,如果可以的话,我会说最好避免使用原始指针(就像你不需要调用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
}

【讨论】:

  • 不,共享指针用于表达所有权。正如我已经明确指出的那样,我示例中的“消费者”不应获得“事物”的所有权。在他的精彩演讲中引用 Herb Sutter Back to the Basics! Essentials of Modern C++ Style:非拥有原始指针仍然很棒。
  • “避免原始指针”是一个神话。应该避免的是原始的拥有指针。然后也没有歧义,原始指针不拥有东西
【解决方案2】:

如果您能够将thing 视为值类型,请这样做。它简化了事情,您不需要智能指针来规避指针/引用失效问题。后者可以有不同的处理方式:

  • 如果在程序期间通过push_frontpush_back 插入新的thing 实例,请使用std::deque 而不是std::vector。然后,不会使该容器中的元素的指针或引用失效(但迭代器会失效 - 感谢@odyss-jii 指出这一点)。如果您担心自己严重依赖 std::vector 的完全连续内存布局的性能优势:创建基准和配置文件。
  • 如果在程序过程中在容器中间插入了新的thing实例,请考虑使用std::list。插入或删除容器元素时,不会使指针/迭代器/引用失效。 std::list 的迭代比 std::vector 慢得多,但请确保这是您的场景中的实际问题,然后再过分担心。

【讨论】:

  • 这些都是值得考虑的好点,谢谢! std::deque 中的内存是如何管理的?不也是某种链表还是连续存储内存?
  • 它将内存存储在连续的块中。这使它成为std::liststd::vector 之间的混合体。查看this thread 以获取有关std::deque 的更多信息。
  • @j00hi 两者兼而有之,它使用了一种块类型布局的链表。不利的一面是,在大多数实现中,块大小非常小并且不会增长 - 通常可以通过宏定义在一定程度上缓解小尺寸 - 但同样应该是分析而不是推测的结果。
  • std::deque 的行为实际上正是我在问题中询问自定义分配器时所寻找的。我将把这个问题留待更长时间,希望有人能指出我关于这种情况下的自定义分配器的更多信息,因为我已经在我的问题标题中询问了这些信息。否则,非常感谢。此答案及其 cmets 中的信息非常简洁且有用。
  • Nitpick,但实际上迭代器在 std::deque::push_backstd::deque::push_front 上是否无效,但不是对实际元素的引用?值得一提的是,这样就不会有人存储期望它在后面插入后仍然有效的迭代器。
【解决方案3】:

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;
}

这样您将提供不会重新分配项目的容器。它不计量 T 所做的事情。

【讨论】:

  • 投反对票:建议“自己动手”(当存在标准解决方案时)
  • 你的意思是std::list?它不像std::list
【解决方案4】:

这个问题没有唯一的正确答案,因为它在很大程度上取决于确切的访问模式和所需的性能特征。

话虽如此,这是我的建议:

继续按原样连续存储数据,但不要存储指向该数据的别名指针。相反,请考虑一种更安全的替代方法(这是一种经过验证的方法),您可以在使用它之前根据 ID 获取指针 - 作为旁注,在多线程应用程序中,您可以锁定尝试调整底层存储的大小,同时这么弱的参考生命。

因此,您的消费者将存储一个 ID,并根据需要从“存储”中获取指向数据的指针。这也使您可以控制所有“获取”,以便您可以跟踪它们、实施安全措施等。

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“弱指针”。

有多种实现方式。您可以使用开放寻址哈希表并使用 ID 作为键;如果您适当地平衡它,这将为您提供大约 O(1) 的访问时间。

另一种选择(最佳情况 O(1),最坏情况 O(N))是使用“参考”结构,具有 32 位 ID 和 32 位索引(因此大小与 64-位指针)——索引用作一种缓存。当您获取时,您首先尝试索引,如果索引中的元素具有预期的 ID,您就完成了。否则,您会得到“缓存未命中”,并且您对存储进行线性扫描以根据 ID 查找元素,然后将最后已知的索引值存储在您的参考中。

【讨论】:

  • 通过 ID 访问“事物”会带来一些新问题:如果给定的 ID 被另一个事物重用(如 ABA 问题),如果消费者需要 RAII 但“事物”不是'没有到破坏时间,那个按ID获取方法的性能重要吗?
  • @IgorG 是的,但这些默认设置是经过实战验证的。对于 ID,通过 std::atomic 使用不断增加的序列 + 互锁增量(锁定 xadd)。至于thing 的所有权:使用此解决方案,消费者可能永远不会拥有thing,商店拥有它。因此,不允许任何消费者假设thing 将在任何时候存在,必须始终对其进行检查。这也是保证内存安全的原因,但是你必须围绕这个原则进行设计。 fetch-by-id 的性能可能很重要。如果正确完成,例如。开放寻址哈希表,会非常快。
  • 我喜欢这个答案(但不明白为什么它被否决了),因为它提供了一种不同但可行的解决问题的方法。这种方法不正是 OpenGL 或 Vulkan 等 API 在引用资源时所做的吗?我的意思是,我不知道他们如何在内部处理它,但我可以想象他们会像这个答案中提出的那样处理它,因为它们总是返回指向纹理或 GPU 缓冲区等资源的句柄的连续数字。这些数字也称为资源的“名称”。
猜你喜欢
  • 2019-01-20
  • 2019-03-09
  • 2017-04-29
  • 2016-05-28
  • 1970-01-01
  • 1970-01-01
  • 2012-03-23
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多