25 April 2015

C#: коллекции только для чтения и LSP

Programming.NETDesigning and refactoringC#
Translation
Original author: Vladimir Khorikov
Часто разработчики утверждают, что read-only коллекции в .NET нарушают принцип подстановки Барбары Лисков. Так ли это? Нет, это не так, потому что IList интерфейс содержит флаг IsReadOnly. Исключением является класс Array, он действительно нарушает LSP принцип начиная с версии .NET 2.0. Но давайте разберемся во всем по порядку.


История read-only коллекций в .NET


На диаграмме показано как read-only коллекции эволюционировали в .NET от версии к версии:



Как вы видите, интерфейс IList содержит два свойства: IsReadOnly и IsFixedSize. Изначальная идея была в том, чтобы разбить эти два понятия. Коллекция могла быть коллекцией только для чтения (read-only), что означало, что ее нельзя было изменить вообще никак; с другой стороны, коллекция так же могла быть фиксированного размера (fixed size), т.е. в ней можно было изменять существующие элементы, но добавлять новые или удалять имеющиеся было нельзя. Другими словами, коллекции с флагом IsReadOnly равным true всегда были IsFixedSize, но IsFixedSize коллекции не всегда были IsReadOnly.

Таким образом, если вы хотите создать свою коллекцию только для чтения, вам было бы необходимо имплементировать оба свойства (IsReadOnly и IsFixedSize) так, чтобы они возвращали true. В BCL во времена .NET 1.0 не было втроенных read-only коллекций, но архитекторы заложили фундамент для будущих реализаций. Изначальный замысел был в том, что разработчики могли бы использовать такие коллекции полиморфно примерно следующим образом:

public void AddAndUpdate(IList list)
{
    if (list.IsReadOnly)
    {
        // No action
        return;
    }
 
    if (list.IsFixedSize)
    {
        // Update only
        list[0] = 1;
        return;
    }
 
    // Both add and update
    list[0] = 1;
    list.Add(1);
}


Конечно, это не самый удобный способ работы с коллекциями, но тем не менее он позволяет избежать исключений не узнавая при этом класс, стоящий за интерфейсом. Таким образом, этот дизайн не нарушает LSP. Конечно, никто не делал подобных проверок во время работы с интерфейсом IList (включая меня), поэтому вы можете слышать столько утверждений о том, что read-only коллекции нарушают LSP.

.NET 2.0


После того как в .NET 2.0 были добавлены generics, команда BCL получила возможность построить новую версию иерархии интерфейсов. Они провели некоторую работу, сделав интерфейсы коллекций более понятными. Помимо того, что они перенесли некоторые члены из IList<T> в ICollection<T>, они решили удалить флаг IsFixedSize.

Это было сделано потому, что массивы были единственным классом, которым этот флаг был нужен. Класс Array был единственным, кто запрещал добавлять новые или удалять имеющиеся элементы, но разрешал модификацию существующих. Команда BCL решила, что флаг IsFixedSize привносил слишком много сложности, не давая при этом почти никакой ценности. Интересно, что они изменили имплементацию флага IsReadOnly для массивов в версии .NET 2.0, так что он больше не отражал имеющееся положение вещей:

public void Test()
{
    int[] array = { 1 };
    bool isReadOnly1 = ((IList)array).IsReadOnly; // isReadOnly1 is false
    bool isReadOnly2 = ((ICollection<int>)array).IsReadOnly; // isReadOnly2 is true
}


Флаг IsReadOnly возвращает true для массива, но при этом коллекцию все равно можно изменить. Вот где происходит нарушение принципа LSP. Если у нас есть метод, принимающий IList<int>, мы не можем просто написать такой код:

public void AddAndUpdate(IList<int> list)
{
    if (list.IsReadOnly)
    {
        // No action
        return;
    }
 
    // Both add and update
    list[0] = 1;
    list.Add(1);
}


Если мы передадим методу объект класса ReadOnlyCollection<int>, то (как и задумано) ничего не произойдет, т.к. коллекция является коллекцией только для чтения. С другой стороны, объект класса List<int> (опять же, как и задумано) будет изменен: в нем будет добавлен новый элемент и изменен существующий. Но если мы передадим массив, то ничего не произойдет, т.к. массивы возвращают true для свойства ICollection<T>.IsReadOnly. И мы никак не можем узнать, есть ли у нас возможность проапдейтить существующие элементы, кроме как с помощью проверки типа, стоящего за интерфейсом:

