gilch / hissp

It's Python with a Lissp.
https://gitter.im/hissp-lang/community
Apache License 2.0
369 stars 9 forks source link

Rethink ->> #186

Closed gilch closed 1 year ago

gilch commented 1 year ago

-> makes a lot of sense. Often functions only take one argument or take a primary argument and some configuration. Methods also have a self as the first argument. If you need to insert something in some other position, ->> works for the tail position, even in a -> context. The reverse isn't true.

X# also works in a -> context and is more general (target any position, or multiple positions), but you have to double-wrap it, although that's no worse than a ->> would have been. ->> only single-wraps when you can use a bare symbol, but in that case, you don't need it, as -> would already insert in the tail position (only one argument, so first is also last).

X# doesn't work by rewriting though. It has the run-time cost of another function definition and call. Clojure has the as-> for targeting some other insertion position inside a -> context. I think any equivalent in Hissp would have the same run-time cost as just using X#. as-> is unusual in Clojure in that the binding name appears on the right of the value, but that's exactly what makes it work in a -> context. Maybe JVM JIT is smart enough to inline this kind of thing. CPython currently wouldn't. as-> would have an advantage of shorter text over X# when run over multiple steps, instead of just one-off within ->.

Clojure has a bunch of core HOFs that take a function first and a collection last. ->> makes sense over multiple steps there, even when not inside a -> context. It's a less obvious fit in Python, especially considering that Python might configure some of them with kwargs. In that case we'd really want ->> to insert into the last positional argument, leaving the kwargs in place. Lisscad currently has a %->> demonstrating this need, although I think the one usage so far could have just used ->> and X#.

Toolz does have a thread_last, which is a run time equivalent of Clojure's ->>. It doesn't seem to have an equivalent for as->. That's a bit of evidence that ->> fits with Python, but probably they just aped Clojure like I did.

The bundled macros are supposed to be fairly minimal, so they shouldn't be overcomplicated. Maybe ->> is a fair compromise that's easy to implement and works in the simple cases, and one would just have to fall back to ->/X# sometimes.

All that to say, now that I think about it, the -<>> "diamond spear" from swiss-arrows might not be that hard to implement, as in nearly as easy as ->>. You don't really need both, so I'd replace ->> altogether, although I'm not sure which name to use. I think I'd use a control word instead of the <>. Maybe :<>?

Should -<> replace -> as well? The case for this one is less obvious.

gilch commented 1 year ago

A smart ->> macro to figure out where the last positional argument goes seems non-trivial. It's not as simple as finding the : and inserting before that (although that would often work), because positional arguments can follow via :?. I think the steps would be to normalize to :-first form and then find the last :?, and insert another positional pair after that. Of course, this only works if it's actually a function call, otherwise the normalize just messed up your macro/special form. Too hard, too brittle. -<>> seems much better. It's just like ->> in the common cases, and you can explicitly put the insertion point wherever you want when you need to, and the human will get it right pretty easily.

Not fully tested, but

(defmacro -<>> (expr : :* forms)
  (functools..reduce XY#(let (i (iter Y))
                          `(,@(i#takewhile X#(op#ne X :<>) i) ,X ,@i))
                     (map X#.#"X if type(X) is tuple else (X,)" forms)
                     expr))

might do it. It's only one line longer than the implementation of ->>. This kind of replace pattern is a lot easier with something like Clojure's vectors, but no dependencies for Hissp. I've used takewhile like this elsewhere.

gilch commented 1 year ago

That implementation is not recursive; it can't detect a :<> in a nested tuple. I think this is fine? The whole point of using threading macros is to unnest things. If you really need it, there's still X#. There are cases when a single form has nested parts (which aren't expressions in their own right), like a params tuple in a lambda (or a binding tuple in let, etc.), but if it's going to contain a :<>, you can just unnest that too.

Contrived, but simple example:

;; Intended structure
(let (x 42)
  (print x))

;; Naiive attempt doesn't work because `:<>` is in a nested tuple.
(-<>>
 42
 (let (x :<>)
   (print x)))

;; Works. (Theoretically.)
(-<>> 42 x (let :<> (print x))

It also doesn't handle multiple :<> (or even detect them), just the first one. This behavior seems fine. : is allowed after the first one in calls and means something different there. :<> would work the same way. If you need multiples, you should just use X# instead.

gilch commented 1 year ago

That last example so happens to only use the first position, so just now I tried

#> (-> 42 x (let (print x)))
>>> # Qz_QzGT_
... # let
... (lambda x=(42):
...   print(
...     x))()
42

which does, in fact, work. -<>> would be able to handle more cases though.

gilch commented 1 year ago

Racket's threading macros, ~> and ~>> were also inspired by Clojure. They also use a placeholder. They call it _ though that's customizable. I don't really like the choice. _ is overloaded already, but maybe it makes more sense in Racket. Apparently names for these things are not really standardized across dialects.

Keeping the ->> name would pretty much preserve backwards compatibility, but I think it's premature to worry too much about that.

The disadvantage is that everyone would expect it to behave like Clojure, without the placeholder ability. I'd like to change the name to emphasize the ability, but what it should be called probably depends on the choice of placeholder control word. -<>> with :<> seems to work. -:<>> might fit even better, but it's getting long. ~>> could fit with :_ or :~ or maybe :>>. The name change seems too subtle.

The EDN Hissp dialects do munge, but a strict reading of EDN symbol spec gives them fewer characters to work with ~ is right out as that's reserved as the unquote reader macro in Clojure. That's a reason not to use ~>>.

I'm inclined to pick ->, -<>>, and :<>.

gilch commented 1 year ago

if it's going to contain a :<>, you can just unnest that too.

Maybe not true if some other macro writes the nested tuple for you. Not really a problem for the normal compile-time macros, since -<>> would get there first, but reader macros could be an issue. Just a possible gotcha. I don't think it changes my decisions. X# remains a viable workaround.

gilch commented 1 year ago

There's still the question of what to call this thing in the docs. My current style guide says symbols should still have a pronunciation. "Diamond spear" is more about the glyphs than the function, and besides, -> is called "Thread-first", not "Arrow". Possibilities:

I'm not sure "thread" was a good name to begin with (too easy to confuse with threads of execution). I might have called it "nest" or "pipeline", but Clojure started it. I think I'll go with Thread for now. There aren't really any compatibility issues with changing a pronunciation suggestion, as the name will still be Qz_QzLT_QzGT_QzGT_ as far as Python is concerned.

gilch commented 1 year ago

I'm wondering just how useful this is. It's a fact that options are often passed as kwargs in Python. They have to be written last. The type of issue I'm imagining is reduce, where there's a lambda, a sequence, and config (initial value, in this case). You'd usually use ->> for sequence functions like this. However, a reduce is usually the last step, so it can wrap outside of the arrow form. But it is possible for a reduce to yield another collection.

I've been looking through the standard library builtins, itertools, and functools, and also toolz for anything that might not work without an X# in either ->> or ->.

Likely an issue:

Possible, but unlikely to be a problem.

Maybe this is enough to justify it.