If b = a, when do changes in a affect b?

From my understanding, there’s no explicit rationale, but this behaviour is a consequence of how “it all works”. Let’s go through it.

1. What does const mean?

At its core, const is a promise to the compiler that the value of a variable won’t change and it’s a binding guarantee that the type of that same variable can’t change. You can see this for yourself:

julia> const a = 1                                                                                   
1                                                                                                    
                                                                                                     
julia> typeof(a)                                                                                     
Int64                                                                                                
                                                                                                     
julia> a = 2                                                                                         
WARNING: redefinition of constant a. This may fail, cause incorrect answers, or produce other errors.
2                                                                                                    
                                                                                                     
julia> a                                                                                             
2                                                                                                    
                                                                                                     
julia> a = 3.0                                                                                       
ERROR: invalid redefinition of constant a                                                            
Stacktrace:                                                                                          
 [1] top-level scope                                                                                 
   @ REPL[5]:1                                                                                       
                                                                                                     
julia> typeof(a)                                                                                     
Int64                                                                                                

Julia lets you change the value of a (but warns that this may affect existing/compiled code), but not its type.

2. Functions and constness

In julia, every function has its own type:

julia> sin(1)                    
0.8414709848078965               
                                 
julia> cos(1)                    
0.5403023058681398               
                                 
julia> typeof(sin), typeof(cos)  
(typeof(sin), typeof(cos))       
                                 
julia> typeof(sin) == typeof(cos)
false                            

julia> isconst(Base, :sin), isconst(Base, :cos)
(true, true)                                   

As a consequence of functions having their own type and functions being constant, you quite literally can’t assign a new function (with a different method table, one being !== to the old one) to a binding that already is const and pointing to a function, since the type of the binding would have to change.

What if we could do that, i.e. that binding wasn’t const? Well, the compiler wouldn’t have the guarantee that the variable/function didn’t change implementation under its nose, while compiling/running other code. It would have to insert more general lookups for every call, even those that don’t change, instead of being able to cache and inline existing compiled code. As you can imagine, this kills performance and a whole bunch of optimizations that rely on code being inlined, unrolled and subsequently eliminated/replaced by faster equivalents.

3. What about other bindings?

If we now introduce a new non-const binding g like you’ve done, we’ve basically got the same situation as described above. At any point, g might point to a new function, thus change its type or it might point to no function at all (though it would still be callable, since everything in julia is callable). So when code uses g, the compiler has to insert checks for each access, simply because it might change type! It’s the same reason any global you use should be const in the first place. Because of this restriction though, you can’t add methods to the function that g is pointing to (let’s call that f), since g is not f at all, it just gives a very unstable direction that, at the moment, points to f.

If you now make g a constant in the first place, the trouble goes away - it can’t readily be distinguished from f, since its type can’t change and functions (or rather, the mathod table associated with the type of the function) only have one instance (otherwise we’d be back at non-constness and lookups everywhere). Thus, it’s possible to create new methods for “f” (which is also “g”).

4. How does local scope play into this?

In your local scope example, f(x) = x and g(x, y) = x+y are two inner functions. In order to be able to use those inside of a, julia does a little trick: it moves those two functions outside of a and compiles them as part of the dependency chain of compiling a. In order to avoid name clashes with functions outside of a, they’re given a generated name, which is what’s being used in a internally.

julia> module Example                                                       
       f(x,y) = x*y                                                          
       
       function a()                                                          
           f(x) = x                                                          
           g = f                                                             
           g(x, y) = x+y                                                     
           return g
       end                                                                   
       end                                                                   
Main.Example                                                                
                                                                             
julia> names(Example, all=true)                                             
11-element Vector{Symbol}:                                                   
 Symbol("#a")                                                                
 Symbol("#eval")                                                             
 Symbol("#f")                                                                
 Symbol("#f#1")            # I'm generated!
 Symbol("#g#2")            # and so am I!
 Symbol("#include")                                                          
 :Example                                                                   
 :a                                                                          
 :eval                                                                       
 :f                                                                          
 :include                                                                    

But here’s something you might not have expected:

julia> x = Example.a()                                     
(::Main.Example.var"#g#2") (generic function with 1 method)

Why is there only one method? Let’s investigate with @code_lowered:

julia> @code_lowered Example.a()           
CodeInfo(                                   
1 ─     f = %new(Main.Example.:(var"#f#1"))
│       g = f                               
│       g = %new(Main.Example.:(var"#g#2"))
└──     return g                            
)                                           

So our intuition was correct - both function definitions are hoisted out of a as regular anonymous functions (which are implemented as structs with no fields in this case, since there’s no captured state) and in place of the function we return an instance of that anonymous function (which has the method table attached). Since g-the-function doesn’t have anything to do with f-the-function and the variable g is not a constant (which don’t exist in local scope anyway), it’s just a regular rebinding of a variable and no method is “added” to f-the-function at all.


All in all, it’s important to remember that = is not mathematical equivalence, it’s an assignment.

7 Likes