Страницы

Поиск по вопросам

четверг, 19 декабря 2019 г.

Access to foreach variable in closure

#c_sharp #net


Имею следующий код:

foreach (NetworkInterfaceInfo info in _comps)
{
    Dispatcher.Invoke(() => SpeedLabel.Content += info.NetInterface.Description +
" - " + info.IncomeSpeed + "kbps\n");
}


На что получаю предупреждение:


  Access to foreach variable in closure. May have different behaviour when compiled
with different versions of compiler.


Какое "различное поведение" может произойти и в каких случаях?
    


Ответы

Ответ 1



Смотрите. Цикл foreach (NetworkInterfaceInfo info in _comps) может быть реализован так: NetworkInterfaceInfo info; using (var iterator = _comps.GetEnumerator()) { while (iterator.MoveNext()) { info = iterator.Current; // тело вашего цикла } } А может и так: using (var iterator = _comps.GetEnumerator()) { while (iterator.MoveNext()) { NetworkInterfaceInfo info = iterator.Current; // тело вашего цикла } } В старых версиях использовался первый вариант. При этом если вы выполняете closure по info (то есть, запоминаете текущее значение info в () => SpeedLabel.Content += info.NetInterface.Description...), к моменту фактической отработки функции переменной info может быть уже присвоено другое значение, совсем не то, которое вы ожидали, потому что цикл при этом уже пробежал до конца. Во втором случае у вас на каждую итерацию свежая переменная, и «изменения» info в цикле нет. При этом каждое замыкание общается со своим экземпляром info. Это и есть то самое изменение поведения, о котором вас предупредил компилятор. Ваш код будет бежать по-разному с разными версиями языка. Дополнительное чтение по теме: Closing over the loop variable considered harmful, Closing over the loop variable, part two.

Ответ 2



Это предупреждение возникает из-за наличия двух вещей в вашем коде - цикла foreach и анонимной функции. И цикл и анонимная функция трансформируются компилятором в несколько другой итоговый год, а в данном случае трансформация цикла оказывает влияние на трансформацию анонимной функции. Посмотрим во что это превращается согласно спецификации (которую можно найти в папке куда установлена Visual Studio в подпапке VC#\Specifications\1033) для c# 5.0 и выше. Цикл foreach в Вашем случае превращается примерно в (параграф 8.8.4): { var enumerator = _comps.GetEnumerator(); try { while (enumerator.MoveNext()) { var info = (NetworkInterfaceInfo)enumerator.Current; Dispatcher.Invoke(() => SpeedLabel.Content += info.NetInterface.Description + " - " + info.IncomeSpeed + "kbps\n"); } } finally { enumerator.Dispose(); } } Далее компилятор трансформирует код вызова анонимный функции (параграф 6.5.3). Так как анонимная функция использует внутри себя локальную переменную компилятору нужно превратить её во что-то имеющее большую продолжительность жизни, иначе она просто может не существовать в момент вызова анонимной функции. Поэтому компилятор создаёт примерно такой код: class SpecialClass { public NetworkInterfaceInfo Info; public void AnonimuoysFunction() { SpeedLabel.Content += Info.NetInterface.Description + " - " + Info.IncomeSpeed + "kbps\n"); } } После этого итоговый код цикла будет выглядеть примерно так: { var enumerator = _comps.GetEnumerator(); try { while (enumerator.MoveNext()) { var specialClass = new SpecialClass(); specialClass.Info = (NetworkInterfaceInfo)enumerator.Current; var delegate = new Delegate(specialClass.AnonimuoysFunction); Dispatcher.Invoke(delegate); } } finally { enumerator.Dispose(); } } Как видно из кода - на каждую итерацию цикла создается новый экземпляр SpecialClass, в его поле Info присваивается текущее значение типа NetworkInterfaceInfo, а на метод AnonimuoysFunction этого экземпляра класса создаётся делегат, который в свою очередь передаётся в метод Dispatcher.Invoke. И все работает хорошо, каждый вызов делегата будет вызывать метод в уникальном экземпляре SpecialClass, который будет присваивать в SpeedLabel.Content значение зависимое от значения его поля Info. Но, до c# версии 5.0 спецификация по другому описывала трансформацию цикла foreach компилятором. Отличие было незначительным и заключалось в том, что локальная переменная info объявлялась один раз, до внутреннего цикла while, примерно так: { var enumerator = _comps.GetEnumerator(); try { NetworkInterfaceInfo info; while (enumerator.MoveNext()) { info = (NetworkInterfaceInfo)enumerator.Current; Dispatcher.Invoke(() => SpeedLabel.Content += info.NetInterface.Description + " - " + info.IncomeSpeed + "kbps\n"); } } finally { enumerator.Dispose(); } } Это незначительное отличие сильно влияло на генерацию вызова анонимной функции. Сам генерируемый класс оставался таким же, но его экземпляры (точнее экземпляр) создавались иначе, что приводило к получению примерно такого кода: { var enumerator = _comps.GetEnumerator(); try { var specialClass = new SpecialClass(); while (enumerator.MoveNext()) { specialClass.Info = (NetworkInterfaceInfo)enumerator.Current; var delegate = new Delegate(specialClass.AnonimuoysFunction); Dispatcher.Invoke(delegate); } } finally { enumerator.Dispose(); } } Как видно из кода - для всех итераций цикла создаётся единственный экземпляр SpecialClass, в поле Info которого, на каждой итерации присваивается новое значение типа NetworkInterfaceInfo. Далее на метод этого экземпляра класса создаётся делегат и передаётся в DispatchInvoke. В тот момент, когда функция будет вызвана нет никакой гарантии того, что в поле Info будет находится то значение, которое было записано на соответствующей итерации цикла. В поле Info на момент вызова функции будет последнее успевшее записаться на этот момент значение. Если все функции будут вызваны после выполнения цикла, то значение этого поля будет равно значению переменной присвоенной в процессе последней итерации цикла. Как было верно замечено в других ответах - в данном случае Dispatcher.Invoke сначала выполняет полученный делегат, а только потом возвращает управление обратно. И в данном конкретном примере все действительно будет работать одинаково и в старой версии компилятора и в новой. Но, если код анонимной функции передаваемой в диспетчер со временем поменяется и будет вызывать что-нибудь асинхронно, например используя Task.Run, или будет вызывать какой-нибудь метод, который внутри себя вызывает что-то асинхронно, то тогда эта проблема может проявится и поведение будет отличаться в зависимости от компилятора.

Ответ 3



Дело тут вот в чем. В C# есть такая штука, как замыкания (и их вы тут как раз используете, захватывая в лямбду переменную цикла info). Так вот если анонимная функция захватывает переменную цикла, то есть опасность получить не то поведение, которое ожидалось. Самый известный пример такой проблемы: var actions = new List(); foreach(var i in Enumerable.Range(1, 3)) { actions.Add(() => Console.WriteLine(i)); } foreach(var action in actions) { action(); } обычная логика подсказывает, что вроде бы на консоль должно быть выведено 1 2 3 но это не так (в версиях до C# 5). Вы можете увидеть вы результате 3 3 3 Связано это с тем, что лямбда-функция разворачивается за кулисами в анонимный класс, а внешние переменные, которые эта лямбда захватывает, становятся полями этого класса, то есть отныне хранятся по ссылке, а не по значению. А потому во всех случаях будет использовано значение последней итерации, то есть 3, так как ссылка во всех трех случаях будет использована одна и та же. Стоит отметить, что это было верным вплоть до c# 4 включительно, но начиная с C# 5 это поведение было изменено - захватываемые переменные внтури циклов теперь копируются (именно так, как это подсказывает сделать решарпер), чтобы не вводить программистов в заблуждение

Ответ 4



В версиях C# до 5-й переменная цикла foreach создавалась вне цикла. Это значит, что после выхода из цикла foreach у лямбд будет захвачено последнее значение из перечисления. В C# 5 это поведение было изменено, и переменная создаётся внутри цикла. Возьмём цикл: var list = new List(); foreach (int i in Enumerable.Range(1, 3)) list.Add(() => Console.WriteLine(i)); list.ForEach(a => a()); В C# 4 код получится такой: int i; using (var it = Enumerable.Range(1, 3).GetEnumerator()) { while (it.MoveNext()) { i = it.Current; list.Add(() => Console.WriteLine(i)); } } Будет выведено: 333. В C# 5 уже такой: using (var it = Enumerable.Range(1, 3).GetEnumerator()) { while (it.MoveNext()) { int i = it.Current; list.Add(() => Console.WriteLine(i)); } } Будет выведено: 123. В C# 4 для правильного поведения приходилось копировать значение: foreach (int i in Enumerable.Range(1, 3)) { int j = i; list.Add(() => Console.WriteLine(j)); } Если замыкание будет вызвано после выхода из цикла, то результат может быть неожиданным. В вашем случае это не имеет значения, так как замыкание полностью обрабатывается внутри Dispatcher.Invoke. Также это не имеет значения, если вы используете только C# 5 и выше. Это один из редких случаев, когда разработчики языка решили пожертвовать обратной совместимостью во имя предсказуемого поведения.

Ответ 5



Вам написали много теории, но конкретно по вашему коду ответа не было. А ответ такой: Поскольку Dispatcher.Invoke не возвращает управления пока тело метода не будет выполнено, никакого различия в поведении в вашем случае наблюдаться не будет. Но ваш код все еще можно улучшить, заодно избавившись от предупреждения. Что из себя представляет переменная _comps? Если это список, массив или любая другая неленивая коллекция, то цикл foreach лучше занести внутрь замыкания: Dispatcher.Invoke - не самая быстрая операция. Если _comps - это IEnumerable или любая другая ленивая коллекция, то надо сначала сделать ей ToList(), а потом повторить то же самое. var temp_comps = _comps.ToList(); Dispatcher.Invoke(() => { foreach (NetworkInterfaceInfo info in temp_comps) { SpeedLabel.Content += info.NetInterface.Description + " - " + info.IncomeSpeed + "kbps\n"); } }

Комментариев нет:

Отправить комментарий