Macro for implicitly defined NamedTuples

I want to write a macro imp that does this

julia> @imp(; a = 2, b = a^2, c = 3) 
(; a = 2, b = 4, c = 3)

Not being any good with metaprogramming I turn to the Julia community:

  • Is there any package that already achieves this?
  • If not, what’s the best way to go about writint this?

Note that this is very similar to the way @kwdef already works, but with NamedTuples instead of structs. Note also that f(; a = 2, b = a^2, c = 3) = (a, b, c) already works, but it is a function not a NamedTuple.

I have a package AddToField.jl which does this, kind of. It’s more verbose and has a slightly different use-case, for if you want to do a lot of calculations and only keep a few variables. You have the opposite scenario.

But it’s implementation might help you write your own macro.

julia> @addnt begin
           @add a = 2
           @add b = a^2
           @add d = 4
       end
(a = 2, b = 4, d = 4)

I quickly build a small macro that does this (probably not in a very robust way) and wanted to give a small outline on how to approach such a problem :slight_smile: For the solution look at the end.

When writing a macro a good first step is to write down what the transformation of the source code should actually be. In this case, as you noted, the desired functionality is already present for functions but also for let blocks. So it is sufficient for the macro to transform:
@imp(; a = 2, b = a^2, c = 3) into

let a = 2, b = a^2, c = 3
    (; a = 2, b = a^2, c = 3) 
end

So the next step is looking at the AST to figure out how to puzzle together the desired structure of Exprs from the ones we recieve. For that we can use dump (and Base.remove_linenums! to strip away unnecessary nodes our macro won’t see but that dump might pick up).

julia> dump(Base.remove_linenums!(quote
               let a = 2, b = a^2, c = 3
                       (; a = 2, b = a^2, c = 3) 
               end
       end); maxdepth=12)
Expr
  head: Symbol block
  args: Array{Any}((1,))
    1: Expr
      head: Symbol let
      args: Array{Any}((2,))
        1: Expr
....

To get the input of the macro, I recommend actually using a macro, e.g:

macro dump(expr)
	return :(dump($(QuoteNode(expr))))
end

Then you get

julia> @dump(; a = 2, b = a^2, c = 3) 
Expr
  head: Symbol parameters
  args: Array{Any}((3,))
    1: Expr
      head: Symbol kw
      args: Array{Any}((2,))
        1: Symbol a
        2: Int64 2
....

With that it is not very hard to come up with the transformation. Here is your solution:

macro imp(expr)
	expr.head == :parameters || error("I don't understand this: $(dump(expr;maxdepth=4))")
	bindings = Expr(:block, (Expr(:(=), e.args[1], e.args[2]) for e in expr.args)...)
	body = Expr(:tuple, expr)
	return esc(Expr(:let, bindings, body))
end
2 Likes

Nice explanation, your macro suffers from the multiple evaluation problem though:

julia> @imp(; a = 2, b = @show(a^2), c = 3)
a ^ 2 = 4
a ^ 2 = 4
(a = 2, b = 4, c = 3)

Better to reuse the let bindings in the expansion:

macro imp2(expr)
    expr.head == :parameters || error("I don't understand this: $(dump(expr;maxdepth=4))")
    bindings = Expr(:block, (Expr(:(=), e.args[1], e.args[2]) for e in expr.args)...)
    body = Expr(:tuple, (Expr(:(=), e.args[1], e.args[1]) for e in expr.args)...)
    return esc(Expr(:let, bindings, body))
end
julia> @imp2(; a = 2, b = @show(a^2), c = 3)
a ^ 2 = 4
(a = 2, b = 4, c = 3)

There is also a corner case due to the parsing of macros:

@imp(; a = 2, b = a^2, c = 3)  # Works
@imp (; a = 2, b = a^2, c = 3) # Does not work ... note the space!

Again @dump (already defined in Meta) is your friend here:

julia> Meta.@dump(; a = 2, b = a^2, c = 3)
Expr
  head: Symbol parameters
  args: Array{Any}((3,))
    1: Expr
      head: Symbol kw
      ...

# Now, with a space
julia> Meta.@dump (; a = 2, b = a^2, c = 3)
Expr
  head: Symbol tuple
  args: Array{Any}((1,))
    1: Expr
      head: Symbol parameters
      args: Array{Any}((3,))
        1: Expr
          head: Symbol kw
          ...
4 Likes

Thanks for the comments :slight_smile: And good catch with the multiple evaluations!

I knew I saw that somewhere but somehow forgot completely that Meta exists :sweat_smile:

If need be, we can easily support both syntax versions with a simple check:

macro imp2(expr2)
    expr = expr2
    if expr.head == :tuple
        expr = expr.args[1]
    end
    expr.head == :parameters || error("I don't understand this: $(dump(expr;maxdepth=4))")
    bindings = Expr(:block, (Expr(:(=), e.args[1], e.args[2]) for e in expr.args)...)
    body = Expr(:tuple, (Expr(:(=), e.args[1], e.args[1]) for e in expr.args)...)
    return esc(Expr(:let, bindings, body))
end
1 Like