Ответственный программист
0,0
рейтинг
28 августа 2013 в 18:14

Разработка → LINQ против LSP перевод

В качестве реакции на мой предыдущий пост о защитном программировании, один из моих читателей прислал мне такой вопрос:
[Один] очень известный сценарий защитного программирования встречается, когда входным параметром является
IEnumerable

public class Publisher { public Publisher(IEnumerable<Subscriber> subscribers) { // defensive copy -> good or bad? this.subscribers = subscribers.ToArray(); } // … }


«Вы можете утверждать следующее: когда получатель намеревается использовать IEnumerable, он не должен предполагать, что последовательность неизменяема. Вы можете обозначить это, используя ICollection, к примеру (прим.переводчика.: Я не уверен, что правильно перевёл последнее предложение. Возможно, не понял контекст, либо читатель блога Марка ошибся в своём вопросе. Разве может ICollection обозначать неизменяемую коллекцию, если этот интерфейс привносит методы, изменяющие коллекцию? По поводу перевода - в личку. Спасибо за понимание). В примере, приведённом выше, вызывающая сторона может молча добавить нового подписчика в свой список и автоматически производить инъекцию этого списка в ‘publisher’ (возможно, что именно это и задумал клиент класса). Однако, защитная копия сломает смысл, который ожидает клиент, потому что внедрённый список с этих пор будет находиться вне контроля вызывающей стороны. Это показывает то, как легко деталь реализации может изменить поведение, которое ожидает клиент.
«Причиной того, что вы часто видите подобный код, является наша любовь к неизменяемым объектам и во-вторых, из-за незнаний относительно того, какое влияние может оказать на производительность IEnumerable. Однажды сделав копию, вы можете предсказывать производительность вашего класса, в противном случае – нет.
«Я склоняюсь к тому, чтобы сказать, что делать защитную копию – это плохо (после прочтения множества ваших записей в блоге), однако буду очень рад услышать ваше мнение по этому поводу.

Вопрос требует глубокого ответа.

Инкапсуляция.

IEnumerable является одним из самых недопонимаемых интерфейсов в .NET. Этот интерфейс даёт очень немного гарантий и вызовы большей части методов на нём, могут, вообще говоря, нарушать принцип подстановки Барбары Лисков (LSP – Liskov Substitution Principle). ToArray() является одним из них, потому что он предполагает, что последовательность, производимая итератором конечна, хотя она может и не являться таковой. Таким образом, если вы вызываете ToArray() на бесконечном итераторе, то вы в конечном итоге получите исключение.
Не имеет особого значения то, в каком месте вы вызовете ToArray() – в конструкторе, или в методе класса, где собираетесь использовать IEnumerable. Однако, с точки зрения «отвалиться как можно раньше» и в целях защиты инвариантов класса, если класс требует, чтобы последовательность была конечной, вы можете утверждать, что нужно вызвать ToArray() (или ToList()) в конструкторе. Однако, это ломает 4-й закон IoC Николы Маловича: конструкторы не должны производить никакой работы. Это должно заставить вам остановиться и задуматься: если вам нужен массив, вы должны объявить это требование сразу:


public class Publisher
{
    public Publisher(Subscriber[] subscribers)
    {
        this.subscribers = subscribers;
    }
    //  …
}

Заметьте, что вместо требования IEnumerable, эта версия требует массив и просто присваивает ссылку на него закрытому полю.
Однако, проблема в том, что массив это не совсем итератор. Самая значительная разница состоит в том, что в случае массива, класс Publisher может изменять элементы. Это может стать проблемой, если массив используется и другим клиентским кодом.
Другой проблемой является то, что если класс Publisher не нуждается в обладании возможностью изменять массив, это теперь нарушает принцип устойчивости, потому что конечный итератор был бы достаточно хорош для нужд класса, однако, не следует забывать, что он по-прежнему предъявляет необоснованное требование к своим клиентам.
Запрос передачи ICollection
, как предлагают мои читатели, является ещё большим нарушением принципа устойчивости, потому что этот интерфейс добавляет 7 новых методой поверх
IEnumerable - три из которых, предназначены исключительно для изменения коллекции.

LINQ и LSP

В своём предыдущем посте я говорил о конфликте между IQueryable и LSP, но даже ограничивая дискуссию рамками LINQ to Objects, выясняется, что LINQ содержит множество встроенных нарушений LSP.
Вспомним смысл LSP: вы должны иметь возможность передать любую реализацию интерфейса клиенту без изменения корректности системы. В то время как «корректность» является специфичной для приложения, наименьшим общим кратным должно являться то, что если метод работает корректно для одной реализации интерфейса, то он не должен выбрасывать исключения для другой. Впрочем, рассмотрим две реализации IEnumerable:
new[] { "foo", "bar", "baz" };

и вот такую:

public class InfiniteStrings : IEnumerable<string>
{
    public IEnumerator<string> GetEnumerator()
    {
        while (true)
            yield return "foo";
    }
 
    System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
    {
        return this.GetEnumerator();
    }
}


Как я уже говорил, вызов ToArray() (или ToList()) на этих двух реализациях меняет корректность системы, ибо вторая реализация (бесконечный итератор) станет причиной выброса исключения. Фактически, насколько я знаю, LSP-совместимыми LINQ-методами являются следующие:

