应该在Queue上使用IEnumerable迭代器时出队一个项目吗?

15

我创建了一个自定义的通用队列,实现了一个通用的IQueue接口,该接口使用了System.Collections.Generic命名空间中的通用队列作为私有内部队列。示例已清除无关代码。

public interface IQueue<TQueueItem>
{
    void Enqueue(TQueueItem queueItem);
    TQueueItem Dequeue();
}

public class CustomQueue<TQueueItem> : IQueue<TQueueItem>
{
    private readonly Queue<TQueueItem> queue = new Queue<TQueueItem>();
    ...
    public void Enqueue(TQueueItem queueItem)
    {
        ...
        queue.Enqueue( queueItem );
        ...
    }

    public TQueueItem Dequeue()
    {
        ...
        return queue.Dequeue();
        ...
    }
}

我希望与核心实现保持一致,注意到核心队列实现了IEnumerable,所以我将通过在类上显式实现IEnumerable或使用IQueue接口继承来达到相同的效果。

我想知道的是,在枚举队列时,每个MoveNext是否都会出队下一个项?我已经使用反射器查看了Microsoft是如何做的,他们只是遍历队列的私有数组,但Microsoft远非万能,因此我想获得一个普遍意见。

public class CustomQueue<TQueueItem> : IQueue<TQueueItem>, IEnumerable<TQueueItem>
{
    ...

    public IEnumerator<TQueueItem> GetEnumerator()
    {
        while (queue.Count > 0)
        {
            yield return Dequeue();
        }
    }

    //Or

    public IEnumerator<TQueueItem> GetEnumerator()
    {
        return queue.GetEnumerator();
    }

    ...
}

我有些犹豫,一方面我认为遍历集合不应该改变集合的状态,但另一方面,特别是对于我的具体实现来说,这样做会使使用看起来更加清晰。
编辑
为了让事情更清楚。我正在实现的类在Dequeue时进行Monitor.Wait,并且队列中没有项目。当一个项目被放入队列时,就会发出Monitor.Pulse。这允许一个线程将东西推到队列中,另一个线程本质上“观察”队列。
从编程角度来看,我正在尝试决定哪种方法更干净:
foreach(QueueItem item in queue)
{
    DoSomethingWithThe(item);
}

//Or

while(systemIsRunning)
{
    DoSomethingWithThe(queue.Dequeue());
}

对于我的特定实现来说,如果有多个进程出列项目,也没有关系。因为它是一个队列,它们都可以选择一个项目,因为不应该处理任何项目超过一次,因此使用了队列。

编辑

有趣的是,我发现有人已经做到了这一点。

链接

编辑

在我关闭这个问题之前,最后再尝试一下。人们对类没有实现IEnumerable但具有一个IEnumerator GetEnumerator()方法来出列项目的想法感觉如何?.net语言支持鸭子类型,foreach就是其中之一的用途。也许这值得提出自己的问题?

编辑

在另一个问题中提出了实现GetEnumerator方法而不实现IEnumerable的问题。


我同意我原本考虑的做法是滥用,这也是我发布的原因,我只是需要被告知10或11次。我认为我真正需要问自己的问题是为什么需要实现IEnumerable?我这样做是因为核心实现需要,而我对于我的队列没有迭代的实际需求吗? - Bronumski
很遗憾我不能将你们所有人都标记为正确,所以我会给你们一天时间来整理你们的答案,并将得票最高的标记为正确。 - Bronumski
10个回答

20

迭代器应该始终是幂等的,也就是说,在迭代过程中不要修改队列。

不能保证没有两个并发的迭代...


针对您的新评论进行编辑:

当另一个程序员(例如您未来的自己;))来添加代码功能时,他们可能会认为迭代器是单次使用的。他们可能会在使用队列之前添加一个记录语句以列出其中的内容(糟糕)。

