Пользователь
0,0
рейтинг
1 августа 2012 в 21:42

Разработка → Duck typing или “так ли прост старина foreach?”

.NET*
Я думаю, что многие разработчики знают, что цикл foreach в языке C# не так прост, каким он кажется на первый взгляд. Для начала давайте ответим на вопрос: «А что нужно, чтобы конструкция foreach успешно компилировалась?». Интуитивным ответом на этот вопрос кажется что-то типа: «Реализация классом интерфейса IEnumerable или IEnumerable<T>.». Однако, это не так, ну, или не совсем так.

Полный ответ на этот вопрос такой: «Для того чтобы конструкция foreach успешно компилировалась необходимо, чтобы у объекта был метод GetEnumerator(), который вернет объект с методом MoveNext() и свойством Current, а если такого метода нет, то тогда будем искать интерфейсы IEnumerable и IEnumerable<T>».

Причин у такого «утиного» поведения две.


Давайте вспомним старые времена языка C# 1.0, когда язык был простым и понятным, и в нем не было никаких обобщений (generics), LINQ-ов и других замыканий. Но раз не было generic-ов, то «обобщение» и повторное использование было основано на полиморфизме и типе object, что, собственно и делалось в классах коллекций и их итераторов.

В качестве этих самых итераторов выступали пара интерфейсов IEnumerable и IEnumerator, при этом последний в свойстве Current возвращал object. А раз так, то использование интерфейса IEnumerator для перебора элементов строго типизированной коллекции значимых типов приводило бы к упаковке и распаковке этого значения на каждой итерации, что, согласитесь, может быть весьма накладно, когда речь идет о столь распространенной операции как перебор элементов.

Чтобы решить эту проблему и было принято решение использовать хак с утиной типизацией, и забить немного на принципы ООП в угоду производительности. В таком случае, класс мог реализовать интерфейс IEnumerable явно и предоставить дополнительный метод GetEnumerator(), который бы возвращал строготипизированный енумератор, свойство Current которого возвращало конкретный тип, например, DateTime без какой либо упаковки.

Ок. Мы разобрались с «динозаврами», а как насчет реального мира? Ведь на дворе, все же не каменный век, СОМ-ы уже дали дуба, Дон Бокс уже не пишет книг, и в нашу с вами дверь уже во всю ломятся гики, навязывая нам всякие функциональные вкусности. Есть ли какие-то выгоды от подобного поведения сейчас?

Можно подумать, что после появления обобщенных версий интерфейсов IEnumerable<T> и IEnumerator<T> трюк с утиной типизацией уже не нужен, но это не совсем так. Если посмотреть внимательно на классы коллекций, такие как List<T>, то можно обратить внимание, что этот класс (как и все остальные коллекции в BCL) реализуют интерфейс IEnumerable<T> явно (explicitely), предоставляя при этом дополнительный метод GetEnumerator():

// Псевдокод!
public class List<T> : IEnumerable<T>
{
    // Итератор класса List<T>
    // Это структура, причем изменяемая!!!
    public struct Enumerator : IEnumerator<T>, IDisposable
    { }

    public List<T>.Enumerator GetEnumerator() { return new Enumerator(this); }

    // Явная реализация интерфеса
    IEnumerator<T> IEnumerator<T>.GetEnumerator()
    {
        return GetEnumerator();
    }
}


Да, все правильно. Метод GetEnumerator() возвращает экземпляр итератора, который является изменяемой структурой (ведь итератор содержит «указатель» на текущий элемент списка). А изменяемые значимые типы по мнению многих, являются самой острой пилой на платформе .NET, способной искалечить ногу даже весьма опытным разработчикам.

ПРИМЕЧАНИЕ
Да-да. Я знаю, что я уже все уши прожужжал изменяемыми енумераторами вообще и изменяемыми значимыми типами в частности, но здесь-то мы с вами помимо всего прочего попробуем найти объяснение причин такого поведения. Так что потерпите еще немного:)

