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

[Feature] Pipe Operator for Function Calls #1144

Open
clsource opened this issue Mar 2, 2023 · 53 comments
Open

[Feature] Pipe Operator for Function Calls #1144

clsource opened this issue Mar 2, 2023 · 53 comments

Comments

@clsource
Copy link

clsource commented Mar 2, 2023

As seen here https://prog21.dadgum.com/32.html

The suffix "K" to indicate kilobytes in numeric literals. For example, you can say "16K" instead of "16384". How many times have you seen C code like this:

char Buffer[512 * 1024];

The "* 1024" is so common, and so clunky in comparison with:

char Buffer[512K];

I personally think this feature is a small improvement to the numeric literals in Wren :)

@mhermier
Copy link
Contributor

mhermier commented Mar 2, 2023

While I see great values in having units in a language, I would say no in the proposed syntax as this. The cited example is fine tuned for a very specific and limited usage (assembly does have to deal a lot with allocations sizes).

@clsource
Copy link
Author

clsource commented Mar 2, 2023

Maybe can be similar to hex.

  • 0k512

Otherwise if more modular is needed I think adding more methods to the Num class would do for these constants.

@mhermier
Copy link
Contributor

mhermier commented Mar 2, 2023

I was thinking to something more general like:

import "my_unit" for Ki

512 <operator_here> Ki // Would invoke: Ki.call(512)

@clsource
Copy link
Author

clsource commented Mar 2, 2023

Maybe

// A class named constants inside Num. 
Num.constants.kilobyte // With static methods with common values such as 1024
Num.constants.kilobyte(512) // A static method with one parameter that will multiply the value by the constant

@mhermier
Copy link
Contributor

mhermier commented Mar 2, 2023

Your solution is the same as doing:

import "constants" for Constant

Constants.kilobyte // With static methods with common values such as 1024
Constants.kilobyte(512) // A static method with one parameter that will multiply the value by the constant

With the advantage that you are in control.

My solution also put you in control, but with a syntax more near to what you originally planned, and can be reused for other stuff:

import "to_list" for ToList

(0..42) <operator_here> ToList

@clsource
Copy link
Author

clsource commented Mar 2, 2023

that reminds me more or less to pipes.

(0..42) |> ToList

In my POC here #944 I overloaded the operator in classes.
Maybe here would be an operator only for using call in functions.?

@mhermier
Copy link
Contributor

mhermier commented Mar 2, 2023

It would make things more functional style, but it is the more extensible sugar syntax I can think of. In extra, if that as importance or extra usage, it would inverse order of evaluation before execution...

@clsource
Copy link
Author

clsource commented Mar 2, 2023

If a pipe operator is added |> just as a syntax sugar to function.call() I totally support the idea.

var Kilobyte = Fn.new{|n| n * 1024}
var Print = Fn.new{|s| System.print(s)}

Print.call(Kilobyte.call(512))

Becomes

var Kilobyte = Fn.new{|n| n * 1024}
var Print = Fn.new{|s| System.print(s)}

512
|> Kilobyte
|> Print

@mhermier
Copy link
Contributor

mhermier commented Mar 2, 2023

At usage, it would definitively be equivalent. The operator has to be accepted or changed, but it gives an extra reason to have that language construction.

@clsource clsource changed the title [Feature] Kilobyte Constants [Feature] Pipe Operator for Function Calls Mar 2, 2023
@clsource
Copy link
Author

clsource commented Mar 2, 2023

I will link to the Pipe proposal for JS https://github.com/tc39/proposal-pipeline-operator

The pipe operator attempts to marry the convenience and ease of method chaining with the wide applicability of expression nesting.

The general structure of all the pipe operators is value |> e1 |> e2 |> e3, where e1, e2, e3 are all expressions that take consecutive values as their parameters. The |> operator then does some degree of magic to “pipe” value from the lefthand side into the righthand side.

var kilobyte = Fn.new{|n| n * 1024}
var division = Fn.new{|x, y| x / y }
var print = Fn.new{|s| System.print(s)}

# print.call(division.call(kilobyte.call(512), 2))
512
|> kilobyte 
|> division(2) 
|> print

@PureFox48
Copy link
Contributor

