确保延迟执行只会执行一次,否则...

11

我遇到了一个奇怪的问题,想知道应该怎么办。

我有一个返回IEnumerable<MyClass>的类,它是延迟执行的。目前有两个可能的使用者,其中一个会对结果进行排序。

看下面的例子:

public class SomeClass
{
    public IEnumerable<MyClass> GetMyStuff(Param givenParam)
    {
        double culmulativeSum = 0;
        return myStuff.Where(...)
                      .OrderBy(...)
                      .TakeWhile( o => 
                      {
                          bool returnValue = culmulativeSum  < givenParam.Maximum;
                          culmulativeSum += o.SomeNumericValue;
                          return returnValue; 
                      };
    }
}
消费者仅调用延迟执行一次,但如果他们调用超过一次,则结果将是错误的,因为culmulativeSum不会重置。 我通过单元测试无意中发现了这个问题。 最简单的解决方法是只需添加 .ToArray()并消除延迟执行,代价是略微增加一些开销。 我也可以在消费者类中添加单元测试,以确保他们只调用一次,但这不能防止未来编写的任何新消费者出现此潜在问题。 我想到的另一件事是使后续执行抛出异常。 类似这样
return myStuff.Where(...)
       .OrderBy(...)
       .TakeWhile(...)
       .ThrowIfExecutedMoreThan(1);

显然这个并不存在。实现这样的事情是个好主意吗?如何做到呢?

否则,如果有一只我看不见的粉色大象,请指出来会很感激。(我感觉有只因为这个问题是关于一个非常基本的场景 :| )

编辑:

以下是一个糟糕的用户使用示例:

public class ConsumerClass
{
    public void WhatEverMethod()
    {
        SomeClass some = new SomeClass();
        var stuffs = some.GetMyStuff(param);
        var nb = stuffs.Count(); //first deferred execution
        var firstOne = stuff.First(); //second deferred execution with the culmulativeSum not reset
    }
}

你是在说多个线程同时调用这个函数的情况吗?否则我不明白这怎么会给你带来错误的结果。cumulativeSum 在函数顶部被重置为零,而且它似乎是局部变量。 - JuanR
2
这就是为什么让LINQ方法具有副作用是一个坏主意的基本原因。虽然代码看起来很简单,但你所做的并不是一个真正的“基本场景”,当然也不是一个推荐的场景。 - vgru
1
@Juan:每次调用GetMyStuff时,cumulativeSum都被设置为0,但不是每次枚举结果时都会这样。因为每次枚举时,你只评估return之后的LINQ部分。因此,每次以后你枚举时都将得到空值,因为cumulativeSum已经大于最大值了。演示:http://ideone.com/VgLbTe。 - mellamokb
3个回答

11
您可以通过将方法转换为 迭代器 来解决结果不正确的问题:
double culmulativeSum = 0;
var query = myStuff.Where(...)
       .OrderBy(...)
       .TakeWhile(...);
foreach (var item in query) yield return item;

它可以被封装在一个简单的扩展方法中:

public static class Iterators
{
    public static IEnumerable<T> Lazy<T>(Func<IEnumerable<T>> source)
    {
        foreach (var item in source())
            yield return item;
    }
}

在这种情况下,你需要做的就是用Iterators.Lazy调用包围原始方法体,例如:

return Iterators.Lazy(() =>
{
    double culmulativeSum = 0;
    return myStuff.Where(...)
           .OrderBy(...)
           .TakeWhile(...);
});

太棒了!整个“GetMyStuff”都变成了延迟执行。 - AXMIM
在问题中明确描述的情况下,这是一个非常合适的答案。但请记住,可枚举对象仍会被多次评估。在许多情况下,这并不理想。我下面的答案允许延迟和缓存评估。 - Andrew Hanlon
1
@AndrewHanlon 惰性求值和缓存求值是不同的概念。当然,缓存可能很有用,但这应该是消费者的决定,而不是实现者的决定。如果查询没有副作用,它将被多次评估。我试图仅使用惰性求值来避免实现的副作用,而消费者并不知道这些副作用。消费者始终可以执行 ToListToArray 或调用像您的方法一样的方法(如果需要的话)。实现本身不需要缓存,因此不应该这样做。 - Ivan Stoev
1
@AndrewHanlon,你对于Lazy<T>是正确的,但这是定义和预期的行为,而对于LINQ查询,标准期望是延迟执行和重新评估(尽管仅看到IEnumerable<T>无法确定它是集合还是查询,但无论如何:)。那么如何防止多次枚举呢?我们的解决方案都没有做到这一点,我的意思是,返回的可枚举对象可以被多次枚举。这是可以接受的,因为实际问题是错误的结果,而不是多次枚举。 - Ivan Stoev
1
@AndrewHanlon 我明白你的意思,也理解了你提出的解决方案。我的观点只是认为这两件事必须分开,因为你的解决方案在消费者仅迭代一次结果时会进行不必要的缓存。无论如何,这是一次很好的讨论,感谢你分享你的想法! - Ivan Stoev
显示剩余2条评论

6
你可以使用以下类:
public class JustOnceOrElseEnumerable<T> : IEnumerable<T>
{
    private readonly IEnumerable<T> decorated;

