Видалення елементів з std :: set під час ітерації


147

Мені потрібно пройти набір і видалити елементи, які відповідають заданим критеріям.

Це тестовий код, який я написав:

#include <set>
#include <algorithm>

void printElement(int value) {
    std::cout << value << " ";
}

int main() {
    int initNum[] = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
    std::set<int> numbers(initNum, initNum + 10);
    // print '0 1 2 3 4 5 6 7 8 9'
    std::for_each(numbers.begin(), numbers.end(), printElement);

    std::set<int>::iterator it = numbers.begin();

    // iterate through the set and erase all even numbers
    for (; it != numbers.end(); ++it) {
        int n = *it;
        if (n % 2 == 0) {
            // wouldn't invalidate the iterator?
            numbers.erase(it);
        }
    }

    // print '1 3 5 7 9'
    std::for_each(numbers.begin(), numbers.end(), printElement);

    return 0;
}

Спочатку я думав, що стирання елемента з набору під час повторення через нього призведе до недійсності ітератора, а приріст у циклі for має невизначене поведінку. Хоча я виконав цей тестовий код і все пройшло добре, і я не можу пояснити, чому.

Моє запитання: Це визначена поведінка для std наборів чи специфічна ця реалізація? Я, до речі, використовую gcc 4.3.3 на ubuntu 10.04 (32-розрядна версія).

Дякую!

Пропоноване рішення:

Це правильний спосіб ітерації та стирання елементів із набору?

while(it != numbers.end()) {
    int n = *it;
    if (n % 2 == 0) {
        // post-increment operator returns a copy, then increment
        numbers.erase(it++);
    } else {
        // pre-increment operator increments, then return
        ++it;
    }
}

Редагувати: ПРЕФЕРЕДНЕ РІШЕННЯ

Я підійшов до рішення, яке здається мені більш елегантним, хоча воно робить саме те саме.

while(it != numbers.end()) {
    // copy the current iterator then increment it
    std::set<int>::iterator current = it++;
    int n = *current;
    if (n % 2 == 0) {
        // don't invalidate iterator it, because it is already
        // pointing to the next element
        numbers.erase(current);
    }
}

Якщо всередині є кілька умов тестування, кожен з них повинен збільшити ітератор. Мені подобається цей код краще, тому що ітератор збільшується лише в одному місці , що робить код менш схильним до помилок і більш читабельним.


1
На запитання та відповідь: stackoverflow.com/questions/263945/…
Мартін Йорк

3
Насправді я читав це питання (та інші), перш ніж задавати моє, але оскільки вони були пов'язані з іншими контейнерами STL і оскільки мій початковий тест, мабуть, спрацював, я подумав, що між ними є якась різниця. Лише після відповіді Метта я подумав використати вальгринд. Хоча я віддаю перевагу моєму НОВОму рішенню перед іншими, оскільки це зменшує шанси помилок, збільшуючи ітератор лише в одному місці. Дякую всім за допомогу!
pedromanoel

1
@pedromanoel ++itмає бути дещо ефективнішим, ніж it++тому, що він не вимагає використання невидимої тимчасової копії ітератора. Версія Корнеля при цьому довше гарантує, що нефільтровані елементи перебираються найбільш ефективно.
Альнітак

@Alnitak Я не думав про це, але думаю, що різниця у виконанні не була б такою великою. Копія створена і в його версії, але лише для елементів, які відповідають. Отже ступінь оптимізації повністю залежить від структури набору. Протягом досить тривалого часу я попередньо оптимізував код, завдаючи шкоди читабельності та швидкості кодування в процесі ... Тому я б провів деякі тести, перш ніж використовувати інший спосіб.
pedromanoel

Відповіді:


178

Це залежить від реалізації:

Стандарт 23.1.2.8:

Члени вставки не повинні впливати на дійсність ітераторів та посилань на контейнер, а елементи стирання недійсні лише ітераторам та посиланням на стирані елементи.

Можливо, ви могли б спробувати це - це відповідає стандарту:

