Can someone explain closures to me

This longstanding issue has moved a bit since the beginning but is far from over, hence the tip. I’m not certain on the exact boundaries of captured variable inferrability, but here’s what I know so far, starting from some basics.

A closure is a function that uses an outer local scope’s variables; this is not different from how other local scopes reuse outer local variables. The distinction of “capturing” is that a nested function is also an instance that can outlive the outer scope, so captured variables can also outlive the scope. It’s important to note that as a consequence, if the captured variable is reassigned anywhere, that change is also shared by all closures.

Example of reassignment of captured variables.
julia> let
         v1 = 0
         f() = v1
         println(f())
         v1 += 1
         g() = v1
         println((f(), g()))
         v1 += 1
         println((f(), g()))
       end
0
(1, 1)
(2, 2)

A method is compiled separately for each unique call signature, which is the types of the function and the ordered runtime arguments. The compiler uses the argument types to infer the types of values and local variables, so inferred types vary among call signatures. Conversely, a variable captured from an outer scope must have the same inferred type in that scope and in the infinite possible call signatures. A captured variable cannot be inferred if it is reassigned in a closure because the compiler cannot infer the value across the infinite call signatures it has not encountered yet.

A reassigned captured variable has an unfixed type.
let
  x = 0 # x::Int
  function setx(y)
    x = y # setx captures x, shares x with let block
  end
  setx(1.0) # x::Float64
  setx(true) # x::Bool
  setx(1im) # x::Complex{Int}
  # ... so the compiler can only say x::Any
end

Since a call-wise compiler can’t be exhaustive, the parser lowerer (or whatever the noun is) checks the expression, and if a variable is never reassigned or has a type declaration, the type is known to be unchanged, despite only being a symbol at that point.

Closures are implemented as functors; each captured variable resides in a field of a hidden struct’s callable instance. Uninferrable or reassigned variables are stored in ::Core.Box fields, which is like an internal version of Ref{Any}; this includes type-declared variables like r::Int in abmult2, the type being restored by convert and assert steps. Inferred and non-reassigned variables are stored in parametric fields ::T; you can see this parameter show up in closure’s types. Given the inherent difficulty of inferring variables shared across call signatures, I think the biggest possible improvements would be 1) doing ::T when all reassignments occur prior to all closures, like in abmult, 2) doing ::Ref{T} when the shared variable has 1 type declaration (>1 causes a syntax error) and is reassigned within or after any closure, and 3) inferring closures better when a captured variable is inferred with a small Union type; someone correct me if there are any misconceptions there.

Inferred and non-reassigned variables contribute type parameters to closures.
julia> let
         x = 1
         f() = x
       end
(::var"#f#1"{Int64}) (generic function with 1 method)

julia> let
         x = 1
         x = 2 # x is no longer inferrable
         f() = x
       end
(::var"#f#2") (generic function with 1 method)

Inferring a variable doesn’t always make things type-stable though. Just today, there was a thread demonstrating that inferring a variable as T::DataType doesn’t make for inferrable calls e.g. one(T). The most adaptable workaround was refactoring to capture an instance of T.

15 Likes