Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Why am I not allowed to return an IAsyncEnumerable in a method returning an IAsyncEnumerable

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?

like image 479
fharreau Avatar asked Jan 27 '21 10:01

fharreau


People also ask

How does IAsyncEnumerable work?

IAsyncEnumerable<T> exposes an enumerator that has a MoveNextAsync() method that can be awaited. This means a method that produces this result can make asynchronous calls in between yielding results. Cool! This method can now yield data asynchronously.


2 Answers

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.

like image 67
Stephen Cleary Avatar answered Nov 02 '22 11:11

Stephen Cleary


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

like image 35
Andrew Shepherd Avatar answered Nov 02 '22 11:11

Andrew Shepherd