for (auto it = numbers.begin(); it != numbers.end(); ) {
    if (*it % 2 == 0) {
        numbers.erase(it++);
    }
    else {
        ++it;
    }
}

Зауважте, що це ++ є постфіксом, отже, він передає стару позицію для стирання, але спочатку переходить на нову завдяки оператору.

Оновлення 2015.10.27: C ++ 11 усунув дефект. iterator erase (const_iterator position);повернути ітератор до елемента, який слід за останнім видаленим елементом (або set::end, якщо останній елемент був видалений). Отже стиль C ++ 11 є:

for (auto it = numbers.begin(); it != numbers.end(); ) {
    if (*it % 2 == 0) {
        it = numbers.erase(it);
    }
    else {
        ++it;
    }
}

2
Це не працює з deque MSVC2013. Або їх впровадження є помилковим, або є ще одна вимога, яка заважає цьому працювати deque. Специфікація STL настільки перекручена, що ви не можете розраховувати, що всі реалізації будуть слідувати за нею, не кажучи вже про те, що ваш випадковий програміст запам’ятав її. STL - це монстр, що не перевищує приручення, і оскільки немає унікальної реалізації (а тестові набори, якщо такі є, очевидно, не охоплюють таких очевидних випадків, як видалення елементів у циклі), це робить STL блискучою крихкою іграшкою, яка може піднятися на чуб, коли дивишся на це збоку.
kuroi neko

@MatthieuM. Це в C ++ 11. У C ++ 17 зараз він приймає ітератор (const_iterator в C ++ 11).
tartaruga_casco_mole

19

Якщо ви запускаєте програму через valgrind, ви побачите купу помилок читання. Іншими словами, так, ітератори недійсні, але вам пощастило у вашому прикладі (або насправді не пощастило, оскільки ви не бачите негативних наслідків невизначеної поведінки). Одне рішення для цього - створити тимчасовий ітератор, збільшити темп, видалити цільовий ітератор, а потім встановити ціль на темп. Наприклад, перепишіть цикл наступним чином:

std::set<int>::iterator it = numbers.begin();                               
std::set<int>::iterator tmp;                                                

// iterate through the set and erase all even numbers                       
for ( ; it != numbers.end(); )                                              
{                                                                           
    int n = *it;                                                            
    if (n % 2 == 0)                                                         
    {                                                                       
        tmp = it;                                                           
        ++tmp;                                                              
        numbers.erase(it);                                                  
        it = tmp;                                                           
    }                                                                       
    else                                                                    
    {                                                                       
        ++it;                                                               
    }                                                                       
} 

Якщо важлива лише умова, яка не потребує ініціалізації в масштабі чи після операції, тоді краще використовувати whileцикл. тобто for ( ; it != numbers.end(); )краще видно зwhile (it != numbers.end())
iammilind

7

Ви неправильно розумієте, що означає "невизначена поведінка". Невизначена поведінка не означає "якщо ви це зробите, ваша програма вийде з ладу або призведе до несподіваних результатів". Це означає, що "якщо ви це зробите, ваша програма може вийти з ладу або призвести до несподіваних результатів" або зробити щось інше, залежно від вашого компілятора, вашої операційної системи, фази місяця тощо.

Якщо щось виконується без збоїв і поводиться так, як ви очікуєте, це не є доказом того, що це не визначена поведінка. Все, що це доводить, - це те, що його поведінка виявилася такою, яка спостерігалася для цього конкретного запуску після компіляції саме з цим компілятором у цій конкретній операційній системі.

Стирання елемента з набору приводить до недійсності ітератора до стираного елемента. Використання недійсного ітератора - це невизначена поведінка. Так вийшло, що спостережувана поведінка була тим, що ви задумали в даному конкретному випадку; це не означає, що код правильний.


О, я добре знаю, що невизначена поведінка також може означати "Це працює для мене, але не для всіх". Тому я задав це питання, бо не знав, чи була така поведінка правильною чи ні. Якби це було, то я б просто так залишив. Тоді використання циклу в той час вирішило б мою проблему? Я редагував своє запитання із запропонованим рішенням. Будь ласка, перевірте це.
pedromanoel

