LINQ против LSP

в 14:14, , рубрики: .net, 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<T>, как предлагают мои читатели, является ещё большим нарушением принципа устойчивости, потому что этот интерфейс добавляет 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 (maybe...)
• Distinct(IEqualityComparer) (maybe...)
• 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<T> в качестве аргумента, вместо IEnumerable. Если вы требуете конечную последовательность и вы пишите под версией, выпущенной ранее версии .NET 4.5, требуйте передачи в качестве аргумента массива (и рассмотрите возможность написания пары юнит-тестов, которые проверят то, что ваши методы не изменяют массив).

Автор: EngineerSpock

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js