I don't think it would be a good idea to have something like this built into the language (or even the standard library) partly because of the confusion over whether the prefix kilo should mean 1000 or 1024 (and similarly for mega, giga and friends) - see Wikipedia - but also because what one would probably do now is very simple and flexible:

var K = 1024
System.print(512 * K)

On the general question of working with units, Wren is an OO language and, if - for some particular quantity - there's a significant difference between the units and the actual numbers, we should be thinking in terms of creating a class to represent that quantity.

An example of this is financial applications where, because of difficulties in working with floating point, most folks prefer instead to work in cents (or whatever the sub-unit is called) even though it's tedious and error prone to convert from and to the actual monetary amounts.

Recognizing this, I recently created a Money module in Wren which does all this in the background for me. It was a fair bit of work but I was able to build a lot of flexibility into it such as different thousand separators, decimal points, currency symbols etc.

With regard to whether Wren should have pipes, I think that's a more general question regarding how best to chain function calls. Although I'm not keen on having to use the call method all the time, I'm worried that if we start talking about pipes and other functional stuff such as currying and monads, it will make people feel that Wren is turning into a kind of mini-Haskell and run for the hills!

@clsource
Copy link
Author

clsource commented Mar 2, 2023

I don't think it would be a good idea to have something like this built into the language (or even the standard library) partly because of the confusion over whether the prefix kilo should mean 1000 or 1024 (and similarly for mega, giga and friends) - see Wikipedia - but also because what one would probably do now is very simple and flexible:

var K = 1024
System.print(512 * K)

On the general question of working with units, Wren is an OO language and, if - for some particular quantity - there's a significant difference between the units and the actual numbers, we should be thinking in terms of creating a class to represent that quantity.

An example of this is financial applications where, because of difficulties in working with floating point, most folks prefer instead to work in cents (or whatever the sub-unit is called) even though it's tedious and error prone to convert from and to the actual monetary amounts.

Recognizing this, I recently created a Money module in Wren which does all this in the background for me. It was a fair bit of work but I was able to build a lot of flexibility into it such as different thousand separators, decimal points, currency symbols etc.

With regard to whether Wren should have pipes, I think that's a more general question regarding how best to chain function calls. Although I'm not keen on having to use the call method all the time, I'm worried that if we start talking about pipes and other functional stuff such as currying and monads, it will make people feel that Wren is turning into a kind of mini-Haskell and run for the hills!

I agree. Having such values inside Wren core may not be a wise idea, if is best to delegate that to an external lib.

For the pipe I think that is just syntax sugar to calling functions. Other functional language constructs would be left to maybe external libs if any, since Wren is OOP after all.

@mhermier
Copy link
Contributor

mhermier commented Mar 2, 2023

Looking at the JS paper, the placeholder operator seems overkill (introduce too much complexity in the compiler). Instead, I propose that we can guarantee that we can put at minimum a function as second argument:

512 |> Fn.new {|n| n * 1024} |> Fn.new {|s| System.print(s)} // expect: 524288

So the priority level of the operator should be thought with care.

That said, I consider |> to be a placeholder, to ease the discussion. But since the pattern emerged at least twice, it shows there is a real need for such language construction/syntax sugar.

@PureFox48
Copy link
Contributor

512 |> Fn.new {|n| n * 1024} |> Fn.new {|s| System.print(s)} // expect: 524288

So, vis-à-vis #1128, you can see some value in System.print returning its argument after all :)

@mhermier
Copy link
Contributor

mhermier commented Mar 2, 2023

Well since there is no chain going further, I didn't thought more about it. For me it does return null as a way to terminate the chain. But the fact, it does return its value is not that important, since we can always chain with something like:

var debug = Fn.new {|value|
  System.print(value)
  return value
}
512 |> Fn.new {|n| n * 1024} |> debug |> ... // do something else

The fact that it can be compressed to a one-liner is nice indeed, but not that important.

@PureFox48
Copy link
Contributor

PureFox48 commented Mar 2, 2023

Well, functional or not, I have to admit that I'm quite sold on pipes as an answer to the present mess of nesting function calls.

They seem particularly elegant when the functions are named and being able to dispense with the dreaded .call syntax is, of course, a big bonus :)

I like this way of injecting additional arguments into the pipeline:

512 |> kilobyte |> division(2) |> print

