Подтвердить что ты не робот

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

Принцип Liskov-substitution требует, чтобы подтипы удовлетворяли контрактам супертипов. По моему мнению, это повлечет за собой нарушение 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, если клиент получает конкретную реализацию. Каков правильный способ справиться с этим?

4b9b3361

Ответ 1

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

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 и т.д. и расширил его с помощью интерфейса для добавления, изменения значений и т.д.

Ответ 2

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

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

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

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

Ответ 3

Только чтения в .Net не идут против LSP.

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

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

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

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

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

"Если вы дадите мне этот вход, когда я в этом состоянии ожидаю, что это произойдет".

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

Ответ 4

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

// 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> без преднамеренного явного приведения, которое не определено в контракте для вашего объекта.

Ответ 5

Как мне это нравится (но, может быть, только мне), нужно иметь методы чтения в интерфейсе и методы редактирования в самом классе. Для вашей группы 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(...) {...}
}

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

Это позволяет вам иметь более сложную структуру чтения. Как и в 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. Это позволяет иметь контравариантность типов аргументов.

Ответ 6

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

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

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

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

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