Автор: Sergey Teplyakov

Нужно сделать небольшой перерыв во всех этих философских вещах, связанных с управлением зависимостями и вернуться на время к языку C#.
В одной из прошлых заметок я писал о том, что изменяемые значимые типы являются достаточно опасным инструментом, который в неумелых руках может привести к неожиданному поведению и трудноуловимым ошибкам. В общем, дело это хорошее, но опасное, а сегодня будет еще пара примеров, подтверждающих все эти мысли.
Disposable структуры
Предположим, у нас есть простенькая структура, реализующая интерфейс IDisposable:
struct Disposable : IDisposable
{
public bool Disposed { get, private set, }
public void Dispose() { Disposed = true, }
}
Если данная структура будет инициализироваться прямо в блоке using, то поведение будет вполне предсказуемым: мы получим обычное управление ресурсами, безопасное с точки зрения исключений. Но что, если переменна будет объявлена вне блока using, а выше, а блок using будет использован лишь для очистки ресурсов?
var d = new Disposable(),
using
(d)
{
// Используем объект d
}
// Выведет: Disposed: true? или Disposed: false?
Console.WriteLine('Disposed: {0}', d.Disposed),
Если бы типом Disposable был класс, то мы бы увидели на экране ожидаемое Disposed: True, однако в случае структур (а это пример изменяемой структуры), поведение будет иным и мы увидим: Disposed: False.
Дело все в том, что блок using разворачивается немного по иному для структур, и в нашем случае мы получим следующий сгенерированный код:
var d = new Disposable(),
{
// Дополнительная область видимости для того, чтобы нельзя было
// использовать переменную, объявленную в блоке using за его пределами
var tmp = d, try
{
// Тело блока using
}
finally
{
// Проверка на null для обычных (не-nullable) структур не нужна
((IDisposable)tmp).Dispose(),
}
}
Главное отличие в этом случае заключается в том, что внутри блока using используется не оригинальная переменная d, а ее копия, что и приводит к тому, что освобождается временная переменная, а состояние исходного объекта остается неизменным.
Блоки using и foreach, и readonly переменные
Однако на этом проблемы с блоком using не заканчиваются.
В языке C# невозможно напрямую объявить, что некоторая локальная переменная предназначена только для чтения (т.е. readonly локальную переменную), существуют поля только для чтения, но локальных переменные только для чтения не бывает. Строго говоря, это не совсем так, поскольку есть как минимум два способа объявления переменной, модификация которой запрещена. Речь идет о цикле foreach и блоке using:
using (var d = new Disposable())
{
// Переменую нельзя переприсвоить
d = new
Disposable(),
// Нельзя инкрементировать ее свойство
d.Counter
++,
// Нельзя передавать по ref или out
PassByRef(ref d),
}
Поведение с переменной цикла foreach будет аналогичным. Компилятор запрещает повторное присваивание переменной, передачу по ref или out, а также использование операторов инкремента или декремента для свойств такой структуры.
Все это очень сильно напоминает поведение обычных readonly полей . Напомню, что readonly поля очень плохо дружат с изменяемыми структурами, поскольку каждый раз при доступе к такому полю мы получаем ее копию (что действительно гарантирует то, что структура не изменится). Поскольку это поведение не вполне очевидно, то может приводить к серьезным проблемам сопровождения.
Давайте изменим нашу структуру Disposable таким образом, чтобы мы могли модифицировать ее состояние с помощью метода, например, IncrementCounter:
struct Disposable : IDisposable
{
public int Counter { get, set, }
public void IncrementCounter()
{
Counter++,
}
public void Dispose() { }
}
Тогда с полем только для чтения мы получим следующее поведение:
class Readonly
{
public readonly Disposable D = new Disposable(),
}
var readonlyInstance = new Readonly(),
// Ошибка компиляции!
readonlyInstance.D.Counter++,
// Модифицируем копию!
readonlyInstance.D.IncrementCounter(),
// Получим Counter: 0
Console.WriteLine('Counter: {0}', readonlyInstance.D.Counter),
ПРИМЕЧАНИЕ
Более подробно об этом можно почитать в разделе Изменяемые значимые типы и модификатор readonly.
Теперь давайте посмотрим на то, как ведут себя переменные только для чтения, объявленные в блоке using. Наш метод IncrementCounter позволяет обойти ограничение компилятора и модифицировать структуру, вот только вопрос: будет ли модифицирована копия, как и в случае readonly полями или нет?
using (var d = new Disposable())
{
// Все еще не компилируется
// d.Counter++,
// d.Counter == 0
d.IncrementCounter(),
// Выводит Counter: 0 или Counter: 1 ?!?
Console.WriteLine('Counter: {0}', d.Counter),
}
К сожалению (позднее будет понятно, почему именно к сожалению) поведение readonly переменных отличается от поведения настоящих readonly полей и в данном случае мы получим на экране Counter: 1. Все дело в том, что доступ к readonly полю всегда сопровождается созданием копии, а в случае с readonly переменной нет.
Добавляем замыкания
Однако на этом странности поведения не заканчиваются.
Как вы, наверное, знаете, существует довольно простой способ в языке C#, превращения локальной переменной в поле класса. Для этого достаточно создать анонимный метод (анонимный делегат или лямбда-выражения), который захватит эту переменную в своем теле. Какое это имеет отношение к нашей теме? Самое прямое!
using (var d = new Disposable())
{
Action a = () =>, Console.WriteLine(d),
// d.Counter == 0
d.IncrementCounter(),
// Выводит Counter: 0!!
Console.WriteLine('Counter: {0}', d.Counter),
}
Простое добавление лямбда-выражения, которое использует переменную d меняет поведение существующего кода, и, в результате, вместо Counter: 1, мы получаем Counter: 0!
Все дело в том, что наличие замыкания приводит к тому, что переменная d становится readonly полем сгенерированного класса, что приводит к тому, что каждое обращение к ней ведет к созданию локальной копии!
class DisposableClosure
{
// Поле не readonly, но трактуется именно так
public
Disposable d,
public void Action() { Console.WriteLine(d), }
}
var closure = new DisposableClosure
(),
closure.d =
new Disposable(),
Disposable temp,
try
{
var action = new Action(closure.Action),
// Доступ к d идет не напрямую, а через временную переменную
temp = closure.d,
temp.IncrementCounter(),
temp = closure.d,
Console.WriteLine('Counter: {0}', temp.Counter),
}
finally
{
temp = closure.d,
((
IDisposable)temp).Dispose(),
}
В результате, как мы видим, поведение изменяется и мы получаем аналогичные проблемы, как и с честными readonly полями. По заявлению Эрика Липперта это является известным багом компилятора, хотя мне теперь сложно сказать, в чем именно заключается баг и какое поведение является ожидаемым: должна ли делаться копия без замыкания или копии не должно быть при наличии замыкания!
ПРИМЕЧАНИЕ
Подробнее об о том, как устроены замыкания можно почитать в заметке: Замыкания в языке C#.
Решается эта проблема достаточно просто: нужно добавить локальную переменную и замыкаться именно на нее, аналогично тому, как мы бороли проблему с замыканием на переменную цикла в C# 3.0 4.0.
Заключительный штрих. Цикл foreach
В языке C# существует два способа объявления локальных переменных только для чтения: цикл foreach и блок using. Поэтому не удивительно, что проблемы с замыканиями ведут себя одинаково в обоих случаях: добавление замыкания на переменную цикла изменяет поведение и приводит к созданию копии при каждом обращении к переменной:
var disposables = new[] { new Disposable() },
foreach (var d in
disposables)
{
// Наличие замыкания ведет к обращению к d через копию!
Action a = () =>, Console
.WriteLine(d),
d.IncrementCounter(),
Console.WriteLine('Counter: {0}', d.Counter),
}
Наличие замыкания (которое стало безопасным в C# 5.0) ведет к тому, что на экране мы увидим Counter: 0, а его отсутствие к выводу Counter: 1!
Это еще один пример того, что изменение поведения циклов foreach в C# 5.0 могут привести к поломке работающего кода. В предыдущих версиях языка C# проблема замыкания на переменную цикла была очень известной и боролись с ней с помощью добавления локальной переменной, которую затем использовали в замыкании. Однако та локальная переменная приводила не только к захвату корректного экземпляра переменной цикла, но и предотвращала проблему с созданием копии при каждом обращении к переменной цикла, если переменная цикла являлась структурой!
var disposables = new[] { new Disposable() },
foreach (var d in
disposables)
{
// Использование временной переменной решает все проблемы
var
temp = d,
Action a = () =>, Console.WriteLine(temp),
temp.IncrementCounter(),
// Выводится: Counter: 1
Console.WriteLine('Counter: {0}', temp.Counter),
}
Заключение
Изменяемые значимые типы довольно опасная штука, как правильно писал Эрик, главное отличие значимых типов от ссылочных типов заключается не в месте их аллокации и времени жизни, а в семантике значения. Тот факт, что структуры передаются и возвращаются по значению играют злую шутку, когда компилятор начинает выполнять определенные действия у нас за спиной. И сегодня мы увидели пару таких примеров, которые могут привести к тотальному недоразумению со стороны команды разработчиков и длительному WTF при выяснении, почему же код ведет себя именно так, а не иначе.
Дополнительные ссылки
  • О вреде изменяемых значимых типов
  • Observable.Generate и перечисление списков
  • Замыкания в языке C#
  • Замыкания на переменных цикла в C# 5.0
  • SO. C# Struct instance behavior changes when captured in lambda
Помогла статья? Оцените её!
0 из 5. Общее количество голосов - 0
 

You have no rights to post comments

Дмитрий Крикунов

Публикую статьи, обучающие курсы и новости по программированию: алгоритмам, языкам (С++, Java), параллельному программированию, паттернам и библиотекам (Qt, boost).