though that only works if the return value of the previous function is the first argument to the next function. Not sure what to suggest if it's the second - perhaps division(2, ).

Another question is how to present multiple arguments to the first function in the chain - perhaps (x, y) would be the most natural and it can't be confused with anything else such as a list or map literal.

@clsource
Copy link
Author

clsource commented Mar 2, 2023

I think a pipeline would work best if the accumulator is the first param.

The accumulator is just the return of the previous operation that is passed down the pipeline.

But functions with more params would need to be named since it wont be possible to more than one params without wrapping it in another function.

var division = Fn.new{|acc, div| acc / div}

512 
|> Fn.new{|acc| acc * 1024}
|> division(2) 
# We need to have a named function for this.
# otherwise if only a single accumulator would be passed around
# we would need a wrapper function
#
#  Fn.new{|acc| division.call(acc, 2)}
# 
|> Fn.new{|acc| System.print(acc)}

So the idea would be.

  • Use the operator <<value>> |> <<function>>.call() to pass the left side value to a function call in the right side.
  • To pass more params to the function call will consider the values inside parenthesis () (similar to methods in classes). This would only be valid if the function is inside a callable variable (named).

Example

100
|> division(2)

Would be translated to

division.call(100, 2)

@PureFox48
Copy link
Contributor

But functions with more params would need to be named since it wont be possible to more than one params without wrapping it in another function.

Yes, I agree with that. So, if the accumulator was the second or n'th argument to division(or whatever named function was being called), we'd need a wrapper function to express that.

Also thinking a little further, if everything after the first element in the pipeline was guaranteed to be a function, we could perhaps dispense with Fn.new and the example would then become:

512 
|> {|acc| acc * 1024}
|> division(2)
|> {|acc| System.print(acc)}

If the first function in the chain took no arguments, that could be expressed as (). in fact, even if it only took a single argument perhaps it would be better to wrap it in parentheses to ease parsing.

@clsource
Copy link
Author

clsource commented Mar 2, 2023

Also thinking a little further, if everything after the first element in the pipeline was guaranteed to be a function, we could perhaps dispense with Fn.new and the example would then become.

I agree.

If the first function in the chain took no arguments, that could be expressed as (). in fact, even if it only took a single argument perhaps it would be better to wrap it in parentheses to ease parsing.

I think pipeline functions must be at least have 1 argument that would be the accumulator.
The accumulator can be any Wren Valid Value. Like Ints, Strings, Fn. Is the next function in the pipeline to decide what to do with the given acc.

var kb = 512 
|> {|acc| acc * 1024}
|> division(2)

System.print(kb) // 262144

@PureFox48
Copy link
Contributor

I think pipeline functions must be at least have 1 argument that would be the accumulator.

I agree with that for all functions after the first though, if we allowed the first function to take no arguments, then we could (optionally) get rid of call altogether by invoking a function, f, like this:

() |> f
//  instead of f.call()

@PureFox48
Copy link
Contributor

The first function could, of course, still return a value to be passed to the next function (if any) in the pipeline even if it took no arguments itself

@clsource
Copy link
Author

clsource commented Mar 2, 2023

() |> f
//  instead of f.call()

I think the pipe operator would be best to ease multiple nested function calls.
For a single call I don't know if is better than just using call

@PureFox48
Copy link
Contributor

Well, it would be a consistent way to make a single call albeit entirely optional.

@PureFox48
Copy link
Contributor

Remember also that, even in a chain, the first function might not need any arguments as (being a closure) it could produce a return value by manipulating captured variables.

@clsource
Copy link
Author

clsource commented Mar 2, 2023

Currently you can pass any arguments to a 0 arity function without Wren complaining.

var print = Fn.new{|s| System.print(s)}
var hello = Fn.new{print.call("Hello Wren")}


hello.call(null)
hello.call(1234)

So if you want you can use the pipe with an empty function

null |> f

@PureFox48
Copy link
Contributor

Yes, but the reason why that works is because any surplus arguments are ignored - another bone of contention!

null could still be a valid argument to a single parameter function.

@clsource
Copy link
Author

clsource commented Mar 2, 2023

If I understand, for the Pipe |> to work both sides must implement the call method (be like functions).
It will first call the left side and use the return value as the first param to the call in the right side.

