5

Is this possible in C#? The following code produces a compiler error.

HashSet<Task<(string Value, int ToNodeId)>> regionTasks =
  new HashSet<Task<(string Value, int ToNodeId)>>();
foreach (Connection connection in Connections[RegionName])
{
    regionTasks.Add(async () =>
    {        
        string value = await connection.GetValueAsync(Key);
        return (value, connection.ToNode.Id);
    }());
}

The C# compiler complains, "Error CS0149: Method name expected." It's unable to infer the lambda method's return type.

Note my technique of invoking the lambda method immediately via the () after the the lambda block is closed {}. This ensures a Task is returned, not a Func.

The VB.NET compiler understands this syntax. I am stunned to find an example of the VB.NET compiler outsmarting the C# compiler. See my An Async Lambda Compiler Error Where VB Outsmarts C# blog post for the full story.

Dim regionTasks = New HashSet(Of Task(Of (Value As String, ToNodeId As Integer)))
For Each connection In Connections(RegionName)
    regionTasks.Add(Async Function()
        Dim value = Await connection.GetValueAsync(Key)
        Return (value, connection.ToNode.Id)
    End Function())
Next

The VB.NET compiler understands the End Function() technique. It correctly infers the lambda method's return type is Function() As Task(Of (Value As String, ToNodeId As Integer)) and therefore invoking it returns a Task(Of (Value As String, ToNodeId As Integer)). This is assignable to the regionTasks variable.

C# requires me to cast the lambda method's return value as a Func, which produces horribly illegible code.

regionTasks.Add(((Func<Task<(string Values, int ToNodeId)>>)(async () =>
{
    string value = await connection.GetValueAsync(Key);
    return (value, connection.ToNode.Id);
}))());

Terrible. Too many parentheses! The best I can do in C# is explicitly declare a Func, then invoke it immediately.

Func<Task<(string Value, int ToNodeId)>> getValueAndToNodeIdAsync = async () =>
{
    string value = await connection.GetValueAsync(Key);
    return (value, connection.ToNode.Id);
};
regionTasks.Add(getValueAndToNodeIdAsync());

