I am using julia for 5+ years and today I learned about a mind-blowing feature which I wasnβt aware that it exists.
julia> function test_internal_state(st)
function update_st()
st = st + 1
nothing
end
update_st()
st
end
test_internal_state (generic function with 1 method)
julia> test_internal_state(1)
2
In Python this is impossible to write and will fail (with UnboundLocalError: local variable 'st' referenced before assignment). Hot stuff apparently.
How to you call this? So that I can google it and learn all the drawbacks about it
update_st is a closure, capturing the variable st. I think the same thing should work in Python, too, itβs just that the shadowing rules are different so youβd have to do it in multiple statements.
With the current Julia implementation, such closures are not implemented as efficiently as might be expected (see Performance tips in the Manual), so when you apply some workarounds, like type annotations, the more efficient version of your code would end up longer, too.
Recently I was comparing different possible ways of implementing something like your example function, so here you go:
f0(n) =
function()
n += 1
n
end
f1(n) =
let n = n
function()
n += 1
n
end
end
f2(n) =
let n::Int = n
function()
n += 1
n
end
end
incremented(n) = n + one(typeof(n))
f3(n) =
let n::Int = n
function()
n = incremented(n)
n
end
end
f4(n) =
let n::Int = n
function()
let m = incremented(n)
n = m
m
end
end
end
f5(n) =
let n::Int = n
function()
let m::Int = incremented(n)
n = m
m
end
end
end
# Same as f5, but with overriden effects analysis.
f6(n) =
let n::Int = n
Base.@assume_effects :nothrow :terminates_locally function()
let m::Int = incremented(n)
n = m
m
end
end
end
f7(n) =
let n::Int = n
Base.@assume_effects :nothrow :terminates_globally function()
let m::Int = incremented(n)
n = m
m
end
end
end
The conclusion was that f0 and f1 are slow, and the other forms, from f2 on, should mostly be equally fast.
it seems your example functions donβt return the closure state, but the output of the inner function, is this intended? can you explain?
it seems all function donβt run at all when called, but just return the inner function. Is this intended?
I guess the answer to both questions is that you run the inner function multiple times and see that they donβt fail and indeed update an inner state, which as such is the same as the one defined in the original function f1, f2, β¦, only that you cannot acccess it any longer from there
Yeah, each function returns a closure. Each closure βremembersβ where it left off with its state, so it doesnβt return the same result each time:
julia> f = f2(0)
#7 (generic function with 1 method)
julia> f()
1
julia> f()
2
julia> f()
3
In Python this is written with the nonlocal declaration.
In [4]: def test_internal_state(st):
...: def update_st():
...: nonlocal st
...: st = st + 1
...: update_st()
...: return st
...:
In [5]: test_internal_state(1)
Out[5]: 2
Itβs not so much that a Ref is special, but that the captured variable n is assigned only once prior to the closureβs instantiation and never reassigned afterward (the instance is mutated). At least, that seems to be the rule implied by the βPerformance of Captured Variablesβ sections in the performance tips. The type inference is incredibly brittle, even throwing a n = n line in the closure will ruin it.
The issue is that captured variables are implemented as fields of (immutable) structs, while the closure is implemented by a method of said struct; the explicit form of this is a functor. If a captured variable is assigned more than once prior to instantiation, the compiler bizarrely gives up on inferring the field even if the variable would be inferrable if not captured. If a captured variable is assigned after instantiation, including by the closure, then of course the field has to be something like a Ref to change the value. However, the compiler makes a Core.Box that knows nothing about the type of the variable, similar to a Ref{Any}. It makes some sense because the instantiation happens before the closure is called, so the type inference in the closure has not happened. Even worse, if a closure makes a captured variable uninferrable, it is uninferrable outside the closure too.
This is a known issue, Github issue #15276 in fact. The thread suggests a compiler improvement would need some non-trivial rewrite of the lowering process, so currently we cope with refactoring to something thatβs not a closure (top-level methods, const global variables, callable objects), explicit type annotations of the captured variables, or inserting let blocks to accomplish the βassign just once beforehandβ rule. Though I really donβt know if that improvement can fully accomplish the expected type inference, weβre expecting that variables shared between 2 methods to be inferred when only one has been called to create the other; it really does seem that we need explicit annotations like those in a callable objectβs type.