This would mean that basic types such as Numbers would need to implement the 512.call() that will just return itself.
For

512
|> division(2)

To work.

var result = Fn.new{}
|> piped_fn_1
|> piped_fn_2

@PureFox48
Copy link
Contributor

If that's the case, then I don't see how we could start with multiple arguments such as (512, 513) because we don't have tuples in the language on which to hang a call() method. We can't use a list [512, 513] because that could be a single argument rather than two.

@PureFox48
Copy link
Contributor

After chewing this over some more, I think - like it or not - we will have to use a list to pass multiple or no arguments to the first function so we can hang a call() function on it.

If the first function takes 1 argument, then the list will be accepted as that argument.

If the first function takes more than 1 argument, then it will need to be wrappped in another function which takes a single argument (namely the list) and then calls the wrapped function with the appropriate arguments taken from the list.

If the first function takes no arguments, then it will simply discard the list (which could then be the empty list []) as surplus to requirements. This is something which I understand from @mhermier is unlikely to change as it makes the implementation much easier.

I'd be happy with that as I could still do my single parameterless function call with:

[] |> f

@mhermier
Copy link
Contributor

mhermier commented Mar 2, 2023

As long as we can pass 1 argument we can passe any argument, using the following trick:

class Apply {
  construct new(fn) {
    _fn = fn
  }
  call(args) {
    var count = args.count
    if (count == 0) return _fn.call()
    if (count == 1) return _fn.call(args[0])
    if (count == 2) return _fn.call(args[0], args[1])
    if (count == 3) return _fn.call(args[0], args[1], args[2])
    ...
  }
}

[] |> Apply.new(f)

If list of arguments are accepted as input, we may need to produce list as output to chain. Though, I wonder how it is really usable and if it does not produce a mess...

@PureFox48
Copy link
Contributor

Yes, that's the sort of approach I had in mind when starting with two or more arguments and it would be nice if the Apply class could be put in the standard library so people don't have to write their own.

Presumably, we wouldn't need to use it for the cases of 0 and 1 argument - we could use the function itself which would work for the reasons given earlier.

@PureFox48
Copy link
Contributor

If list of arguments are accepted as input, we may need to produce list as output to chain.

I didn't follow that. Isn't the output of the chain just going to be the result of the last function called, which could be anything?

@clsource
Copy link
Author

clsource commented Mar 3, 2023

I think since this is a c level operator there is no need for having arguments as list. We can use the parser to sugar the arguments to the call.

The idea is passing arguments as any normal call method

Func1 |> Func2(myarg)

Would be the same as
Func2.call(func1.call(), myarg)

The pipe operator would accept two arguments. Both must implement the call method.

At the low level something like the following can happen

1 if pipe continue else return last result
2 take left section
3 check if has parenthesis
4 pass arguments to call if has parenthesis
5 store the result
6 check right section
7 check if has parenthesis
8 pass result as first param. The rest of the arguments to the call.
9 store the result
10 go to 1

Analyzing, only the right function would accept the accumulator as the first param.

The left function would be called as is without the accumulator as the first param. Since it assumes is the result of the previous operation.

This of course would need.

  1. Implement the call method in primitives that return itself
  2. That 1(true) |> 2 would be valid wren code. Although it would be returned just 2

@mhermier
Copy link
Contributor

mhermier commented Mar 3, 2023

@PureFox48 for consistency, either each element should work with 1 argument or a parameter list. Else it will be hard to reason about. Since we don't have type checking, it will be complex to handle without some consistency.

[...] |> Foo // Should output an argument list
42 |> Bar // Should output a single argument

Else we have to introduce some kind of ArgumentList and make |> expand differently depending on the type of argument.

[...].toArgumentList |> Foo // Since it is an argument list, invoke `Foo.callAll(...)` 
[...] |> Foo // Invoke.call([...])

@mhermier
Copy link
Contributor

mhermier commented Mar 3, 2023

@clsource I think we don't want to enter that territory. It introduce to much problems.
The % operator in the js proposition is trying to solve exactly that.
The fact that we can produce function inline quite shortly, should solve most of the use case.

edit: Thinking about it, the % seems to be a poor imitation of the c++ std::bind idea, baking it in the language. Nothing prevent us from having something like:

