Часто разработчики утверждают, что 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

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


  1. DragonFire
    25.04.2015 21:58
    +1

    Угу, и добавить IStack и IQueue… и аккуратно вписать в иерархию INotifyCollectionChanged…


    1. Mingun
      11.05.2015 17:41

      Не понимаю, кто мешает это сделать сейчас. Ввели же обобщенные интерфейсы. Ну так введите еще одно пространство имен с новыми, более логичными интерфейсами. И их иерархию лучше подсмотреть у джавы, а то в .NET-е такие чудеса порой. Пока единственная нелогичность в джаве с итераторами — у итератора есть метод remove(), который может кидать исключение, если итератор read-only, но почему-то нет аналогичного метода add() и set().

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


      1. PsyHaSTe
        13.05.2015 19:03

        Видимо, проще наплодить еще интерфейсов, чем изменять CLR. Эрик очень любит на любой фичреквест бегать, обхватив голову руками, и кричать «да вы представляете, СКОЛЬКО кода нужно, чтобы реализовать эту вашу финтифлюшку?.. Так что нафиг её». Собственно, rosylin имхо для этого и делают, чтобы можно было легко и непринужденно вносить изменения в язык, задумываясь не об их сложности, а об их необходимости.


  1. TheTony
    26.04.2015 12:19

    Слово «фундамент» поправьте, пожалуйста…


    1. vkhorikov Автор
      26.04.2015 16:40

      Поправил, спасибо


  1. Viacheslav01
    26.04.2015 15:19

    Принцип Лисков как священное писание как только его не трактуют :)


  1. PsyHaSTe
    26.04.2015 17:31

    Не согласен с вашей архитектурой. У вас получается, что массив наследует IList со всеми текущими проблемами. Ради интереса

                int[] arr = {1, 2, 3};
                var methods = typeof(IList<int>).GetMethods().Concat(typeof(IList).GetMethods());
                int success = 0, failed = 0;
                foreach (var methodInfo in methods)
                {
                    try
                    {
                        var paramss = methodInfo.GetParameters().Select(x => x.DefaultValue).ToArray();
                        methodInfo.Invoke(arr, paramss);
                        Console.WriteLine("{0} successed", methodInfo.Name);
                        success++;
                    }
                    catch (Exception ex)
                    {
                        Console.WriteLine("{0} failed for reason {1}", methodInfo.Name, ex.GetType().Name);
                        failed++;
                    }
                }
                Console.WriteLine("Success = {0}\tFailed={1}", success, failed);
    

    удачно выполняется 5 методов, а 11 падает с исключением. О чем может идти речь, большая часть методов от IList в массиве бросает исключения?..

    Мне намного больше нравится идея парня со stackoverflow
    IMO there should be several more (generic) collection interfaces depending on the features of a collection. And the names should have been different too, List for something with an indexer is really stupid IMO.

    • Just Enumeration IEnumerable<T>
    • Readonly but no indexer (.Count, .Contains,...)
    • Resizable but no indexer, i.e. set like (Add, Remove,...) current ICollection<T>
    • Readonly with indexer (indexer, indexof,...)
    • Constant size with indexer (indexer with a setter)
    • Variable size with indexer (Insert,...) current IList<T>


    I think the current collection interfaces are bad design. But since they have properties telling you which methods are valid(and this is part of the contract of these methods) it doesn't break the substitution principle.


    1. PsyHaSTe
      26.04.2015 17:37

      А, неправильно прочитал диаграмму. Так да, намного более логичная иерархия. Но всё равно имхо перегружено. Когда я хочу простенький индексатор в свою коллекцию, мне приходится переопределять миллиард всевозможных методов, это же просто беда какая-то. Почему бы не разделить эти интерфейсы, а для совокупности их сделать общий интерфейс? К примеру IFixedList<T>: IReadOnlyList<T>, IIndexable<T>, с убиранием из IReadOnlyList индексатора соответственно.


      1. PsyHaSTe
        26.04.2015 18:00

        В моём понимании это как-то так должно выглядеть:
        image

        Хотя в любом случае, даже приведенный выше дизайн много лучше того, что сейчас наворотили во фреймворке. Жалко, что груз обратной совместимости не дает исправить это…


        1. vkhorikov Автор
          26.04.2015 18:04
          +2

          Идея кардинально разделить интерфейсы интересная, но мне кажется тут как минимум нужно чтобы все они наследовали от IEnumerable<T>, иначе перестанет работать LINQ.


          1. PsyHaSTe
            26.04.2015 21:25

            Да, конечно, это просто зарисовка, тут много чего нужно исправлять. Конечно, IEnumerable<T> должен быть самым общим интерфейсом.

            Просто очень печально, когда я хочу сделать биективный словарь (то есть для каждого x,y, если dict[x] = y, то dict[y] = x), то мне нужно либо переопределить миллиард методов IDictionary<T,T>, либо наследовать обычный Dictionary, и определять новый Add, но не переопределять!, поэтому при вызове по ссылке базового класса всё посыпется.

            Хотелось бы как-то побольше гибкости.


        1. darkdimius
          27.04.2015 11:28
          +2

          То что вы нарисовали — Иерархия коллекций Scala. Правда упрощенная.
          Советую обратить внимание туда, ИМХО на данный момент это самые близкие к полнофункциональным коллекции.


          1. PsyHaSTe
            27.04.2015 12:53

            Действительно, загуглил — отличная архитектура. Спасибо за информацию