понедельник, 7 мая 2012 г.

Инициализаторы объектов в блоке using

UPDATE: наткнулся на объяснение данного поведения Эриком Липпертом (подробности здесь).

Инициализаторы объектов (Object Initializers) – это полезная возможность языка C#, которая позволяет инициализировать необходимые свойства объекта прямо во время его создания. Поскольку синтаксически эта «фича» очень близка к инициализации объекта с передачей параметров через конструктор, многие разработчики начинают забивать на принципы ООП (в частности на понятие инварианта) и использовать ее, где только можно.

Но даже если не переходить к холиварам и малопонятным терминам, давайте рассмотрим небольшой пример, и подумаем над тем, может ли он привести к проблемам или нет:

// position передается извне или настраиватся каким-то образом
long position = -1;
using (var file = new FileStream("d:\\1.txt", FileMode
.Append)
                        {
                           
// Мы точно знаем, что нужные данные расположены
                            // с некоторым сдвигом!
                            Position = position
                        })
{
   
// Делаем чего-то с файлом
}

В данном фрагменте внутри директивы using создается ресурс (файл) и устанавливается одно из его свойств (Position) с помощью инициализатора объекта. При этом самое главное в этом коде то, что setter этого свойства может генерировать исключение.

В отличие от языка C++ в .NET-ах мы довольно редко сталкиваемся с проблемами безопасностью исключений, но это один из тех редких случаев, когда код является небезопасным с точки зрения исключений.

Чтобы понять это, давайте посмотрим, как реализуются компилятором инициализатор объекта:

class Person
{
   
public string Name { get; set
; }
   
public int Age { get; set
; }
}


// ...
var person = new Person {Name = "Jonh", Age = 42};

С первого взгляда может показаться, что инициализатор объекта – это не что иное, как вызов конструктора с последующим изменением его свойств. И, по сути, так оно и есть, только с небольшим уточнением:

var tmp = new Person();
tmp.Name =
"Jonh"
;
tmp.Age = 42;

var person = tmp;

Временная переменная является необходимым условием «атомарности» инициализации, без которой сторонний код (например, из другого потока) смог бы получить ссылку на объект в промежуточном состоянии. Кроме того, отсутствие временной переменной сделало бы код еще менее безопасным с точки зрения исключений, ведь тогда генерация исключения из setter-а свойства привело бы частичной инициализации переменной:

// _person - это поле класса и, предположим, компилятор 
// разворачивал бы инициализатор «по простому»

var _person = new Person
();
_person.Name =
"Jonh";
_person.Age = 42;

В этом случае, если сеттер одного из свойств упадет с исключением, то поле _person будет уже проинициализировано, но не до конца, что нарушило бы «атомарность» инициализатора объектов, такую привычную по использованию конструкторов.

Однако, хотя временная переменная решает ряд проблем, этого не происходит в случае использования инициализатора объектов внутри директивы using. Как вы знаете, директива using разворачивается в такой код:

var file = new FileStream("d:\\1.txt", FileMode.OpenOrCreate);
try
{}
finally
{
   
if (file != null
)
        ((
IDisposable)file).Dispose();
}

Теперь, если сложить 2 и 2, то мы получим, что наш исходный пример разворачивается в следующее:

long position = -1;
var tmpFile = new FileStream("d:\\1.txt", FileMode.OpenOrCreate);
// Упс! Если мы здесь упадем, то Dispose вызван не будет!
tmpFile.Position = position;
var
file = tmpFile;

try
{ }
finally
{
   
if (file != null
)
        ((
IDisposable)file).Dispose();
}

И это означает, что если свойство, инициализируемое с помощью инициализатора объекта, упадет с исключением, метод Dispose нашего объекта вызван не будет.

Заключение

Инициализаторы объектов – это полезная возможность, но ее нужно еще и уметь готовить использовать. Хотя синтаксически (и семантически, кстати, тоже) эта возможность очень близка к вызову конструктора, но, к сожалению, они не всегда эквиваленты. Что касается принципов ООП, то тут вам самим решать, когда использовать инициализатор объектов, а когда нет, но если речь заходит о безопасности исключений и управлении ресурсами, то тут однозначно нужно понимать, как устроена эта возможность и что из этого следует.

Язык C# отличается довольно предсказуемым поведением в большинстве случаев (хотя есть и исключения, типа тонкостей с изменяемыми значимыми типами), но смешивание инициализаторов объектов с блоком using, интуитивностью не отличается и желательно понимать, как именно устроена эта комбинация, чтобы не отсрелить себе ногу по неосторожности.

