ReadOnlyCollection vs Liskov — как правильно моделировать неизменяемые представления изменяемой коллекции

Принцип подстановки Лисков требует, чтобы подтипы удовлетворяли контрактам супертипов. В моем понимании это будет означать, что ReadOnlyCollection<T> нарушает Лисков. Контракт ICollection<T> предоставляет операции Add и Remove, но подтип только для чтения не соответствует этому контракту. Например,

IList<object> collection = new List<object>();
collection = new System.Collections.ObjectModel.ReadOnlyCollection<object>(collection);
collection.Add(new object());

    -- not supported exception

Очевидно, что необходимы неизменяемые коллекции. Что-то не так с тем, как .NET их моделирует? Как лучше это сделать? IEnumerable<T> хорошо раскрывает коллекцию, по крайней мере, кажущуюся неизменной. Однако семантика сильно отличается, прежде всего потому, что IEnumerable не раскрывает явно какое-либо состояние.

В моем конкретном случае я пытаюсь создать неизменяемый класс DAG для поддержки FSM. Мне, очевидно, понадобятся методы AddNode/AddEdge в начале, но я не хочу, чтобы можно было изменить конечный автомат, когда он уже запущен. Мне трудно представить сходство между неизменяемыми и изменяемыми представлениями DAG.

Прямо сейчас мой дизайн включает в себя предварительное использование DAG Builder, а затем создание неизменного графа один раз, после чего его больше нельзя редактировать. Единственный общий интерфейс между Builder и конкретной неизменной DAG — это Accept(IVisitor visitor). Я обеспокоен тем, что это может быть слишком сложным/слишком абстрактным перед лицом, возможно, более простых вариантов. В то же время у меня возникают проблемы с тем, что я могу выставлять методы в интерфейсе графа, которые могут выдать NotSupportedException, если клиент получит конкретную реализацию. Как с этим справиться правильно?


comment
Ну, они реализованы, но выдают исключения. Интересно, если бы разработчики фреймворка начали заново, были бы базовые классы только для чтения для типов коллекций.   -  person Jodrell    schedule 11.12.2012
comment
Принцип @Jodrell Liskov также гласит, что метод в дочернем классе не должен вызывать новое исключение. Только те же исключения или исключения, полученные из исключений, созданных в методе родительского класса.   -  person Guillaume    schedule 11.12.2012
comment
Я согласен: ReadOnlyCollection нарушает LSP.   -  person Daniel Hilgarth    schedule 11.12.2012
comment
@Guillaume Спасибо, это мой сегодняшний день, который я узнал на сегодня.   -  person Rawling    schedule 11.12.2012
comment
Принципы, чтобы их сломать. :)   -  person Hamlet Hakobyan    schedule 11.12.2012
comment
Что ж, полный контракт интерфейса IList<T> включает тот факт, что список может быть только для чтения или нет из-за неявного свойства ICollection<T>.IsReadOnly. Итак, что касается этого состояния «Только для чтения», я не думаю, что договор об интерфейсе/наследовании действительно что-то оговаривает сам по себе. Другими словами, если вы являетесь IList<T>, вы можете свободно бросать вызов при вызове Add при условии, что IsReadOnly возвращает значение true. Я согласен, что на самом деле это не ответ на ваш вопрос :-)   -  person Simon Mourier    schedule 14.12.2012
comment
@Guillaume: Для производных классов совершенно законно генерировать исключения, которые родитель никогда не выдает, при условии, что родитель определяет условия, при которых дочерние классы могут генерировать исключения.   -  person supercat    schedule 09.02.2014
comment
@SimonMourier: На самом деле реализация ReadOnlyCollection<T> IList<T> не так сильно нарушает LSP, как реализация T[], поскольку ReadOnlyCollection<T> точно говорит, что запись не удастся. Хотя Mammal[] реализует IList<Mammal>, и индексированный установщик этого IList<Mammal> будет во время компиляции принимать что-либо типа Mammal, невозможно сказать, будет ли попытка сохранить конкретный Mammal успешной, кроме как с использованием Reflection, или попытка перехвата исключение.   -  person supercat    schedule 09.02.2014


