Just thinking about design a little. Thought I'd written something about this already but can't find it. Inspired by https://brettrowberry.com/an-f-devs-perspective-on-clojure. In Rust it is very convenient and nice to thread method calls together like so:
foo.bar()
.baz()
.bop()
In functional languages you compose functions to do a similar thing while having data flow through them:
bar foo
|> baz
|> bop
BUT, the Rust form is a little more powerful because it namespaces the methods based on the type of the result, so really to write it in F# you'd have to do:
bar foo
|> BarReturnType.baz
|> BazReturnType.bop
However, Clojure and Fennel have a separate threading macro that basically does that for you, and is a bit more concise/easy to compose:
(-> "clojure"
.toUpperCase
.toCharArray
first
str)
And they have separate macros for "thread first arg" and "thread last arg", which is another weakness of the F#/Haskell style.
...Not sure that this is actually superior to the Rust approach though, at least for this language. Kinda tops out to "see what they must do to achieve a fraction of our power". Still, it's something I've pondered and the Clojure style is a new solution to me for what was previously an intractable issue.
Very interesting is https://srfi.schemers.org/srfi-197/srfi-197.html , which allows a placeholder for the value to chain:
(chain (alpha) (beta _) (gamma _) (delta _) (epsilon _) (zeta _) (eta _)) (chain bowl (add flour _) (add sugar _) (add eggs _) (mix _) (pour _) (bake _ (fahrenheit 350)))This solves a big problem with this sort of syntax imo.
I tried to think of a way to incorporate pattern matching into this, but it didn't work. Go far enough with it and it just becomes "calling a function". Still!
Sooooo what would the Scheme-like chaining look like in Garnet? To start off with the Rust-y model with no placeholders it would have to be:
bowl .add(flour) .add(sugar) .add(eggs) .mix() .pour() .bake(farenheit(350))That would literally translate into something like
chain bowl as add(_, flour) add(_, sugar) add(_, eggs) mix(_) pour(_) bake(_, farenheit(350)) end
There's two disconnected issues here: the variable positioning and the namespacing. This bit only considers the variable positioning. If you add namespacing to it, you could write it as above and have it compile to:
let _ = bowl.add(bowl, flour) let _ = _.add(_, sugar) let _ = _.add(_, eggs) let _ = _.mix(_) let _ = _.pour(_) let _ = _.bake(_, farenheit(350))
Whether or not that is desirable, idk. Not sure how it would interact with the ergonomics of Garnet's modules in practice. But you could do the Clojure-ish approach of having a preceeding
.
to make the namespacing explicit, so:chain bowl as .add(_, flour) .add(_, sugar) .add(_, eggs) mix(_) pour(_) bake(_, farenheit(350)) end
could mean:
let _ = bowl.add(bowl, flour) let _ = _.add(_, sugar) let _ = _.add(_, eggs) let _ = mix(_) let _ = pour(_) let _ = bake(_, farenheit(350))
which seems like it would do everything we want it to...
Technomancy on the topic sez:
13:00 < technomancy> it's anaphoric unfortunately 13:00 < technomancy> once you solve the anaphoric problem it ends up looking a lot less clean 13:03 < Icefoz> I don't even know what you would call those operators, besides "compose" or maybe "expression threading" and those don't search well. 13:04 < Icefoz> aha! 13:04 < Icefoz> SRFI 197: pipeline operators 13:04 < Icefoz> whew 13:10 < technomancy> https://clojuredocs.org/clojure.core/as-%3E this is the clojure version of the same thing which does it without anaphora 13:11 < technomancy> but it also introduces new bindings without [] which is icky 13:11 < Icefoz> wait you were talking to me. what the heck is "anaphoric" in this context? 13:11 < technomancy> it means it introduces a new identifier "out of thin air" 13:12 < Icefoz> hmmm, I see. 13:12 < technomancy> that's bad macro design; macros should always make it clear when new identifiers are introduced; typically by taking them as arguments. fennel's backtick is designed to prevent accidentally doing this, but it's possible to work around it. 13:14 < technomancy> I think clojure's as-> omits [] in order to make it work inside other arrows, but ... ugh 13:14 < technomancy> https://clojuredocs.org/clojure.core/as-%3E#example-568eeddae4b0f37b65a3c280 do not want
(The proposed Garnet syntax for this is kinda dumb, just consider it WIP.
Elixir is a decent case of a language where everything is module+function, kinda like Garnet, without Rust's
&self
-y thing. And it uses the|>
pipe operator form and it works extremely well. It just makes it a magical operator that rewritesfoo |> thing(x)
tothing(foo, x)
.
After using Elixir a lot more, it's not terrible. Definitely a little more work than method chaining, but having to specify which type to dispatch on instead of having it inferred feels more like it meshes with ML platypi vs typeclasses already.
It does get a bit icky if you don't always want the "receiver" to be the first arg in the function call. Elixir helps this with a shortcut lambda syntax but I don't like it very much, though possibly only 'cause Elixir lambdas are too complicated already. Having lots of Fancy Combinators that swap around args of functions like Haskell is even worse imo. Being able to specify a placeholder identifier like the Scheme example is an option, but as Technomancy says, that can get hairy. Basically it creates the hygenic macro problem, it appears. So not trying to make some clever way to nest pipeline/thread calls is probably the best choice.
For more discussion on that, see https://fennel-lang.org/reference#macro-gotchas and https://gist.github.com/nimaai/2f98cc421c9a51930e16#variable-capture
At some point I just started assuming that we would just use pipe operators rather than method chaining. This is still worth a bit of real research and a proof of concept, though.