wren icon indicating copy to clipboard operation
wren copied to clipboard

[Feature] Pipe Operator for Function Calls

Open clsource opened this issue 2 years ago • 53 comments

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

clsource avatar Mar 02 '23 12:03 clsource

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

mhermier avatar Mar 02 '23 13:03 mhermier

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.

clsource avatar Mar 02 '23 13:03 clsource

I was thinking to something more general like:

import "my_unit" for Ki

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

mhermier avatar Mar 02 '23 13:03 mhermier

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

clsource avatar Mar 02 '23 13:03 clsource

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

mhermier avatar Mar 02 '23 13:03 mhermier

that reminds me more or less to pipes.

(0..42) |> ToList

In my POC here https://github.com/wren-lang/wren/pull/944 I overloaded the operator in classes. Maybe here would be an operator only for using call in functions.?

clsource avatar Mar 02 '23 13:03 clsource

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

mhermier avatar Mar 02 '23 14:03 mhermier

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

clsource avatar Mar 02 '23 14:03 clsource

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.

mhermier avatar Mar 02 '23 14:03 mhermier

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

clsource avatar Mar 02 '23 15:03 clsource

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!

PureFox48 avatar Mar 02 '23 15:03 PureFox48

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.

clsource avatar Mar 02 '23 15:03 clsource

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.

mhermier avatar Mar 02 '23 15:03 mhermier

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

PureFox48 avatar Mar 02 '23 16:03 PureFox48

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.

mhermier avatar Mar 02 '23 16:03 mhermier

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.

PureFox48 avatar Mar 02 '23 17:03 PureFox48

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)

clsource avatar Mar 02 '23 17:03 clsource

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.

PureFox48 avatar Mar 02 '23 17:03 PureFox48

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

clsource avatar Mar 02 '23 17:03 clsource

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 avatar Mar 02 '23 18:03 PureFox48

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

PureFox48 avatar Mar 02 '23 18:03 PureFox48

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

clsource avatar Mar 02 '23 18:03 clsource

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

PureFox48 avatar Mar 02 '23 18:03 PureFox48

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.

PureFox48 avatar Mar 02 '23 18:03 PureFox48

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

clsource avatar Mar 02 '23 18:03 clsource

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.

PureFox48 avatar Mar 02 '23 18:03 PureFox48

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

clsource avatar Mar 02 '23 18:03 clsource

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 avatar Mar 02 '23 18:03 PureFox48

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

PureFox48 avatar Mar 02 '23 19:03 PureFox48

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

mhermier avatar Mar 02 '23 20:03 mhermier