【问题标题】:how can I create a truly immutable doubly linked list in C#?如何在 C# 中创建一个真正不可变的双向链表?
【发布时间】:2012-06-01 05:03:56
【问题描述】:

这更像是一个理论问题:在 C# 中是否可以通过任何方式创建一个真正不可变的双向链表?我看到的一个问题在于 2 个相邻节点的相互依赖。

“真正”是指使用只读字段。

【问题讨论】:

  • 如果您愿意在每个变异操作中制作列表的完整副本,我不明白为什么不这样做。您可以拥有任意数量的私有构造函数,负责根据旧列表创建新列表并进行修改。
  • 乔恩,复制一个已经创建的列表不是问题,问题在于使用 C# 的“只读字段”功能创建这样的列表。
  • 出于好奇,我一直在研究这个以帮助解决我正在研究的类似问题,我暂时搁置了低效率和替代方案,我仍然很想知道构造函数是否如果插入到列表中不是第一个或最后一个位置的位置,解决方案会起作用,因为插入到链表的中间需要双向递归操作?我认为接受的答案仅涵盖最后一个位置的插入是否正确?还是我误解了?
  • 您不能插入到不可变列表中(不可变意味着它不可更改)。您可以创建一个原始列表的副本,稍作更改 - 中间有一个新节点。对于单链表,将节点添加到链表的头部不会产生任何开销,因为新链表由链表中的新元素组成,该链表链接到整个旧链表(其头元素)。

标签: c# data-structures language-features


【解决方案1】:

你激起了我的好奇心。 ReadOnlyNode 的类很容易定义:

public class ReadOnlyNode<T>
{
   public readonly T Value;
   public readonly ReadOnlyNode<T> Next;
   public readonly ReadOnlyNode<T> Prev;

   public Node(T value, ReadOnlyNode<T> next, ReadOnlyNode<T> prev)
   {
      Value = value;
      Next = next;
      Prev = prev;
   }
}

双向链表中readonly 的问题在于,对于每个节点,您必须在构造函数中指定该节点的前一个和下一个节点,因此如果它们是从构造函数外部传递的,它们必须已经存在.但是,当您调用构造函数时,节点 M 需要一个预先存在的节点 N 作为其“下一个”节点,但该节点 N 需要 M 作为其“上一个”节点才能被构造。这会造成“鸡和蛋”的情况,即 N 和 M 都需要先实例化另一个节点。

但是,给这只猫剥皮的方法不止一种。如果列表的每个节点都是从一个 ReadOnlyNode 的构造函数中递归实例化的会怎样?在每个构造函数完成之前,每个级别的属性仍然是可变的,并且对每个节点的引用将存在于其构造函数中,因此在所有内容都设置好之前没有设置所有内容并不重要。下面的代码编译,给定一个预先存在的 IEnumerable 将产生一个不可变的双向链表:

public class ReadOnlyNode<T>
{
    public readonly T Value;
    public readonly ReadOnlyNode<T> Next;
    public readonly ReadOnlyNode<T> Prev;

    private ReadOnlyNode(IEnumerable<T> elements, ReadOnlyNode<T> prev)
    {
        if(elements == null || !elements.Any()) 
           throw new ArgumentException(
              "Enumerable must not be null and must have at least one element");
        Next = elements.Count() == 1 
           ? null 
           : new ReadOnlyNode<T>(elements.Skip(1), this);
        Value = elements.First();
        Prev = prev;
    }

    public ReadOnlyNode(IEnumerable<T> elements)
        : this(elements, null)
    {
    }
}


//Usage - creates an immutable doubly-linked list of integers from 1 to 1000
var immutableList = new ReadOnlyNode<int>(Enumerable.Range(1,1000));

您可以将它与任何实现 IEnumerable 的集合一起使用(几乎所有内置集合都可以,并且您可以使用 OfType() 将非泛型 ICollections 和 IEnumerables 转换为泛型 IEnumerables)。唯一需要担心的是调用堆栈;您可以嵌套多少方法调用是有限制的,这可能会导致 SOE 出现在有限但很大的输入列表上。

编辑: JaredPar 提出了一个很好的观点;此解决方案使用 Count() 和 Any() ,它们必须考虑 Skip() 的结果,因此不能使用这些方法中内置的“快捷方式”,这些方法可以使用集合类的基数属性。这些调用变成线性的,这使算法的复杂性平方。如果您只使用 IEnumerable 的基本成员,这将变得更加高效:

public class ReadOnlyNode<T>
{
    public readonly T Value;
    public readonly ReadOnlyNode<T> Next;
    public readonly ReadOnlyNode<T> Prev;