我刚想到的另一件事是,Visual Studio调试器通常会枚举您的类以进行显示。这将导致一些非常令人困惑的错误 :)

如果您正在实现IEnumerable的子接口,并且不想支持IEnumerable,则应抛出NotSupportedException。虽然这不会给出任何编译时警告,但运行时错误将非常清晰,而奇怪的IEnumerable实现可能会浪费未来你的时间。


1
这比并发问题还要简单。IEnumerables是只能向前遍历,只读的。修改底层的具体集合可能会导致IEnumerable实现“丢失”,因此大多数不允许这样做。以List为例。将其插入到一个删除每个元素的foreach中。在第一次循环中,索引0消失了,索引1变成了索引0。当枚举器调用MoveNext时,它将尝试从索引0移动到索引1,而实际上现在是索引2。这将导致不希望出现的行为。 - KeithS
3
总的来说,Keith提出的是一个很好的观点,但在原帖作者的代码中,枚举器实现并没有表现出那个特定的问题。 - Rob Fonseca-Ensor
此外,直到现在我还没有想过的一件事是,我不必实现IEnumerable接口,我只需要提供一个IEnumerator<TQueueItem> GetEnumerator()方法,因为.net语言支持鸭子类型,foreach是其中一个地方。现在问题是这仍然算滥用吗? - Bronumski
你可能会对我其他问题的一些回答感兴趣,链接为http://stackoverflow.com/questions/4194900/should-a-class-that-has-a-getenumerator-method-but-does-not-implement-ienumerable。 - Bronumski
我对任何修改其迭代集合的迭代器持谨慎态度。我认为这是错误的做法,通常只有在别无选择时才接受它作为有效的方式。Jon Skeet在回答您其他问题时给出了可能需要这样做的示例,尽管即使在这些情况下,我的第一反应也是尝试避免使用IEnumerable而选择Stream或缓存响应。但可能有些情况下,使用修改集合的迭代器可能更优。 - Brian
显示剩余2条评论

12
绝对不可以在迭代集合时进行集合的修改。迭代器的整个 目的 就是提供一种只读的非破坏性视图。如果查看它会改变它,那么使用您的代码的人将感到极其惊讶。
特别地,您不希望在调试器中检查队列的状态会改变队列的状态。调试器像任何其他消费者一样调用IEnumerable接口,如果它具有副作用,那么副作用是被执行的。

你可能会对我其他问题的一些回答感兴趣,链接为http://stackoverflow.com/questions/4194900/should-a-class-that-has-a-getenumerator-method-but-does-not-implement-ienumerable。 - Bronumski
为什么BlockingCollection<T>.GetConsumingEnumerable Method会从集合中移除并返回项呢?这绝对会在你迭代它的时候改变集合。 - Zaid Masud
@EricLippert 不是,但比原帖的读者更多 :) 我刚开始使用 BlockingCollection,最初认为 GetConsumingEnumerable 不会修改底层集合,部分原因是我在这里读到了你的答案。因此我被误导了,想指出你可能考虑修改措辞 :) - Zaid Masud
1
@ZaidMasud:方法名称GetConsumingEnumerable的设计旨在告诉您发生了一些不寻常的事情。 - Eric Lippert
@EricLippert 很酷,我想我们的意思是,如果你要打破规则,请不要在 IEnumerable.GetEnumerator() 实现中打破它们。请在另一个返回 IEnumerable 的方法中这样做。 - Zaid Masud
显示剩余3条评论

5
我建议您可以创建一个名为DequeueAll的方法,该方法返回一个类的项,该类具有GetEnumerator方法,表示队列中的所有内容,并清除队列(如果在创建iEnumerable时添加了队列项,则新项应出现在当前所有项已出队而不在队列中,或在队列中但不在当前调用中)。如果该类实现了iEnumerable,应以这样的方式构造返回对象,即使枚举器已创建并处理过(允许多次枚举),返回对象仍然有效。如果这种方法不可行,可能会有用给该类命名,表明不应持久化该类的对象。仍然可以使用foreach(QueueItem theItem in theQueue.DequeueAll()) {},但不太可能(错误地)将theQueue.DequeueAll的结果保存到iEnumerable中。如果想要最大的性能,同时允许将theQueue.DequeueAll的结果用作iEnumerable,可以定义一个扩展转换,它将获取DequeueAll结果的快照(从而允许旧项目被丢弃)。