Це працює і для мене. Але коли я змінюю умову, if (n > 2 && n < 7 )то я отримую 0 1 2 4 7 8 9. - Конкретний результат тут, ймовірно, більше залежить від деталей реалізації методу стирання та встановлення ітераторів, а не від фази Місяця (не тієї колись слід розраховувати на деталі впровадження). ;)
ДядькоБенс

1
STL додає багато нових значень до "невизначеної поведінки". Наприклад, "Microsoft вважає розумним покращити специфікацію, дозволяючи std::set::eraseповернути ітератор, тому ваш код MSVC підніметься з ударом, коли компілюється gcc", або "Microsoft робить прив'язку перевірок, std::bitset::operator[]щоб ваш ретельно оптимізований алгоритм бітса сповільнився до сканувати під час компіляції з MSVC ". STL не має унікальної реалізації, і його специфікація є експоненціально зростаючим роздутим безладом, тому недарма для видалення елементів із внутрішньої петлі потрібен досвід старшого програміста ...
kuroi neko

2

Просто попередити, що у випадку контейнера deque, всі рішення, які перевіряють рівність ітератора deque на числа.end (), швидше за все, не вдасться на gcc 4.8.4. А саме, стираючи елемент deque, як правило, недійсний покажчик на numbers.end ():

#include <iostream>
#include <deque>

using namespace std;
int main() 
{

  deque<int> numbers;

  numbers.push_back(0);
  numbers.push_back(1);
  numbers.push_back(2);
  numbers.push_back(3);
  //numbers.push_back(4);

  deque<int>::iterator  it_end = numbers.end();

  for (deque<int>::iterator it = numbers.begin(); it != numbers.end(); ) {
    if (*it % 2 == 0) {
      cout << "Erasing element: " << *it << "\n";
      numbers.erase(it++);
      if (it_end == numbers.end()) {
    cout << "it_end is still pointing to numbers.end()\n";
      } else {
    cout << "it_end is not anymore pointing to numbers.end()\n";
      }
    }
    else {
      cout << "Skipping element: " << *it << "\n";
      ++it;
    }
  }
}

Вихід:

Erasing element: 0
it_end is still pointing to numbers.end()
Skipping element: 1
Erasing element: 2
it_end is not anymore pointing to numbers.end()

Зауважте, що хоча трансформація deque є правильною у цьому конкретному випадку, кінцевий покажчик був недійсним на цьому шляху. При деке різного розміру помилка більш очевидна:

int main() 
{

  deque<int> numbers;

  numbers.push_back(0);
  numbers.push_back(1);
  numbers.push_back(2);
  numbers.push_back(3);
  numbers.push_back(4);

  deque<int>::iterator  it_end = numbers.end();

  for (deque<int>::iterator it = numbers.begin(); it != numbers.end(); ) {
    if (*it % 2 == 0) {
      cout << "Erasing element: " << *it << "\n";
      numbers.erase(it++);
      if (it_end == numbers.end()) {
    cout << "it_end is still pointing to numbers.end()\n";
      } else {
    cout << "it_end is not anymore pointing to numbers.end()\n";
      }
    }
    else {
      cout << "Skipping element: " << *it << "\n";
      ++it;
    }
  }
}

Вихід:

Erasing element: 0
it_end is still pointing to numbers.end()
Skipping element: 1
Erasing element: 2
it_end is still pointing to numbers.end()
Skipping element: 3
Erasing element: 4
it_end is not anymore pointing to numbers.end()
Erasing element: 0
it_end is not anymore pointing to numbers.end()
Erasing element: 0
it_end is not anymore pointing to numbers.end()
...
Segmentation fault (core dumped)

Ось один із способів виправити це:

#include <iostream>
#include <deque>