    public JustOnceOrElseEnumerable(IEnumerable<T> decorated)
    {
        this.decorated = decorated;
    }

    private bool CalledAlready;

    public IEnumerator<T> GetEnumerator()
    {
        if (CalledAlready)
            throw new Exception("Enumerated already");

        CalledAlready = true;

        return decorated.GetEnumerator();
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        if (CalledAlready)
            throw new Exception("Enumerated already");

        CalledAlready = true;

        return decorated.GetEnumerator();
    }
}

使用这个类可以将可枚举对象 decorate,使其只能被枚举一次。之后会抛出异常。

你可以像这样使用该类:

return new JustOnceOrElseEnumerable(
   myStuff.Where(...)
   ...
   );

请注意,我不建议这种方法,因为它违反了 IEnumerable 接口的契约,从而违反了Liskov替换原则。消费者可以合法地假设他们可以随意枚举可枚举对象。
相反,您可以使用缓存的可枚举对象来缓存枚举结果。这确保可枚举对象仅被枚举一次,并且所有后续的枚举尝试都将从缓存中读取。有关更多信息,请参见此答案

好东西和好的推荐。实际上对于我的情况来说有点过度,但我可能会在其他地方使用它。 - AXMIM

5

Ivan的回答非常适用于OP示例中的根本问题,但对于一般情况,我过去使用类似于下面的扩展方法来处理此问题。这可以确保Enumerable只有一个评估,但也是延迟的:

public static IMemoizedEnumerable<T> Memoize<T>(this IEnumerable<T> source)
{
    return new MemoizedEnumerable<T>(source);
}

private class MemoizedEnumerable<T> : IMemoizedEnumerable<T>, IDisposable
{
    private readonly IEnumerator<T> _sourceEnumerator;
    private readonly List<T> _cache = new List<T>();

    public MemoizedEnumerable(IEnumerable<T> source)
    {
        _sourceEnumerator = source.GetEnumerator();
    }

    public IEnumerator<T> GetEnumerator()
    {
        return IsMaterialized ? _cache.GetEnumerator() : Enumerate();
    }

    private IEnumerator<T> Enumerate()
    {
        foreach (var value in _cache)
        {
            yield return value;
        }

        while (_sourceEnumerator.MoveNext())
        {
            _cache.Add(_sourceEnumerator.Current);
            yield return _sourceEnumerator.Current;
        }

        _sourceEnumerator.Dispose();
        IsMaterialized = true;
    }

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

    public List<T> Materialize()
    {
        if (IsMaterialized)
            return _cache;

        while (_sourceEnumerator.MoveNext())
        {
            _cache.Add(_sourceEnumerator.Current);
        }

        _sourceEnumerator.Dispose();
        IsMaterialized = true;

        return _cache;
    }

    public bool IsMaterialized { get; private set; }

    void IDisposable.Dispose()
    {
        if(!IsMaterialized)
            _sourceEnumerator.Dispose();
    }
}

public interface IMemoizedEnumerable<T> : IEnumerable<T>
{
    List<T> Materialize();

    bool IsMaterialized { get; }
}

使用示例:

void Consumer()
{
    //var results = GetValuesComplex();
    //var results = GetValuesComplex().ToList();
    var results = GetValuesComplex().Memoize();

    if(results.Any(i => i == 3)) 
    {
        Console.WriteLine("\nFirst Iteration");
        //return; //Potential for early exit.
    }

    var last = results.Last(); // Causes multiple enumeration in naive case.        

    Console.WriteLine("\nSecond Iteration");
}

IEnumerable<int> GetValuesComplex()
{
    for (int i = 0; i < 5; i++)
    {
        //... complex operations ...        
        Console.Write(i + ", ");
        yield return i;
    }
}
  • Naive: ✔ 延迟计算,✘ 单次枚举。
  • ToList: ✘ 延迟计算,✔ 单次枚举。
  • Memoize: ✔ 延迟计算,✔ 单次枚举。

.

经过编辑后,使用正确的术语并完善实现细节。


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