Все дело в том, что использование структуры в качестве итератора совместно с «утиной» природой цикла foreach предотвращает от выделения памяти в куче при использовании этой конструкции:

var list = new List<int> {1, 2, 3};
            
// Вызываем List<T>.Enumerator GetEnumerator
foreach(var i in list)
{}

// Вызываем IEnumerable<T> GetEnumerator
foreach(var i in (IEnumerable<int>)list)
{} 


В первом примере, за счет «утиной» природы вызывается метод GetEnumerator() класса List, возвращающий объект значимого типа, который будет спокойно себе жить в стеке без каких либо дополнительных выделений памяти в управляемой куче. Во втором же случае, мы приводим переменную list к интерфейсу, что приведет к вызову метода интерфейса и, соответственно, упаковке итератора. Да, разработчики языка C# положили на полиморфизм и ряд других принципов ООП только ради повышения эффективности.

var list = new List {1, 2, 3};

var x1 = new { Items = ((IEnumerable<int>)list).GetEnumerator() };
while (x1.Items.MoveNext())
{
    Console.WriteLine(x1.Items.Current);
}
            
Console.ReadLine();
 
var x2 = new { Items = list.GetEnumerator() };
while (x2.Items.MoveNext())
{
    Console.WriteLine(x2.Items.Current);
}


Именно по этой причине первый цикл while выведет ожидаемые 1, 2, 3, а второй цикл while … ну, проверьте сами.

Подобное решение (использование изменяемой структуры) кажется сверх микро оптимизацией, но не стоит забывать, что циклы foreach могут быть вложенными, и что не все работают на многоядерных процессорах с гигабайтами памяти. Прежде чем принять такое решение, команда BCL провела серьезные исследования, которые показали, что использование структур действительно того стоит.

ПРИМЕЧАНИЕ
Только не стоит сразу же пользоваться этим примером при реализации собственных итераторов или других вспомогательных классов. Использование структур – это оптимизация сама по себе, использование же изменяемых структур – это серьезнейшее решение, так что вы должны очень четко отдавать себе отчет в том, какие выгоды вы получаете, что готовы настолько пожертвовать безопасностью.

Небольшое дополнение: а для чего нужен вызов Dispose?


Еще одной особенностью реализации цикла foreach является то, что он вызывает метод Dispose итератора. Ниже представлена упрощенная версия кода, генерируемая компилятором при переборе переменной list в цикле foreach:

{
var enumerator = list.GetEnumerator();
try
{
    while(enumerator.MoveNext())
    {
        int current = enumerator.Current;
        Console.WriteLine(current);
    }
}
finally
{
    enumerator.Dispose();
}
}


Может возникнуть резонный вопрос о том, откуда у итератора могут возникнуть управляемые ресурсы? Ну, да, при переборе коллекции в памяти и правда им взяться не откуда, но не стоит забывать, что енумераторы в языке C# могут использовать не только как итераторы для коллекций в памяти; нам никто не мешает сделать итератор, возвращающий построчно содержимое файла:

public static class FileEx
{
    public static IEnumerable<string> ReadByLine(string path)
    {
        if (path == null)
            throw new ArgumentNullException("path");
        return ReadByLineImpl(path);
    }

    private static IEnumerable<string> ReadByLineImpl(string path)
    {
        using (var sr = new StreamReader(path))
        {
            string s;
            while ((s = sr.ReadLine()) != null)
                yield return s;
        }
    }
}
foreach(var line in FileEx.ReadByLine("D:\\1.txt"))
{
    Console.WriteLine(line);
}


Итак, у нас есть метод ReadByLine, в котором мы открываем файл, безусловно, являющийся ресурсом и закрывает … когда? Явно не каждый раз, когда управление покидает метод ReadByLineImpl, ведь тогда я закрою его столько раз, сколько в этом файле находится строк.
На самом деле, файл будет закрыт один раз, как раз таки при вызове метода Dispose итератора, который происходит в блоке finally цикла foreach. Это один из тех редких случаев на платформе .NET, когда блок finally не вызывается автоматически, а вызывается исключительно «ручками». Так что если вы вдруг будете итерироваться по некоторой последовательности вручную, то не стоит забывать о том, что итератор может-таки содержать ресурсы, и было бы очень даже неплохо очистить их с помощью явного вызова метода Dispose итератора.

