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

Автор: Сергей Тепляков
Опубликовано: 11.08.2012
Версия текста: 1.1
Небольшое дополнение: для чего нужен вызов Dispose?

Я думаю, что многие разработчики знают, что цикл 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<int> {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#.

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

PPS Кстати, блок foreach это далеко не единственный пример утиной типизации в языке C#. А cколько еще примеров вы можете вспомнить?