实现std::vector::push_back强异常安全性

5
我正在实现基于2018年圣迭戈草案(N4791)的向量,并在实现强异常安全性方面遇到了一些问题。
以下是一些代码:
template <typename T, typename Allocator>
void Vector<T, Allocator>::push_back(const T& value)
{
    if (buffer_capacity == 0)
    {
        this->Allocate(this->GetSufficientCapacity(1));
    }
    if (buffer_size < buffer_capacity)
    {
        this->Construct(value);
        return;
    }
    auto new_buffer = CreateNewBuffer(this->GetSufficientCapacity(
        buffer_size + 1), allocator);
    this->MoveAll(new_buffer);
    try
    {
        new_buffer.Construct(value);
    }
    catch (...)
    {
        this->Rollback(new_buffer, std::end(new_buffer));
        throw;
    }
    this->Commit(std::move(new_buffer));
}

template <typename T, typename Allocator>
void Vector<T, Allocator>::Allocate(size_type new_capacity)
{
    elements = std::allocator_traits<Allocator>::allocate(allocator,
        new_capacity);
    buffer_capacity = new_capacity;
}

template <typename T, typename Allocator> template <typename... Args>
void Vector<T, Allocator>::Construct(Args&&... args)
{
    // TODO: std::to_address
    std::allocator_traits<Allocator>::construct(allocator,
        elements + buffer_size, std::forward<Args>(args)...);
    ++buffer_size;
}

template <typename T, typename Allocator>
Vector<T, Allocator> Vector<T, Allocator>::CreateNewBuffer(
    size_type new_capacity, const Allocator& new_allocator)
{
    Vector new_buffer{new_allocator};
    new_buffer.Allocate(new_capacity);
    return new_buffer;
}

template <typename T, typename Allocator>
void Vector<T, Allocator>::Move(iterator first, iterator last, Vector& buffer)
{
    if (std::is_nothrow_move_constructible_v<T> ||
        !std::is_copy_constructible_v<T>)
    {
        std::move(first, last, std::back_inserter(buffer));
    }
    else
    {
        std::copy(first, last, std::back_inserter(buffer));
    }
}

template <typename T, typename Allocator
void Vector<T, Allocator>::MoveAll(Vector& buffer)
{
    Move(std::begin(*this), std::end(*this), buffer);
}

template <typename T, typename Allocator>
void Vector<T, Allocator>::Rollback(Vector& other, iterator last) noexcept
{
    if (!std::is_nothrow_move_constructible_v<T> &&
        std::is_copy_constructible_v<T>)
    {
        return;
    }
    std::move(std::begin(other), last, std::begin(*this));
}

template <typename T, typename Allocator>
void Vector<T, Allocator>::Commit(Vector&& other) noexcept
{
    this->Deallocate();
    elements = other.elements;
    buffer_capacity = other.buffer_capacity;
    buffer_size = other.buffer_size;
    allocator = other.allocator;
    other.elements = nullptr;
    other.buffer_capacity = 0;
    other.buffer_size = 0;
}

我看到这段代码有两个问题。我尝试遵循 std::move_if_noexcept 的逻辑,但如果元素是 nothrow 可移动构造的,但 allocator_traits::construct 在自定义分配器内部的某些日志记录代码中抛出异常怎么办?那么我的 MoveAll 调用将抛出并仅产生基本保证。这是否是标准上的缺陷?Allocator::construct 应该有更严格的措辞吗?
另一个问题在于 Rollback。只有移动的元素是 nothrow 可移动可分配的,它才会真正产生强烈的保证。否则,同样只有基本保证。这是它应该的方式吗?

这篇论文写道:注意:这是早期草稿。已知它不完整、不正确,并且格式很差。 - Damian
是的,越多人审查文档越好。这个论坛是突出这些问题的好地方。 - Damian
1
@Damian 是的,那是 C++ 标准的工作草案。 - Barry
先构造值,再移动元素! - Oliv
@NicolBolas 哦,我错过了那个。还有移动对象时会调用allocator::construct!我只关注了代码的一个怪异之处,暗示了一个不必要的回滚。 - Oliv
显示剩余4条评论
1个回答

3
区间移动/复制的std::move/copy函数无法提供强异常保证。如果发生异常,您需要一个迭代器指向最后成功复制/移动的元素,以便可以正确地撤销操作。您必须手动复制/移动(或编写专门的函数来执行此操作)。
至于您问题的具体情况,标准并没有真正解决如果construct发生不是在正在构造的对象的构造函数中抛出的异常应该怎么办。标准的意图(由于我将在下面解释的原因)可能是这种情况永远不应该发生。但我还没有找到标准中关于此的任何声明。因此,让我们暂时假设这是有意为之的。
为了使支持分配器的容器能够提供强异常保证,construct至少在构造对象后不能抛出异常。毕竟,您不知道抛出了什么异常,否则您将无法确定对象是否已经成功构造。那会使实现标准所要求的行为变得不可能。因此,让我们假设用户没有做出使实现变得不可能的事情。
考虑到这种情况,您可以编写代码,假设由construct引发的任何异常意味着对象未被构建。如果construct尽管给出了调用noexcept构造函数的参数仍然引发异常,则假定构造函数从未被调用。然后您相应地编写代码。
在复制的情况下,您只需要删除任何已复制的元素(当然是倒序)。移动情况有点棘手,但仍然可行。您必须将每个成功移动的对象重新分配回其原始位置。
问题在哪里?vector<T>::*_back不要求T为MoveAssignable。它只要求T是MoveInsertable:也就是说,您可以使用分配器在未初始化的内存中构造它们。但是,您不会将其移动到未初始化的内存中;您需要将其移动到已移动的T存在的位置。因此,为了保留此要求,您需要销毁所有成功移动的T,然后将它们MoveInsert回原位。
但是由于MoveInsertion需要使用construct,而正如之前所述,它可能会抛出异常...糟糕。确实,这正是为什么vector的重新分配函数不移动元素的原因,除非该类型是无法抛出异常的可移动类型或者是不可复制的(如果是后一种情况,则不会得到强异常保证)。

因此,对我来说,任何分配器的construct方法都应该只在所选构造函数抛出异常时才会抛出异常,这似乎很清楚地符合标准对于vector所需的行为。但是,鉴于没有明确的说明这一要求,我认为这是标准中的一个缺陷。而且这不是一个新的缺陷,因为我查看了C++17标准而不是工作文件。

显然,自2014年以来,这一问题一直是LWG问题的主题,解决它是...麻烦的。


我使用std::back_inserter,它调用push_backpush_back又调用Construct,最终调用allocator_traits::construct。在CreateNewBuffer中复制了分配器,因此所有内容都具有分配器感知能力。 - user3624760
@Lyberta: 已修复 - Nicol Bolas
2
作用域分配器相当系统地打破了这种期望。这是LWG 2461。 - T.C.
1
@T.C.:所有这些都真正展示了我们的分配器模型有多么糟糕。分配器不应该成为我们“构造”对象的手段;它们应该一直是关于为它们分配存储空间的。 - Nicol Bolas
具有讽刺意味的是,似乎没有人关心花哨的分配器。看看5年前报告的这个libstdc++错误,它仍然没有被修复,这基本上禁止了任何花哨的“指针”分配器。更不用说那个丑陋的“重新绑定”语法了。 - llllllllll

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接