Skip to content

Instantly share code, notes, and snippets.

@jmarolf
Last active February 23, 2022 18:32
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jmarolf/23048544e60b96197e4524ea8851412c to your computer and use it in GitHub Desktop.
Save jmarolf/23048544e60b96197e4524ea8851412c to your computer and use it in GitHub Desktop.
Task-like type member access operator

Task-like type member access operator

  • Proposed
  • Prototype: Not Started
  • Implementation: Not Started
  • Specification: Not Started

Summary

Have a new operator that transforms task-like types to make them easier to work with in expressions.

Motivation

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.

Detailed design

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("!");
    }
}

Drawbacks

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.

Alternatives

  • 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.

Unresolved questions

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.

Design meetings

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment