ANN: Underscores.jl: Placeholder syntax for closures

I think this is interesting (and I do think Observables could do with nicer syntax!) though this seems like a special enough case that Observables should just define its own special purpose macro.

For Observables you need to distinguish between variables which are Observable vs those which are not (for the purposes of creating the closure). This is a kind of “escaping” so it might be natural to use $x syntax. For example:

x = 1
a, b = Observable(1), Observable(2)
c = @Observable (a+b)*a + $x

Alternatively, if you think most variables will be captured (rather than form arguments to the closure) you could just reuse the existing syntax for observable getindex, but automatically lift it out into a closure:

c = @Observable (a[] + b[])*a[] + x

A third option would be to just lift the names of the things being observed out of a closure argument list to also take them from the current scope. This is general and the syntax is very evocative:

c = @Observable (a,b) -> (a + b)*a + x

You could even allow a combination of these forms, for example the first for short one-liners, and the third for more general cases

2 Likes

Does it make sense to lower

@_ mapreduce(f, _, xs) do x, y
    x + y
end

to

mapreduce(f, (x, y) -> x + y, xs)

and

@_ reduce(op, Filter(_), xs) do x
    x > 0
end

to

reduce(op, Filter(x -> x > 0), xs)

?

5 Likes

Oh right, do syntax for arbitrary slots; I hadn’t considered that, that’s a really cool idea!

Now that you’ve mentioned it, I really want to do something like this though I fear we can’t use _ because it changes the meaning of _ in a weird way. It also disallows using _ for it’s “normal” meaning which could be really handy for higher order functions which accept multiple closures.

So I think we need a different marker. An ugly but straightforward possibility is just to use _do:

@_ mapreduce(2^_, _do, xs) do x, y
    x + y
end
1 Like

It’s interesting that there’s several precedents here which are quite different. In yet another case, Elixir uses &, Kernel.SpecialForms — Elixir v1.15.7, to get things like &(&1 + &2). Amusingly / surprisingly this syntax is available in Julia (though it may eventually be used for Ref?). In some ways I find &1 more visually pleasing than _1 but I do think the “outer” placement of the @_ is quite nice.

Mathematica uses # or #1 for our _ and _1, but it’s arguably a bit of a different case if the vast majority of Mathematica users use it via the standard UI where # is highlighted and in a different font Functional Operations—Wolfram Language Documentation

I was thinking “two step” lowering which (conceptually) transforms above pieces of code to

@_ ans = mapreduce(f, _, xs)
ans() do x, y
    x + y
end

@_ ans = reduce(op, Filter(_), xs)
ans() do x
    x > 0
end

and then to

ans = _1 -> mapreduce(f, _1, xs)
ans() do x, y
    x + y
end

ans = _1 -> reduce(op, Filter(_1), xs)
ans() do x
    x > 0
end

The last step already works so I guess the fishy part is the first one? That is to say, inserting ans = alters the boundary of the implicit anonymous function?

Exactly. Though __ is actually consistent in this two-step scheme. So maybe it would be fine to have

@_ mapreduce(2^_, __, xs) do x, y
    x + y
end

I think this might be correct! Is there anything else this could mean?

1 Like

Nice! So, it’d mean that do block acts like |> and delimits the range of “whole expression?”

Yes that’s a good way to state it! The way Expr(:do) is parsed this is also very easy and natural to implement with the Expr(:call) nesting inside the Expr(:do) in the AST.

Is there anything else this could mean?

Thinking about it, there’s a choice, but the other alternative is relatively useless:

@_ map(__, xs) do x
    x+y
end

Could be the current meaning:

__1 -> (map(__1, xs) do x
    x+y
end)

but this is only really helpful inside piping or composition, IMO and one wouldn’t use a do block in conjunction with that. (__ can also be used to create an anonymous function but that’s not the core use case for @_ as I see it.)

Compared this to the more useful meaning:

f = (__1 -> map(__1, xs))
f() do x
    x+y
end

This second one seems fine, though there’s one oddity which is the need to insert an extra Expr(:call) (the () in f()). But I think that’s ok. Also __2 can’t mean something sensible in this case, but that’s probably not a big deal, I’ll just make it an error.

I think in practice I’ll lower this slightly differently to avoid creating another nested closure:

let __1 = (x,)->begin  # The closure from `Expr(:do)`
        x+y
    end
    map(__1, xs)
end

Here’s a WIP implementation of do block support: Allow __ to stand for the argument which accepts a do block by c42f · Pull Request #4 · c42f/Underscores.jl · GitHub

I know you’ve thought a lot about mapreduce and related functions. Do you have a compelling example of how this would be used somewhere which I could put in the docs?

1 Like

Well… I do sometimes :sweat_smile:. But can’t it be done with single _? Something like

@_ itr |> 
   filter(_) do x
       x > 0
   end |>
   map(_) do x
       x^2
   end

Haha, I take it back :slight_smile:

But no, that example doesn’t seem like it will work very nicely if you also want mapreduce(_+1, __, xs) do ... end to work.

So what I’ve written was equivalent to

itr |> 
filter(identity) do x
    x > 0
end |>
map(identity) do x
    x^2
end

?

Yes. The rule is that the boundary for _ is inside the “outer” call, where piping (and do in the PR) doesn’t count for the purposes of determining “outerness”

So if you want this to work, we’d need a separate marker for do so you can have __ mean the current thing and have this work

@_ itr |> 
   filter(__) do x
       x > 0
   end |>
   map(__) do x
       x^2
   end

then, unrelatedly have a _do marker for

@_ mapreduce(_+1, _do, xs) do x,y
    x + y
end

which we could compose to get

@_ itr |> 
   filter(__) do x
       x > 0
   end |>
   mapreduce(_+1, _do, __) do x
       x + y
   end

It’s getting complicated :grimacing:

Actually I think not being able to use do with |> is OK :slight_smile: I was just wondering what the rule is. I was probably mixing it with some other proposals.

So how do you apply the rules to lower @_ map("X $(repeat(_,_))", ["a","b","c"], [1,2,3]) in the documentation to map((a, b) -> "X $(repeat(a, b))", ["a","b","c"], [1,2,3]) and not map("X $((a, b) -> repeat(a, b))", ["a","b","c"], [1,2,3]) or map("X $(repeat(identity, identity))", ["a","b","c"], [1,2,3])?

This is no longer a valid example :-/ Read the dev docs rather than stable docs — people further up the thread have (mostly) persuaded me that _ always means _1 and that’s what’s on master.

3 Likes

Hmm. The need to add an extra Expr(:call) implicitly in the more specialized lowering of do does make me worry that the most convenient interpretation of __ may not be entirely consistent.

1 Like

After thinking about it for longer, I actually am disagreeing with myself now. In particular, if _ indicates a different argument upon each use, then one might expect __ to have this same property; it seems clear you wouldn’t want that to happen to __. On that front does it make sense to have __1, __2, etc…???

Yes, this was actually implemented from the start :slight_smile: If you look at the implementation you’ll see it’s the exact same code which lowers _ and __ (and associated numbered versions) — it’s just some regexes which differ and the part of the expression that they’re applied to. So it’s all quite uniform and things like @_ __₁ ^ __₁ work.

1 Like

@c42f Thanks, this is a cool package! I just commented on the lengthy discussion of PR #24990, so I don’t want to duplicate everything I said there. But basically, I like this approach, except for the double underscores. I feel like the double underscore syntax adds complexity to the syntax in order to solve a problem not directly related to syntax for closures. Namely, the base pipe simply isn’t powerful enough. Syntax like data |> d -> map(x -> x.f, d) demonstrates that we need better pipes.

It would be great if we had

x |> f(a, b)  # translated to f(x, a, b)

and

x |>> f(a, b)  # translated to f(a, b, x)

Then your example would be written as

@_ t |>>
    filter(_.age > 27) |>>
    group(_.sex) |>>
    map(length)

Would it be possible to add a new macro (or update the @_ macro) to support the more advanced pipes, and thus support the syntax shown in the pipeline above?

(I’m guessing that a symbol other than |>> would have to be found, since |>> doesn’t parse as a binary operator.)