  • Any()
  • AsEnumerable()
  • Concat(IEnumerable)
    DefaultIfEmpty()
    DefaultIfEmpty(T)
    Distinct (возможно...)
    Distinct(IEqualityComparer) (возможно...)
    ElementAt(int)
    ElementAtOrDefault(int)
    First()
    FirstOrDefault()
    OfType()
    Select(Func<TSource, TResult>)
    Select(Func<TSource, int, TResult>)
    SelectMany(Func<TSource, IEnumerable>)
    SelectMany(Func<TSource, int, IEnumerable>)
    SelectMany(Func<TSource, IEnumerable>, Func<TSource, TCollection, TResult>)
    SelectMany(Func<TSource, int, IEnumerable>, Func<TSource, TCollection, TResult>)
    Single()
    SingleOrDefault()
    Skip(int)
    Take(int)
    Where(Func<TSource, bool>)
    Where(Func<TSource, int, bool>)
    Zip(IEnumerable, Func<TFirst, TSecond, TResult>)


    Если вы можете обойтись использованием этих LINQ-методов, то вы можете быть спокойны. Если нет, вы возвращаетесь к выбору между IEnumerable или массивом, т.е., между нарушением LSP и принципа устойчивости.
    Это показывает необходимость наличия интерфейса конечного итератора, и, надо признать, что до написания этой статьи, я был не в курсе насчёт существования IReadOnlyCollection, но вот оно что: похоже, что это новый интерфейс, который появился только в .NET 4.5. Я думаю, что теперь начну пользоваться этим интерфейсом.

    Заключение.

    Подводя черту, надо сказать, что защитной копии IEnumerable следует избегать. Если вам удаётся обойтись использованием LSP-совместимых LINQ-методов, то всё хорошо (но рассмотрите возможность написания пары юнит-тестов с использованием бесконечных итераторов). Если вашим требованием является конечная последовательность и вы пишите под .NET 4.5, требуйте передачи IReadOnlyCollection в качестве аргумента, вместо IEnumerable. Если вы требуете конечную последовательность и вы пишите под версией, выпущенной ранее версии .NET 4.5, требуйте передачи в качестве аргумента массива (и рассмотрите возможность написания пары юнит-тестов, которые проверят то, что ваши методы не изменяют массив).
Перевод: Mark Seemann
Фофанов Илья @EngineerSpock
карма
21,0
рейтинг 0,0
Ответственный программист
Реклама помогает поддерживать и развивать наши сервисы

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

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

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

  • –1
    От себя хочу добавить, что до версии 4.5 уже был собственно класс ReadOnlyCollection, но я толком о нём ещё ничего не знаю)))
  • 0
    Лично я побаиваюсь иногда использовать даже FirstOrDefault() ставя его всегда только в конце LINQ-цепочки методов, а посередине спокойнее поставить Take(1) (да, звучит как быдлокод, но на пустой коллекции код выполняется мгновенно, а вот на нулевой ссылке — вываливается исключение)
    • +2
      Ну, FirstOrDefault в середине цепочки для ссылочного типа — это почти гарантированный NullReferenceException. Если уверены, что элемент в коллекции всегда есть — нужно использовать просто First, чтобы это задекларировать.
  • 0
    LSP в определении Роберт С. Мартина:
    Функции, которые используют базовый тип, должны иметь возможность использовать подтипы базового типа не зная об этом.

    Так что ни о каком нарушении LSP интерфейсом речи быть не может. Проблема не в IEnumerable, а в реализации ToArray, расчитывающей на конечность коллекции. ИМХО, автор тут перекладывает с больной головы на здоровую — IEnumerable нигде конечность коллекции не декларирует.
    • 0
      Именно. IEnumerable предполагает только то, что по коллекции можно пройтись, ничего более.
      И кстати это одна из причин не использовать IEnumerable на практике: интерфейс не налагает почти никаких предусловий, что приводит к тому, что реализации интерфейса гарантируемо нарушают принцип LSP, т.к. невозможно реализовать его, не усилив предусловия: требуется либо коннект к базе (в случае с LINQ-2-SQL), либо конечность коллекции (во всех остальных случаях).
      Лично я использую и всем рекомендую использовать IList<>, либо IReadOnlyList<>
  • +1
    Есть еще новая библиотека Microsoft Immutable Collections из BCL, но пока еще в бете. Но очень клевая. Вот там настоящие неизменные коллекции.
  • +1
    «В своём предыдущем посте я говорил о конфликте между IQueryable и LSP, но даже ограничивая дискуссию рамками LINQ to Objects, выясняется, что LINQ содержит множество встроенных нарушений LSP.
    Вспомним смысл LSP: вы должны иметь возможность передать любую реализацию интерфейса клиенту без изменения корректности системы. В то время как «корректность» является специфичной для приложения, наименьшим общим кратным должно являться то, что если метод работает корректно для одной реализации интерфейса, то он не должен выбрасывать исключения для другой. Впрочем, рассмотрим две реализации IEnumerable:»

    С тем же успехом мы можем написать такую структуру:

    class A {}
    class B: A
    {
    public B()
    {
    throw new Exception()
    }
    }

    и говорить, что видите ли наследование нарушает LSP. Мы же не можем подставить B вместо A!

    А может дело не в нем, а в том, как мы используем? LINQ ничего не нарушает, если вы хотите сделать бесконечный массив, он попробует его для вас сделать и выпадет и исключением. Как говорится «Машина дура, что ей скажешь, то она и сделает». А если я буду писать код и не буду уверен, что он в точности будет исполняться, то…
  • 0
    Интересно, каким образом IReadOnlyCollection решает проблему с генераторами (бесконечными итераторами)?

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