In the performance tips section of the officials docs, when it comes to captured variables it suggests that:
… if it is known that a captured variable does not change its type, then this can be declared explicitly with a type annotation.
And they give the following example:
function abmult2(r0::Int)
r::Int = r0
if r < 0
r = -r
end
f = x -> x * r
return f
end
But here the function argument is already type annotated, and according to the docs:
Function arguments themselves act as new variable bindings (new “names” that can refer to values), much like assignmentsargument_name = argument_value
Which would suggest that the second binding is redundant. Why would the captured variable need to be rebound if it is already type annotated in the outer function definition?
I think the docs may be slightly confusing here because the outer type annotation is not relevant, which you can check for yourself:
julia> function a(r0::Int)
r::Int = r0
if r < 0
r = -r
end
f = x -> x * r
return f
end
a (generic function with 1 method)
julia> function b(r0)
r::Int = r0
if r < 0
r = -r
end
f = x -> x * r
return f
end
b (generic function with 1 method)
julia> function c(r0::Int)
r = r0
if r < 0
r = -r
end
f = x -> x * r
return f
end
c (generic function with 1 method)
julia> f_a = a(1)
#3 (generic function with 1 method)
julia> f_b = b(1)
#5 (generic function with 1 method)
julia> f_c = c(1)
#7 (generic function with 1 method)
julia> using BenchmarkTools
julia> @btime f_a(1)
9.009 ns (0 allocations: 0 bytes)
1
julia> @btime f_b(1)
9.425 ns (0 allocations: 0 bytes)
1
julia> @btime f_c(1)
18.932 ns (0 allocations: 0 bytes)
1
julia> @btime f_a(1)
8.967 ns (0 allocations: 0 bytes)
1
julia> @btime f_b(1)
9.384 ns (0 allocations: 0 bytes)
1
julia> @btime f_c(1)
18.328 ns (0 allocations: 0 bytes)
1
Thanks this was very helpful and cleared up an ambiguity.
Though docs state that “function arguments themselves act as new variable… much like assignments” thinking on it a bit more, clearly the annotations themselves are a bit different. Assignment annotations (on the left) perform conversion, whereas argument annotations do not (presumably for dispatch). Maybe there’s other differences?
Still, I’m a little surprised that the argument annotation is ignored by the compiler and a separate assignment annotation is required. I’m sure there’s probably a reason.
I think you’re over-interpreting the manual a bit.
“Function arguments act as new variables” is just an assertion that a function argument is a binding like any other. That doesn’t per se interact with type information.
You are right that type annotations inside the body of a function behave differently from those on function arguments – dispatch is the biggest difference and others should be implied by it. But this doesn’t contradict (1) unless you interpret (1) as meaning “function arguments are exactly assignment expressions”, which is definitely false.
This example doesn’t provide much useful evidence about what the compiler does with assignment annotations because there’s two different identifiers: r0 and r and the important issues are all about the type information known about r.
The only thing here is that the original function in the docs, which they later modify, did not have two different identifiers. There was only one r with a type annotation that is apparently ignored.
function abmult(r::Int)
if r < 0
r = -r
end
f = x -> x * r
return f
end
Any idea why the compiler would ignore that as useful information about the type of r?
It already knows the information without you telling it. Julia specializes functions on the types of inputs, so when you type abmult(4) it compiles a special version for abmult(::Int) whether you tell it the input is an int or not.
I think this example is misleading you because so many different issues are in play.
r and r0 are two different variables. Properties of one don’t necessarily apply to the other. So there’s no tension between Julia’s behavior being influenced by annotations on r – that is orthogonal to annotations on r0.
Both r and r0 are mutable bindings – in particular, because Julia is a dynamic language, both of them could be rebound to values of a new type. Examples will come below to clarify this.
The actual invariant that Julia’s compiler knows is that when the function body is entered, the type of the value bound to r0 is known exactly. This type is not necessarily invariant during the rest of the body.
Example 1: r0 is rebound during the function body to a new type.
julia> function f1(r0::Int)
println(typeof(r0))
r0 = 1.2
println(typeof(r0))
end
f1 (generic function with 1 method)
julia> f1(123)
Int64
Float64
Example 2: r is rebound during the function body to a new type.
julia> function f2(r0::Int)
r = r0
println(typeof(r))
r = 1.2
println(typeof(r))
end
f2 (generic function with 1 method)
julia> f2(123)
Int64
Float64
Thanks @johnmyleswhite for taking the time to explain this in detail. It helped a number of things finally fall into place!
To recap: annotating the function argument does not guarantee type stability of the binding throughout the rest of the body. Whereas the annotation on the assignment, does.
I think if you have an assignment with a type assert like r::Int = 1 then Julia simply edits a conversion and type assert into every other assignment to r in the function. Like the next r = something statement will become something like (pseudocode) r = assert(convert(Int, something) isa Int). And those should compile away if the compiler can prove that the new assignments are going to be Int anyway. Maybe one can see this with @code_lowered.