Unfortunately inference can’t help us much here. The boxing is occurring at the syntax level. This is before Julia has access to inference. It’s before compilation. That’s what makes it so tricky. We’re in #development — let’s make it more #development-y. Here’s one of the simpler examples from the big #15276 thread:
julia> Meta.@lower function f()
if true
end
r = 1
cb = ()->r
cb()
end
There’s a lot going on there, and that’s pretty hard to read for folks not well versed in Julia’s intermediate representation, but it’s approximately:
function f end
struct __9_10 <: Core.Function
r::Core.Box
end
(self::__9_10)() = isdefined(self.r, :contents) ? self.r.contents : error()
function f
r = Core.Box()
if true
end
r.contents = 1
cb = __9_10(r)
return cb()
end
That syntax isn’t just lowering to the method you wrote — it’s also defining a callable struct that represents the anonymous function, and more importantly it’s transforming r
into something that has behavior. I highly recommend folks who are interested to play around with this basic framework and see how the Meta.@lower
output changes as you add an ::Int
annotation or remove the branch or add another assignment to r
in between constructing and calling cb
. You can see how this being a syntax-level transform is important: assignments to r
need to mutate something that has identity and behavior in order to also affect the inner function. It can no longer just be a machine integer.
If the parser can prove that r
is constant-valued over its entire lifespan, the above becomes approximately:
struct __9_10{R} <: Core.Function
r::R
end
(self::__9_10)() = self.r
function f
if true
end
r = 1
cb = __9_10(r)
return cb()
end
Now that’s the kind of thing inference/inlining/codegen is gonna love. The hard part is proving it.
I hope that helps de-mystify the boxes and anonymous functions. I know just writing this up helped me get a better handle on what’s happening here.
Alright, so, what can we do about this? Well, since it’s at the syntax-level, that means that linting it can be really easy. Just check Meta.@lower
— no runtime values or types needed. At the same time, since it’s syntax-level, expanding and improving this analysis requires working in julia-syntax.scm. That’s also why the let x=x
trick works: it simplifies the scope of the captured variable to a super-trivial block that’s easy on the parser.
I’d love it if we could get the parser to the point where a rule like “only one assignment and no uses before definition” would be sufficient to get the optimized behavior. That’d be my ideal documented rule. I have absolutely no idea how hard that is; I fear the “before definition” part makes it tantamount to the halting problem. I don’t know what the rule is now, but there is one and it’s somewhere between “no other statements or control flow blocks beyond the variable and its closure with a well-defined lifespan” (the let
trick) and my ideal.
In the meantime, we have let
workarounds. Perhaps this would be a good impetus for getting local const
s working — they’ll only become available after 0.7 but maybe that could be an easier task? It’d be a slightly less-annoying workaround, and we already have the syntax for it.