ReadOnlyCollection vs Liskov - 如何正确建模可变集合的不可变表示

时间:2012-12-11 11:08:12

标签: c# immutability directed-acyclic-graphs liskov-substitution-principle readonly-collection

Liskov替换原则要求子类型必须满足超类型的契约。根据我的理解,这将导致ReadOnlyCollection<T>违反Liskov。 ICollection<T>的合同公开AddRemove次操作,但只读子类型不符合此合同。例如,

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的方法。 正确处理此问题的方法是什么?

6 个答案:

答案 0 :(得分:10)

您可以始终拥有(只读)图形界面,并使用读/写可修改图形界面对其进行扩展:

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

这就是我希望微软用他们的只读集合类做的 - 为get-count,get-by-index行为等创建一个接口,并通过添加,更改值等接口扩展它。 / p>

答案 1 :(得分:3)

我不认为您当前使用构建器的解决方案是过度设计的。

它解决了两个问题:

  1. 违反LSP
    你有一个可编辑的界面,其实现永远不会在NotSupportedException / AddNode上抛出AddEdge,并且你有一个不可编辑的界面,根本没有这些方法。

  2. Temporal coupling
    如果您使用一个接口而不是两个接口,那么一个接口需要以某种方式支持“初始化阶段”和“不可变阶段”,最有可能通过某些方法标记这些阶段的开始和可能结束。

答案 2 :(得分:2)

.Net中的只读集合不会违反LSP。

如果调用add方法,你似乎对只读集合抛出一个不支持的异常感到困扰,但是没有什么特别之处。

许多类表示可以处于多种状态之一的域对象,并非每个操作在所有状态下都有效:流只能打开一次,窗口在处理后无法显示等等。

只要有办法测试当前状态并避免异常,那么在这些情况下抛出异常是有效的。

.Net集合被设计为支持状态:只读和读/写。这就是IsReadWrite方法存在的原因。它允许调用者测试集合的状态并避免异常。

LSP要求子类型遵守超类型的合同,但合同不仅仅是一个方法列表;它是基于对象状态的输入和预期行为列表:

“如果你给我这个输入,当我处于这种状态时,预计会发生这种情况。”

当集合的状态为只读时,ReadOnlyCollection通过抛出一个不受支持的异常来完全遵守ICollection的合同。请参阅ICollection documentation中的例外部分。

答案 3 :(得分:1)

您可以使用explict接口实现将修改方法与只读版本中所需的操作分开。对于只读实现,还有一个方法将方法作为参数。这允许您将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>的形式访问它,而未在对象的合同中定义。

答案 4 :(得分:1)

我喜欢它的方式(但也许只是我),是在接口中使用读取方法,在类本身中使用编辑方法。对于你的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。这允许你有参数类型的逆转。

答案 5 :(得分:1)

我喜欢首先将数据结构设计为不可变的想法。有时它只是不可行,但有一种方法可以经常实现这一目标。

对于您的DAG,您最有可能在文件或用户界面中有一些数据结构,并且您可以将所有节点和边缘作为IEnumerables传递给您的不可变DAG类的构造函数。然后,您可以使用Linq方法将源数据转换为节点和边。

然后,构造函数(或工厂方法)可以以对您的算法有效的方式构建类的私有结构,并进行非循环的前期数据验证。

这个解决方案与构建器模式的区别在于,数据结构的迭代构造是不可能的,但通常并不是真正需要的。

就个人而言,我不喜欢具有用于由同一类实现的读取和读取/写入访问的单独接口的解决方案,因为写入功能实际上并未隐藏...将实例转换为读取/写入接口会暴露出变异方法。在这种情况下,更好的解决方案是使用AsReadOnly方法创建一个真正不可变的数据结构来复制数据。

相关问题