ПРИМЕЧАНИЕ
Подробнее об итераторах в языке C# можно почитать в заметке … Итераторы в языке C#.

З.Ы. А кто сразу сможет ответить на такой вопрос: а зачем мне нужно два метода ReadByLine и ReadByLineImpl, почему бы мне не воспользоваться лишь одним методом?

З.Ы.Ы. Кстати, блок foreachэто далеко не единственный пример утиной типизации в языке C#, а сколько еще примеров вы можете вспомнить?
Сергей Тепляков @SergeyT
карма
196,5
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Спецпроект

Самое читаемое Разработка

Комментарии (35)

  • +3
    Навскидку, инициализаторы коллекций. Достаточно иметь метод Add.
  • +2
    Про duck typing в foreach для .net\C# уже было на хабре
  • +4
    Linq весь — тоже duck typing, благодаря чему можно делать linq monads
    • +1
      Не совсем понял в каком месте там duck typing, поясните пожалуйста.
      • 0
        Чтобы компилировались выражения запроса достаточно, чтобы компилятор нашел метод Select, Where и т.д. При этом даже не важно, как компилятор их найде: как статические методы, методы расширения или экземплярные методы.
      • +1
        var q = from x in a select x.B

        Для выполнения этого кода необходимо, что бы существовал метод Select либо в самом a, либо как extension метод для a. Тоже самое для джойнов, групп и так далее. А используя SelectMany можно комбинировать вызовы — from x in a from y in b select new {x,y}

  • +3
    Может возникнуть резонный вопрос о том, откуда у итератора могут возникнуть управляемые ресурсы?

    Откуда-откуда… открыли коннекшн в БД, вот вам и ресурс. Что характерно, в EF именно так и работает — соединение (при неявном управлении ими) открывается при первом обращении к итератору, а закрывается после его использования.
  • +2
    Еще можно добавить, в пятом шарпе foreach изменил свое поведения и теперь переменная итератора объявляется во внутреннем контексте, а не во внешнем.
    • +1
      Да, причем я не зря в псевдокоде разворота цикла foreach объявил переменную цикла current именно внутри while-а, а не снаружи. И еще, туц.
  • +1
    хак с утиной типизацией, и забить немного на принципы ООП

    разработчики языка C# положили на полиморфизм и ряд других принципов ООП

    В статье так говорится об утиной типизации, будто строгая типизация и ООП — это буквально синонимы. Однако существуют строго-типизированные не-ООП языки и ООП-языки с динамической (в том числе и утиной) типизацией.
    • 0
      Ну, здесь как минимум забили на полиморфизм.
  • +1
    Кстати, в копилку знаний, если компилятор, определяет, что в данном foreach обрабатываются только массивы (даже если мы его пропустили через ICollection), то IL код получается с перебором массива, без итератора и заметно более быстрый, чем если этот foreach иногда подхватывает и коллекции. Причем, нередко такой foreach еще и оптимальнее for для массивов. Для проверки сделайте метод, который берет ICollection на вход и несколько замеров с массивом, списком, списком + массивом по очереди.

    Update:
    Пруф с IL листингом: abi.exdream.com/Blog/post/2009/04/22/For-vs-Foreach-Performance.aspx
  • 0
    Поясните что в этом примере происходит? Почему он не работает?
    var x2 = new { Items = list.GetEnumerator() };...
    • +1
      Тут Items является свойством, с помощью него инкапсулируется экземпляр энумератора значимого типа. При его получении x2.Items он всегда копирует инкапсулируемое поле. Таким образом, x2.Items.MoveNext() никогда не сдвигает курсор на следующий элемент, а x2.Items.Current всегда содержит значение по-умолчанию.
      • 0
        > Тут Items является свойством

        А… никогда не понимал, стремление везде использовать свойства.
        • 0
          Дык, тут можно и с readonly полем пофокусничать:))

          class Program
          {
              static readonly List<int>.Enumerator Items = (new List<int> { 1, 2, 3 }).GetEnumerator();
              static void Main(string[] args)
              {
                  while (Items.MoveNext())
                  {
                      Console.WriteLine(Items.Current);
                  }
              }
          }
          
          • 0
            То есть для компилятора readonly — это тоже самое что свойство {get;}? Печально, что разработчики компилятора упростили себе задачу.
            • 0
              Мне даже стало интересно, чем они должны отличаться, и как это относится к данному случаю.
              • 0
                Там довольно букав, чтобы не повторяться я даже ссылку давал на заметку под названием «О вреде изменяемых значимых типов».
                • 0
                  Да я-то понимаю, в чем тут дело. Чего я не понимаю — так это какого поведения vf1 ожидает от readonly-поля.
              • 0
                Если в двух словах, то для readonly полей мы всегда получаем копию.
              • 0
                Мне всегда казалось, что readonly более строгое утверждение, и соот-но возможны оптимизации которые невозможны для getter. Это же value type, а не ссылка, было бы более логично если бы для стуктуры readonly «заражал» бы всю структуру.
                Я не понимаю, в нынешнем ввиде что такое readonly для стуктуры, префикс для каких переменных?! Больше похоже на заплатку, по принципу «чтоб было».
                • 0
                  Это же value type, а не ссылка, было бы более логично если бы для стуктуры readonly «заражал» бы всю структуру.

                  Нет ничего логичного, более того, это нарушение инкапсуляции.

                  Я не понимаю, в нынешнем ввиде что такое readonly для стуктуры, префикс для каких переменных?!

                  Не бывает readonly для структуры, бывает readonly для поля.
                  • 0
                    более того, это нарушение инкапсуляции.


                    Структуры всегда отличались от классов.

                    Не бывает readonly для структуры, бывает readonly для поля.

                    Не придерайтесь к словам, я имел ввиду это:
                    readonly Struct Member;

                    Что здесь значит этот префикс? readonly для каких конкретно данных?
                    • 0
                      Структуры всегда отличались от классов.

                      Это еще не повод нарушать инкапсуляцию.

                      Что здесь значит этот префикс? readonly для каких конкретно данных?

                      Для поля.
                      • 0
                        Поле в данном случае разве не вся структура целиком? Если нет, то что именно?
                        • 0
                          Поле — это поле. То, как его содержимое хранится, не должно влиять на его семантику.
                          • 0
                            Но оно влияет, см. тот пример с которого мы начали разговор! И поэтому поле не может быть просто полем.
                            • 0
                              Нифига. Семантика поля не меняется, потому что так себя ведет любое value-type поле.

                              Проблема не в том, что это поле, а в том, что C# вообще позволяет мутабельные value types.
                              • 0
                                Ну вот, у нас уже есть value-type поля и поля. Тогда повторю вопрос, что именно readonly для value-type поля?
                                • 0
                                  Что этому полю нельзя присвоить значение. Полю, не его содержимому.
                                  • 0
                                    Вообще-то я спросил другое. Мне кажется, свою точку зрения я донес, с вами не согласен, спасибо за ответы, на этом заканчиваю.
    • +1
      Здесь создается анонимный тип с одним свойством Items являющийся структурой (поскольку у List a итератор реализован в виде структуры). Далее при каждом обращении к свойству Items в цикле будет возвращена копия именно поэтому мы зациклимся.
  • +2
    «а зачем мне нужно два метода ReadByLine и ReadByLineImpl, почему бы мне не воспользоваться лишь одним методом?»
    IEnumerable не вычисляется до момента обращения, поэтому проверку входных параметров делаем отдельно и отлавливаем сразу ошибки, а само вычисление будет отложенным, Как-то так.
  • 0
    СОМ-ы уже дали дуба

    А как же WinMD?

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.