New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Proposal: Pipe-forward operator #96

Open
alrz opened this Issue Feb 14, 2017 · 16 comments

Comments

Projects
None yet
@alrz
Copy link
Contributor

alrz commented Feb 14, 2017

Ported from dotnet/roslyn#5445

Pipe-forward operator

Summary

Lets you pass an intermediate value onto the next method, in the same order that they will be evaluated.

Proposal

When you are chaining multiple methods to one another you might end up with something like this:

Console.WriteLine(BitConverter.ToString(SHA1.Create().ComputeHash(File.ReadAllBytes(Console.ReadLine()))));

Using pipe-forward operator it can be written as (in the same order that they will be executed):

Console.ReadLine()
|> File.ReadAllBytes()
|> SHA1.Create().ComputeHash()
|> BitConverter.ToString()
|> Console.WriteLine();

Since C# is not exactly a functional language, forwarding to the last parameter wouldn't be useful most of the time, because not every method parameters are written with a sensible order for currying purposes. Also, in functional languages we don't have overload resolution, that is, functions are often numbered like iter1, iter2, etc. In C#, however, we can utilize overload resolution and optional/named arguments, to be able to use this in a wide variety of use cases without introducing any other mechanism.

Argument Lists

Applicability of the argument list in the RHS is roughly defined as follow:

Empty argument list

It's a compile-time error if the method in the RHS doesn't accept any arguments.

Action<int> a = q => {};
arg |> a;       // ERROR

void M() {}
arg |> M();     // ERROR

void M(int a) {}
arg |> M();     // OK

Positional arguments

Each positional argument will be matched in order to the list of method parameters. If there was more positional arguments than number of parameters minus one and the last parameter was not params, the method is not applicable. Otherwise, the LHS goes to the last element in the expanded form.

void M(int a) {}
arg |> M();     // M(arg);

void M(params int[] a) {}
arg |> M();     // M(new[] { arg });
arg |> M(1);    // M(new[] { 1, arg });
arr |> M();     // M(arr);

Optional arguments

In case of optional arguments, LHS goes to the leftmost unspecified parameter which has the identical or implicitly convertible type of LHS. If there was a more specific parameter, then we skip other less specific ones.

void M(int a, int b, double c = 0, int d = 0) {}
4d |> M(2, 3);  // M(2, 3, 4d, 0);
4  |> M(2, 3);  // M(2, 3, 0, 4);

Named arguments

Each named argument will be matched to a parameter with the given name. If one of the named arguments failed to match, or matches an argument already matched with another positional or named argument, the method is not applicable. Otherwise, we'll do as above.

void M(int a, int b, int c) {}
void M(int a, double b, int c) {}
1  |> M(2,  c: 3);      // M(2, 1, 3);
1d |> M(a: 2,  c: 3);   // M(2, 1d, 3);

The method is not applicable (1) if more than one of non-optional parameters are not specified, (2) LHS was not implicitly convertible to its type (3) or it's a ref or out parameter.

Evaluation order

The LHS will be evaluated in the lexical order, i.e. first.

F() |> M(G());  // var t = F(); M(G(), t);

Variations

Null-conditional forwarding

