Убедитесь, что отложенное выполнение будет выполнено только один раз или иначе

Я столкнулся с странной проблемой, и мне интересно, что мне с этим делать.

У меня есть этот класс, который возвращает 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 не будет reset. Я нашел проблему по ошибке при модульном тестировании.

Самый простой способ устранить проблему - просто добавить .ToArray() и избавиться от отложенного исполнения за счет небольшого количества накладных расходов.

Я мог бы добавить unit test в класс потребителей, чтобы убедиться, что он вызывает его только один раз, но это не помешает новому пользователю, закодированному в будущем, из этой потенциальной проблемы.

Еще одна вещь, которая пришла мне в голову, заключалась в следующем: Что-то вроде

return myStuff.Where(...)
       .OrderBy(...)
       .TakeWhile(...)
       .ThrowIfExecutedMoreThan(1);

Очевидно, этого не существует. Было бы хорошей идеей реализовать такую ​​вещь и как бы вы это сделали?

В противном случае, если есть большой розовый слон, которого я не вижу, это будет оценено. (Я чувствую, что есть один, потому что этот вопрос касается очень простого сценария: |)

EDIT:

Вот пример использования плохих потребителей:

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

Ответ 1

Вы можете решить проблему с неправильным результатом, просто переведя свой метод в iterator:

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

Ответ 2

Вы можете использовать следующий класс:

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

to украсить перечислимый, чтобы его можно было только перечислить один раз. После этого он выкинет исключение.

Вы можете использовать этот класс следующим образом:

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

Обратите внимание, что я не рекомендую этот подход, потому что он нарушает договор интерфейса IEnumerable и, следовательно, Принцип замены Лискова. Для потребителей этого контракта законно предположить, что они могут перечислять перечисляемые столько раз, сколько им нравится.

Вместо этого вы можете использовать кэшированный перечислимый, который кэширует результат перечисления. Это гарантирует, что перечислимый только один раз перечисляется и что все последующие попытки перечисления будут считываться из кеша. Подробнее см. этот ответ.

Ответ 3

Ответ 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;
    }
}
  • Наивное: ✔ Отложенное, ✘ Одиночное перечисление.
  • Список ToList: ✘ Отложен, ✔ Единая перечисление.
  • Memoize: ✔ Отложенные, ✔ Единая перечисление.

.

Отредактировано, чтобы использовать правильную терминологию и воплотить реализацию.