Pull to refresh

IEnumerable интерфейс в C# и LSP

Reading time5 min
Views43K
Original author: Vladimir Khorikov
Эта статья — продолжение статьи 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
Tags:
Hubs:
+7
Comments13

Articles

Change theme settings