为什么std :: for_each删除元素不会破坏迭代?

时间:2014-02-24 02:10:48

标签: c++ c++11

据我所知,在集合迭代期间擦除元素应该会破坏迭代或导致跳过元素。为什么调用带有删除谓词的std :: for_each不会导致这种情况发生? (它有效)。

代码片段:

#include <iostream>
#include <map>
#include <algorithm>

using namespace std;

int main() {
    map<int,long long> m;
    m[1] = 5000;
    m[2] = 1;
    m[3] = 2;
    m[4] = 5000;
    m[5] = 5000;
    m[6] = 3;

    // Erase all elements > 1000
    std::for_each(m.begin(), m.end(), [&](const decltype(m)::value_type& v){
        if (v.second > 1000) {
            m.erase(v.first);
        }
    });

    for (const auto& a: m) {
      cout << a.second << endl;
    }
    return 0;
}

打印出来

1
2
3

编辑:我现在看到 if 它实际上在调用函数之前递增迭代器然后它可以工作。但这是否算作编译器特定/未定义的行为?

4 个答案:

答案 0 :(得分:1)

这恰好适合你,但我不会指望它 - 它可能是未定义的行为。

具体来说,我担心在运行std::for_each时擦除map元素会尝试增加无效的迭代器。例如,它看起来像libc++ implements std::for_each一样:

template<typename _InputIterator, typename _Function>
_Function 
for_each(_InputIterator __first, _InputIterator __last, _Function __f) {
   // concept requirements
   __glibcxx_function_requires(_InputIteratorConcept<_InputIterator>)
     __glibcxx_requires_valid_range(__first, __last);
   for (; __first != __last; ++__first)
     __f(*__first);
   return _GLIBCXX_MOVE(__f);
 }

如果调用__f最终执行删除操作,则__first似乎可能会失效。尝试随后增加无效迭代器将是未定义的行为。

答案 1 :(得分:1)

std::for_eachC++ draft standard(25.2.4)中定义为非修改序列操作。使用函数实现修改序列的事实可能只是运气。

这绝对是实现定义的,你不应该这样做。该标准要求您修改函数对象内的容器。

答案 2 :(得分:1)

这是未定义的行为,无法可靠地运行。在你的擦除lambda函数中添加一行打印键和值后,我看到:

1=5000
2=1
3=2
4=5000
2=1          // AGAIN!!!
3=2          // AGAIN!!!
5=5000
6=3

使用我的标准库的map实现,在用键4擦除元素之后,迭代返回到具有键2的节点!然后,它使用键3重新访问节点。因为你的lambda很高兴地重新测试了这样的节点(v.second > 1000)并且没有任何副作用返回,所以破坏的迭代不会影响输出。

您可能会合理地问:“但不是天文数据不太可能在没有崩溃的情况下设法继续迭代(即使是错误的下一个节点)吗?”

实际上,这很有可能。

擦除节点导致为节点占用的内存调用delete,但通常执行delete的库代码只会:

  • 调用析构函数(没有特别的理由浪费时间覆盖左子指针,右子指针和父指针),然后

  • 修改其分配的内存区域与可用内存的记录。

不可能“浪费”时间任意修改被释放的堆内存(尽管某些实现将在内存使用调试模式中)。

因此,擦除的节点可能保持不变,直到执行其他堆分配为止。

并且,当erase中的map元素时,标准要求容器的其他元素都不会在内存中移动 - 迭代器,指针和对其他元素的引用必须< / em>仍然有效。它只能修改维护二叉树的节点的左/右/父指针。

因此,如果继续使用迭代器到擦除元素,它可能会在擦除之前看到擦除元素链接到左/右/父节点的指针,operator++()将“迭代”到如果擦除的元素仍在map中,它们将使用相同的逻辑。

如果我们考虑一个示例映射的内部二叉树,其中N3是一个带有键3的节点:

                           N5
                          /  \
                        N3    N7
                       / \    /
                      N1  N4 N6

迭代的完成方式可能是:

  • 最初,从N1开始; map必须直接跟踪这是确保begin()为O(1)

  • 如果在没有子节点的节点上,重复{Nfrom =你在哪里,移动到父节点,如果是nullptr或右边!= N from break}(例如N1-> N3,N4-> N3-> ; N5,N6-> N7-> N5-> nullptr)

  • 如果在有右手孩子的节点上,则接受任意数量的左手链接(例如N3-> N4,N5-> N7-> N6)

所以,如果说N4被移除(所以N3->right = nullptr;)并且没有重新平衡发生,那么迭代记录NFrom = N4然后移动到父N3,然后N3-&gt; right!= Nfrom,所以它会思考它应该停在已经迭代过的N3上,而不是继续向上移动到N5。

另一方面,如果树在erase之后已经重新平衡,则所有投注都会关闭,无效的迭代器可以重复或跳过元素,甚至可以“按照希望”进行迭代。

相反,我只是表明一个理智的实现可以解释你的意外观察。

答案 3 :(得分:0)

我觉得这个操作很常见,所以为了避免上面的未定义行为,我写了一个基于容器的算法。

void remove_erase_if( Container&&, Test&& );

要处理关联容器和非容器,我在自定义特征类is_associative_container上标记调度 - 关联转到手动while循环,而其他转到remove_if - {{ 1}}版本。

在我的情况下,我只是对特征中的4个关联容器进行硬编码 - 你可以对它进行类型化,它是一个更高级别的概念,所以无论如何你都只是模式匹配。