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

Posted

技术标签:

【中文标题】ReadOnlyCollection vs Liskov - 如何正确建模可变集合的不可变表示【英文标题】:ReadOnlyCollection vs Liskov - How to correctly model immutable representations of a mutable collection 【发布时间】:2012-11-28 22:26:44 【问题描述】:

里氏替换原则要求子类型必须满足超类型的契约。据我了解,这意味着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&lt;T&gt; 在公开集合方面做得很好,至少看起来是不可变的。但是,语义非常不同,主要是因为IEnumerable 没有显式公开任何状态。

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

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

【问题讨论】:

@Jodrell Liskov 原则还指出子类中的方法不应抛出新异常。只有相同的异常或从父类的方法中抛出的异常派生的异常。 我同意:ReadOnlyCollection 违反了 LSP。 @Guillaume 谢谢,这是我今天的“今天我学到的”。 原则才能被打破。 :) 好吧,IList&lt;T&gt; 接口完整合同包括列表可以只读或不只读的事实,因为隐含的ICollection&lt;T&gt;.IsReadOnly 属性。所以关于这个只读状态,我认为接口/继承合同本身并没有真正规定任何东西。换句话说,如果您是IList&lt;T&gt;,则在调用 Add 时可以随意抛出,前提是 IsReadOnly 返回 true。我同意这并不能真正回答你的问题:-) 【参考方案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

这就是我希望微软对他们的只读集合类所做的事情——为 get-count、get-by-index 行为等创建一个接口,并用一个接口扩展它以添加、更改值等。

【讨论】:

我试过这样的东西,但我认为界面仍然需要一些工作。将添加/删除操作与访问操作分开时我意识到的问题是分区的组件确实没有明确的名称..ISupportAdd?我还希望看到删除和添加功能的区别,因为我经常遇到不需要从集合中删除任何内容并且不希望在特定上下文中启用该功能的情况。 (例如委托注册表) 我经常希望ICollection 接口也以这种方式完成,而不是从读写案例中“反向形成”只读集合。 (我也希望只读集合是一个接口,而不是一个具体的类。ReadOnlyObservableCollection 也是如此。) @smartcaveman 我明白你的意思,你最终可能会得到很多接口,但是......如果你有很多不同的行为,你将需要很多 somethings 代表他们所有的东西,无论最终是什么。 @smartcaveman:小心不要过度。很明显,只读接口对 API 设计非常有用。然而,仅仅为了说“有时我不需要那个”而增加接口并不是一个好习惯。它给你一种错误的感觉,认为你问题的概念很好,但事实上,它并没有增加任何东西,让你的生活变得复杂。想象一下,如果 .NET 创建了 IAddableList、IRemovableList、ICountableList,它们中的每一个都可以在不同的上下文中使用,但实际上它只会让使用变得尴尬。仅在需要时创建接口。【参考方案2】:

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

它解决了两个问题:

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

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

【讨论】:

我认为我目前的解决方案也没有过度设计。问题是我也不认为有人认为他们的解决方案是过度设计的。不过,谢谢。【参考方案3】:

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

如果调用 add 方法,您似乎对只读集合抛出不受支持的异常感到困扰,但这并没有什么异常。

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

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

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

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

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

ReadOnlyCollection 在集合状态为只读时抛出不受支持的异常,从而完全遵守 ICollection 的约定。请参阅ICollection documentation 中的例外部分。

【讨论】:

恕我直言,.net 集合接口的一个主要弱点是它们只允许代码识别两个“状态”,而 T 的各种集合可能能够进行许多不同的事物组合到不能做。许多只能通过尝试并查看它们是否有效来进行测试,而一个重要的(“始终返回同一组T”)根本无法测试。 有趣的解释。 嗯,也许我遗漏了一些东西,但 LSP 的全部意义在于子类型的实例可以替换原始对象,并且程序将继续工作。很明显这不是这里的情况:如果你用一个不可变对象的实例代替一个可变对象,它会破坏程序。如果不可变对象以相同的可变状态开始,并且可以通过调用方法将其更改为不可变,那么您与状态的论点将是正确的。事实上,它以错误的状态开始以满足 LSP 的要求。 @max 您缺少的一点是ReadOnlyCollection 不是Collection 的子类型。它是ICollection 的子类型。 ICollectionIsReadOnly 方法,如果你威胁任何ICollection,就好像它是读写的一样,你就没有正确使用它。 .Net 库可能会受到批评,因为它有一个IReadOnlyCollection 接口,但没有IReadWriteCollection。仅使用库,函数可以指定它需要一个只读集合,但它不能指定它需要一个读/写集合。【参考方案4】:

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

// 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&lt;IModifiable&lt;T&gt;&gt; m) 来访问可修改的版本。并且方法调用返回一个不可变的。如果没有有意的显式转换,就无法以IModifiable&lt;T&gt; 访问它,这在您的对象的合同中没有定义。

【讨论】:

【参考方案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(...) ...

然后,当您需要编辑结构时,您传递类,如果您只需要只读结构,则传递接口。这是一个假的“只读”,因为您总是可以转换为类,但无论如何只读永远不是真实的......

这使您可以拥有更复杂的阅读结构。与在 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 方法将源数据转换为节点和边。

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

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

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

【讨论】:

以上是关于ReadOnlyCollection vs Liskov - 如何正确建模可变集合的不可变表示的主要内容,如果未能解决你的问题,请参考以下文章

根据 FxCop,为啥 ReadOnlyCollection<ReadOnlyCollection<T>> 不好,以及在生成不可变二维对象时有啥替代方法? [复制]

ReadonlyCollection,对象是不可变的吗?

ReadOnlyCollection 或 IEnumerable 用于公开成员集合?

更新 GridView 中的 ReadOnlyCollection

readOnlycollection 的 .Item 属性不起作用

为啥 List.AsReadOnly 返回 ReadOnlyCollection 而 Dictionary.AsReadOnly 返回 IReadOnlyDictionary?