Ответы (6)


Вы всегда можете иметь интерфейс графа (только для чтения) и расширить его с помощью интерфейса модифицируемого графа для чтения/записи:

public interface IDirectedAcyclicGraph
{
    int GetNodeCount();
    bool GetConnected(int from, int to);
}

public interface IModifiableDAG : IDirectedAcyclicGraph
{
    void SetNodeCount(int nodeCount);
    void SetConnected(int from, int to, bool connected);
}

(Я не могу понять, как разделить эти методы на get/set половины свойства.)

// Rubbish implementation
public class ConcreteModifiableDAG : IModifiableDAG
{
    private int nodeCount;
    private Dictionary<int, Dictionary<int, bool>> connections;

    public void SetNodeCount(int nodeCount) {
        this.nodeCount = nodeCount;
    }

    public void SetConnected(int from, int to, bool connected) {
        connections[from][to] = connected;
    }

    public int GetNodeCount() {
        return nodeCount;
    }

    public bool GetConnected(int from, int to) {
        return connections[from][to];
    }
}

// Create graph
IModifiableDAG mdag = new ConcreteModifiableDAG();
mdag.SetNodeCount(5);
mdag.SetConnected(1, 5, true);

// Pass fixed graph
IDirectedAcyclicGraph dag = (IDirectedAcyclicGraph)mdag;
dag.SetNodeCount(5);          // Doesn't exist
dag.SetConnected(1, 5, true); // Doesn't exist

Это то, что я хотел бы, чтобы Microsoft сделала со своими классами коллекций только для чтения - создала один интерфейс для поведения get-count, get-by-index и т. д. и расширила его интерфейсом для добавления, изменения значений и т. д.

person Rawling    schedule 11.12.2012
comment
Я пробовал что-то вроде этого, но я думаю, что интерфейс все еще нуждается в доработке. Проблема, которую я понял при отделении операций добавления/удаления от операций доступа, заключается в том, что на самом деле нет четкого имени для компонентов раздела.. ISupportAdd? Я также хотел бы, чтобы удаление и добавление функций были разными, поскольку у меня часто бывают ситуации, когда мне никогда не нужно ничего удалять из коллекции, и я не хочу, чтобы эта возможность была включена в определенном контексте. (например, реестр делегатов) - person smartcaveman; 11.12.2012
comment
Мне часто хотелось, чтобы интерфейс ICollection тоже был реализован таким же образом, а не обратное формирование коллекции только для чтения из случая чтения-записи. (Я также хочу, чтобы коллекция только для чтения была интерфейсом, а не конкретным классом. То же самое для ReadOnlyObservableCollection.) - person JaredReisinger; 21.12.2012
comment
@smartcaveman Я понимаю вашу точку зрения, и у вас потенциально может быть много интерфейсов, но... если у вас много разных поведений, вам понадобится много что-то для представлять их всех, чем бы что-то ни закончилось. - person Rawling; 21.12.2012
comment
@smartcaveman: будь осторожен, не переусердствуй. Понятно, что наличие интерфейса только для чтения может оказаться очень полезным для разработки API. Однако умножение интерфейсов только ради того, чтобы сказать «иногда мне это не нужно», не является хорошей практикой. Это дает вам ложное ощущение хорошего концептуального представления вашей проблемы, но на самом деле ничего не добавляет и усложняет вашу жизнь. Представьте, если бы .NET создал IAddableList, IRemovableList, ICountableList, каждый из них может иметь смысл в различном контексте, но на самом деле это просто сделало бы использование неудобным. Создавайте интерфейсы только тогда, когда это необходимо. - person edeboursetty; 21.12.2012

Я не думаю, что ваше текущее решение с застройщиком слишком сложное.

Он решает две проблемы:

  1. Нарушение LSP
    У вас есть редактируемый интерфейс, реализация которого никогда не вызовет NotSupportedExceptions для AddNode / AddEdge, и у вас есть нередактируемый интерфейс, в котором вообще нет этих методов.

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