17 комментариев:

  1. ИМХО компилер нужно "обучить" генерить такой код:

    long position = -1;
    var tmpFile = new FileStream("d:\\1.txt", FileMode.OpenOrCreate);
    FileStream file = null;

    try
    {
    //Иницализатор работает здесь:
    tmpFile.Position = position;
    //...
    //Конец инициализатора
    file = tmpFile;
    }
    finally
    {
    if (tmpFile != null)
    ((IDisposable)tmpFile).Dispose();
    else if (file != null)
    ((IDisposable)file).Dispose();
    }

    ОтветитьУдалить
  2. @Mastro Ombroj: у меня почему-то есть смутное подозрение, что ждать такого поведения от компилятора не стоит. Простой комбинацией двух этих фич подобный код не получить, а шансов, что под одну фичу будут жестко затачивать другую - мало, поскольку в конечном итоге это приведет к комбинаторному взрыву хаков, и дико усложнить понимание и реализацию этих возможностей.

    Здесь, скорее, нужно понимать, чем это грозит, да и все. Подобных мест в C# довольно много. Вот, к примеру, виртуальные события, для какого количества разработчиков текущее поведение является интуитивно понятным?

    ОтветитьУдалить
  3. Хочу добавить, что если запустить Code Analysis, то будет выдано предупреждение:
    Warning 1 CA2000 : Microsoft.Reliability : In method 'Program.Main(string[])', object '<>g__initLocal0' is not disposed along all exception paths. Call System.IDisposable.Dispose on object '<>g__initLocal0' before all references to it are out of scope.
    (CA2000: удалите объекты до того, как будет потеряна область действия)
    А вот ссылка на русском:
    http://msdn.microsoft.com/ru-ru/library/ms182289.aspx

    ОтветитьУдалить
  4. @biogenez: Да, здорово!

    Здесь проявляется фича анализатора, что он анализирует не исходный код, а скомпилированный. Хотя для многих вид предупреждения может быть неожиданным:)

    ОтветитьУдалить
  5. Сергей, спасибо за интересный лик без. Может быть Вам стоит написать статью по подобным узким местам? Так, сказать, из личного опыта.

    Кстати, ссылка на "виртуальные события" неверна. Должна быть:
    http://sergeyteplyakov.blogspot.com/2011/02/c.html

    ОтветитьУдалить
  6. @Anton: я периодически как раз и пишу о подобных узких местах. То были виртуальные события (спасибо за правильную ссылку), то - замыкания, потом были изменяемые значимые типы и т.п.

    Все эти заметки может быть стоит в одну рубрику объединить, чтобы их искать было легче.

    ОтветитьУдалить
  7. Да было бы неплохо, или хотя бы общий тэг им присваивать.

    ОтветитьУдалить
  8. Оффтоп: когда хочу оставить коммент, то рядом с мемкой коммента показывается аватар пользователя Mastro Ombroj (применительно к данной статье, вероятно это аватарка первого комментатора к статье). Так и должно быть?

    Авы в гуглоплюсе у меня нет.

    ОтветитьУдалить
  9. @Anton: странно все это. Может глюки именно в форме редактирования, потому что в самих комментах аватарки нет.

    ОтветитьУдалить
  10. Никогда так не делал. Может подсознательно :). Но теперь сознательно так делать не буду :)... Показал на работе - обсудили. Знаешь, как-то аргументы о том, что инициализацию нельзя внести под try - не очень убедительны. foreach и замыкания в пятом дотнете же изменили поведение с переносом временной переменной в цикл. Мне кажется такое поведение возможно, но подозреваю это сложно, поэтому и не делается...

    ОтветитьУдалить
  11. @eugene: ну, я не думаю, что эту фичу уж очень сложно реализовать, но ее цена/качество слишком низкое, чтобы Dev Team с нею заморачивался.

    Вон, даже JetBrains пока не сделали ворнинга на такое поведение, так что шансов на исправления в компиляторе, думаю, мало.

    ОтветитьУдалить
  12. Ну тогда не понятно. Поведение с внесением инициализации под try делает поведение ОДНОЗНАЧНО безопаснее. И если это "ничего не стоит" - тогда не понятно почему этого нет в запросах на улучшения.

    ОтветитьУдалить
  13. По-моему, проблема надуманна. ЛЮБОЕ место, потенциально грозящее исключением, должно быть внутри ловушки. Тем более, с такой классической вещью как файлы.

    ОтветитьУдалить
  14. @vt2012: насколько блок catch на высоком уровне поможет избежать недетерминированной очистки ресурсов, которая будет на уровне ниже?

    Опять таки, такая проблема можем быть не только с файлами, но и с любым другим disposable объектом, примем даже если сеттер его свойства не генерирует исключение.

    ОтветитьУдалить
  15. Спасибо за блог. Очень интерестные статьи. Каждый раз узнаю чтото новое

    ОтветитьУдалить
  16. Я под dotPeek и Reflector и даже под IL смотрю и в упор не вижу вот этой вот временной переменной...Может это зависит от версии Framework?

    ОтветитьУдалить
  17. Но вот инициализация последнего примера действительно проходит перед конструкцией using, а переменная уже записывается в using:
    Person person1 = new Person {
    Name = "John",
    Age = 0x21
    };
    using (new FileStream(@"d:\1.txt", FileMode.OpenOrCreate))
    {
    }
    long num = -1L;
    FileStream stream1 = new FileStream(@"d:\1.txt", FileMode.OpenOrCreate) {
    Position = num
    };
    using (stream1)
    {
    }

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