You should expect the same results from the returned functions, but they won’t perform the same. In both cases there’s capturing of the variable r
by x -> x * r
. What’s different is abmult
boxes the captured variable, that’s the Core.Box
you’re seeing in @code_warntype
. A box provides semantically correct flexibility if the variable can be reassigned to an instance of a different type after capturing. However, r
can not be reassigned after capturing, and we ourselves can infer the stable type of r
throughout. Turns out abmult
is doing this unnecessarily.
Digging further, each nested function definition gets 1 underlying type like typical named functions; we wouldn’t want ballooning overhead of types and their method tables. You can see a bit of the definition’s structure in @code_warntype
, but it’s possible to display it more obviously:
julia> dump(typeof(abmult(1)).name.wrapper)
var"#3#4" <: Function
r::Core.Box
julia> dump(typeof(abmult_new(1)).name.wrapper)
UnionAll
var: TypeVar
name: Symbol r
lb: Union{}
ub: Any
body: var"#1#2"{r} <: Function
r::r
abmult
’s type captures the r
variable in a boxed field (kind of like a Base.RefValue{Any}
), while abmult_new
’s type captures r
in a parametric field, which allows inferring a stable type for r
. abmult_new
’s parameter looks like an obvious win for type inference…at first.
In the general case, call-wise type inference is unfeasible for variables shared by multiple methods. When the first outer method is called and compiled, you need to infer the variable’s type but you can’t narrow it down so much that the other methods fail to compile on their calls. So, we fall back to an uncommitted Core.Box
, but we can try our best to find optimization opportunities. For example, if none of the nested methods ever reassign the variable, we could infer the variable’s type from the first outer method’s call and set that as the parameter. Unfortunately, we currently make the boxing decision when the method definitions are lowered, far before any call or compilation where type inference could help. It roughly and conservatively considers whether the variable is reassigned in a parameter-worthy way.
The absence of type inference in determining the parameter means abmult_new
’s decision actually isn’t a clear win for type stability. Consider this type-unstable version:
julia> function abmult_newer(r::Int)
r = if r < 0
-r
else
Ref(r)
end
f = x -> x * r
return f
end
abmult_newer (generic function with 1 method)
julia> abmult_newer(2) |> dump
#17 (function of type var"#17#18"{Base.RefValue{Int64}})
r: Base.RefValue{Int64}
x: Int64 2
julia> abmult_newer(-2) |> dump
#17 (function of type var"#17#18"{Int64})
r: Int64 2
You could see this a bit better with the color coding of @code_warntype
, but now the anonymous function is type-unstable, using the runtime input’s type at instantiation like Ref
calls do. Although inferring two types for the parametric type is arguably equally bad as inferring two types for the parameter instead, @code_warntype
currently doesn’t even infer the anonymous function’s type as these two types, despite inferring r
itself as a union of two types.
For now, if we want that type inference optimization given no reassignments in nested methods or after them, we need the let
trick to make a new assigned-once variable for capturing. For better control of the captured types, we could make function-like objects and instantiate them in place of nested methods.