Сегодня поговорим о неочевидной особенности некоторых коллекций в .NET. Долго вокруг да около ходить не будем и начнём с задачки на самопроверку.
An enumerator remains valid as long as the collection remains unchanged
Итак, вопрос: как будут вести себя методы SetRemove и ListRemove? Для удобства продублирую код текстом; результаты начнём обсуждать сразу после листинга кода.
void SetRemove()
{
int[] arr = { 1, 2, 3 };
HashSet<int> set = arr.ToHashSet();
foreach (var item in set)
{
set.Remove(item);
}
}
void ListRemove()
{
int[] arr = { 1, 2, 3 };
List<int> list = arr.ToList();
foreach (var item in list)
{
list.Remove(item);
}
}
Самое интересное начинается при тестировании этих методов на разных фреймворках:
на .NET Framework и в ListRemove, и в SetRemove ожидаемо возникает исключение InvalidOperationException;
на современном .NET в методе ListRemove ожидаемо возникает исключение, а вот в SetRemove — нет.
И если с поведением ListRemove всё понятно, то SetRemove вызывает вопросы. Разве нет контракта на то, что итерируемая в foreach коллекция не должна изменяться?
Освежим память и посмотрим доки по HashSet<T>.GetEnumerator на learn.microsoft.com:
An enumerator remains valid as long as the collection remains unchanged. If changes are made to the collection, such as adding, modifying, or deleting elements, the enumerator is irrecoverably invalidated and the next call to MoveNext or IEnumerator.Reset throws an InvalidOperationException.
Ага, с .NET Framework сходится, а с .NET — нет.
Что ж, пойдём известным и проверенным способом — покопаемся в исходниках.
Как работает проверка на изменение коллекции?
Если вы знаете ответ на вопрос из заголовка, раздел можно пропустить. Однако я бы всё равно рекомендовал пробежаться по нему, чтобы освежить воспоминания.
Мы рассмотрим пример с List<T>, так как он чуть проще. В целом, для остальных коллекций суть та же. К HashSet<T> вернёмся немного позже.
Сама проверка работает достаточно просто:
коллекция хранит свою версию в поле _version;
метод GetEnumerator создаёт итератор, в конструкторе которого сохраняется текущая версия коллекции, а также ссылка на саму коллекцию;
методы MoveNext и Reset прямо или косвенно версию проверяют;
если версия, сохранённая в итераторе, отличается от текущей версии коллекции, значит коллекция изменилась — генерируется исключение.
Рассмотрим пример:
int[] arr = { 1, 2, 3 };
var list = arr.ToList();
foreach (var item in list)
{
list.Remove(item);
}
Убираем сахар и явно вводим работу с итераторами — код станет примерно таким:
...
var list = arr.ToList();
var enumerator = list.GetEnumerator();
try
{
while (enumerator.MoveNext())
{
int current = enumerator.Current;
list.Remove(current);
}
}
finally
{ ... }
Проверка версий здесь отработает так:
создаётся объект list; list._version — 0;
создаётся объект enumerator, в него записывается версия из list; list._version — 0, enumerator._version — 0;
вызов enumerator.MoveNext(). Версии совпадют, всё ОК;
вызов list.Remove() меняет версию коллекции; list._version — 1; enumerator._version — 0;
вызов enumerator.MoveNext() — версии не совпадают, выбрасывается исключение InvalidOperationException.
Надеюсь, стало понятнее. Теперь возвращаемся к HashSet<T>.
В чём загвоздка с HashSet.Remove?
Вернёмся к нашему примеру со множеством:
int[] arr = { 1, 2, 3 };
HashSet<int> set = arr.ToHashSet();
foreach (var item in set)
{
set.Remove(item);
}
Мы помним, что на .NET Framework этот код работает ожидаемо и кидает исключение. Всё потому, что концепт укладывается в описанный нами алгоритм:
при создании HashSet<T> устанавливается версия;
при получении итератора в него тоже записывается версия;
HashSet<T>.Remove изменяет версию;
Enumerator.MoveNext проверяет совпадение версий и генерирует исключение, если версии отличаются.
А что же в современном .NET?
в HashSet<T> всё так же есть версия;
при получении итератора в него всё так же сохраняется версия;
[!] HashSet<T>.Remove больше НЕ ИЗМЕНЯЕТ версию;
Enumerator.MoveNext всё так же проверяет совпадение версий.
Вот мы и нашли основную причину странного поведения: Remove не меняет версию -> в MoveNext проверка на совпадение успешно проходит -> исключение не генерируется -> в современном .NET можно изменять интегрируемый HashSet<T>.
Факт того, что версия действительно не меняется, элементарно проверяется в отладчике:
var set = new HashSet<int>();
// set._version -> 0
set.Add(62);
// set._version -> 1
set.Remove(62);
// set._version -> 1
Вернёмся к исходникам. Покопавшись в blame, можно найти интересующий нас коммит:
Видим комментарий:
Functionally, bringing over Dictionary's implementation yields a few notable changes, namely that Remove and Clear no longer invalidate enumerations. The tests have been updated accordingly.
Выходит, что такое поведение актуально и для Dictionary<TKey, TValue>, а также для метода Clear.
Поведение словаря так же легко проверяется через отладчик:
var dictionary = new Dictionary<int, string>();
// dictionary._version -> 0
dictionary.Add(0, string.Empty);
// dictionary._version -> 1
dictionary.Remove(0);
// dictionary._version -> 1
Ну и последний интересный момент: соответствующий коммит датируется 2020 годом, то есть это достаточно старые правки. Интересно, как много разработчиков знало об этих особенностях? :)
**
Что интересно лично для меня — это даже не детали реализации, а что контракт вида "итерируемые в foreach коллекции не должны меняться" теперь со звёздочкой. Да, в целом не должны, но могут. Не все, и не на всех фреймворках, но могут.
Я бы предпочёл, чтобы обсуждаемый контракт остался "as is". Кажется, использование описанных особенностей для множеств / словарей только привнесёт дополнительной неразберихи.
Что ж, поживём — увидим ¯_(ツ)_/¯
Комментарии (24)
CrazyElf
01.07.2024 03:21Если очень нужно лучше всегда делать копию коллекции и итерироваться по ней уже. Но так то ещё лучше вообще без такого сценария обойтись, а сделать вычитание множеств или ещё что-то более высокоуровневое, чем удаление отдельных элементов по одному.
ritorichesky_echpochmak
01.07.2024 03:21+1сделать вычитание множеств или ещё что-то более высокоуровневое
А можно пару примеров на пальцах? Кажется, в плане потребления ресурсов это не будет "ещё лучше"
CrazyElf
01.07.2024 03:21+2Зависит от конкретного use case. Где-то вообще будет лучше фильтрация коллекции в новую через
Linq
. Нужно смотреть - сколько вообще данных, какой процент предполагается удалить, и т.п.
mynameco
01.07.2024 03:21У нас в юнити борьба даже за боксинг итераторов. А тут целую копию коллекции делать. Сейчас выкручиваемся таким ужасным способом, что стыдно рассказывать. После удаления, делаем goto перед циклом, и ищем следующий элемент. При том количестве элементов и удаляемых данных, все всех устраивает.
aamonster
01.07.2024 03:21+1Чувак, попиши на крестиках – там нарушение подобных контрактов не бросит тебе простое и понятное исключение, а приведёт к UB. И ты навсегда перестанешь полагаться на исключения в таких местах, и начнёшь использовать filter или ещё что.
Кстати, емнип JavaScript позволяет менять коллекции во время итерации. Но я настолько привык так не делать...
ЗЫ: В опросе не хватает варианта "мне всё равно".
domix32
01.07.2024 03:21JS в худшем случае просто бросит исключение про
undefined
т.к. его задача не ронять фронт никогда.использовать filter или ещё что
во-во, удивляет желание людей заниматься подобным в обычных циклах.
aamonster
01.07.2024 03:21+3JS – это не только фронт, но и на фронте не всегда лучше "не ронять" – порой лучше упасть и дать юзеру перезагрузить страницу, чем, к примеру, испортить его данные.
Но суть в том, что там изменение объекта во время итерации (ну, одного из видов объектов) не просто "в худшем случае бросит исключение", а абсолютно законно и ведёт себя предсказуемо: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for...in#deleted_added_or_modified_properties
Но всё равно проще так не делать, чтобы не выстрелить себе в ногу при переходе с языка на язык и чтобы тот, кто читает твой код, не должен был лезть в документацию.
Понятно, почему так сделано в JS, понятно, почему так не сделано в C++, ну и подход .net тоже понятен (раз уж можно задёшево предупредить юзера, что он стреляет себе в ногу – почему нет?) и даже понятно, почему в текущей версии нет exception (раз уж мы не ломаем коллекцию при итерации по ней – в этом нет большого смысла).
SergVasiliev Автор
01.07.2024 03:21+1попиши на крестиках – там нарушение подобных контрактов не бросит тебе простое и понятное исключение, а приведёт к UB
При чём тут плюсы вообще? В C++ одно поведение, в C# — другое. Речь вообще ни разу не шла о "полагаться на исключения". Очень сомневаюсь, что в C# / .NET кто-то пытается менять итерируемую коллекцию с расчётом на исключения — "контракт" о том, что так в принципе не надо делать.
ЗЫ: В опросе не хватает варианта "мне всё равно".
Если всё равно, можно не голосовать или воздержаться.
aamonster
01.07.2024 03:21+1Так контракт вам никто не мешает соблюдать (лично я его соблюдаю даже в JS, потому как не желаю запоминать, где он есть, а где его нет: мой код должен читаться без привлечения документации). Просто больше никто не будет проверять, что вы его соблюдаете, потому что его нарушение перестало ломать данные.
И да, C++ я привёл в качестве примера, поскольку там был ровно тот же контракт. Просто никто не следил за его выполнением, а нарушение могло сломать вам вообще всё и без всякой диагностики.
aegoroff
01.07.2024 03:21+2Могу предположить, что сделали это лишь для удобства, несколько странно конечно, но как есть. К примеру надо удалить из HashSet, существующие элементы по какому-либо условию, - к примеру (если хэшсет чисел), удалить все четные, которые там есть, если это строки, удалить строки удовлетворяющие каким-либо условиям. Использовать для удаления всего - глупо ибо есть метод Clear, использовать для удаления заранее известных элементов - тоже глупо, ибо итерироваться можно по этому, известному множеству.
А вот в случае что я упомянул, иначе бы пришлось сначала найти все такие элементы, скопировав их в другую коллекцию (привет выделение памяти и дополнительный проход по элементам), и только потом, итерируясь по этой новой коллекции, поменять HashSeta-tk
01.07.2024 03:21Очистка от элементов по какому-то критерию во время итерирования - вполне себе юз-кейс.
ignorance
01.07.2024 03:21Есть встроенная Hashset.RemoveWhere(Predicate)
aegoroff
01.07.2024 03:21+1public int RemoveWhere(Predicate<T> match) { if (match == null) { ThrowHelper.ThrowArgumentNullException(ExceptionArgument.match); } Entry[]? entries = _entries; int numRemoved = 0; for (int i = 0; i < _count; i++) { ref Entry entry = ref entries![i]; if (entry.Next >= -1) { // Cache value in case delegate removes it T value = entry.Value; if (match(value)) { // Check again that remove actually removed it. if (Remove(value)) { numRemoved++; } } } } return numRemoved; }
так она по сути и использует то, что описано в статье
ignorance
01.07.2024 03:21Только она никак не проверяет инкремент версий, соответственно, изменение поведения Remove на ней не должно было отразиться
Hallfire
01.07.2024 03:21так она по сути и использует то, что описано в статье
насколько я понимаю, нет
эта функция использует циклfor
для прохода по внутреннему массиву (при этом, судя по этой реализации метода Remove из внутреннего массива ничего не удаляется, а меняется значение наdefault(T)
)
но не итераторaegoroff
01.07.2024 03:21Не совсем так -
RemoveWhere идет по списку значений, а Remove по списку бакетов, а это не совсем одно и тоже, точнее совсем разное - в один бакет может попасть несколько разных значений (если у них хэш код один), а в списке значений такое невозможно. Но собственно, ни один, ни другой метод не меняют поле _version у коллекции, изменение которого, приводит к инвалидации итератора.
Hallfire
01.07.2024 03:21а это не совсем одно и тоже, точнее совсем разное
да, но в данном случае это не важно, т.к. речь про способ прохода по коллекции
и то что методы не меняют поле
_version
будет играть роль только если использоватьRemove
илиRemoveWhere
внутриforeach
(или если самостоятельно использовать итератор)зы
так она по сути и использует то, что описано в статье
я трактовал это сообщение не как
метод
RemoveWhere
тоже использует внутри себяRemove
но как
метод
RemoveWhere
"пользуется" описанным в статье "багом", поэтому успешно выполняется и не падает при вызовеRemove
соотв. в предыдущем комментарии я и хотел сказать, что это не так, т.к. даже если бы в
Remove
менялось поле_version
, то методRemoveWhere
всё так-же нормально работал бы, из-за того, что в нём не используется итератор, а значит и выкидывать исключение некому
casualkex
01.07.2024 03:21Возможно позволяют такое делать потому что элементы списка имеют порядок, а элементы словаря/сета не обязаны иметь порядка
KamushekDev
Осталось понять зачем они сделали это изменение в Dictionary
mayorovp
Скорее всего - потому что могли. Просто в какой-то момент пришли к такой структуре Dictionary, в которой вызов Remove посреди итерации ничего не ломает.
freeExec
А чего он ломает в списках?
troepolik
При удалении все последующие элементы смещаются и дальше в зависимости от места удаления относительно текущей позиции итерации- ты можешь один и тот же элемент проитерировать 2 раза или пропустить один из элементов.
mayorovp
Проитерировать два раза - это при добавлении, при удалении может случиться только пропуск.