понедельник, 24 декабря 2012 г.

О вреде изменяемых значимых типов. Часть 2

Нужно сделать небольшой перерыв во всех этих философских вещах, связанных с управлением зависимостями и вернуться на время к языку 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 при выяснении, почему же код ведет себя именно так, а не иначе.
Дополнительные ссылки

4 комментария:

  1. В первом примере с using и disposable struct в finally точно ли будет type-cast к IDisposable и => боксинг?

    ОтветитьУдалить
  2. А вот это очень сложный вопрос. В спеке написано, что using для значимых (не-nullable) типов разворачивается так:

    {
    ResourceType resource = expression;
    try {
    statement;
    }
    finally {
    ((IDisposable)resource).Dispose();
    }
    }

    Если же посмотреть в ILDASM-е, то будет вот что:

    IL_003a: ldloca.s d
    IL_003c: constrained. Samples.Disposable
    IL_0042: callvirt instance void [mscorlib]System.IDisposable::Dispose()

    Т.е. вызов идет с модификатором constrained, который достаточно накуренный. Например, подобный вызов метода ToString на значимом типе будет приводить к упаковке, если структура не переопределяет ToString и не будет приводить к упаковке, если переопределяет.

    Возможно, этот каст является "логическим", чтобы using работал и при явной реализации интерфейса (что, формально, привело бы к невозможности скомпилить этот код, если бы IDisposable был реализован явно).

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

    ОтветитьУдалить
  3. Очень интересная заметка. Особенно понравилось изменение поведения при наличии даже не использованного замыкания - интересно, такое поведение в только в Debug версии или в Release тоже?
    ЗЫ. Пример с DisposableClosure совершенно непонятный - повторное присваивание после вызова инкремента, ничего не понял :(
    И в последнем примере форматирование немного "поехало" ;)

    ОтветитьУдалить
  4. А разве Dispose не вызывается у структур после того как к ней пропадает доступ? Если да, то зачем использовать using, в конечном счете можно создать блок, если так уж надо
    {
    var a = new Disspose();
    a.job();
    }//А вот с Foreach уже интересно

    ОтветитьУдалить