【问题标题】:How to handle object destruction in a custom container?如何处理自定义容器中的对象销毁?
【发布时间】:2022-01-14 22:27:33
【问题描述】:

我正在编写一个自定义 Stack 容器,它将其元素存储在一个固定大小的数组中:

template<typename T, uint32 TCapacity>
class Stack {
    // Member functions omitted
    T mData[TCapacity];
    uint32 mSize;
};

当一个元素从堆栈中弹出时,我可以减小它的大小。但是,我认为如果项目从堆栈中弹出,您还希望在对象上调用析构函数。

所以,我可以在弹出对象时手动调用析构函数,如下所示:

void Pop() { 
    assert(mSize > 0);
    mSize--;
    mData[mSize].~T();
}

但是,当 Stack 对象本身被破坏时,这不会导致为 mData 中的每个对象再次调用析构函数,从而有效地“双重破坏”某些元素吗?双重破坏所有类型可能不安全,所以这似乎不是一个好主意。

我想另一种方法是构造一个新对象来覆盖以前的对象,但这似乎有点效率低下:

void Pop() { 
    assert(mSize > 0);
    mSize--;
    mData[mSize] = T();
}

我唯一能想到的另一件事是让数据数组只是一个字节数组(无符号字符),然后处理在该原始内存中构造/破坏对象的额外复杂性。哪个看起来不太理想,但也许才是真正的解决方案?

有没有人知道解决这个问题的好方法?我想像 std::vector 这样的内置容器也必须处理这个问题(当然,在这种情况下,数据内存是从堆中分配的)。

【问题讨论】:

  • 您缺少的是placement new。它将对象构造成预先存在的存储而不是分配。像std::vector&lt;T&gt; 这样的容器实际上并不拥有T[] 数组。他们使用分配器来获取存储空间,在其上执行放置new 以插入元素并显式调用析构函数,就像您建议稍后删除它们一样。您可以使用std::allocator&lt;T&gt; 轻松获取和释放存储空间。
  • 好的,有道理 - 所以这里避免“双重删除”的关键是没有 T 类型的数组。这会阻止每个元素的析构函数在超出范围时被调用. (到目前为止)似乎可行的方法是定义一个固定大小的字节数组unsigned char mData[TCapacity * sizeof(T)];,然后当我需要将其视为 T 数组时,我可以使用 reinterpret_cast:reinterpret_cast&lt;T*&gt;(mData)[index] = instanceOfT
  • @kromenak 基本上,是的。但是,您应该使用 std::aligned_storage 而不是原始数组。
  • @RemyLebeau 感谢您的注意 - 我忽略了对齐问题,但我绝对可以看到这是一个问题。内存缓冲区应针对特定类型 T 对齐。

标签: c++ memory stack destructor


【解决方案1】:

如 cmets 中所述,关键是没有 T 类型的数组。你想要一个像optional&lt;T&gt; 这样的数组,即一个可以保存T 或什么都不保存的类型。但是在这种情况下,容器知道填充了哪些项目,因此optional&lt;T&gt; 内部知道它是否持有T 是不必要的。

例如,下面的类可选类型应该可以工作:

template <typename T>
union storage
{
    char no_value_ = {};
    T value_;

    constexpr void assign(T&& obj)
        noexcept(std::is_nothrow_move_constructible_v<T>)
    {
        this->value_ = std::move(obj);
    }

    constexpr void assign(const T& obj)
        noexcept(std::is_nothrow_copy_constructible_v<T>)
    {
        this->value_ = obj;
    }

    constexpr void reset() noexcept
    {
        this->value_.T::~T();
        this->no_value_ = {};
    }
};

但是这种类型本身很难用,因为它不知道自己是否持有值。

所以,在容器中使用时,一定要特别小心:

template <typename T, std::size_t Capacity>
class stack
{
public:
    constexpr stack() noexcept = default;

    constexpr stack(const stack& other) noexcept
        requires std::is_copy_constructible_v<storage<T>>
        = default;

    constexpr stack(const stack& other)
        noexcept(std::is_nothrow_copy_constructible_v<T>)
        : size_{other.size_}
    {
        for (std::size_t i = 0; i < size_; ++i)
            data_[i].assign(other.data_[i].value_);
    }

    constexpr stack(stack&& other) noexcept
        requires std::is_move_constructible_v<storage<T>>
        = default;

    constexpr stack(stack&& other)
        noexcept(std::is_nothrow_move_constructible_v<T>)
        : size_{std::exchange(other.size_, 0)}
    {
        for (std::size_t i = 0; i < size_; ++i)
        {
            data_[i].assign(std::move(other.data_[i].value_));
            other.data_[i].reset();
        }
    }

    constexpr ~stack()
        requires std::is_destructible_v<storage<T>>
        = default;

    constexpr ~stack()
    {
        for (std::size_t i = 0; i < size_; ++i)
            data_[i].reset();
    }

    constexpr void push(T&& obj)
        noexcept(std::is_nothrow_move_constructible_v<T>)
    {
        assert(size_ < Capacity);
        data_[size_++].assign(std::move(obj));
    }
    constexpr void push(const T& obj)
        noexcept(std::is_nothrow_copy_constructible_v<T>)
    {
        assert(size_ < Capacity);
        data_[size_++].assign(obj);
    }

    constexpr T& top() noexcept
    {
        assert(size_ > 0);
        return data_[size_-1].value_;
    }

    constexpr const T& top() const noexcept
    {
        assert(size_ > 0);
        return data_[size_-1].value_;
    }

    constexpr void pop()
    {
        assert(size_ > 0);
        data_[size_--].reset();
    }

private:
    std::size_t size_ = 0;
    storage<T> data_[Capacity];
};

查看full example

【讨论】:

  • 通常会使用 std::aligned_storage (en.cppreference.com/w/cpp/types/aligned_storage)。
  • 虽然在常用的情况下,std::aligned_storage 有很大的缺点,包括对 constexpr 有敌意。请注意,如果在标准 C++ 中实现,则 std::optional 不能使用它。我的类型同样是表示可选值的存储(将 has_value 外部化),我没有特别的理由在 constexpr 上下文中禁止它。另一个缺点是 std::aligned_storage 也可能在 C++23 中被弃用并最终从 C++ 中删除,因为委员会库工作组(至少)认为不应该使用它:github.com/cplusplus/papers/issues/197
  • 啊,我想我理解这种方法。因此,“T”在联合中的事实阻止了构造函数/析构函数被自动调用。大概创建一个联合对象数组也会导致正确的对齐,尽管我不太熟悉联合是如何处理这个问题的。就我而言,我确实需要向联合中添加一个构造函数/析构函数,以使其与更复杂的类一起工作。
  • 对于std::aligned_storage,即使它在某些时候被弃用/删除,在这种情况下,您似乎可以用alignas(alignof(T)) unsigned char data[Capacity * sizeof(T)]; 之类的东西复制它的行为。虽然关于 constexpr-hostile 的其他问题可能仍然是个问题!
  • 是的,联合阻止构造函数/析构函数被自动调用。工会拥有的唯一特殊成员是对所有成员都微不足道的成员。联合是与所有成员指针互转换的,因此对齐必须与每个成员一样对齐。同样,大小必须至少与最大成员一样大。构造函数不是必需的,因为有一个成员初始化器,但那是 GCC 错误 #98423。是的,析构函数是必要的。此外,堆栈的赋值运算符。此外,std::construct_at 应该用于切换联合成员。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2023-02-08
  • 1970-01-01
  • 1970-01-01
  • 2011-08-09
  • 1970-01-01
相关资源
最近更新 更多