Scope of variables

The scope of a variable is the region of a code where the variable is visible. There are two main scopes in Julia: global and local. The global scope can contain multiple local scope blocks. Local scope blocks can be nested. There is also a distinction in Julia between constructs which introduce a hard scope and those which only introduce a soft scope. This affects whether shadowing a global variable by the same name is allowed.

The following table shows constructs that introduce scope blocks.

ConstructScope typeAllowed within local
module, baremoduleglobal
structlocal (soft)
macrolocal (hard)
for, while, trylocal (soft)
let, functions, comprehensions, generatorslocal (hard)

This table contains several constructs which we have not introduced yet. Modules and structures will be discussed later in the course. The rest is described in the official documentation.

Local scope

A function declaration introduces a new (hard) local scope. It means that all variables defined inside a function body can be accessed and modified inside the function body. Moreover, it is impossible to access these variables from outside the function.

julia> function f()
           z = 42
           return
       end
f (generic function with 1 method)

julia> f()

julia> z
ERROR: UndefVarError: `z` not defined

Thanks to this property, we can use the names most suitable for our variables (i, x, y, etc.) without the risk of clashing with declarations elsewhere. It is possible to specify a global variable inside a function by the global keyword.

julia> function f()
           global z = 42
           return
       end
f (generic function with 1 method)

julia> f()

julia> z
42

However, this is not recommended. If we need a variable defined inside a function, we should probably return that variable as an output of the function

julia> function f()
           z = 42
           return z
       end
f (generic function with 1 method)

julia> z = f()
42

julia> z
42

In the example above, the z variable in the function is local, and the z variable outside of the function is global. These two variables are not the same.

Global scope

Each module introduces a new global scope, separate from the global scope of all other modules. The interactive prompt (aka REPL) is in the global scope of the module Main.

julia> module A
           a = 1 # a global in A's scope
           b = 2 # b global in A's scope
       end
A

julia> a # errors as Main's global scope is separate from A's
ERROR: UndefVarError: `a` not defined

Modules can introduce variables of other modules into their scope through the using (or import) keyword. Variables can be accessed by the dot-notation.

julia> using .A: b # make variable b from module A available

julia> A.a
1

julia> b
2

While variables can be read externally, they can only be changed within the module they belong to.

julia> b = 4
ERROR: cannot assign a value to imported variable A.b from module Main
[...]

Global scope variables can be accessed anywhere inside the global scope, even in the local scopes defined in that global scope. In the following example, we define a variable c in the Main global scope, and then we define a function foo (that introduces a new local scope inside the Main global scope), and inside this function, we use the variable c,

julia> c = 10
10

julia> foo(x) = x + c
foo (generic function with 1 method)

julia> foo(1)
11

However, it is not recommended to use global variables in this way. The reason is that global variables can change their type and value at any time, and therefore they cannot be properly optimized by the compiler. We can see the performance drop in a simple test.

julia> x = rand(10);
julia> y = rand(10);
julia> f_global() = x .+ yf_global (generic function with 1 method)
julia> f_local(x, y) = x .+ yf_local (generic function with 1 method)
julia> hcat(f_global(), f_local(x, y))10×2 Matrix{Float64}: 1.19108 1.19108 1.00555 1.00555 0.561073 0.561073 0.134862 0.134862 0.856898 0.856898 1.27392 1.27392 0.897026 0.897026 1.47343 1.47343 1.143 1.143 1.51979 1.51979

In the example above, we defined two functions that do the same thing. The first function has no arguments and returns a sum of two global variables, x and y. The second function also returns a sum of variables x and y. However, in this case, these variables are local since they are introduced as the inputs to the function. If we use the @time macro, we can measure the time needed to call these two functions.

julia> @time f_global();  0.000016 seconds (3 allocations: 208 bytes)
julia> @time f_local(x, y); 0.000004 seconds (1 allocation: 144 bytes)

The second function is faster and also needs fewer allocations. The reason is that when we call the f_local function for the first time, the function is optimized for the given arguments. Each time a function is called for the first time with new types of arguments, it is compiled. This can be seen in the following example: the first call is slower due to the compilation.

julia> a, b = 1:10, 11:20;
julia> @time f_local(a, b); 0.003888 seconds (3.00 k allocations: 204.990 KiB, 99.43% compilation time)
julia> @time f_local(a, b); 0.000005 seconds (1 allocation: 48 bytes)

On the other hand, the f_global function cannot be optimized because it contains two global variables, and these two variables can change at any time.