Skip to content
10

Proposal: Pipe-forward operator #96

alrz asked this question in General
Proposal: Pipe-forward operator #96
Feb 14, 2017 · 22 answers

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;

Replies

1

Is this issue acceptable for pull request ?

0 replies
1

What's the difference between this and #74 ?

0 replies
1

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

0 replies
1

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.

1 reply
@slaymaker1907

This is not just as easy to read as evidenced by the number of languages supporting this feature. Additionally, this is not as readable because it doesn't make it clear what the dataflow is.

You can derive basically derive the pipeline operator without any regard to functional programming by first recognizing that coming up with names is hard. One idea to solve this is to just keep reassigning the name variable imperatively. That solution has the problem in a static typed context if you are changing the type (this can be solved by allowing shadowing within a block as with Rust). However, reassignment without static types is clunky if the name is long and without shadowing introduces mutation which reduces referential transparency.

1

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?

0 replies
1

@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(?? <|);
0 replies
1

@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

0 replies
1

@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).

0 replies
1

@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);
0 replies
1

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....
0 replies
1

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())?

0 replies
1

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

0 replies
1

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

0 replies
1

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

0 replies
1

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())
0 replies
1

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"

0 replies
1

Is there any progress on this?

I think it would benefit the language a lot as it would make it much easier to write composeable code. The fluent pattern is very verbose to implement, and is much less flexible than writing several pure functions/static methods and using them in a pipeline fashion, just like in F#.
I would like the C# pipe operator to be as close to the syntax of F#, which I find very useful.

0 replies
1

I'm doing pipe-forward using extension methods.

static class FunctionChaining
{
    internal static U _<T,U>(this T input, Func<T,U> fun) => fun(input);
    internal static void _<T>(this T input, Action<T> fun) => fun(input);
}

Sample code:
https://gist.github.com/2jacobtan/d410957c6136461090e7d04de2bde314

Edit:

Try at https://dotnetfiddle.net/KvNhXi

Bare-bones blog post at https://medium.com/@jacob.tan.en/c-function-chaining-pipe-forward-using-extension-methods-acbbf497550a

0 replies
1

Thanks Jacob, I like that, I would just rename underscore to "Then"

0 replies
1

Here's another example of using extension methods to pipe-forward:

Article: https://dev.to/tomydurazno/pipe-in-c-1aga
Code: https://github.com/TomyDurazno/PipeExtensions

0 replies
2

The problem with extension methods is that they are not free - lambdas create a fairly large overhead.

However, using an operator as it is done in other functional languages is unlikely to work since C # requires an expression result identifier.

Just another option:
1 + 2 |acc|> acc+3 |acc|> { acc++; return acc + 4; } |acc|> Console.WriteLine(acc.ToString());

using |identifier_name|> instead of just |>

0 replies
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Category
#️⃣
General
Converted from issue
Beta