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

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

 IList collection = new List(); collection = new System.Collections.ObjectModel.ReadOnlyCollection(collection); collection.Add(new object()); -- not supported exception 

显然需要不可变的集合。 有没有关于.NET的建模方法的事情? 有什么比这更好的方法呢? IEnumerable很好地暴露了一个集合,至少看起来是不可变的。 但是,语义是非常不同的,主要是因为IEnumerable没有明确地暴露任何状态。

在我的特定情况下,我正在尝试构建一个不可变的DAG类来支持FSM 。 我显然在开始时需要AddNode / AddEdge方法,但我不希望它一旦运行就可以更改状态机。 我很难表示DAG的不可变和可变表示之间的相似性。

现在,我的设计涉及预先使用DAG Builder,然后创建一次不可变图,此时它不再可编辑。 Builder和具体的不可变DAG之间唯一的通用接口是Accept(IVisitor visitor) 。 我担心,面对可能更简单的选择,这可能是过度设计/过于抽象。 与此同时,我无法接受我可以在我的图形界面上公开可能在客户端获得特定实现时抛出NotSupportedException的方法。 处理这个问题的正确方法是什么?

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

 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> 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行为等创建一个接口,并通过添加,更改值等接口扩展它。

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

它解决了两个问题:

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

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

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

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

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

只要有一种方法可以测试当前状态并避免exception,那么在这些情况下抛出exception是有效的。

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

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

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

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

您可以使用explict接口实现将修改方法与只读版本中所需的操作分开。 在只读实现上还有一个方法,它将方法作为参数。 这允许您将DAC的构建与导航和查询隔离开来。 请参阅下面的代码及其评论:

 // your read only operations and the // method that allows for building public interface IDac { IDac Build(Action> f); // other navigation methods } // modifiable operations, its still an IDac public interface IModifiableDac : IDac { void AddEdge(T item); IModifiableDac CreateChildNode(); } // implementation explicitly implements IModifableDac so // accidental calling of modification methods won't happen // (an explicit cast to IModifiable is required) public class Dac : IDac, IModifiableDac { public IDac Build(Action> f) { f(this); return this; } void IModifiableDac.AddEdge(T item) { throw new NotImplementedException(); } public IModifiableDac CreateChildNode() { // crate, add, child and return it throw new NotImplementedException(); } public void DoStuff() { } } public class DacConsumer { public void Foo() { var dac = new Dac(); // 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 does not have modification methods newDac.DoStuff(); } } 

从此代码中,用户只能调用Build(Action> m)来访问可修改的版本。 并且方法调用返回一个不可变的方法。 如果没有故意的显式转换,则无法以IModifiableforms访问它,这在您的对象的合同中没有定义。

我喜欢它的方式(但也许只是我),是在接口中使用读取方法,在类本身中使用编辑方法。 对于你的DAG,你不太可能有多个数据结构的实现,所以有一个编辑图形的界面是一种矫枉过正,通常不是很漂亮。

我发现让表示数据结构的类和作为读取结构的接口非常干净。

例如:

 public interface IDAG { public int NodeCount { get; } public bool AreConnected(int from, int to); public T GetItem(int node); } public class DAG : IDAG { 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 FindPathBetween(this IDAG dag, int from, int to) { // Use backtracking to determine if a path exists between `from` and `to` } public static IDAG Cast(this IDAG dag) { // Create a wrapper for the DAG class that casts all T outputs as U } } 

这对于将数据结构的定义与“您可以用它做什么”分开非常有用。

此结构允许的另一件事是将generics类型设置为out T 这允许你有参数类型的逆转。

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

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

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

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

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