using namespace std;
int main() 
{

  deque<int> numbers;
  bool done_iterating = false;

  numbers.push_back(0);
  numbers.push_back(1);
  numbers.push_back(2);
  numbers.push_back(3);
  numbers.push_back(4);

  if (!numbers.empty()) {
    deque<int>::iterator it = numbers.begin();
    while (!done_iterating) {
      if (it + 1 == numbers.end()) {
    done_iterating = true;
      } 
      if (*it % 2 == 0) {
    cout << "Erasing element: " << *it << "\n";
      numbers.erase(it++);
      }
      else {
    cout << "Skipping element: " << *it << "\n";
    ++it;
      }
    }
  }
}

Ключова істота do not trust an old remembered dq.end() value, always compare to a new call to dq.end().
Джессі

2

У C ++ 20 буде "рівномірне стирання контейнера", і ви зможете написати:

std::erase_if(numbers, [](int n){ return n % 2 == 0 });

І це буде працювати vector, set, dequeі т.д. Див cppReference для отримання додаткової інформації.


1

Така поведінка є специфічною для впровадження. Щоб гарантувати правильність ітератора, слід використовувати "it = numbers.erase (it);" оператор, якщо вам потрібно видалити елемент і просто ітератор інцеременту в іншому випадку.


1
Set<T>::eraseверсія не повертає ітератор.
Аркаїц Хіменес

4
Насправді це так, але тільки щодо впровадження MSVC. Отже, це справді конкретна відповідь на реалізацію. :)
Євген

1
@Eugene Це робиться для всіх реалізацій з C ++ 11
mastov

Деяка реалізація gcc 4.8с c++1yмає помилку в стиранні. it = collection.erase(it);повинен працювати, але це може бути безпечніше використовуватиcollection.erase(it++);
Джессі

1

Я думаю, що використання методу STL ' remove_if' від може допомогти запобігти деяку дивну проблему при спробі видалити об'єкт, який обгорнуто ітератором.

Це рішення може бути менш ефективним.

Скажімо, у нас є такий контейнер, як вектор або список, який називається m_bullets:

Bullet::Ptr is a shared_pr<Bullet>

' it' - ітератор, який ' remove_if' повертається, третій аргумент - це лямбда-функція, яка виконується на кожному елементі контейнера. Оскільки контейнер містить Bullet::Ptr, функція лямбда повинна передати цей тип (або посилання на цей тип) як аргумент.

 auto it = std::remove_if(m_bullets.begin(), m_bullets.end(), [](Bullet::Ptr bullet){
    // dead bullets need to be removed from the container
    if (!bullet->isAlive()) {
        // lambda function returns true, thus this element is 'removed'
        return true;
    }
    else{
        // in the other case, that the bullet is still alive and we can do
        // stuff with it, like rendering and what not.
        bullet->render(); // while checking, we do render work at the same time
        // then we could either do another check or directly say that we don't
        // want the bullet to be removed.
        return false;
    }
});
// The interesting part is, that all of those objects were not really
// completely removed, as the space of the deleted objects does still 
// exist and needs to be removed if you do not want to manually fill it later 
// on with any other objects.
// erase dead bullets
m_bullets.erase(it, m_bullets.end());

' remove_if' видаляє контейнер, де функція лямбда повертається в істину, і переміщує цей вміст на початок контейнера. ' it' Вказує на невизначений предмет, який можна вважати сміттям. Об'єкти з 'it' до m_bullets.end () можна стерти, оскільки вони займають пам'ять, але містять сміття, тому метод 'delete' викликається в цьому діапазоні.


0

Я зіткнувся з тим самим старим питанням і знайшов нижче код, більш зрозумілий, який є способом, що стосується вищезазначених рішень.

std::set<int*>::iterator beginIt = listOfInts.begin();
while(beginIt != listOfInts.end())
{
    // Use your member
    std::cout<<(*beginIt)<<std::endl;

    // delete the object
    delete (*beginIt);

    // erase item from vector
    listOfInts.erase(beginIt );

    // re-calculate the begin
    beginIt = listOfInts.begin();
}

Це працює лише в тому випадку, якщо ви завжди будете стирати кожен елемент. ОП - це вибіркове стирання елементів і все ще має дійсні ітератори.
Джессі
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.