class Bind {
  call(arg0, fn) { bindAll([arg0], fn) }
  call(arg0, arg1, fn) { bindAll([arg0, arg1], fn) }
  callAll(args, fn) { /* implementation detail */ }
}

class PlaceHolder {
  static new(index) { /* implementation detail */ }
}

Func1 |> Bind.call(PlaceHolder.new(0), myarg, Func2)

But I don't think it is particularly more readable or flexible than:

Func1 |> Fn.new {|x|Func2.call(x, myarg)) }

@clsource
Copy link
Author

clsource commented Mar 3, 2023

Here is a proof of concept of Piped functions in Wren

class Pipe {
  result {_result}
  handle_func(acc, func) {
    System.print("handle_func | acc: %(acc), func: %(func)")
    return func.call(acc)
  }
  handle_list(acc, func, args) {
    System.print("handle_list | acc: %(acc), func: %(func), args: %(args)")
    if (args.count == 1) {
      return func.call(acc, args[0])
    }
    if (args.count == 2) {
      return func.call(acc, args[0], args[1])
    }
    if (args.count == 3) {
      return func.call(acc, args[0], args[1], args[2])
    }
    if (args.count == 4) {
      return func.call(acc, args[0], args[1], args[2], args[3])
    }
    if (args.count == 5) {
      return func.call(acc, args[0], args[1], args[2], args[3], args[4])
    }
    if (args.count == 6) {
      return func.call(acc, args[0], args[1], args[2], args[3], args[4], args[5])
    }
    if (args.count == 7) {
      return func.call(acc, args[0], args[1], args[2], args[3], args[4], args[5])
    }
    if (args.count == 8) {
      return func.call(acc, args[0], args[1], args[2], args[3], args[4], args[5], args[6])
    }
    return handle_func(acc, func)
  }
  construct new(acc, items) {
    items.each{|args| 
      if (args is List) {
        acc = handle_list(acc, args[0], args[1..-1])
      }
      
      if (args is Fn) {
        acc = handle_func(acc, args)
      }
    }
    _result = acc
  }
}

var multiply = Fn.new{|acc| acc * 2}

var res = Pipe.new(12, [
  [Fn.new{|acc, n| acc + 2 + n}, 1],
  Fn.new{|acc| System.print(acc)},
  multiply
]).result

System.print("result %(res)")

This means we can do something like

func1 |> [func2, arg1, arg2] |> func3

@mhermier
Copy link
Contributor

mhermier commented Mar 3, 2023

@clsource It does indeed works and have pretty compact syntax. Thought it is pretty limited, because the position of the previous argument is imposed.

@clsource
Copy link
Author

clsource commented Mar 3, 2023

I think it a fair trade off.
Later other more complex structures can be created that translates down to a list with first item as the function I guess.

@mhermier
Copy link
Contributor

mhermier commented Mar 4, 2023

