I think your problem comes from the fact this little claim “Julia already knows what fields are in my tuple” is not well defined. Inside a macro, Julia, in fact, does not know what fields are in your tuple. The macro are executed before the function is compiled, and it has no information about the values (and therefore the types) of the symbols/expressions passed to them, they only know about the symbols themselves.
This is, a macro takes as arguments the text/“source code” you are passing to it, without any values inside the bindings or anything. It has no way to know the binding var you passed has a value of type T that has fields a, b, and c which it could expand to a, b, c = var.a, var.b, var.c. This is the reason @unpack works the way it works, it needs the user to pass to it the names of the variables, so it knows what to extract from the symbol var that may have any value (or even not be defined) in runtime.
A macro can expand to a call of fieldnames (which return a list of the struct fields) to construct a Dict (mapping the name of a field to the field in the correct struct) in runtime and the macro can also expand the reference to bindings in your expression to queries in such Dict (which would also happen in runtime). The result would be a code many times slower than the one done by hand.
First, I fully agree with everyone here that this kind of thing tends to be a footgun and using UnPack leads to far more readable code.
That said, worth pointing out that while you can’t do this with macros since as said above they only operate on expessions, you can do it quite cleanly with generated functions, which also have access to the type of your object, as well as lowered code to get the scoping right. Here’s a MWE:
using MacroTools: postwalk
@generated function with(func, obj::T) where {T}
method = Base._methods_by_ftype(Tuple{func}, -1, typemax(UInt64))[1][3]
code = Base.uncompressed_ast(method).code
ir_to_expr(i) = postwalk(x -> x isa Core.SSAValue ? ir_to_expr(x.id) : x, code[i])
postwalk(ir_to_expr(length(code))) do x
x isa GlobalRef && hasfield(T,x.name) ? :(obj.$(x.name)) : x
end
end
subtotal = (price=12.99, shipping=3.99)
handling = 0.99
with(subtotal) do
price + shipping + handling
end # 17.97
And if you check out the generated code, its optimal / inferred:
This works in a local scope too as long as you don’t use other local variables, although thats just a limitation of the way I wrote it, you could fix that by doing a better job of modifying the IR code than the simple thing I did above. Someone with more expertise can probably do something even cleaner, maybe with Cassette or IRTools or something, but it was fun to work out this proof of concept.
@MA_Laforge If you update your example to be a complete MWE that defines the structure and uses let, I’d be delighted to switch the “solution” for new Julia developers who are looking for the simple, canonical replacement pattern.
@marius311 – that’s amazing. I’d love a performant implementation that works at multiple levels where prefixes aren’t needed. Given how many attempts at @with there are, it’d be great if one were type stable.
@stevengj So, I’m trying to separate two concerns: (a) where the variables are coming from, with (b) how they are used. Sometimes this is helpful (and sometimes it’s not). In @marius311’s example, the handling variable comes from the global context and is not treated any differently as price and shipping.
subtotal = (price=12.99, shipping=3.99)
handling = 0.99
with(subtotal) do
price + shipping + handling
end # 17.97
I can fix this by throwing in a Base.invokelatest invocation into the macro.
julia> function genfunc(nt, body)
local props = propertynames(nt)
f = :( (; $(props...) )-> $body )
g = Meta.eval(f)
end
genfunc (generic function with 1 method)
julia> macro with(nt,body)
local b = QuoteNode(body)
quote
let f = genfunc($(esc(nt)), $b)
Base.invokelatest(f; $(esc(nt))...)
end
end
end
@with (macro with 1 method)
julia> function f(subtotal)
@with subtotal begin
price + shipping
end
end
f (generic function with 1 method)
julia> subtotal = (price=12.99, shipping=3.99)
(price = 12.99, shipping = 3.99)
julia> f(subtotal)
16.98
julia> function with(f; kwargs...)
d = Dict(kwargs...)
f(d)
end
with (generic function with 1 method)
julia> with(;subtotal...) do d
d[:price] + d[:shipping]
end
16.98