(From dotnet/roslyn#8593)

var r = Foo.Bar?.Baz ?> F() ?? false;
var r = ((temp = Foo.Bar?.Baz) != null ? F(temp) : null) ?? false;

Function F won't get executed if the forwarded value was null, and also, Foo.Bar?.Bar only evaluates once. Note that the value forwarded to the target function F is of a "non-nullable type".

Just like ?. operator, you don't need to use ?> if the target function doesn't return a nullable value, so for chaining you should use the regular |> operator to not perform an additional null-checking.

Syntax

relational-expression:
forward-expression

statement-expression:
forward-expression

forward-expression:
relational-expression |> shift-expression
relational-expression ?> shift-expression

The expression on the right-hand-side must be one of the kinds that a value can be forwarded to, namely, invocation-expression, object-creation-expression, an await-expression that contains any of applicable expressions (recursively), etc.

Examples

var r = nullable?.Foo.Bar ?? value;
var r = (nullable != null ? nullable.Foo.Bar : null) ?? value;

var r = value |> F() ?> G() ?? value;
var r = ((temp = F(value)) != null ? G(temp) : null) ?? value;

var r = nullable ?> F() |> G() ?? value;
var r = (nullable != null ? G(F(nullable)) : null) ?? value;

var r = nullable ?> F() ?> G() ?? value;
var r = (nullable != null ? (temp = F(nullable)) != null ? G(temp) : null : null) ?? value;

var r = value |> foo?.F();
var r = foo?.F(value);

var r = nullable ?> foo?.F();
var r = nullable != null ? foo?.F(nullable) : null;
@a1n1

This comment has been minimized.

Copy link

a1n1 commented Feb 25, 2017

Is this issue acceptable for pull request ?

@chrisaut

This comment has been minimized.

Copy link

chrisaut commented Feb 27, 2017

What's the difference between this and #74 ?

@alrz

This comment has been minimized.

Copy link
Contributor

alrz commented Feb 27, 2017

@a1n1 Not sure, but supposedly, there will be a "champion" issue once this passes the first stage.

@chrisaut This has the input parameter added to the argument list. That proposal requires there be no argument list i.e. the LHS would be forwarded to the "value" of the RHS, hence, it would not permit forwarding to methods with more than a single (formal) parameter and you'd need to use @ syntax.

@AlexanderMorou

This comment has been minimized.

Copy link

AlexanderMorou commented Apr 15, 2017

I don't understand what actual problem this solves.

Sure, it unwraps the call chain so it places the methods in their actual execution order, but it doesn't make the code any shorter. There are few (if any?) cases I've encountered where I couldn't follow nested parameters. You could turn the example into:

Console.WriteLine(
  BitConverter.ToString(
    SHA1.Create().ComputeHash(
      File.ReadAllBytes(
        Console.ReadLine()))));

And it's just as easy to read, as the parenthesis make the execution order clear. It's also crystal clear, within which parameter an argument will actually be placed, without worrying about implicit conversions coercing the actual parameter you're specifying.

What implications does this have for IntelliSense, would it provide context on what parameter it goes into by hovering over the |> characters? Wouldn't you also have to ensure the right-hand side of the operator, within the intellisense that it's accepting one forwarded operator in some manner?

The only thing I do like is the argument-level short-circuiting, though that sounds like it could be a feature request of its own for standard calling conventions.

@alrz

This comment has been minimized.

Copy link
Contributor

alrz commented Apr 15, 2017

but it doesn't make the code any shorter.

that's not the intention here.

And it's just as easy to read

It's not as easy to write since you should be writing the methods in the reverse order. Using locals however, can help with that but then you have to leave the expression context.

The only thing I do like is the argument-level short-circuiting, though that sounds like it could be a feature request of its own for standard calling conventions.

What syntax are you imagining for that as a separate feature?

@AlexanderMorou

This comment has been minimized.

Copy link

AlexanderMorou commented Apr 15, 2017

@alrz : The biggest gripe I have with the feature is the disconnect between what you're writing and where the data actually goes. The notion that it would bind differently based on the type of the parameter: overload resolution is already complicated enough as it is, if there was some way to explicitly stipulate which parameter was the target of the forwarded pipe, through say a special character combination of some sort:

Console.ReadLine()
|> File.ReadAllBytes(<|)
|> SHA1.Create().ComputeHash(<|)
|> BitConverter.ToString(<|)
|> Console.WriteLine(<|);

This way if it were targeting a specific parameter, the notion of overload resolution wouldn't be a concern, if you wanted parameter z of type U to be the target, and optional parameters were in play, you could simply go: A() |> B(z:<|);

That would make the binding of the operation much simpler to understand, and I think the reader would benefit, as well.

What syntax are you imagining for that as a separate feature?

Probably something like: ?? prefixing the argument
?? alone would probably be sufficient because it is evident that it's not a nullable coalescing operator as there's only one operand.

As for weaving it into the language spec as it is today, it would probably be something like:

argument_value
    : '??'? expression
    | '??'? '<|'
    | 'ref' '??'? variable_reference
    | 'out' variable_reference
    ;

It wouldn't be allowed on the out parameter because it's implicitly unassigned within the scope of the called method, so it wouldn't really make sense.

The default value of the method would be the result of the expression if it short-circuited.

If you were to take the two ideas together, you would only need one pipe-forward operator: |>, the ?> would be replaced by: ?? <| on the parameter you intended it to be for. So instead of:

if (args != null)
{
   format
   ?> String.Format(args: args)
   ?> Console.WriteLine();
}

You'd end up with:

String.Format(?? format, ?? args)
|> Console.WriteLine(?? <|);
@alrz

This comment has been minimized.

Copy link
Contributor

alrz commented Jul 24, 2017

@MorleyDev

Here I suggested to just "add the LHS as an argument to the method invocation on the right." (other than some cases with optional arguments which can be excluded if seen as rather unreliable than helpful). that doesn't require you to fallback to lambda when there is more than a single parameter (which I've found very likely). There's a discussion on the other variation here: #74

@MorleyDev

This comment has been minimized.

Copy link

MorleyDev commented Jul 24, 2017

@alrz I read further on the other discussion and saw it talks about this suggestion so deleted my post. Seems you got in there right before, sorry about that :)

Since it's too late and I've been replied to now, I guess I'll sum up what you were replying to: This doesn't look to me like it fits the established expectations and design of the C# language.

The basic pipe operator part of #74 fits closer to what people would closer to what would be intuitive in C# with delegation, whilst this RHS proposal currently requires a mental context-switch from "normal overload resolution" to "pipe papp", where how you would by-default mentally parse the syntax is incorrect. This...seems problematic.

