Caching IEnumerable

Viewed 6992
public IEnumerable<ModuleData> ListModules()
{
    foreach (XElement m in Source.Descendants("Module"))
    {
        yield return new ModuleData(m.Element("ModuleID").Value);
    }
}

Initially the above code is great since there is no need to evaluate the entire collection if it is not needed.

However, once all the Modules have been enumerated once, it becomes more expensive to repeatedly query the XDocument when there is no change.

So, as a performance improvement:

public IEnumerable<ModuleData> ListModules()
{
    if (Modules == null)
    {
        Modules = new List<ModuleData>();
        foreach (XElement m in Source.Descendants("Module"))
        {
            Modules.Add(new ModuleData(m.Element("ModuleID").Value, 1, 1));
        }
    }
    return Modules;
}

Which is great if I am repeatedly using the entire list but not so great otherwise.

Is there a middle ground where I can yield return until the entire list has been iterated, then cache it and serve the cache to subsequent requests?

7 Answers

Just to sum up things a bit:

  • In this answer a solution is presented, complete with an extension method for easy use and unit tests. However, as it uses recursion, performance can be expected to be worse than the other non recursive solution due to fewer allocations.
  • In this answer a non recursive solution is presented, including some code to account for the case where the enumerable is being enumerated twice. In this situation, however, it might not maintain the order of the original enumerable and it does not scale to more than two concurrent enumerations.
  • In this answer the enumerator method is rewritten to generalize the solution for the case of multiple concurrent enumeration while preserving the order of the original enumerable.

Combining the code from all answers we get the following class. Beware that this code is not thread safe, meaning that concurrent enumeration is safe only from the same thread.

public class CachedEnumerable<T> : IEnumerable<T>, IDisposable
{
    private readonly IEnumerator<T> enumerator;
    private readonly List<T> cache = new List<T>();

    public CachedEnumerable(IEnumerable<T> enumerable) : this(enumerable.GetEnumerator()) { }

    public CachedEnumerable(IEnumerator<T> enumerator)
        => this.enumerator = enumerator ?? throw new ArgumentNullException(nameof(enumerator));

    public IEnumerator<T> GetEnumerator()
    {
        int index = 0;
        while (true) {
            if (index < cache.Count) {
                yield return cache[index];
                index++;
            }
            else if (enumerator.MoveNext())
                cache.Add(enumerator.Current);
            else
                yield break;
        }
    }

    public void Dispose() => enumerator.Dispose();

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

With the static extension method for easy use:

public static class EnumerableUtils
{
    public static CachedEnumerable<T> ToCachedEnumerable<T>(this IEnumerable<T> enumerable) 
        => new CachedEnumerable<T>(enumerable);
}

And the corresponding unit tests:

public class CachedEnumerableTest
{
    private int _count;

    [Test]
    public void MultipleEnumerationAreNotCachedForOriginalIEnumerable()
    {
        _count = 0;

        var enumerable = Enumerable.Range(1, 40).Select(incrementCount);

        enumerable.Take(3).ToArray();
        enumerable.Take(10).ToArray();
        enumerable.Take(4).ToArray();

        Assert.AreEqual(17, _count);
    }

    [Test]
    public void EntireListIsEnumeratedForOriginalListOrArray()
    {
        _count = 0;
        Enumerable.Range(1, 40).Select(incrementCount).ToList();
        Assert.AreEqual(40, _count);

        _count = 0;
        Enumerable.Range(1, 40).Select(incrementCount).ToArray();
        Assert.AreEqual(40, _count);
    }

    [Test]
    public void MultipleEnumerationsAreCached()
    {
        _count = 0;

        var cachedEnumerable = Enumerable.Range(1, 40).Select(incrementCount).ToCachedEnumerable();

        cachedEnumerable.Take(3).ToArray();
        cachedEnumerable.Take(10).ToArray();
        cachedEnumerable.Take(4).ToArray();

        Assert.AreEqual(10, _count);
    }

    [Test]
    public void FreshCachedEnumerableDoesNotEnumerateExceptFirstItem()
    {
        _count = 0;

        Enumerable.Range(1, 40).Select(incrementCount).ToCachedEnumerable();

        Assert.That(_count <= 1);
    }

    [Test]
    public void MatrixEnumerationIteratesAsExpectedWhileStillKeepingEnumeratedValuesCached()
    {
        _count = 0;

        var cachedEnumerable = Enumerable.Range(1, 5).Select(incrementCount).ToCachedEnumerable();

        var matrixCount = 0;

        foreach (var x in cachedEnumerable) {
            foreach (var y in cachedEnumerable) {
                matrixCount++;
            }
        }

        Assert.AreEqual(5, _count);
        Assert.AreEqual(25, matrixCount);
    }

    [Test]
    public void OrderingCachedEnumerableWorksAsExpectedWhileStillKeepingEnumeratedValuesCached()
    {
        _count = 0;

        var cachedEnumerable = Enumerable.Range(1, 5).Select(incrementCount).ToCachedEnumerable();

        var orderedEnumerated = cachedEnumerable.OrderBy(x => x);
        var orderedEnumeratedArray = orderedEnumerated.ToArray(); // Enumerated first time in ascending order.
        Assert.AreEqual(5, _count);

        for (int i = 0; i < orderedEnumeratedArray.Length; i++) {
            Assert.AreEqual(i + 1, orderedEnumeratedArray[i]);
        }

        var reorderedEnumeratedArray = orderedEnumerated.OrderByDescending(x => x).ToArray(); // Enumerated second time in descending order.
        Assert.AreEqual(5, _count);

        for (int i = 0; i < reorderedEnumeratedArray.Length; i++) {
            Assert.AreEqual(5 - i, reorderedEnumeratedArray[i]);
        }
    }

    private int incrementCount(int value)
    {
        _count++;
        return value;
    }
}
Related