Skip to content
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

Pipeline-operator for inline invocation of static functions. #43

Open
lrhn opened this issue Oct 11, 2018 · 7 comments
Open

Pipeline-operator for inline invocation of static functions. #43

lrhn opened this issue Oct 11, 2018 · 7 comments
Assignees
Labels
feature Proposed language feature that solves one or more problems state-backlog

Comments

@lrhn
Copy link
Member

lrhn commented Oct 11, 2018

Solution to #40. This is not a full proposal, just a primer for discussion.

A very simple solution to making static functions easier to use in long chains of invocations, is to allow them to be used in-line.

The "pipelie" operator (e.g., JavaScript strawman, F#) is, at its simplest, just an infix operator taking first an object and then a unary function, and then calling the function with the object. Example:

let double(s: string) = string + string
"42" |> double |> printfn  // prints "4242\n"

Dart can define a similar operator (|> is an option, so is .>, :> or ->).

The F# language is very functional, more than dart, and the pipe operator is one of a number of function composition operators.
In Dart, we want to allow easy chaining of object operations, so the operator should bind as tightly as .. That makes parsing harder if just writing e1 |> foo.bar. Does it mean foo(e1).bar or foo.bar(e1)?
So, it is probably easier to parse if we require parentheses.

Example: something|>function().foo is the same as function(something).foo (except that something is evaluated before function, we do want a consistent left-to-right evaluation). Likewise something|>foo.bar() is similar to foo.bar(something) - the |> binds to the next argument block.

If we always have the parentheses, then we can also add more arguments:
e1|>foo(2, 3) is the same as foo(e1, 2, 3). We could put the pre-pipe expression as the last argument instead of the first, but we want to treat the first expression like a kind of "receiver" for the function, and that object should be the one that's always required by the function, so we put it first.
Both are really reasonable, we just have to pick one.

Examples:

String double(String x) => x + x;
var x= someList.map((x) => "$x").firstWhere(someTest)|>double().indexOf("foo");

Since the syntax is just syntactic sugar for a static function call, type inference can likely not treat the first argument different from the other arguments. Type inference for will have to proceed normally. The type of the first parameter of the function can be used as context type for the first pipe operand expression.

This is a very simple idea, compared to static extension methods (#41) or static extension types (#42).
It has the advantage of working with any static function, not just one explicitly written as an extension method. It has the disadvantage of using a different syntax to call the function.

@lrhn lrhn added the feature Proposed language feature that solves one or more problems label Oct 11, 2018
@lrhn lrhn self-assigned this Oct 11, 2018
@zoechi
Copy link

zoechi commented Oct 11, 2018

This is a very simple idea

The main disadvantage I see is that it doesn't provide a way to disambiguate functions with the same name for different types.
Perhaps function overloading, if planned, could help here.

Besides that it appears to provide the most value for the amount of concepts/syntax necessary to learn and understand.

@MichaelRFairhurst
Copy link

I would prefer a means of currying to the option of passing additional args and requiring parenthesis.

Practically by definition, I expect code using <| to be tricky functional code. I therefore highly expect, and this is just my own gut reaction, that foo <| bar() is equivalent to bar()(foo) rather than bar(foo), and similarly for foo <| bar(1, 2, 3) which I expect to be bar(1, 2, 3)(foo). Basically, if you're using <|, you are likely the type of person writing functions that return functions, in my mind.

I'd rather, for the case of bar(foo, baz) be able to use currying etc and write say foo <| bar(_, baz) where bar(_, baz) is syntactic sugar for (_) => bar(_, baz). Or I can write currying functions myself and do foo <| (bar <| swap2 <| curry2) <| baz. Notably, I don't think any such currying library of feature would be required for this proposal to be useful, but its an option that would be open to us for later on down the road.

I actually really like this proposal because it's simple. However, seeing even my own examples of how it could be used, (and I lean functional in my style), I'm concerned about how well it fits Dart as currently designed. I'd want to see many other people also liking it too before seeing it chosen as the solution to #40.

@MichaelRFairhurst
Copy link

Also the issue of precedence is a very big one.

I read <| as lower precedence than .. Perhaps we could introduce an operator .| as well, that shares the precedence of <| but the semantics of ., so that code can avoid parens and read better:

foo <| bar.baz; // bar.baz(foo);
foo <| bar .| baz // bar(foo).baz

@lrhn
Copy link
Member Author

lrhn commented Nov 16, 2018

The issue of precedence is why I prefer to have explicit parentheses where the "LHS" is inserted.
It means that it is unambiguous that:

foo->bar();
foo->bar.baz();
foo->(bar()).baz(qux);

becomes

bar(foo);
bar.baz(foo);
(bar()).baz(foo, qux);

(I use -> for the operator, I just can't wrap my head around |> or <| enough to read them properly).

One important thing to consider about a language feature is how much it costs to work around it. If A user wants something slightly different from the most common examples, how much extra will it cost them to get there. So, for foo->bar.baz.qux, how much would it cost me if I wanted to use foo as argument to bar, to bar.baz or to bar.baz.qux. We can pick a solution that makes one of them free, but at what cost to the other ones?

If foo->bar.baz.qux means bar.baz.qux(foo), what must I write to get bar(foo).baz.qux?
Most likely it will be (foo->bar).baz.qux.

If foo->bar.baz.qux means bar(foo).baz.qux, what must I write to get bar.baz.qux(foo)?
Most likely foo->(bar.baz.qux).

Both of these workarounds are annoying. None of them allow me to pass extra arguments along with foo.

By using the first argument block, foo->bar().baz.qux and foo->bar.baz.qux(), to select the argument position for foo, you distribute the work over all the use-cases in a homogeneous manner.
There are still needs for parentheses, like foo->(bar.baz()).qux(), but it actually reads fairly well, as if the foo skips around parenthesized expressions to find the first "top-level" function call.

@munificent
Copy link
Member

I agree with @zoechi that the main limitation of this is that by desugaring to top-level functions, you are forced to come up with long, unique function names. That defeats much of the value of OOP-style left-to-right method invocations where you take advantage of the fact that receiver type is the namespace, and not the top-level lexical one.

@pschiffmann
Copy link

I don't know whether this is a good solution for #40, but I would really like to have this operator for another purpose. As @zoechi argued, it might not be a great fit for extension methods that I want to export for use in other libraries/packages. But it could be very helpful for local helper functions, when I want to apply a series of transformations to an object (as discussed in #166).

The issue of precedence is why I prefer to have explicit parentheses where the "LHS" is inserted.

foo->bar();
foo->bar.baz();

FWIW, I strongly dislike this syntax for several reasons.

  1. I know the -> operator from C and PHP, and in both languages it means property access, not function application.
  2. Other enclosing postfix operators (is there an official term for that?) form pairs: Every ( or [ is matched by ) or ].
    -> and () don't form a visual union. As a new user, I probably wouldn't guess that these two belong together. f() is valid syntax already, so I would just assume that -> is an infix operator.
  3. When I see a subexpression f(p1, ..., pn) in a larger expression, I can't immediately tell what operator I'm looking at. I have to scan the whole expression from left to right and search for the presence or absence of ->.

But I really like @MichaelRFairhurst's suggestion, if I understand it correctly!

First, I agree that the RHS of a pipeline operator should be a callable object, and that it shouldn't be marked with an additional pair of parens. If I want to pass additional arguments, then I should use a dedicated currying syntax (that is also valid outside of the pipeline operator).

Second, the extra operator to avoid parenthesizing.
(I find the operators more readable when I turn them around. I guess you read the expression x <| f as "apply f to x"? This is just my personal experience, but I've grown more accustomed to the wording "pass x to f", x |> f. Maybe because I have only really worked in languages with mutable objects, and passing an object to a function means giving up control over it?)

If I understand you correctly, |> would have it's own precedence level, between unary postfix and unary prefix (precedence table), and would be left-to-right associative.
By placing |> on a lower precedence level than ., these two lines would be equivalent:

" " |> ("I don't like foo/bar/baz examples".split) |> print;
" " |> "I don't like foo/bar/baz examples".split |> print;

Now, if I wanted to access a property on the result of the RHS, parenthesizing looks bad ...

(" " |> "I don't like foo/bar/baz examples".split)
  .where(RegExp(r'^\w+$').hasMatch)
  |> print;

... but I could use the |. operator:

" "
  |> "I don't like foo/bar/baz examples".split
  |. where(RegExp(r'^\w+$').hasMatch)
  |> print;

Again, this is just my opinion, but I think this would be a great solution. The precedence is made clear by the whitespace around the pipeline operator, and unary postfix operators can be used in both LHS and RHS without the need to parenthesize.

@pschiffmann
Copy link

Oh, and to give an explicit example how I could pass additional arguments:

Let's suppose Dart had a syntax to partially apply a function. For this example, let's use &(), so these two function expressions have the same observable behaviour:

String Function(String) callback;
callback = "hello world!".replaceRange&(6, 11);
callback = (replacement) => "hello world!".replaceRange(6, 11, replacement);

Then I can obviously use this mechanism in a pipeline chain:

"Philipp" |> "hello world!".replaceRange&(6, 11) |> print;

Partially applied functions are also useful outside of the context of a pipeline operator, so it would make sense to use the same mechanism everywhere.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature Proposed language feature that solves one or more problems state-backlog
Projects
None yet
Development

No branches or pull requests

6 participants