mpope9 / krarup

The krarup language, a dialect of Erlang.
Apache License 2.0
6 stars 0 forks source link

[Proposal] Pipeline operator #2

Open leobm opened 4 weeks ago

leobm commented 4 weeks ago

which would be cool if you had a pipeline or chaining feature in erlang (krarup). Something like here https://tour.gleam.run/functions/pipelines/ I would just turn it around, so that the value is passed in at the end.

mpope9 commented 3 weeks ago

Hi!

This is a good suggestion, and it was one of the first things that I considered adding.

If we think about Gleam and Elixir beyond the language, and look at the stdlib they provide the pipeline operator appears to work quite naturally within that specific ecosystem. This is the Gleam definition:

pub external fn get(
  from: Map(key, value),
  get: key,
) -> Option(value)

And this is the Elixir definition:

get(map, key, default \\ nil)

Both have the map as the first argument. If we look at the Erlang library:

get(Key, Map)

The map is the second argument.

With these examples in mind, krarup is (currently) intended to be a 'dialect' of Erlang, where it relies on the entirety of what the Erlang stdlib provides. Redefinition of the standard library is not a current goal. This limits the usefulness of the pipe operator as it exists in Elixir and Gleam, where both of the standard libraries provided by them sets up great ergonomics of the pipe operator in their forms.

That being said, the pipe operator provided by Elixir works with the & operator to send the RHS to a specific function argument position, and there have been several proposals for a more 'natural' mechanism such as Function.pipe_to. Every example I see is unreadable. Erlang is a much simpler language than Elixir, and krarup should generally retain this simplicity.

There are some examples that have come to mind.

  1. Inline argument: #{a => b} |2> maps:get(a) a. I think this is reasonable but still 'ugly' and not quite Erlang-y.

    [1, 2, 3]
    |2> lists:map(fun(A) -> A + 2 end)
    |> sets:from_list()

    In this example, I wouldn't say that which argument location is always obvious.

  2. Named piping: #{a => b} |A> maps:get(a, A) a. This is nearly similar to the #{a => b} = A, maps:get(a, A)

[1, 2, 3],
|X> lists:map(fun(B) -> B + 2 end, X),
|Y> lists:foldr(fun(C, Acc) -> [C|Acc] end, X, Y).

In this (contrived) example the named piped parameters can be reused in the lexical scope of this, where it is almost treated as it's own block. I do think its debatable if that is more readable compared to what I would write without piping:

X = [1, 2, 3],
Y = lists:map(fun(B) -> B + 2 end, X),
lists:foldr(fun(C, Acc) -> [C|Acc] end, X, Y).

I'm open to more examples, I've seen several for Erlang as parse transforms like fancyflow which uses the [pipe] to indicate pipelining. What I think is very interesting in this library that it uses the _ element to indicate which argument to foward to:

-spec pipe() -> string().
pipe() ->
    [pipe]("a b c d e f",
           string:tokens(_, " "),
           lists:map(fun string:to_upper/1, _),
           string:join(_, ",")).

This could be re-imagined as:

"a b c d e f"
|> string:tokens(" "),
|> lists:map(fun string:to_upper/1, _),
|> string:join(",").

Where the _ is optional, and it default to the first argument if it is not provided in the function. This however does have the issue of parser ambiguity, as krarup is implemented through a yecc parser, and _ already has a meaning. I do not have proof of this being an issue. But I could see it being feasible to using ^ as the indicator:

"a b c d e f"
|> string:tokens(" "),
|> lists:map(fun string:to_upper/1, ^),
|> string:join(",").

All of that being said, when it comes down to it I thin we need to consider how this would look with what krarup is actually trying to bring to the table around async/await.

async sum(List) ->
  lists:sum(List).

main() ->
  await [[1, 2, 3, 4, 5], [6, 7, 8, 9, 10]] |> lists:map(fun(X) -> sum(X) end, ^).

Which could be reasonable. But, could this work with the list comprehension?

await [[1, 2, 3, 4, 5], [6, 7, 8, 9, 10]] |> [sum(X) || X <- ^].

Lists = [[1, 2, 3, 4, 5], [6, 7, 8, 9, 10]],
await Lists |> [sum(X) || X <- ^].

And here I think we are approaching a bit unreadable. Not quite J of course but at a glace that is doing quite a bit and requires thought.

These are just some thoughts I've collected over time written down, so I think some more work is needed in how this could be added without introducing too much complexity.