-
Notifications
You must be signed in to change notification settings - Fork 556
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
Comments
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). |
Maybe can be similar to hex.
Otherwise if more modular is needed I think adding more methods to the Num class would do for these constants. |
I was thinking to something more general like: import "my_unit" for Ki
512 <operator_here> Ki // Would invoke: Ki.call(512) |
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 |
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 |
that reminds me more or less to pipes. (0..42) In my POC here #944 I overloaded the operator in classes. |
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... |
If a pipe operator is added 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 |
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. |
I will link to the Pipe proposal for JS https://github.com/tc39/proposal-pipeline-operator
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 |
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 |
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. |
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 |
So, vis-à-vis #1128, you can see some value in |
Well since there is no chain going further, I didn't thought more about it. For me it does return 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. |
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 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 Another question is how to present multiple arguments to the first function in the chain - perhaps |
I think a pipeline would work best if the The 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.
Example 100
|> division(2) Would be translated to division.call(100, 2) |
Yes, I agree with that. So, if the accumulator was the second or n'th argument to 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 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 |
I agree.
I think pipeline functions must be at least have 1 argument that would be the accumulator. var kb = 512
|> {|acc| acc * 1024}
|> division(2)
System.print(kb) // 262144 |
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 () |> f
// instead of f.call() |
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 |
() |> f
// instead of f.call() I think the pipe operator would be best to ease multiple nested function calls. |
Well, it would be a consistent way to make a single call albeit entirely optional. |
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. |
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 |
Yes, but the reason why that works is because any surplus arguments are ignored - another bone of contention!
|
If I understand, for the Pipe This would mean that basic types such as Numbers would need to implement the 512
|> division(2) To work. var result = Fn.new{}
|> piped_fn_1
|> piped_fn_2 |
If that's the case, then I don't see how we could start with multiple arguments such as |
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 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 I'd be happy with that as I could still do my single parameterless function call with: [] |> f |
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... |
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 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. |
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? |
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 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 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.
|
@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 [...].toArgumentList |> Foo // Since it is an argument list, invoke `Foo.callAll(...)`
[...] |> Foo // Invoke.call([...]) |
@clsource I think we don't want to enter that territory. It introduce to much problems. edit: Thinking about it, the 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)) } |
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 |
@clsource It does indeed works and have pretty compact syntax. Thought it is pretty limited, because the position of the previous argument is imposed. |
I think it a fair trade off. |
I looked a bit more deeper to the js |
I think using the Hack Pipe ( One way to work with this would be using the Hack Pipe
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 Elixir/F# Pipe
|
I don't know what to think of it. It may introduce a lot of complication in the compiler. Your solution with |
Ok here is another proof of concept. It was only required a new 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 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) |
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). edit: Basically this is: var Pipe = Fn.new {|lhs, rhs| rhs.call(lhs) } |
Sounds good. Since the Maybe leaving the more complex operator Using your example then I modified class Fn {
+(other) {
return Fn.new{other.call(this.call())}
}
} We just need to put a single 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")
} |
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 |
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:
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. |
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. |
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. |
If you asked me 3-4 years ago, I would agree with you. Things changed, and
a recent video I saw, where the creator of smalltalk explained is vision of
OOP, confirmed the change.
OOP is not about creating a huge taxonomy with complex hierarchy of
objects. The fact that dot syntax is used a lot is a consequence of that
miss interpretation. OOP is about having objects that quack like duck but
are not necessarily duck.
And the big misconception about OOP that there are no functions, only
methods, is plain wrong. Even smalltalk the father of OOP had functions.
And they are everywhere and hidden in plain sight, because they use a
syntax similar to block.
Wren have exactly the same core ideas, even in the way we declare our
functions. Since pipe is a great tool to work with functions, they must be
considered. And they should also work with objects that act like functions
according to OOP principles.
The fact that it is not beginner friendly is not a valid reason. Every
language has a learn curve, with easy and advanced features. Their presence
do not affect existing code. If users try to use them the wrong way, it is
a user problem. When you have a tool, it doesn't mean you have to use it
everytime and everywhere.
|
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 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. |
It would be confusing if the same code uses both method chaining and pipe operators together at the same time, consider the following example:
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. |
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. |
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. |
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 :)
The text was updated successfully, but these errors were encountered: