Hi, I was surprised by the boxing behavior of closure capture, and I’d love to hear about its motivation and discuss whether it’s still relevant.
I came across this when I noticed how it can introduce a race condition in @async
calls. I opened an issue here to discuss that:
I didn’t know about boxing during closure capture before encountering this code.
Here is a simple example. I would’ve expected this to print 0
, but it prints 1
:
julia> function closure_surprise()
x = 0
# I would've expected the closure to be "constructed" here, with the value in `x`
# captured by copy at _this point_.
f = ()->println("INNER: $x")
x = 1
f()
end
closure_surprise (generic function with 1 method)
julia> closure_surprise()
INNER: 1
Against my expectations, when the outer-scope modifies x
, it also changes the value of x
seen inside the closure.
Let me expand a bit on why this wasn’t what I expected:
-
The docs imply
x
should be a copy:
https://docs.julialang.org/en/v1.1.0/devdocs/functions/#Closures-1function adder(x) return y->x+y end
is lowered to (roughly):
struct ##1{T} x::T end (_::##1)(y) = _.x + y function adder(x) return ##1(x) end
If that’s really how it was implemented,
_.x
would simply be anInt
, whose value would be set to whateverx
was at construction time. -
You can return an anonymous function that captures local variables without wondering what happens when the local variable goes out of scope. And that’s because it’s copied into closure, not passed as a reference to a local stack-allocated variable.
-
This doesn’t match up with the behavior in the rest of Julia for immutable types like
Int
: a variable name is supposed to just be a name that refers to a value. Assigningx=0; y=x
doesn’t bindy
tox
; instead they both point atx
's current value, and changingx
doesn’t changey
.
Even an actual reference (Ref
) to an integer variable just becomes a reference to a copy of the variable!julia> begin x = 0 xref = Ref(x) x = 1 xref[] end 0
-
Lambdas are pass-by-copy by default in C++, which might be part of why I expected that here.
That said, I now understand that this is following the rules referenced here in the Performance Tips:
This style of code presents performance challenges for the language. The parser, when translating it into lower-level instructions, substantially reorganizes the above code by extracting the inner function to a separate code block. “Captured” variables such as
r
that are shared by inner functions and their enclosing scope are also extracted into a heap-allocated “box” accessible to both inner and outer functions because the language specifies thatr
in the inner scope must be identical tor
in the outer scope even after the outer scope (or another inner function) modifiesr
.
And indeed, we can see that is what is happening:
julia> @code_lowered closure_surprise()
CodeInfo(
2 1 ─ x = (Core.Box)()
│ (Core.setfield!)(x, :contents, 0)
5 │ #76 = %new(Main.:(##76#77), x)
│ f = #76
7 │ (Core.setfield!)(x, :contents, 1)
8 │ %6 = (f)()
└── return %6
)
And we can also see that if we delete the x=1
at the end of the function, x
stops being Boxed:
julia> @code_lowered closure_surprise()
CodeInfo(
80 1 ─ x = 0
83 │ %2 = Main.:(##84#85)
│ %3 = (Core.typeof)(x)
│ %4 = (Core.apply_type)(%2, %3)
│ #84 = %new(%4, x)
│ f = #84
86 │ %7 = (f)()
└── return %7
)
The problem I have here, is that I can’t figure out the right way to get the behavior I wanted through closure. Inside the function, I can’t mark it local
or anything, and it’s too late to make a copy.
Okay, so all that said, I’m sure there are interesting reasons that I don’t yet understand for why this is done. Are there situations where the boxing is necessary? I’d love to hear those if anyone can spare the time! Thanks!