person Daniel Hilgarth    schedule 11.12.2012
comment
Я также не думаю, что мое текущее решение чрезмерно продумано. Проблема в том, что я также не думаю, что кто-то когда-либо думал, что их решения перепроектированы. Спасибо хоть. - person smartcaveman; 11.12.2012

Коллекции только для чтения в .Net не противоречат LSP.

Вы, кажется, обеспокоены тем, что коллекция только для чтения выдает неподдерживаемое исключение, если вызывается метод добавления, но в этом нет ничего исключительного.

Многие классы представляют объекты предметной области, которые могут находиться в одном из нескольких состояний, и не каждая операция допустима во всех состояниях: потоки могут быть открыты только один раз, окна не могут быть показаны после их удаления и т. д.

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

Коллекции .Net были разработаны для поддержки состояний: только чтение и чтение/запись. Именно поэтому присутствует метод IsReadWrite. Это позволяет вызывающим объектам проверять состояние коллекции и избегать исключений.

LSP требует, чтобы подтипы соблюдали контракт супертипа, но контракт — это больше, чем просто список методов; это список входных данных и ожидаемого поведения, основанного на состоянии объекта:

«Если вы дадите мне этот ввод, когда я буду в этом состоянии, ожидайте, что это произойдет».

ReadOnlyCollection полностью соблюдает контракт ICollection, вызывая неподдерживаемое исключение, когда состояние коллекции доступно только для чтения. См. раздел исключений в документации по ICollection.

person Eli Algranti    schedule 21.12.2012
comment
ИМХО, основной недостаток интерфейсов коллекций .net заключается в том, что они позволяют коду идентифицировать только два состояния, когда существует много разных комбинаций вещей, которые различные коллекции T могут быть не в состоянии сделать. Многие из них можно протестировать, только попробовав их и посмотрев, работают ли они, а один важный (всегда возвращать один и тот же набор T) вообще не может быть протестирован. - person supercat; 02.01.2013
comment
Интересное объяснение. - person Mic; 04.02.2015
comment
Хм, может я что-то упускаю, но вся суть LSP в том, что экземпляр подтипа можно подставить вместо исходного объекта, и программа продолжит работать. Совершенно очевидно, что здесь это не так: если вы подставите экземпляр неизменяемого объекта вместо изменяемого объекта, это сломает программу. Ваш аргумент с состояниями был бы правильным, если бы неизменяемый объект начинался в том же изменяемом состоянии и мог быть изменен на неизменяемый путем вызова метода. Как бы то ни было, он запускается в неправильном состоянии, чтобы удовлетворить требованиям LSP. - person max; 31.07.2020
comment
@max вы упускаете то, что ReadOnlyCollection не является подтипом Collection. Это подтип ICollection. ICollection имеет метод IsReadOnly, если вы угрожаете какому-либо ICollection как будто это чтение-запись, вы используете его неправильно. Библиотеку .Net можно критиковать за то, что она имеет интерфейс IReadOnlyCollection, но не IReadWriteCollection. Используя только библиотеку, функция может указать, что она ожидает коллекцию только для чтения, но не может указать, что она ожидает коллекцию для чтения/записи. - person Eli Algranti; 02.08.2020

Вы можете использовать явные реализации интерфейса, чтобы отделить ваши методы модификации от операций, необходимых в версии только для чтения. Также в вашей реализации только для чтения есть метод, который принимает метод в качестве аргумента. Это позволяет изолировать построение DAC от навигации и запросов. см. приведенный ниже код и его комментарии:

// your read only operations and the
// method that allows for building
public interface IDac<T>
{
    IDac<T> Build(Action<IModifiableDac<T>> f);
    // other navigation methods
}

// modifiable operations, its still an IDac<T>
public interface IModifiableDac<T> : IDac<T>
{
    void AddEdge(T item);
    IModifiableDac<T> CreateChildNode();
}

// implementation explicitly implements IModifableDac<T> so
// accidental calling of modification methods won't happen
// (an explicit cast to IModifiable<T> is required)
public class Dac<T> : IDac<T>, IModifiableDac<T>
{
    public IDac<T> Build(Action<IModifiableDac<T>> f)
    {
        f(this);
        return this;
    }