I looked a bit more deeper to the js % operator rabbit hole, and the situation is crazy. To put it simply, it creates a whole DSL (that would need to replicate near all the JS syntax), to make some sort of inlined unary lambda syntax. This is meant to avoid some await async problems (that I don't fully understand, but nothing forbid to use named async functions in the pipeline intentionaly or by mistake), and avoid some concerns about creating to much one shot functions (that could be identified by a smarter compiler and optimized out).

@clsource
Copy link
Author

clsource commented Mar 4, 2023

I think using the Hack Pipe (%) style brings more complexity than using the Elixir/F# Pipe style.
Since not only a new pipe operator |> would be needed, but also some sort of binded variable that will hold the result of the previous call.

One way to work with this would be using the Meta module and evaluate some placeholder string as param, but I don't know if using Meta would be recommended for this.

Hack Pipe

The righthand side can be any expression, and the placeholder can go anywhere any normal variable identifier could go, so we can pipe to any code we want without any special rules:

  • value |> foo(%) for unary function calls.
  • value |> foo(1, %) for n-ary function calls.

I think that Wren would be more compatible with Elixir/F# style pipes. Since my proof of concept demostrates that you can create a datastructure for a pipeline. It would only needed a new operator |> that is just sugar for creating the Pipeline datastructure.

Elixir/F# Pipe

In the F# language’s pipe syntax, the righthand side of the pipe is an expression that must evaluate into a unary function, which is then tacitly called with the lefthand side’s value as its sole argument. That is, we write value |> one |> two |> three to pipe value through the three functions. left |> right becomes right(left). This is called tacit programming or point-free style.

@mhermier
Copy link
Contributor

mhermier commented Mar 4, 2023

I don't know what to think of it. It may introduce a lot of complication in the compiler. Your solution with [...] while interesting could be better if we add tuples instead... So I would go to the easy route and only evaluate functions in first place.

@clsource
Copy link
Author

clsource commented Mar 10, 2023

Ok here is another proof of concept. It was only required a new static pipe function in the Fn class.

class FnPipe_ {

  value {_value}

  construct new(value) {
    _value = value
  }

  call(val) {
    return this.value.call(val)
  }

  +(other) {
    return FnPipe_.new(other.call(this.value))
  }

  toString {this.value.toString}
}

class Fn {
  static pipe(value) {
    return FnPipe_.new(value)
  }
}

Now we can just concatenate function calls with + operator.

var multiply = Fn.pipe {|acc| acc * 2}
var print = Fn.pipe {|acc| System.print(acc)}

Fn.pipe(5) + 
Fn.pipe {|acc| acc + 5} + 
multiply + 
print // 20

// Can also be
System.print((Fn.pipe(5) + Fn.pipe {|acc| acc + 5} + multiply).value)

@mhermier
Copy link
Contributor

mhermier commented Mar 11, 2023

These solution have a problem in common, they allocate and you have to ask the allocated object for the result value (mimic of a monad).
There are already hard to avoid allocations like functions and possibly results. The fact that the pipe also allocate is quite a no go for me.
If it is an operator simulation use a static function instead, since it will likely be a built-in operator not an opt-in one.

edit: Basically this is:

var Pipe = Fn.new {|lhs, rhs| rhs.call(lhs) }

@clsource
Copy link
Author

clsource commented Mar 11, 2023

Sounds good.
My POC mainly was for testing out if using the already available + operator would work.

Since the + have different behaviours depending on the objects. I though that maybe implementing in function would do to concatenate function calls.

Maybe leaving the more complex operator |> free to implement more complex pipelines such as variable binding in the future (Like Hack Pipe).

imagen

Using your example then I modified Fn class with the following result:

class Fn {
  +(other) {
    return Fn.new{other.call(this.call())}
  }
}

We just need to put a single call() at the end

var print = Fn.new{|acc| System.print(acc) }

(Fn.new{5} + Fn.new{|acc| acc + 5} + print).call()

This means we can pass pipelines as a normal variable

var num = (Fn.new{5} + 
    Fn.new{|acc| acc + 5})

var result = num.call()

if (result is Num) {
    System.print("%(result) is Number")
}

or create more complex pipelines

var sum5 = Fn.new{|acc| acc + 5}
var sum10 = Fn.new{|acc| (Fn.new{acc} + sum5 + sum5).call()}
var sum20 = Fn.new{|acc| (Fn.new{acc} + sum10 + sum10).call()}

var result = (Fn.new{5} + sum20).call()

System.write(result)

if (result is Num) {
    System.print(" is Number")
}

@mhermier
Copy link
Contributor

Here is your dose of sugar. It was trivial to implement. Thought I don't know how I'll publish it, since it is really interesting in when coupled with #1151

--------------- test/language/pipeline_operator/pipeline.wren ----------------
new file mode 100644
index 000000000..0dd60722e
@@ -0,0 +1,9 @@
+var double = Fn.new {|x| x * 2}
+var triple = Fn.new {|x| x * 3}
+
+System.print(1 |> double) // expect: 2
+System.print(1 |> double |> triple) // expect: 6
+
+// Swallow a trailing newline.
+System.print(2 |>
+    double) // expect: 4

@PureFox48
Copy link
Contributor

In view of what @mhermier said about avoiding allocations, here's a Pipe class which only uses static methods. As such it does not create any intermediate objects except in those cases where a list is needed but only a scalar has been returned by the previous function call.

Not as elegant as using pipe operators but reasonably efficient and seems to be working OK.

class Pipe {
    static call_(fn, arg, spread) {
        var a = fn.arity
        if (!(arg is List) || !spread) {
            if (a == 0) return fn.call()
            if (a == 1) return fn.call(arg)
            Fiber.abort("Too few arguments.")
        } else {
            if (arg.count < a) Fiber.abort("Too few arguments.")
            if (a == 0) return fn.call()
            if (a == 1) return fn.call(arg[0])
            if (a == 2) return fn.call(arg[0], arg[1])
            if (a == 3) return fn.call(arg[0], arg[1], arg[2])
            // etc
        }
    }

    static [arg, fns, autoSpread] {
        if (!(autoSpread is Bool)) Fiber.abort("autoSpread must be a boolean.")
        if (!(fns is List)) fns = [fns]
        var spread = autoSpread
        for (fn in fns) {
            if (fn is Bool) {
                spread = fn
            } else if (fn is List) {
                if (!(arg is List)) arg = [arg]
                arg.addAll(fn)
                spread = true
            } else if (fn is Map) {
                if (!(arg is List)) arg = [arg]
                for (me in fn) arg.insert(me.key, me.value)
                spread = true
            } else if (fn is Fn) {                
                arg = call_(fn, arg, spread)
                spread = autoSpread
            } else {
                Fiber.abort ("Invalid argument.")
            }
        }
        return arg
    }

    static [arg, fns] { this[arg, fns, false] }
}

var kilobyte = Fn.new { |n| n * 1024 }
var division = Fn.new { |x, y| x / y }
var print    = Fn.new { |s| System.print(s) }
var hello    = Fn.new { print.call("Hello Wren") }
var multiply = Fn.new { |acc| acc * 2 }
var sum5     = Fn.new { |acc| acc + 5 }
var sum10    = Fn.new { |acc| Pipe[acc, [sum5, sum5]] }
var sum20    = Fn.new { |acc| Pipe[acc, [sum10, sum10]] }
var count    = Fn.new { |lst| lst.count }

Pipe[512, [kilobyte, [2], division, print]] // 262144
Pipe[null, hello] // Hello Wren
Pipe[[10, 2], [true, division, print]] // 5
Pipe[15, [multiply, {1: 3}, division, print]] // 10
Pipe[3, [{0: 45}, division, print]] // 15
Pipe[5, [Fn.new { |acc| acc + 5 }, multiply, print]] // 20
Pipe[5, [sum20, print]] // 25
Pipe[(1..30).toList, [false, count, print], true] // 30

NOTES:

A static indexer is used to create the pipeline though a named static method (such as 'new') could be used instead.

'arg' is the argument to be passed to the first function in the 'fns' list. If the latter takes no arguments it will be ignored.

'fns' can include values other than functions:

  1. true - causes a list passed to the next function to be spread rather than treated as a single argument.

  2. false - opposite of 'true'.

  3. a list - adds the contents of the list to a list returned by the previous function and spreads it.

  4. a map - takes each (index, value) pair and inserts 'value' at index 'index' into a list returned by the previous function and spreads it.

It's an error to pass any other kind of value.

In general, any excess arguments will be ignored but it's an error to pass too few arguments to a particular function.

'autoSpread' is a boolean representing the default value for spreading lists. If not present, 'autoSpread' is set to false. Passing true or false in 'fns' overrides the default for the next function in the pipeline.

@mhermier
Copy link
Contributor

mhermier commented Mar 12, 2023

While thinking about this, I wondered how could we compose beforehand. It goes a bit further the original proposal. But if we allow pipe calls we may need a complementary pipe composition operator.

@HallofFamer
Copy link

HallofFamer commented Mar 30, 2023

thh I dont think Wren needs pipe operator. From my experience, writing code that combines dot and pipe operators, lead to very messy and confusing code. Pipe operator works best for FP languages that do not support objects/dot notation at all, or at least when objects are rarely used. Wren is primarily an OO language, with objects and dot operators being widely used at its core.

Introducing a syntax that looks similar to method calls but differ in subtle ways, will make it beginner-unfriendly. Its like having both interface and trait at the same time(lol PHP!), having both struct and record at the same time(hmm C#...). I am also very against JS adding pipe operators for the same reason.

@mhermier
Copy link
Contributor

mhermier commented Mar 30, 2023 via email

@PureFox48
Copy link
Contributor

Well, we're only talking here about the pipe operator being applied to a chain of functions (not methods) so I don't really see why this would be confusing.

We already have this dichotomy in the language between functions and methods - the former are objects, the latter are not - and that's something which newcomers to Wren have to be clear about. As @mhermier has just said, functions are everywhere in Wren and bring a lot of benefits to the language.

Now we can already chain methods using dot notation but, for functions, we have to nest them which I think most people would agree is not very pleasant, particularly when we have to use .call to invoke each function. If this feature were to land, then it would give us a fluent way of chaining functions analagous to chaining methods, albeit using a pipe operator (|> or whatever) rather than dot notation to distinguish between the two.

Even so, there are clearly problems with designing this feature, let alone implementing it, and it may be that we're trying to make it too complicated. Perhaps the return value of the previous function should always be the first (or only argument) of the next function in the pipeline and we should leave it at that.

@HallofFamer
Copy link

HallofFamer commented Mar 31, 2023

It would be confusing if the same code uses both method chaining and pipe operators together at the same time, consider the following example:

a.b(c)|>d(e, f).g(h, i, j)|>k(l, m);

Code like this becomes quickly unreadable, now imagine chaining and pipe are also used in the arguments. The pipe operator works the best if theres no object or method chaining(ie. pure FP langs and mostly FP langs), but the ship has already sailed for Wren. The issue is that pipe operator looks like dot operator but it behaves in a very different way, when used together with dot operator its very difficult to tell the program flow. The examples of existing mainstream languages already show us that having two feature similar in syntax but differing in subtle ways, can be a serious source of confusion.

And nope beginner-friendliness is a valid argument, since in practice every feature introduces new cognitive load which has to be justified against the use-cases it tries to solve. In another word, is the benefit of introducing such a feature enough to justify the extra cognitive load it brings? For me, the answer is no for pipe operator. Wren is primarily an OO language, of course OOP languages dont mean they dont have functions, but most OO languages do not need something like pipe operators. Functions are everywhere yes, but you rarely compose functions in OO languages the same way as you do in FP languages.

If you want to talk about what is OOP, the original definition from Smalltalk is actually about message passing. It will be more useful and interesting to push Wren into this direction instead, about messages. If messages are first class citizens, and the language has dedicated syntax for first class messages, it is an upgrade over the mainstream OO languages, even Smalltalk itself. I see there is already a discussion about this topic.

@PureFox48
Copy link
Contributor

Well, even method chaining on its own can produce some confusing looking code and the usual solution to that is to put each method call on a separate line following a dot. A similar solution would be available for this proposal.

But, at the end of the day, it's up to the maintainer what goes into Wren and I think the pipes proposal has a high chance of rejection anyway not just because of the issues it has but because the problem it's trying to solve - the ugliness of function call nesting - can be easily solved (albeit less elegantly) by a Wren library class.

There are other proposals such as enums and named tuples for which library based solutions are available though, personally and i don't think I'm alone in this, I regard enums as important enough to be given support in the language proper.

When I hear talk about message passing I tend to think more in terms of the Actor model rather than OOP. This is a perfectly good model on which to build a language (Pony is a recent example) but I think it's more of interest for languages which have preemptive concurrency (because it avoids the need for locking) rather than the co-operative concurrency which Wren has.

I may be wrong but I think @mhermier has proposed the message passing syntax in #1160 just as an aid to building a reflection capability into Wren rather than something more general.

@mhermier
Copy link
Contributor

It is less readable because you didn't put spaces around binary operators (ignoring the call convention errors):

a.b(c) |> d(e, f).g(h, i, j) |> k(l, m)

It is trivial stuff, but white-spaces is information that helps readability and understand-ability.

It is understandable, if you don't consider the inclusion syntax. In the center argument, you don't know where to append the result of the first argument. With the patch I proposed (ignoring evaluation order), it is trivial to understand and become:

k(l, m).call(d(e, f).g(h, i, j).call(a.b(c)))

All in all, it is an advanced convenience for users that need to use it. You don't teach something by exposing all the concepts at once.

Yes I implemented #1160 as a band aid to perform dynamic invocation. While full message passing would be great, its generalization is not great. As @PureFox48 mention the actor model is more suitable. Efficient message passing can happen internally, and complete message passing can happens for unlikely portal RPC objects to the outside world. As such, I prefer having to have an hard to maintain external RPC compiler, than deal with an hard to maintain language implementation performance.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants