【问题标题】:Inserting into vector by reference to element of same vector通过引用同一向量的元素插入向量
【发布时间】:2017-04-15 19:33:20
【问题描述】:

我想知道是否有更有经验的人可以澄清这是否是对向量执行的错误操作:

std::vector<int> v{1, 2, 3, 4, 5};
v.insert(v.begin() + 1, v[0]);

我问的原因是因为要插入的元素是对向量中第 0 个元素的引用。如果插入强制向量调整大小(因为它的容量已满),那么对 v[0] 的引用将无效,并且代码可能会插入不正确的值。下面是一些可能演示的伪代码:

template <typename T>
void vector_insert_method(Iterator pos, const T& value) {
    if capacity full:
        reallocate array and copy over element
        // if value was reference to elem in this vector,
        // that reference might be invalidated


    insert element

    ++size
}

这个问题在并发系统上可能更现实。

一个类似且相关的问题是,如果您尝试插入位于您尝试插入的位置之后的元素,会发生什么情况。例如,执行v.insert(v.begin(), v[2]) 之类的操作,因为标准指出对插入点之后的元素的引用无效。这能保证有效吗?

【问题讨论】:

  • @OliverCharlesworth IMO 这不是一个特例,因为单个元素 insert 是它自己的函数,独立于范围插入选项。
  • @cdhowie 或旧的 +v[0]。虽然事实证明它不是UB。
  • @DanielJour 我编辑了这个问题以反映我的初衷,但我同意你指出的问题同样有趣。我已将另一种情况附加到问题中,因为我认为这两个问题是相关的。对此感到抱歉,感谢您的出色回答!

标签: c++ vector reference


【解决方案1】:

原来的问题问...

[..] 这是否是对向量执行的错误操作:

v.insert(v.begin(), v[0]);
//              ^^^ no + 1 here!

可能是的,它可能是未定义的行为。引用N3337 ("almost C++11" AFAIK):

[..] 如果没有发生重新分配,插入点之前的所有迭代器和引用仍然有效。 [..]

§23.3.6.5/1

虽然这不是阅读标准时所习惯的清晰措辞,但我将其解释为:迭代器和对插入点及其之后的引用无效。

因此,对v[0] 的引用因对insert 的调用(*) 而无效。即使没有发生重新分配,insert 必须将所有元素移动到更高的索引(因此 v[0] 被重新定位到 v[1])。

假设元素类型不是容易被破坏,那么无论是如何重新定位(通过移动或复制),在v[1] 之后从v[0] 分配/构造,v[0] 的析构函数需要被调用(并且它的生命周期结束)一个新对象(您要插入的对象)可以放置在内存位置的v[0]。因此,在这里您的引用变成了一个悬空引用,当它被直接用于构造新的v[0] 时,这会导致未定义的行为。不,据我所知,这个问题可以通过让std::vector::insert()构造新对象而是分配新对象到“旧”对象来规避.我不确定std::vector 是否需要这样做,尽管它的元素类型必须是CopyInsertable 暗示这可能是这种情况。

更新: I've played around with the code 在另一个答案中提供,添加了一些打印调试。这表明不是我所期望的(破坏一个元素然后访问它)仍然表明标准库实现(这里由 ideone 使用)确实不是 符合标准:它使对插入点之前的元素的引用无效即使容量足够大。这样它就绕过了上述问题(但违反了标准......所以)。

(*):人们可能会争论何时会发生失效。该问题的最佳答案是 IMO 引用在调用 insert 函数之前有效,并且在从该函数返回后无效(肯定)。失效的确切点未指定。


编辑后的问题提出了同样的问题,但做了一个极其重要的修改:

v.insert(v.begin() + 1, v[0]);
//                ^^^^^

现在情况完全不同了。对v[0] 的引用不一定会因对insert 的调用而失效,因为插入点在“后面”v[0]。唯一可能出现的问题是 std::vector 是否必须重新分配其内部缓冲区,但在这种情况下,以下操作序列应保证正确的行为:

  1. 分配更大容量的新内存
  2. 在新分配的内存区域的正确索引处构造新元素。
  3. 将元素从旧(太小)内存复制/移动到新分配的内存区域。
  4. 释放旧内存。

【讨论】:

  • 向量的任何元素都是这种情况,还是仅适用于自从他在开头插入以来的第一个元素? v.insert(v.begin(),v[2]); 会安全吗?或者无论我们访问什么元素,我们都会面临同样的情况?
  • 我读到的真正问题是 UB 是否被触发,而不是引用在insert() 调用之后 是否仍然有效。答案可能是不,如果insert() 实现在重新分配之前复制对象,然后从副本中移动构造新的向量元素。当然,这要求类型既可复制又可移动。
  • @JackVanier 如果引用是指向插入点之前的对象,例如v.insert(std::next(v.begin()), v[0]),则您是安全的。但当然只有插入不会导致重新分配。
  • 我不同意该操作存在错误,并且我隐约记得讨论此问题的问题(和缺陷报告)。实际的函数调用满足了所有要求,因此由std::vector::insert 来完成所有工作。如果这需要在重新分配之前复制元素,那就这样吧。确实,建议的实现太简单了。
  • 稍后我会扩展我的答案。关于“由 [插入] 让一切正常工作”......它如何在不导致未定义行为的情况下实现这一点?它如何检查要插入的对象是否在移动/复制的对象范围内?比较地址是行不通的……如果对象不是来自向量本身,那就是 UB。
【解决方案2】:

好吧,经过大量的环顾,似乎标准库有义务完成这项工作。这个想法是,由于标准没有明确说明这不应该起作用,所以它必须起作用。

Andrew Koenig 在这里写了一点,并提出了一个解决方案,即分配新内存,移动元素,然后释放旧内存。

还有其他讨论herehere (#526)

对于第二种情况,标准库(MacOS 上的 clang)似乎也解释了您尝试在向量中插入对位于您尝试插入的点之后的元素的引用的情况.为了测试这一点,我为 int 编写了一个名为 Integer 的包装类,它的行为与 int 完全相同,只是析构函数将其内部值设置为 -5。因此,如果 std::vector 插入不考虑这种情况,我们应该看到插入了 -5 而不是实际值。

#include <vector>
#include <iostream>


using std::cout;    using std::endl;
using std::ostream; using std::vector;


class Integer {

public:
    Integer() {
        x = -1;     // default value
    }

    ~Integer() {
        x = -5;     // destructed value. Not 0 so we can clearly see it
    }

    Integer(int r) {
        x = r;
    }

    Integer(const Integer& other) {
        x = other.x;
    }

    Integer operator=(const Integer& other) {
        x = other.x;
        return *this;
    }

    operator int() const{
        return x;
    }

    friend ostream& operator<<(ostream& os, const Integer& thing) {
        os << thing.x;
        return os;
    }

private:
    int x;
};


ostream& operator<<(ostream& os, const vector<Integer> &v) {
    std::copy(v.begin(), v.end(), std::ostream_iterator<Integer>(os, ", "));
    return os;
}

int main() {
    std::vector<Integer> ret {18, 7, 4, 24,11};
    cout << "Before: " << ret << endl;
    ret.insert(ret.begin(), ret[1]);
    cout << "After: " <<  ret << endl;
    return 0;
}

这给了我们正确的输出:

Before: 18, 7, 4, 24, 11, 
After: 7, 18, 7, 4, 24, 11, 

【讨论】:

  • 您的代码非常有用,但并非如此:将cout 语句添加到构造函数/赋值运算符/析构函数。 reserve 向量足够大,以至于不需要发生重新分配。 (ideone.com/YVwJHF)
  • 注意:在析构函数中设置成员可能会被优化器删除,因此您的原始测试将无法按预期可靠地工作。
猜你喜欢
  • 2013-04-02
  • 1970-01-01
  • 1970-01-01
  • 2019-08-08
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2023-01-02
相关资源
最近更新 更多