public void AddAndUpdate(IList<int> list)
{
    if (list is int[])
    {
        // Update only
        list[0] = 1;
        return;
    }
 
    if (list.IsReadOnly)
    {
        // No action
        return;
    }
 
    // Both add and update
    list[0] = 1;
    list.Add(1);
}


Таким образом, массивы нарушают LSP. Заметьте, что они нарушают этот принцип только в случае если мы работаем с обобщенными (generic) интерфейсами.

Было ли это ошибкой со стороны Microsoft? Это был компромисс. Это было взвешанное решение: такая архитектура проще, но при этом нарушает LSP в одном конкретном месте.

.NET 4.5


Несмотря на то, что иерархия интерфейсов стала проще, в ней все еще имелся существенный недостаток: вам необходимо каждый раз проверять флаг IsReadOnly для того, чтобы узнать можно ли изменить коллекцию. Это не тот способ, к которому привыкли разработчики. И в общем-то, никто не использовал этот флаг для этих целей. Это свойство использовалось только в сценариях с автоматическим data binding: data binding был односторонний в случае если IsReadOnly возвращал true и двусторонный в остальных случаях.

Для остальных сценариев все просто использовали IEnumerable<T> интерфейс либо класс ReadOnlyCollection<T>. Для того, чтобы решить эту проблему, в .NET 4.5 были добавлены два новых интерфейса: IReadOnlyCollection<T> и IReadOnlyList<T>.

Эти интерфейсы были добавлены в существующую экосистему, так что архитекторы не могли допустить поломки обратной совместимости. Вот почему класс ReadOnlyCollection<T> реализует интерфейсы IList, IList<T> и IReadOnlyList<T>, а не просто IReadOnlyList<T>. Подобное изменение привело бы к ошибкам в работе существующих сборок, скомпилированных на более старых версиях .NET. Чтобы они заработали, разработчикам пришлось бы перекомпилировать их в новой версии.

Переписать всё с нуля


Не смотря на то, что имеющееся положение вещей нельзя изменить из-за требований обратной совместимости, все же интересно подумать о том, как иерархия коллекций могла бы быть сформирована сегодня, с учетом накопленных знаний.

Я думаю, что она бы выглядела следующим образом:



Вот что было сделано:
1) Необобщенные (non-generic) интерфейсы были удалены, т.к. они не добавляют ценности в общую картину.
2) Был добавлен интерфейс IFixedList<T>, так что класс Array больше не обязан имплементировать интерфейс IList<T>.
3) Класс ReadOnlyCollection<T> был переименован в ReadOnlyList<T>, т.к. это более подходящее для него имя. Так же, он теперь наследуется только от интерфейса IReadOnlyList<T>.
4) Удалены флаги IsReadOnly и IsFixedSize. Они могут быть добавлены для сценариев с data binding, но я удалил их чтобы показать, что они больше не нужны для полиморфной работы коллекциями.

Вопрос по LSP


В BCL есть интересный пример кода:

public static int Count<T>(this IEnumerable<T> source)
{
    ICollection<T> collection1 = source as ICollection<T>;
    if (collection1 != null)
        return collection1.Count;
 
    ICollection collection2 = source as ICollection;
    if (collection2 != null)
        return collection2.Count;
 
    int count = 0;
    using (IEnumerator<T> enumerator = source.GetEnumerator())
    {
        while (enumerator.MoveNext())
            checked { ++count; }
    }
    return count;
}


Это имплементация метода-расширения Count для LINQ-to-objects из класса Enumerable. Входящий объект здесь тестируется на совместимость с интерфейсами ICollection и ICollection<T> для подсчета количества элементов. Нарушает ли этот метод принцип LSP?

Нет, не нарушает. Несмотря на то, что метод проверяет объект на принадлежность к реальным классам, все эти классы имеют одинаковую имплементацию свойства Count. Другими словами, свойства ICollection.Count и ICollection<T>.Count имеют те же постусловия (postconditions), что и выражение, подсчитывающее количество элементов в цикле while.

Ссылка на оригинал статьи: C# Read-Only Collections and LSP
Tags:коллекцииLSPрефакторинг
Hubs: Programming .NET Designing and refactoring C#
+21
21.3k 88
Comments 13
Ads