Эта статья — продолжение статьи C#: коллекции только для чтения и LSP. Сегодня мы посмотрим на интерфейс IEnumerable с точки зрения принципа подстановки Барбары Лисков (LSP), а также разберемся, нарушает ли этот принцип код, имплементирующий IEnumerable.

LSP и IEnumerable интерфейс


Чтобы ответить на вопрос, нарушают ли классы-наследники IEnumerable LSP принцип, давайте посмотрим, что как вообще можно нарушить этот принцип.

Мы можем утверждать, что LSP нарушен в случае если соблюдено одно из следующих условий:
  • Подкласс класса (или, в нашем случае, интерфейса) не сохраняет инварианты родителя
  • Подкласс ослабляет постусловия родителя
  • Подкласс усиливает предусловия родителя

Проблема с IEnumerable интерфейсом в том, что его предусловия и постусловия не обозначены явно и часто интерпретируются некорректно. Официальные контракты для IEnumerable и IEnumerator интерфейсов не особо нам в этом помогают. Даже более того, различные имплементации интерфейса IEnumerable часто противоречат друг другу.

Имплементации IEnumerable интерфейса


Прежде чем мы погрузимся в имплементации, давайте взглянем на сам интерфейс. Вот код интерфейсов IEnumerable<T>, IEnumerator<T> и IEnumerator. Интерфейс IEnumerable практически не отличается от IEnumerable<T>.

public interface IEnumerable<out T> : IEnumerable
{
    IEnumerator<T> GetEnumerator();
}
 
public interface IEnumerator<out T> : IDisposable, IEnumerator
{
    T Current { get; }
}
 
public interface IEnumerator
{
    object Current { get; }
    bool MoveNext();
    void Reset();
}


Они довольно просты. Тем не менее, различные классы BCL имплементируют их по-разному. Возможно, наиболее показательным примеромм будет имплементация в классе List<T>.

public class List<T>
{
    public struct Enumerator : IEnumerator<T>
    {
        private List<T> list;
        private int index;
        private T current;
 
        public T Current
        {
            get { return this.current; }
        }
 
        object IEnumerator.Current
        {
            get
            {
                if (this.index == 0 || this.index == this.list._size + 1)
                    throw new InvalidOperationException();
                return (object)this.Current;
            }
        }
    }
}


Свойство Current с типом T не требует вызова MoveNext(), в то время как свойство Current с типом object требует:

public void Test()
{
    List<int>.Enumerator enumerator = new List<int>().GetEnumerator();
    int current = enumerator.Current; // Возврашает 0
    object current2 = ((IEnumerator)enumerator).Current; // Бросает exception
}

Метод Reset() также реализован по-разному. В то время как List<T>.Enumerator.Reset() добросовестно переводит Enumerator в начало списка, итераторы не имплементируют их вовсе, так что следующий код работать не будет:

public void Test()
{
    Test2().Reset(); // Бросает NotSupportedException
}
 
private IEnumerator<int> Test2()
{
    yield return 1;
}

Получается, что единственное, в чем мы можем быть уверены при работе с IEnumerable, это то, что метод IEnumerable<T>.GetEnumerator() возвращает ненулевой (non-null) объект энумератора. Класс, имплементирующий IEnumerable, может быть как пустым множеством:

private IEnumerable<int> Test2()
{
    yield break;
}

Так и бесконечной последовательностью элементов:

private IEnumerable<int> Test2()
{
    Random random = new Random();
    while (true)
    {
        yield return random.Next();
    }
}

И это не выдуманный пример. Класс BlockingCollection имплементирует IEnumerator таким образом, что вызывающий поток блокируется на методе MoveNext() до тех пор, пока какой-нибудь другой поток не добавит элемент в коллекцию:

public void Test()
{
    BlockingCollection<int> collection = new BlockingCollection<int>();
    IEnumerator<int> enumerator = collection.GetConsumingEnumerable().GetEnumerator();
    bool moveNext = enumerator.MoveNext(); // The calling thread is blocked
}

Другими словами, интерфейс IEnumerable не дает никаких гарантий о нижележащем множестве элементов, он даже не гарантирует, что это множество конечно. Все, что он нам говорит, — это то, что это множество может быть каким-то образом проитерировано.

IEnumerable и LSP


Итак, нарушают ли LSP классы, имплементирующие IEnumerable? Рассмотрим следующий пример:

public void Process(IEnumerable<Order> orders)
{
    foreach (Order order in orders)
    {
        // Do something
    }
}

