Lazy Yield Problems

Zanim przejdę do sedna sprawy nakreślę najpierw ciąg wydarzeń który mnie ku napisaniu tego posta skłonił.

Ostatnimi czasy wykonywałem małą biblioteczkę "na własne potrzeby" której podstawą był interfejs który można przedstawić następująco:

public interface IProcessor<T>
{
	IEnumerable<Result> Process(T input);
}

Interfejs ten ma wiele implementacji jednak z punktu widzenia tego postu kluczowy jest "AggregateProcessor" który łączy wyniki kilku procesorów.

class AggregateProcessor<T> : IProcessor<T>
{
	private IProcessor<T>[] subProcessors;

	public AggregateProcessor(params IProcessor<T>[] subProcessors)
	{
		this.subProcessors = subProcessors;
	}

	public IEnumerable<Result> Process(T input)
	{
		return subProcessors.SelectMany(p => p.Process(input));

		/* ekwiwalent
		foreach (var processor in subProcessors)
		{
			foreach (var result in processor.Process(input))
			{
				yield return result;
			}
		}
		 */
	}
}

Następnie dla tej klasy napisałem prosty test sprawdzający czy podprocesor zostanie wywołany po wywołaniu Process na AggregateProcessorze.

Test jest napisany w NUnicie do Mockowania wykorzystałem FakeItEasy.

[Test]
public void CheckIfSubProcessorIsCalled()
{
	// Given
	var parameter = new TestClass();

	var fakeProcessor = A.Fake<IProcessor<TestClass>>();
	A.CallTo(() => fakeProcessor.Process(parameter)).Returns(Enumerable.Empty<Result>());

	var aggregate = new AggregateProcessor<TestClass>(fakeProcessor);

	// When
	aggregate.Process(parameter);

	// Then
	A.CallTo(() => fakeProcessor.Process(parameter)).MustHaveHappened();
}

Wielkie było moje zdziwienie gdy okazało się że Test nie przechodzi.

Przyczyną tej sytuacji jest fakt iż zarówno operacje w zapytaniu LINQ jak i operacja yield wywoływane są w momencie iteracji po wyniku metody której w tym przykładzie nie ma. W większości przypadków fakt ten nie ma większego znaczenia ponieważ iteracja zazwyczaj w końcu następuje, chyba że tak jak w tym przypadku metody będącej funkcją używamy jak metody będącej procedurą, albo wykonujemy na wyniku operacje typu Any, First/FirstOrDefault które nie iterują po całym wyniku.

Wracając do mojego przypadku ostatecznie stwierdziłem że fakt wywołania metody wewnętrznej nie jest dla mnie istotny, a bardziej obchodzi mnie zwrócenie wyniku procesora wewnętrznego.

[Test]
public void CheckIfSubProcessorIsCalled()
{
	// Given
	var parameter = new TestClass();
	var result = new Result();

	var fakeProcessor = A.Fake<IProcessor<TestClass>>();
	A.CallTo(() => fakeProcessor.Process(parameter)).Returns(new[] { result });

	var aggregate = new AggregateProcessor<TestClass>(fakeProcessor);

	// When
	var resultCollection = aggregate.Process(parameter);

	// Then
	CollectionAssert.Contains(resultCollection, result);
}

Drugim zagrożeniem jakie wiąże się z yield i LINQ jest fakt iż wszystkie operacje wykonywane pod spodem wykonują się przy każdej iteracji po wynikowej kolekcji. Sam się na tym nigdy nie przejechałem ponieważ przed błędem tego typu skutecznie chroni mnie Resharper, jednak ludzi nie posiadających tego genialnego narzędzia warto ostrzec że poniższy kod:

static void Main(string[] args)
{
	var sourceCollection = Enumerable.Range(1, 10);

	var resultCollection = from number in sourceCollection
						   select Compute(number);

	foreach (var number in resultCollection)
	{
		Console.Write("{0}, ", number);
	}

	Console.WriteLine();

	foreach (var number in resultCollection)
	{
		Console.Write("{0}, ", number);
	}
}

private static int Compute(int parameter)
{
	// Some Heavy operation
	Thread.Sleep(1000);
	return parameter + 1;
}

będzie uruchamiał się 20 a nie 10 sekund. Jakimś obejściem tego problemu jest użycie metody ToList. Najlepiej w następujący sposób:

static void Main(string[] args)
{
	var sourceCollection = Enumerable.Range(1, 10);

	var resultCollection = from number in sourceCollection
						   select Compute(number);

	/*
	 * Przed wywołaniem ToList upewniam się że kolekcja nie jest już listą
	 * W tym przypadku nie ma to sensu ponieważ wiem że kolekcja nie jest listą
	 */
	var resultList = resultCollection as List<int> ?? resultCollection.ToList();

	foreach (var number in resultList)
	{
		Console.Write("{0}, ", number);
	}

	Console.WriteLine();

	foreach (var number in resultList)
	{
		Console.Write("{0}, ", number);
	}
}

Podsumowując zarówno LINQ jak i yield są bardzo fajnymi narzędziami, przed ich użyciem należy jednak przynajmniej w minimalnym stopniu dowiedzieć się jak one działają.