23

Liskov-substitution 原则要求子类型必须满足超类型的契约。据我了解,这将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如果客户端获得特定的实现,这些方法可能会抛出。处理这个问题的正确方法是什么?

4

6 回答 6

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);
}

(我不知道如何将这些方法拆分为属性的一半。getset

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

于 2012-12-11T11:36:36.190 回答
3

I don't think that your current solution with the builder is overengineered.

It solves two problems:

  1. Violation of LSP
    You have an editable interface whose implementations will never throw NotSupportedExceptions on AddNode / AddEdge and you have a non-editable interface that doesn't have these methods at all.

  2. Temporal coupling
    If you would go with one interface instead of two, that one interface would need to somehow support the "initialization phase" and the "immutable phase", most likely by some methods marking the start and possibly end of those phases.

于 2012-12-11T11:26:32.133 回答
3

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

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

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

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

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

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

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

当集合的状态为只读时,ReadOnlyCollection 通过引发不受支持的异常来完全遵守 ICollection 的约定。请参阅ICollection 文档中的异常部分。

于 2012-12-21T00:42:08.313 回答
1

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

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

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

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

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

于 2012-12-20T21:03:10.453 回答
1

您可以使用显式接口实现将修改方法与只读版本中所需的操作分开。在您的只读实现中还有一个将方法作为参数的方法。这使您可以将 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>没有有意的显式强制转换,就无法访​​问它,这在您的对象的合同中没有定义。

于 2012-12-20T15:13:39.217 回答
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(...) {...}
}

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

这使您可以拥有更复杂的阅读结构。与在 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. 这使您可以对参数类型进行逆变。

于 2012-12-20T17:38:43.453 回答