В случае если нижележащий тип у переменной orders — List<Orders>, все в порядке: элементы списка могут быть легко проитерированы. Но что если orders на самом деле представляет из себя бесконечный генератор, создающий новый объект каждый раз при вызове MoveNext()?

internal class OrderCollection : IEnumerable<Order>
{
    public IEnumerator<Order> GetEnumerator()
    {
        while (true)
        {
            yield return new Order();
        }
    }
}

Очевидно, метод Process не сработает как задумано. Но будет ли это из-за того, что класс OrderCollection нарушает LSP? Нет. OrderCollection скрупулезно следует контракту интерфейса IEnumerable: он предоставляет новый объект каждый раз, когда его просят об этом.

Проблема в том, что метод Process ожидает от объекта, реализующего IEnumerable, большего, чем этот интерфейс обещает. Нет никакой гарантии, что нижележащий класс переменной orders — конечная коллекция. Как я упомянул ранее, orders может быть экземпляром класса BlockingCollection, что делает бесполезными попытки проитерировать все его элементы.

Чтобы избежать проблем, мы можем просто изменить тип входящего параметра на ICollection<T>. В отличие от IEnumerable, ICollection предоставляет свойство Count, которое гарантирует, что нижележащая коллекция конечна.

IEnumerable и коллекции только для чтения


Использование ICollection имеет свои недостатки. ICollection позволяет изменять свои элементы, что часто нежелательно если вы хотите использовать коллекцию как коллекцию только для чтения. До версии .Net 4.5, IEnumerable интерфейс часто использовался для этой цели.

В то время как это кажется хорошим решением, это накладывает слишком большие ограничения на подребителей интерфейса.

public int GetTheTenthElement(IEnumerable<int> collection)
{
    return collection.Skip(9).Take(1).SingleOrDefault();
}

Это один из наиболее часто встречаемых подходов: использование LINQ для обхода ограничений IEnumerable. Не смотря на то, что такой код довольно прост, он имеет один очевидных недостаток: в нем происходит итерирование коллекции 10 раз, в то время как тот же результат может быть достигнут простым обращением по индексу.

Решение очевидно — использовать IReadOnlyList:

public int GetTheTenthElement(IReadOnlyList<int> collection)
{
    if (collection.Count < 10)
        return 0;
    return collection[9];
}

Нет никакой причины продолжать использовать IEnumerable интерфейс в местах, где вы ожидаете, что коллекция является исчислимой (а вы ожидаете этого в большинстве случаев). Интерфейсы IReadOnlyCollection<T> и IReadOnlyList<T>, добавленные в .Net 4.5, делают эту работу намного проще.

Имплементации IEnumerable и LSP


Что насчет имплементаций IEnumerable, которые нарушают LSP? Давайте взглянем на пример, в котором нижележащим типом IEnumerable<T> является DbQuery<T>. Мы можем получить его следующим образом:

private IEnumerable<Order> FindByName(string name)
{
    using (MyContext db = new MyContext())
    {
        return db.Orders.Where(x => x.Name == name);
    }
}

В этом коде есть очевидная проблема: обращение к БД отложено до тех пор, пока клиентский код не начнет итерировать получившееся множество. Т.к. к этому моменту подключение к БД закрыто, обращение приведет к исключению:

public void Process(IEnumerable<Order> orders)
{
    foreach (Order order in orders) // Exception: DB connection is closed
    {
    }
}

Такая имплементация нарушает LSP, т.к. интерфейс IEnumerable сам по себе не имеет никаких предусловий, требующих наличия открытого подключения к базе данных. Следуя этому интерфейсу, вы должны иметь возможность проитерировать IEnumerable не зависимо от того, существует ли такое подключение. Как мы видим, класс DbQuery усилил предусловия IEnumerable и, таким образом, нарушил LSP.

В общем-то, это не обязательно является признаком плохого дизайна. Ленивые вычисления — довольно распространенный подход при работе с БД. Он позволяет выполнять несколько запросов за одно обращение к БД и таким образом увеличивает общую производительность системы. Ценой здесь является нарушение LSP принципа.