我喜欢那个,思维超越常规。 - Bronumski

3
我要离开这个流行潮流,说“是的”。这似乎是一个合理的方法。虽然我必须提出一个建议。那就是,在GetEnumerator方法中不要实现这个消耗迭代器。相反,调用GetConsumingEnumerator或类似的方法。这样很明显会发生什么,foreach机制也不会默认使用它。你不会是第一个这样做的人。事实上,Microsoft已经在BCL中通过BlockingCollection类这样做了,他们甚至使用GetConsumingEnumerator作为该方法1。我想你知道我接下来要建议什么了吧?2 1你觉得我是怎么想到这个名字的呢? 2为什么不直接使用BlockingCollection呢?它可以满足你的所有需求,而且还有更多功能。

我之前注意到了IProducerConsumerCollection<T>接口,但从未注意到BlockingCollection<T>,谢谢提醒。 - Bronumski

2
我会选择第二种方法,推荐使用第一种方法。
这种做法与内置的队列类更加一致,同时也符合 IEnumerable<T> 接口应该是只读的事实。
此外,你觉得这样做真的很直观吗?
//queue is full
foreach(var item in queue)
    Console.WriteLine(item.ToString());
//queue is empty!?

如果我是要这样使用它,那么我同意使用它有点滥用。我认为我真正应该问自己的问题是,我的队列是否真的应该实现IEnumerable,只因为核心队列是这样做的。由于没有要求这样做,所以我不得不说不。 - Bronumski
@Bronumski:既然只涉及一行代码,我认为真正的问题是,为什么不呢?我可以想象枚举(或特别是在队列元素上使用Linq)的能力是有用的,所以我真的不明白为什么你不这样做。 - BlueRaja - Danny Pflughoeft
当涉及到核心API时,我可以看到它很有用,但是在采用敏捷方法进行开发并且接受我不会滥用语言的情况下,我必须要调用YAGNI。 - Bronumski
1
@Bronumski:我认为你在错误的情境下运用了敏捷口号;考虑到你所获得的巨大好处(包括像许多其他人提到的调试好处),这绝对值得考虑。如果这只是一个小时的工作,我或许可以理解YAGNI的论点...但这只是一行非常干净且没有后果的代码,而且你已经写好了! - BlueRaja - Danny Pflughoeft

1

严格来说,队列仅提供push-backpop-frontis-empty和可能的get-size操作。除此之外添加的任何内容都不是队列的一部分,因此如果您决定提供其他操作,则无需遵循队列的语义。

特别地,迭代器不是标准队列接口的一部分,因此您不需要在迭代器中删除当前正在迭代的项目。(正如其他人指出的那样,这也会违反对迭代器的期望。)


我认为这是我的问题之一,我从一个纯粹的角度来看待它,认为队列只应该进行推入和弹出操作。解释得很好。 - Bronumski

0

遍历队列时应该不要出队。

这是为了检查队列内容而设计的,而不是出队。这也是MSMQMQ Series的工作方式。


0

我认为不应该这样做。这会非常隐晦,并且对于习惯使用 .net 框架的人来说,无法传达这种意图。


0
我会选第二个。你的枚举器绝不能改变集合的状态。

0

我曾经写过一段代码,其中一个属性的获取不是幂等的... 这真是让人头疼。请坚持使用手动出队。

此外,你可能不是唯一一个在队列上工作的人,所以如果有多个消费者,可能会变得混乱。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接