If a form of currying gets added in the future it would behave similar to the above does, and an intermediate lambda solves so much of the issue in the short-term that it is what I'd expect that for an initial implementation into C#, leaving room for currying/papp later without committing to any particular syntax.

(Interestingly, I've seen the usage of an intermediate lambda cited as a reason for a lack of motivation in introducing currying to ECMAScript nowadays as well).

@IgorX2

This comment has been minimized.

Copy link

IgorX2 commented Feb 21, 2018

@alrz: It would be cool to be able to pipe tuples as arguments, but the argument order could be a problem.

void F(int a, int b) {}
(arg1, arg2) |> F();     // F(arg1, arg2);
@vbodurov

This comment has been minimized.

Copy link

vbodurov commented May 10, 2018

Can we have pipe aliases? For example:

Console.ReadLine()
:ReadBytesAndComputeHash
    |> File.ReadAllBytes(<|)
    |> SHA1.Create().ComputeHash(<|)
:ConvertToStringAndOutput
    |> BitConverter.ToString(<|)
    |> Console.WriteLine(<|);

Now I would have ReadBytesAndComputeHash method defined in the class that I can reuse it elsewhere.

The advantage of this is that I achieve 2 goals at the same time:

  1. I have the entire pipeline of operations defined in one sequence
  2. I can still reuse different parts of it elsewhere

Otherwise it would be impossible to see the entire pipeline with all steps in one place, but at the same time be able to reuse parts of it.

The same could apply for linq:

GetPersons()
:PeoplesNamesStartingWithA
    .Select(p => p.Name)
    .Where(name => name.StartsWith("A"))
:SomeOtherGroup
    .Select....
@masaeedu

This comment has been minimized.

Copy link

masaeedu commented Jun 18, 2018

Sorry if I missed it in the discussion, but why is it:

Console.ReadLine()
|> File.ReadAllBytes()
|> SHA1.Create().ComputeHash()
// ...

instead of the following?

Console.ReadLine()
|> File.ReadAllBytes
|> SHA1.Create().ComputeHash
// ...

Isn't the function here File.ReadAllBytes (and not File.ReadAllBytes())?

@theunrepentantgeek

This comment has been minimized.

Copy link

theunrepentantgeek commented Jun 18, 2018

I think it's because simply naming a method (as File.ReadAllBytes) returns a method group - which might include multiple overloaded methods.

Including the () at the end provides a way to specify which overload should be called - in the example, it should call File.ReadAllBytes(string).

@masaeedu

This comment has been minimized.

Copy link

masaeedu commented Jun 18, 2018

Isn't the overload determined by the LHS of the expression? "foo" |> f desugars to f("foo"), which is unambiguous for f = File.ReadAllBytes.

@theunrepentantgeek

This comment has been minimized.

Copy link

theunrepentantgeek commented Jun 18, 2018

It's unambiguous for File.ReadAllBytes right now - in the current version of the framework.

But, what happens if that changes in the future?

For example, and purely for illustration, what if a future version of the framework adds a new overload: File.ReadAllBytes(string, Encoding) ?

The method group would then return two methods, which isn't unique. If the parenthesis were not permitted for the single parameter case, then we'd end up with a disconnect.

For the original overload:

Console.ReadLine()
|> File.ReadAllBytes
|> SHA1.Create().ComputeHash()

But for the new overload:

Console.ReadLine()
|> File.ReadAllBytes(new UTF7Encoding())
|> SHA1.Create().ComputeHash()

I think it's substantially cleaner to require the () to always be there.

Also, and quite separate from my argument above, I suspect that leaving out the () and allowing a method group to be specified would make things ambiguous in some way (though I haven't quite been able to put an example together).

@masaeedu

This comment has been minimized.

Copy link

masaeedu commented Jun 18, 2018

To my mind

Console.ReadLine() |> File.ReadAllBytes

always just means:

File.ReadAllBytes(Console.ReadLine())

Console.ReadLine() |> File.ReadAllBytes(new UTF7Encoding()) is an error because there's no overload of ReadAllBytes that takes an Encoding and returns a Func<string, byte[]>. This doesn't have anything to do with the pipeline operator, it's the same error you'd get from trying to do:

File.ReadAllBytes(new UTF7Encoding())(Console.ReadLine())

If we want to deal with a two argument function, we can just do:

Console.ReadLine()
|> (x => File.ReadAllBytes(x, new UTF7Encoding()))

If that's too painful, it can be mitigated at the library level, or with an independent language feature for making partial application more convenient, e.g:

File.ReadAllBytes(?, new UTF7Encoding())
// desugars to
x => File.ReadAllBytes(x, new UTF7Encoding())
@hickford

This comment has been minimized.

Copy link

hickford commented Jul 10, 2018

Some C# code I wrote today that I think could be improved by a pipe operator:

prefixes.Any(header.StartsWith)

With pipe operator as in F#, it could be written:

header.StartsWith |> prefixes.Any

This is closer to the natural language description "header starts with any prefix"

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