    private ReadOnlyNode(IEnumerator<T> elements, ReadOnlyNode<T> prev, bool first)
    {
        if (elements == null) throw new ArgumentNullException("elements");

        var empty = false;
        if (first) 
           empty = elements.MoveNext();

        if(!empty)
        {
           Value = elements.Current;
           Next = elements.MoveNext() ? new ReadOnlyNode<T>(elements, this, false) : null;
           Prev = prev;
        }
    }

    public ReadOnlyNode(IEnumerable<T> elements)
        : this(elements.GetEnumerator(), null, true)
    {
    }
}

使用此解决方案,您会失去一些更优雅的错误检查,但如果 IEnumerable 为 null,则无论如何都会引发异常。

【讨论】:

  • 请注意,此解决方案会导致对原始集合进行许多不必要的遍历。由于Skip 的工作方式,每次调用Count 都会遍历整个集合,即使它只计算结果元素。此外,Any 将遍历列表中每个阶段之前的N 跳过的元素。我在我的解决方案中选择IEnumerator&lt;T&gt; 的原因是它保证原始集合只被遍历一次。
  • 是的。不过,您可以将枚举器逻辑放入我的解决方案中。我会编辑
  • 这个答案 still 包含意外评估输入序列三次的损坏实现。这样的代码永远不应该投入生产;这是越野车。这不是特定于Skip;这只是贾里德举的一个例子。这也不仅仅是糟糕的表现。它违反了预期的语义。除非该方法的语义明确要求它(在这种情况下他们没有),否则您不得多次评估给定的 IEnumerable&lt;T&gt;
  • 此外,您的解决方案会抛出一个 InvalidOperationException 并带有神秘的消息“枚举已经完成”。当输入集合为空时,因为您在耗尽的枚举器上调用 Current
  • 添加了一个保护子句来防止空枚举(和 null)出现问题。但是,我看不到第二个已编辑的实现如何导致可枚举被评估三次。通过调用“MoveNext”,每个元素都只被单步执行一次,并被评估一次以将其设置为节点的 Value 属性。这是一个简单的一维递归循环。最后,对于一个接受不同答案的问题,答案已经超过一年半了;我看不出反对它的意义,尤其是因为它把这个老问题带回了栈顶。
【解决方案2】:

是的,您可以创建一个用于设置链接的“link-setter”对象,将其发送到节点的构造函数中,或者拥有一个返回“link-setter”的静态创建方法。节点中的链接是私有的,只能通过“link-setter”访问,当你使用它们设置列表时,你就把它们扔掉。

但是,这是一个非常无用的练习。如果列表是不可变的,那么当简单数组效果更好时,使用双向链表是没有意义的。

【讨论】:

  • 我只是在一定程度上同意“无用”的说法,有时列表更便于遍历,内存使用效率更高(数组需要无碎片的内存)
  • @bonomo:对于值类型的数组,在遍历它时使用未分段的内存通常是一个很大的优势,因为以下项目很可能已经在内存缓存中。对于引用类型数组,只有引用需要一块未分段的内存,实际对象不需要。
  • 是的,我同意这一点,但是如果这很重要,数组也不是不可变的
  • @bonomo:我的意思当然是你可以在数组周围做一个薄包装,而不是创建处理链表的所有逻辑。 :)
【解决方案3】:

这可能与棘手的构造函数逻辑有关。例如

public sealed class Node<T> { 
  readonly T m_data;
  readonly Node<T> m_prev;
  readonly Node<T> m_next;

  // Data, Next, Prev accessors omitted for brevity      

  public Node(T data, Node<T> prev, IEnumerator<T> rest) { 
    m_data = data;
    m_prev = prev;
    if (rest.MoveNext()) {
      m_next = new Node(rest.Current, this, rest);
    }
  }
}

public static class Node {    
  public static Node<T> Create<T>(IEnumerable<T> enumerable) {
    using (var enumerator = enumerable.GetEnumerator()) {
      if (!enumerator.MoveNext()) {
        return null;
      }
      return new Node(enumerator.Current, null, enumerator);
    }
  }
}

Node<string> list = Node.Create(new [] { "a", "b", "c", "d" });

【讨论】:

  • 很好,一秒钟前也这么想! :)
  • 也与我的回答非常相似;我会保留我的,因为所有必要的逻辑都包含在一个类中,但是 +1。
猜你喜欢
  • 2016-07-18
  • 1970-01-01
  • 1970-01-01
  • 2011-07-22
  • 1970-01-01
  • 1970-01-01
  • 2018-11-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多