17

I have the following interface:

public interface IValidationSystem<T>
{
    IAsyncEnumerable<ValidationResult> ValidateAsync(T obj);
}

And I am trying to implement it this way:

public class Foo
{ }

public class Bar
{ }

public class BarValidationSystem : IValidationSystem<T>
{   
    public async IAsyncEnumerable<ValidationResult> ValidateAsync(Bar bar)
    {
        var foo = await GetRequiredThingAsync();

        return GetErrors(bar, foo).Select(e => new ValidationResult(e)).ToAsyncEnumerable();
    }

    private static IEnumerable<string> GetErrors(Bar bar, Foo foo)
    {
        yield return "Something is wrong";
        yield return "Oops something else is wrong";
        yield return "And eventually, this thing is wrong too";
    }
    
    private Task<Foo> GetRequiredThingAsync()
    {
        return Task.FromResult(new Foo());
    }
}

But this does not compile:

CS1622 Cannot return a value from an iterator. Use the yield return statement to return a value, or yield break to end the iteration.

I know I can fix by iterating the enumerable:

foreach (var error in GetErrors(bar, foo))
{
    yield return new ValidationResult(error);
}

Or by returning a Task<IEnumerable<ValidationResult>>:

public async Task<IEnumerable<ValidationResult>> ValidateAsync(Bar bar)
{
    var foo = await GetRequiredThingAsync;

    return GetErrors(bar, foo).Select(e => new ValidationResult(e));
}

But I would like to understand why I cannot return an IAsyncEnumerable in my case. When writing "classic" IEnumerable methods, you can either return an IEnumerable or yield return several values. Why am I not allowed to do the same with IAsyncEnumerable?

fharreau
  • 2,105
  • 1
  • 23
  • 46
  • I tried to look at this, but there are too many things missing to make this compile. Can you provide a [mcve] (ideally with a fiddle link)? – Heinzi Jan 27 '21 at 10:42
  • @Heinzi: I added some stuff. I think it will compile right now – fharreau Jan 27 '21 at 10:48
  • As the error suggests, you need to use `yield return...`. So drop the `ToAsyncEnumerable` and instead loop over the result of the `Select` and yield return every item. – DavidG Jan 27 '21 at 10:52
  • @DavidG: I don't think that answers the question: *"But I would like to understand why I cannot return an IAsyncEnumerable in my case."* – Heinzi Jan 27 '21 at 10:54
  • @Heinzi Nope, that's why I wrote a comment instead of an answer. – DavidG Jan 27 '21 at 10:54
  • 1
    @fharreau: Nope, fiddle still misses the definition for `ToAsyncEnumerable()`. Sorry, I'm out... too much work getting it to compile (need to return to work). – Heinzi Jan 27 '21 at 10:54
  • @DavidG: Ah, ok. – Heinzi Jan 27 '21 at 10:55
  • @DavidG: I am aware of that solution as I said in my question (and showed the code). I just want to know why. When writing "classic" IEnumerable methods, you can either return an IEnumerable or yield return value. Why am I not allowed to do the same with IAsyncEnumerable? – fharreau Jan 27 '21 at 10:55
  • 1
    Related: [Pass-through for IAsyncEnumerable?](https://stackoverflow.com/questions/59876417/pass-through-for-iasyncenumerable) – Theodor Zoulias Jan 27 '21 at 10:58
  • 4
    This looks like a bug or at least an unintentional limitation, when reading the [spec proposal](https://learn.microsoft.com/dotnet/csharp/language-reference/proposals/csharp-8.0/async-streams#syntax-1). The intent was clearly for an async iterator to be signaled by the use of `yield`, just like sync iterators; yet the mere combination of `async` and the return type seems to lock it in as an iterator. – Jeroen Mostert Jan 27 '21 at 11:01
  • 1
    @Jeroen: That's my understanding of the situation. Thank you for putting this into a clear sentence! – fharreau Jan 27 '21 at 11:03
  • @Heinzi: looks like fiddle is not up to date to use this new feature. I can't get it compiling neither. `IAsyncEnumerable` is part of the `System.Collections.Generic` namespace. – fharreau Jan 27 '21 at 11:09
  • Another related: https://stackoverflow.com/questions/59689529/return-iasyncenumerable-from-an-async-method – fharreau Jan 27 '21 at 11:15
  • @fharreau - I've got it compiling in .NET Fiddle: you must include the nuget package `System.Interactive.Async` – Andrew Shepherd Jan 27 '21 at 11:15

2 Answers2

13

This looks like a bug or at least an unintentional limitation, when reading the spec proposal.

The spec states that the presence of yield results in an iterator method; and the presence of both async and yield results in an asynchronous iterator method.

But I would like to understand why I cannot return an IAsyncEnumerable in my case.

The async keyword is making this into an asynchronous iterator method. Since you need the async for the await, then you'll need to use yield as well.

When writing "classic" IEnumerable methods, you can either return an IEnumerable or yield return several values. Why am I not allowed to do the same with IAsyncEnumerable?

With both IEnumerable<T> and IAsyncEnumerable<T>, you can perform synchronous work before returning the enumerable directly. In this case, the method is not special at all; it just does some work and then returns a value to its caller.

But you can't do asynchronous work before returning an asynchronous enumerator. In this case, you need the async keyword. Adding the async keyword forces the method to either be an asynchronous method or an asynchronous iterator method.

To put it another way, all methods can be classified into these different types in C#:

  • Normal methods. No async or yield present.
  • Iterator methods. A yield in the body without async. Must return IEnumerable<T> (or IEnumerator<T>).
  • Asynchronous methods. An async is present without yield. Must return a tasklike.
  • Asynchronous iterator methods. Both async and yield are present. Must return IAsyncEnumerable<T> (or IAsyncEnumerator<T>).

From yet another perspective, consider the state machine that must be used to implement such a method, and especially think about when the await GetRequiredThingAsync() code runs.

In the synchronous world without yield, GetRequiredThing() would run before returning the enumerable. In the synchronous world with yield, GetRequiredThing() would run when the first item of the enumerable is requested.

In the asynchronous world without yield, await GetRequiredThingAsync() would run before returning the async enumerable (and in that case, the return type would be Task<IAsyncEnumerable<T>>, since you have to do asynchronous work to get the async enumerable). In the asynchronous world with yield, await GetRequiredThingAsync() would run when the first item of the enumerable is requested.

Generally speaking, the only case when you want to do work before returning the enumerable is when you're doing precondition checks (which are synchronous by nature). Doing an API/DB call is not normal; most of the time the expected semantics are that any API/DB calls will be done as part of enumeration. In other words, even the synchronous code probably should have been using foreach and yield, just like the asynchronous code is forced to do.

On a side note, it would be nice in these scenarios to have a yield* for both synchronous and asynchronous iterators, but C# does not support that.

Stephen Cleary
  • 437,863
  • 77
  • 675
  • 810
5

The usual syntax for an async method is to return a Task:

public async Task<Foo> GetFooAsync()
{
    /...
}

If you make it async but not return a Task<T> then the compiler will flag an error in the header.

There is an exception: an iterator method that returns IAsyncEnumerable

private static async IAsyncEnumerable<int> ThisShouldReturnAsTask()
{
    yield return 0;
    await Task.Delay(100);
    yield return 1;
}

In your example, you have an async function that returns IAsyncEnumerable, but is NOT an iterator. (It just returns a straight enumerable object, instead of yielding values one-by-one.)

Hence the error: "Cannot return a value from an iterator"

If you changed the signature to return Task<IAsyncEnumerable<ValidationResult>>

public async Task<IAsyncEnumerable<ValidationResult>> ValidateAsync(Bar bar)
{
    var foo = await GetRequiredThingAsync();
    return GetErrors(bar, foo).Select(e => new ValidationResult(e)).ToAsyncEnumerable();
}

then you will need to change the way you invoke it: it would have to be

await foreach(var f in await ValidateAsync(new Bar()))

Instead of

await foreach(var f in ValidateAsync(new Bar()))

See examples to play with here: https://dotnetfiddle.net/yPTdqp

Andrew Shepherd
  • 44,254
  • 30
  • 139
  • 205
  • Nop, absolutely not. It is possible to return an async IAsyncEnumerable, check the new C# 8 features: https://learn.microsoft.com/en-us/archive/msdn-magazine/2019/november/csharp-iterating-with-async-enumerables-in-csharp-8#a-tour-through-async-enumerables – fharreau Jan 27 '21 at 11:02
  • @fharreau: OK, I wasn't aware of this. But in these examples, it's an iterator method. My guess is that if it's not returning a Task then it is an iterator method. I'll modify my answer. – Andrew Shepherd Jan 27 '21 at 11:05
  • That would work. But I don't want to modify this interface. I know how to fix the compilation as I said in the question, but I want to understand why I can't just return an `IAsyncEnumerable`. – fharreau Jan 27 '21 at 11:06
  • @fharreau - I've modified the answer based on the link you've sent. (I've learned a lot out of being wrong this evening :-) – Andrew Shepherd Jan 27 '21 at 11:14
  • 4
    A method returning a `Task>` is not the same thing as one returning an `IAsyncEnumerable`, even if the latter method is `async`. A method returning `Task>` cannot be used in an `await foreach`; the result of that method would itself need to be awaited. – Jeroen Mostert Jan 27 '21 at 11:15
  • 1
    The true issue here is that the use of the `async` keyword in the method signature turn the method into an `iterator method` (probably not the right wording for it), forcing the uses of `yield return` (check the 2 answers of Stephen Cleary in the linked questions) – fharreau Jan 27 '21 at 11:19
  • 1
    @fharreau Thanks for posting the question. My answer generated interesting comments so I'll leave it here rather than deleting it. – Andrew Shepherd Jan 27 '21 at 11:30