Has anyone found a more elegant solution?

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
Erik Madsen
  • 497
  • 6
  • 17
  • 1
    The problem with the IIFE style is that the compiler hasn't determined if it's an expression tree or an anonymous method. Very irritating at times but it is rarely encountered. – Aluan Haddad Jul 04 '20 at 21:17
  • That sheds some light on why the C# compiler can't infer the return type. Though I'm still confused why the VB.NET compiler can. I guess I need to read up on .NET expression trees. – Erik Madsen Jul 04 '20 at 21:26
  • It is odd indeed. I expect that VB assumes it is an anonymous method. A practical choice if so. – Aluan Haddad Jul 04 '20 at 21:27
  • @ErikMadsen it seems that expression trees [are not related](https://stackoverflow.com/a/4966409/2501279) to the question. – Guru Stron Jul 04 '20 at 21:37
  • @GuruStron I don't think that's it. I declare the type of the regionTasks variable. The questioner indicated the strongly-typed version of his code does compile. He only encountered a compiler error with the var-typed version. – Erik Madsen Jul 04 '20 at 22:03
  • @ErikMadsen you don't declare the delegate type so it can be compared to `var` declaration. Compiler can't infer what is your lamba, `Action`, `Func` or some other delegate, as with `var`. I would say `regionTasks` type is irrelevant here. – Guru Stron Jul 04 '20 at 22:08
  • @ErikMadsen please see the updated answer. – Guru Stron Jul 04 '20 at 22:16
  • In vb.net project does `Option Strict` set to `On` or `Off`? I think you will get similar behaviour when this option is `On`. – Fabio Jul 05 '20 at 06:40
  • @Fabio Interesting idea. I tried it and the VB code does compile successfully even with Option Strict On. – Erik Madsen Jul 05 '20 at 12:29

3 Answers3

3

If .NET Standard 2.1 (or some .NET Framework versions, see compatibility list) is available for you, you can use LINQ with ToHashSet method:

var regionTasks = Connections[RegionName]
    .Select(async connection => 
    {        
        string value = await connection.GetValueAsync(Key);
        return (Value: value, ToNodeId: connection.ToNode.Id);
    })
    .ToHashSet();

Or just initialize HashSet with corresponding IEnumerable.

UPD

Another workaround from linked in comments answer:

static Func<R> WorkItOut<R>(Func<R> f) { return f; }

foreach (Connection connection in Connections[RegionName])
{
    regionTasks.Add(WorkItOut(async () =>
    {        
        string value = await connection.GetValueAsync(Key);
        return (value, connection.ToNode.Id);
    })());
}
Guru Stron
  • 102,774
  • 10
  • 95
  • 132
  • That's really clever! Thanks. – Erik Madsen Jul 04 '20 at 21:19
  • Why did I know my question was going to lead to an answer by Eric Lippert? Ha ha. I'm an avid reader of his blog and totally respect his efforts to educate all of us on the inner workings of the .NET runtime and C# compiler. Though in this particular case (the question you link to), I agree with user541686 who commented on Eric's answer, "it's slightly misleading to illustrate this as something that's not possible, as this actually works completely fine in D. It's just that you guys didn't choose to give delegate literals their own type, and instead made them depend on their contexts..." – Erik Madsen Jul 05 '20 at 13:15
  • Thanks for your second recommendation. That's what Theodor Zoulias suggested too, though with a more practical function name, Materialize. WorkItOut (I know it's Lippert's term, not yours) strikes me as very didactic. – Erik Madsen Jul 05 '20 at 13:17
1

When I first read the title of your question I thought "Eh? Who would propose trying to assign a value of type x to variable of type y, not in an inheritance relationship with x? That's like trying to assign an int to a string..."

I read the code, and that changed to "OK, this isn't assigning a delegate to a Task, this is just creating a Task and storing it in a collection of Tasks.. But it does look like they're assigning a delegate to a Task...

Then I saw

Note my technique of invoking the lambda method immediately via the () after the the lambda block is closed {}. This ensures a Task is returned, not a Func.

The fact that you have to explain this with commentary means it's a code smell and the wrong thing to do. Your code has gone from being readably self documenting, to a code golf exercise, using an arcane syntax trick of declaring a delegate and immediately executing it to create a Task. That's what we have Task.Run/TaskFactory.StartNew for, and it's what all the TAP code I've seen does when it wants a Task

You'll note that this form works and doesn't produce an error:

HashSet<Task<(string Value, int ToNodeId)>> regionTasks =
  new HashSet<Task<(string Value, int ToNodeId)>>();
foreach (Connection connection in Connections[RegionName])
{
    regionTasks.Add(Task.Run(async () =>
    {        
        string value = await connection.GetValueAsync(Key);
        return (value, connection.ToNode.Id);
    }));
}

It is far more clear how it works and the 7 characters you saved in not typing Task.Run means you don't have to write a 50+ character comment explaining why something that looks like a delegate can be assigned to a variable of type Task

I'd say the C# compiler was saving you from writing bad code here, and it's another case of the VB compiler letting developers play fast an loose and writing hard to understand code

Caius Jard
  • 72,509
  • 5
  • 49
  • 80
  • Upvoted. It should be noted though that the OP could have valid reasons to avoid the `Task.Run`. For example they could manipulate UI-elements inside the lambda, and so staying in the current synchronization context could be desirable. – Theodor Zoulias Jul 05 '20 at 07:31
  • Wrapping asynchronous operation with `Task.Run` would be a resource waste. Based on the name `connection.GetValueAsync` method accessing external resources. So i would say code `Task.Run(async () => ...)` is a code smell – Fabio Jul 05 '20 at 10:11
  • Task.Run is intended for CPU-bound work. My code is very much I/O bound- waiting for HTTP responses. https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/async/task-asynchronous-programming-model#BKMK_Threads. – Erik Madsen Jul 05 '20 at 12:14
  • @CaiusJard. I get what you're saying. You are correct that my technique of explicitly declaring a Func, then invoking it in the next line works. Though I think you included the wrong code in your "you'll note that this form works..." comment. I think you're too harsh calling my initial attempt code golf. I believe it's in the spirit of anonymous functions, which are intended to reduce code ceremony. I'm just trying to understand why the C# compiler doesn't understand my code. I find it ironic the VB.NET compiler does, considering VB tends to force excessively verbose code. – Erik Madsen Jul 05 '20 at 12:24
  • @ErikMadsen the `Task.Run` understands asynchronous lambdas, and can be used for I/O work too. It ensures that the current thread will not be blocked, in case the asynchronous lambda is behaving poorly (in case it's not returning an incomplete `Task` in a timely manner as it should). It does introduce a tiny overhead, but unless you are calling `Task.Run(async` 100,000 times per second the overhead should not be noticeable. – Theodor Zoulias Jul 05 '20 at 16:12
  • @TheodorZoulias I realize `Task.Run` understands async lambdas. But using it to await I/O-bound work goes against Microsoft's recommendations per https://learn.microsoft.com/en-us/dotnet/csharp/async#recognize-cpu-bound-and-io-bound-work. – Erik Madsen Jul 05 '20 at 17:58
  • @ErikMadsen yeap, it is against Microsoft's recommendations, but I consider this guide to be entry-level material. If you want to go deeper into `Task.Run`, you can read [this](https://devblogs.microsoft.com/pfxteam/task-run-vs-task-factory-startnew/) interesting article. Quote: *"You can use Task.Run either with either regular lambdas/anonymous methods or with async lambdas/anonymous methods, and the right thing will just happen."* – Theodor Zoulias Jul 05 '20 at 18:05
  • 1
    Thanks @TheodorZoulias. Stephen Toub definitely is an expert. – Erik Madsen Jul 05 '20 at 18:58
1

An easy way to invoke asynchronous lambdas in order to get the materialized tasks, is to use a helper function like the Run below:

public static Task Run(Func<Task> action) => action();
public static Task<TResult> Run<TResult>(Func<Task<TResult>> action) => action();

Usage example:

regionTasks.Add(Run(async () =>
{
    string value = await connection.GetValueAsync(Key);
    return (value, connection.ToNode.Id);
}));

The Run is similar with the Task.Run, with the difference that the action is invoked synchronously on the current thread, instead of being offloaded to the ThreadPool. Another difference is that an exception thrown directly by the action will be rethrown synchronously, instead of being wrapped in the resulting Task. It is assumed that the action will be an inline async lambda, as in the above usage example, which does this wrapping anyway, so adding another wrapper would be overkill. In case you want to eliminate this difference, and make it more similar with the Task.Run, you could use the Task constructor, and the RunSynchronously and Unwrap methods, as shown below:

public static Task Run(Func<Task> action)
{
    Task<Task> taskTask = new(action, TaskCreationOptions.DenyChildAttach);
    taskTask.RunSynchronously(TaskScheduler.Default);
    return taskTask.Unwrap();
}
public static Task<TResult> Run<TResult>(Func<Task<TResult>> action)
{
    Task<Task<TResult>> taskTask = new(action, TaskCreationOptions.DenyChildAttach);
    taskTask.RunSynchronously(TaskScheduler.Default);
    return taskTask.Unwrap();
}
Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
  • This is very clean. I like it. I'm curious, did you write these helper methods because you've run into this issue in your own code? Or was it more a matter of the root cause being clear (at this point with all the sample code and commentary) and you thought to simply overcome the compiler's shortcomings via a helper method? – Erik Madsen Jul 05 '20 at 13:04
  • 1
    @ErikMadsen the later. :-) Actually I had the need to materialize lambdas in many occasions, and never occurred to me to use a generic `Materialize` method like the one above. I always settled with using a local function, or `Task.Run`, or whatever could do the job in the case at hand. – Theodor Zoulias Jul 05 '20 at 15:51
  • 1
    Yeah, perhaps I should get over my hangup with local functions or explicitly declaring a `Func` then invoking it immediately- and accept them as reasonable techniques. It comes down to a developer's preferred style, really. – Erik Madsen Jul 05 '20 at 17:29