    void IModifiableDac<T>.AddEdge(T item)
    {
        throw new NotImplementedException();
    }

    public IModifiableDac<T> CreateChildNode() {
        // crate, add, child and return it
        throw new NotImplementedException();
    }

    public void DoStuff() { }
}

public class DacConsumer
{
    public void Foo()
    {
        var dac = new Dac<int>();
        // build your graph
        var newDac = dac.Build(m => {
            m.AddEdge(1);
            var node = m.CreateChildNode();
            node.AddEdge(2);
            //etc.
        });

        // now do what ever you want, IDac<T> does not have modification methods
        newDac.DoStuff();
    }
}

Из этого кода пользователь может вызвать только Build(Action<IModifiable<T>> m), чтобы получить доступ к модифицируемой версии. и вызов метода возвращает неизменяемый. Невозможно получить к нему доступ как IModifiable<T> без преднамеренного явного приведения, которое не определено в контракте для вашего объекта.

person Charles Lambert    schedule 20.12.2012

Как мне это нравится (но, возможно, это только мне), чтобы иметь методы чтения в интерфейсе и методы редактирования в самом классе. Для вашей DAG очень маловероятно, что у вас будет несколько реализаций структуры данных, поэтому наличие интерфейса для редактирования графа является излишним и, как правило, не очень красивым.

Я считаю, что класс, представляющий структуру данных, и интерфейс, являющийся структурой чтения, довольно чист.

например:

public interface IDAG<out T>
{
    public int NodeCount { get; }
    public bool AreConnected(int from, int to);
    public T GetItem(int node);
}

public class DAG<T> : IDAG<T>
{
    public void SetCount(...) {...}
    public void SetEdge(...) {...}
    public int NodeCount { get {...} }
    public bool AreConnected(...) {...}
    public T GetItem(...) {...}
}

Затем, когда вам требуется редактирование структуры, вы передаете класс, если вам просто нужна структура только для чтения, передаете интерфейс. Это подделка «только для чтения», потому что вы всегда можете использовать класс, но в любом случае «только для чтения» никогда не бывает...

Это позволяет вам иметь более сложную структуру чтения. Как и в Linq, вы можете расширить структуру чтения с помощью методов расширения, определенных в интерфейсе. Например:

public static class IDAGExtensions
{
    public static List<T> FindPathBetween(this IDAG<T> dag, int from, int to)
    {
        // Use backtracking to determine if a path exists between `from` and `to`
    }

    public static IDAG<U> Cast<U>(this IDAG<T> dag)
    {
        // Create a wrapper for the DAG class that casts all T outputs as U
    }
}

Это чрезвычайно полезно, чтобы отделить определение структуры данных от того, что вы можете с ней сделать.

Еще одна вещь, которую позволяет эта структура, — установить общий тип как out T. Это позволяет вам иметь контравариантность типов аргументов.

person edeboursetty    schedule 20.12.2012

Мне нравится идея проектирования неизменяемых структур данных в первую очередь. Иногда это просто невозможно, но довольно часто это можно сделать.

Для вашей DAG у вас, скорее всего, есть какая-то структура данных в файле или пользовательском интерфейсе, и вы можете передать все узлы и ребра как IEnumerables в конструктор вашего неизменяемого класса DAG. Затем вы можете использовать методы Linq для преобразования исходных данных в узлы и ребра.

Затем конструктор (или фабричный метод) может создавать частные структуры класса таким образом, чтобы он был эффективен для вашего алгоритма, и выполнять предварительную проверку данных, например ациклическую.

Это решение отличается от шаблона построителя тем, что итеративное построение структуры данных невозможно, но часто это и не требуется.

Лично мне не нравятся решения с отдельными интерфейсами для доступа для чтения и чтения/записи, реализованные одним и тем же классом, потому что функциональность записи на самом деле не скрыта... приведение экземпляра к интерфейсу чтения/записи раскрывает изменяющиеся методы. Лучшим решением в таком сценарии является наличие метода AsReadOnly, который создает действительно неизменяемую структуру данных, копируя данные.

person CSharper    schedule 20.12.2012