Ссылка на оригинал статьи: IEnumerable interface in .NET and LSP

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


  1. kekekeks
    11.05.2015 14:56
    +3

    Было уже на эту тему же вроде.


  1. a553
    11.05.2015 15:20
    +5

    Сначала вы берёте List как эталонную реализацию и говорите, что Reset, Current и MoveNext могут бросать исключения когда им вздумается:

    единственное, в чем мы можем быть уверены при работе с IEnumerable, это то, что метод IEnumerable.GetEnumerator() возвращает ненулевой (non-null) объект энумератора
    А потом запрещаете другой реализации кидать исключения:
    Такая имплементация нарушает LSP, т.к. интерфейс IEnumerable сам по себе не имеет никаких предусловий, требующих наличия открытого подключения к базе данных.
    Здесь либо и то, и другое нарушают LSP, потому что накладывают дополнительные ограничения на вызовы методов, либо ничто не нарушает.
    метод Process ожидает от объекта, реализующего IEnumerable, большего, чем этот интерфейс обещает
    Вовсе нет. Нигде не объявлено, что Process работает с конечной коллекцией. Бесконечно обрабатывать запросы — вполне валидная задача.


    1. vkhorikov Автор
      11.05.2015 15:37

      Сначала вы берёте List как эталонную реализацию и говорите, что Reset, Current и MoveNext могут бросать исключения когда им вздумается:

      Нет, я как раз пишу, что Reset и Current могут бросать исключения как им вздумается (исходя из нескольких реализаций в .NET), а вот MoveNext всегда возвращает bool, и не имеет при этом никаких предусловий. DbQuery нарушает принцип, потому что усиливает предусловия именно в MoveNext, а не в Reset или Current. Думаю стоило обозначить это более явно.

      Вовсе нет. Нигде не объявлено, что Process работает с конечной коллекцией. Бесконечно обрабатывать запросы — вполне валидная задача.

      Тут тоже наверное нужно было подобрать более подходящий пример. Этим примером я хотел показать, что Process ожидает именно конечную коллекцию. То, что бесконечная обработка запросов является валидной задачей — это безусловно.


      1. a553
        11.05.2015 15:39

        MoveNext всегда возвращает bool, и не имеет при этом никаких предусловий
        Ещё как имеет.

        IEnumerable<int> list = new List<int> { 1 };
        IEnumerator<int> etor = list.GetEnumerator();
        ((List<int>)list).Add(2);
        etor.MoveNext(); // exception
        


        1. vkhorikov Автор
          11.05.2015 15:47
          +2

          Да, этот момент я пропустил. Согласен, предусловия у MoveNext есть, но все же они не требуют наличия коннекта к БД (если за эталон брать реализацию List-а и пары других реализаций в BCL). Тут опять же вопрос — что брать за эталон, т.к. явных контрактов эти интерфейсы не объявляют.


  1. vkhorikov Автор
    11.05.2015 15:36
    +1

    (ошибся веткой)


  1. mird
    11.05.2015 16:55

    Я, честно говоря, по названию полагал, что будут обсуждаться библиотечные реализации IEnumerable, которые нарушают LSP, а автор обсуждает вместо этого 3 кастомные реализации, причем две из них абсолютно валидны с точки зрения LSP, а третья (связанная с базой данных), откровенно некорректная, но не из-за Принципа Подстановки Лисков, а потому что не выполняет того для чего она сделана (не возвращает последовательность строк из бд) вследствие ошибки реализации.


    1. withkittens
      11.05.2015 18:17
      +1

      а потому что не выполняет того для чего она сделана
      Ну почему, реализация обещает вернуть нечто итерируемое, а не проитерированное, контракт соблюдён. Готовая последовательность строк из бд — это про IReadOnlyCollection(Of T)/IReadOnlyList(Of T)


      1. mird
        11.05.2015 21:47

        Реализация обещает итерируемое, но при попытке его проитерировать — бросает всегда исключение. Это явно не то поведение, которое закладывал разработчик. Так зачем обсуждать эту реализацию? В правильной реализации будет создаваться дб контекст при получении экземпляра итератора, и диспозится при вызове метода Dispose() итератора.


  1. withkittens
    11.05.2015 18:24
    +3

    Свойство Current с типом T не требует вызова MoveNext(), в то время как свойство Current с типом object требует:
    Что, кстати, по идее, ошибка реализации:
    After an enumerator is created or after the Reset method is called, the MoveNext method must be called to advance the enumerator to the first element of the collection before reading the value of the Current property; otherwise, Current is undefined.
    MSDN


  1. muradovm
    11.05.2015 23:06

    >> public int GetTheTenthElement(IEnumerable collection)
    >> public int GetTheTenthElement(IReadOnlyList collection)
    я завидую, с какой легкостью ужесточается интерфейс метода


  1. niq
    12.05.2015 13:28

    Разве Skip и Take 10 раз итерируют перечиление? Один обход там только будет, насколько я помню («Не смотря на то, что такой код довольно прост, он имеет один очевидных недостаток: в нем происходит итерирование коллекции 10 раз»)


    1. mird
      12.05.2015 15:16
      +2

      Он имеет ввиду, что там будет сделано 10 раз MoveNext, тогда как если брать по индексу — одна операция.