- Proposed
- Prototype: Not Started
- Implementation: Not Started
- Specification: Not Started
Have a new operator that transforms task-like types to make them easier to work with in expressions.
Method chaining is a very common way to manipulate data.
void M(){
string text = File.ReadAllText(path).ReplaceLineEndings();
}
and today expressions are one of the main ways in which data gets manipulated in the language.
Unfortunately, once async comes into the picture you need to wrap things in parenthesis in order for the associativity to keep working the way the developer wants and we need to be in an async method.
async void M(){ // need to be async method
string text = (await File.ReadAllTextAsync(path)).ReplaceLineEndings(); // must wrap all awaits in parenthesis or have on separate line
}
You can still use await
in expressions but due to the associativity mis-match it becomes harder to work with.
This is a similar problem that ?.
simplified for checking null:
string? text = a == null ? null : a.ReplaceLineEndings();
string? text = a?.ReplaceLineEndings();
If you are Stephen Toub you know that you can use ContinueWith
to get the result from a task because the task has already completed
string text = await File.ReadAllTextAsync(path).ContinueWith(static t => t.Result.ReplaceLineEndings());
But this is not obvious to some and doesn't make the expression easier to work with. There are also some 15+ overloads for ContinueWith
for scenarios that are more advanced than what we are describing. In the same way that I can use ?.
to apply transformations over null values I want to easily do the same thing for task-like-types.
In other languages like C++ there were similar problems with pointer dereferencing. You had to first dereference the pointer and then call the member ((*(e)).member
). For this reason C++ added the member access operator ->
so you could just write e->member
.
class Data
{
public:
int x;
};
int main()
{
Data d;
Data* ptr;
ptr = &d
// these two expressions are equivalent
int x1 = (*(ptr)).x;
int x2 = ptr->x;
}
I am proposing a similar syntactic concept:
void M(){
Task<string> text = File.ReadAllTextAsync(path)@.ReplaceLineEndings();
}
task_like_type_member_access_expression
: primary_expression null_conditional_operations
;
task_like_type_member_access_operations
: task_like_type_member_access_operations@ '@' '.' identifier type_argument_list@
| task_like_type_member_access_operations@ '@' '[' argument_list ']'
| task_like_type_member_access_operations '.' identifier type_argument_list@
| task_like_type_member_access_operations '[' argument_list ']'
| task_like_type_member_access_operations '(' argument_list? ')'
;
Which would be semantically equivalent to
void M(){
Task<string> text = File.ReadAllTextAsync(path).ContinueWith(static t => t.Result.ReplaceLineEndings());
}
But the actual lowering could be something more efficient like this:
void M(){
Task<string> textTask = <>__asyncHelper(File.ReadAllTextAsync(path));
static async Task<string> <>__asyncHelper(Task<string> task){
string result = await task;
result.ReplaceLineEndings();
return result;
}
}
I bring this lowered form up as a way to reason about what sets of constructs would be legal to use together but I think there are several valid approaches.
I should also note that I have no preference for @
as the symbol that is used and invite the reader to replace it with whatever their heart tells them is right. Here are a few examples to help you out:
Task<string> text = File.ReadAllTextAsync(path)|.ReplaceLineEndings();
Task<string> text = File.ReadAllTextAsync(path)#.ReplaceLineEndings();
Task<string> text = File.ReadAllTextAsync(path)~.ReplaceLineEndings();
Task<string> text = File.ReadAllTextAsync(path)^.ReplaceLineEndings();
Task<string> text = File.ReadAllTextAsync(path)%.ReplaceLineEndings();
Task<string> text = File.ReadAllTextAsync(path)$.ReplaceLineEndings();
Task<string> text = File.ReadAllTextAsync(path)`.ReplaceLineEndings();
Anyways, if we assume the compiler generates something semantically equivalent to this for these situations:
developer code:
void M(){
Task<string> text = File.ReadAllTextAsync(path)@.ReplaceLineEndings();
}
lowered:
void M(){
Task<string> textTask = <>__asyncHelper(File.ReadAllTextAsync(path));
static async Task<string> <>__asyncHelper(Task<string> task){
string result = await task;
return result.ReplaceLineEndings();
}
}
developer code:
void M(){
string oldValue = "foo";
string newValue = "bar";
Task<string> text = File.ReadAllTextAsync(path)@.Replace(oldValue, newValue);
}
lowered:
void M(){
string oldValue = "foo";
string newValue = "bar";
Task<string> textTask = <>__asyncHelper(File.ReadAllTextAsync(path), oldValue, newValue);
static async Task<string> <>__asyncHelper(Task<string> task, string oldValue, string newValue){
string result = await task;
return result.Replace(oldValue, newValue);
}
}
that implies that the following is not legal:
class A {
public void M(out int x);
}
void M(Task<A>) {
Task ab = a@.M(out var x);
}
Because it cannot be transformed in this manner:
void M(Task<A> a) {
Task ab = <>__asyncHelper(a, out var x)
static async Task <>__asyncHelper(Task<A> task, out int x){ // CS1988: Async methods cannot have ref, in or out parameters
A result = await task;
a.M(out x);
}
}
However you could do something like this:
void M(){
Task task = new Task<List<string>>(() =>
{
List<string> result = new List<string>();
result.Add("Hello");
result.Add("World");
return result;
})@.Add("!");
}
which becomes
void M(){
Task task = <>__asyncHelper(new Task<List<string>>(() =>
{
List<string> result = new List<string>();
result.Add("Hello");
result.Add("World");
return result;
}));
static async Task <>__asyncHelper(Task<List<string>>task){
List<string> result = await task;
result.Add("!");
}
}
The implicit assumption of this proposal is that monadic transformations over task-like types (this proposal in essence) are becoming as necessary as monadic transformations over null
(?.
and its family of ??
expressions). If this is not seen as necessary and we think that, while a special syntax to transform null
makes sense in the language, nothing else in the type system needs this special treatment then this proposal doesn't really have any merit.
- Forward pipe operators would solve this and more.
- General monadic transformations in the language. While there is no proposal for this, having a general syntax for monadic transformations of a value in C# would solve this. If we think that is something we want to do we shouldn't special case task-like types and instead design the general case.
- Keep the same semantics of this design but instead have an agreed upon framework type that the compiler calls (perhaps just
ContinueWith
) instead of generating that static local function. This could also open the door up to allow developers to override what this operator does.
This code
void M(){
string oldValue = "foo";
string newValue = "bar";
ConfiguredTaskAwaitable<string> text = File.ReadAllTextAsync(path)@.Replace(oldValue, newValue).ConfigureAwait(false);
}
Would seem to imply that we generate this
void M(){
string oldValue = "foo";
string newValue = "bar";
ConfiguredTaskAwaitable<string> textTask = <>__asyncHelper(File.ReadAllTextAsync(path), oldValue, newValue).ConfigureAwait(false);
static async Task<string> <>__asyncHelper(Task<string> task, string oldValue, string newValue){
string result = await task;
return result.Replace(oldValue, newValue);
}
}
The developer could always write:
void M(){
string oldValue = "foo";
string newValue = "bar";
ConfiguredTaskAwaitable<string> text = File.ReadAllTextAsync(path).ConfigureAwait(false)@.Replace(oldValue, newValue).ConfigureAwait(false);
}
to get
void M(){
string oldValue = "foo";
string newValue = "bar";
ConfiguredTaskAwaitable<string> textTask = <>__asyncHelper(File.ReadAllTextAsync(path).ConfigureAwait(false), oldValue, newValue).ConfigureAwait(false);
static async Task<string> <>__asyncHelper(ConfiguredTaskAwaitable<string> task, string oldValue, string newValue){
string result = await task;
return result.Replace(oldValue, newValue);
}
}
but its unclear if that is intuitive or helpful. I would really like to avoid discussing context capture as part of this proposal but that may be something that we need to examine, or use as an argument to outright reject this.