Engee documentation

Scope of variables

The visibility area (or for short: scope) of a variable is the part of the code within which the variable is accessible. By limiting scopes, you can avoid variable name conflicts. The meaning is very simple: two different functions can have arguments with the same name x', and these two arguments `x refer to different objects. There may be many other situations where the same name has different meanings in different code blocks. In which cases variables with the same name have the same or different values depends on the scope definition rules. They are discussed in detail in this section.

With the help of certain language constructs, _blocks are formed by scopes, that is, parts of the code that can act as scopes for certain sets of variables. The scope of a variable cannot be an arbitrary set of source code lines; the scope must correspond to one of these blocks. There are two main types of areas in Julia: global and local. The second type can be nested. Julia also distinguishes between designs that introduce strict areas as opposed to non-strict ones. The opportunity depends on it. https://en.wikipedia.org/wiki/Variable_shadowing [shadowing] a global variable with the same name.

Field of view designs

The area blocks are created using the following constructions.

Construction Type of area The area where you can use

module, baremodule

Global

Global

struct

Local (non-strict)

Global

for, while, try

Local (non-strict)

Global, Local

macro

Local (strict)

Global

Functions, blocks do, blocks let, inclusions, generators

Local (strict)

Global, Local

It is noteworthy that this table does not begin blocks and if blocks that do not form new areas. There are slightly different rules for these three types of areas, which will be discussed later.

Julia uses https://en.wikipedia.org/wiki/Scope_ (computer_science)#Lexical_scope_vs._dynamic_scope[lexical areas]. This means that the scope of the function is inherited not from the scope of the caller, but from the scope in which the function is defined. For example, in the following code, the variable x inside foo refers to x in the global scope of the module Bar:

julia> module Bar
           x = 1
           foo() = x
       end;

and not on the x in the area where foo is used:

julia> import .Bar

julia> x = -1;

julia> Bar.foo()
1

Thus, the "lexical scope" means that it is possible to determine what a variable refers to in a particular place of the code based only on the code where it is used. The execution of the program does not affect this. The nested area has variables available from all the external areas in which it is nested. In turn, variables from the inner regions are not available in the outer regions.

Global viewport

Each module forms a new global domain, independent of the global domains of the other modules - there is no comprehensive global domain. Variables from other modules can be entered into the module area using the operators using and import, as well as access them by qualified name through dot notation. That is, each module is a so-called namespace, as well as a first-class data structure that associates names with values.

If a top-level expression contains a variable declaration with the keyword local, then this variable is not available outside of this expression. A variable inside an expression does not affect global variables with the same name. An example is the declaration of local x in the begin or if block at the top level.

julia> x = 1
       begin
           local x = 0
           @show x
       end
       @show x;
x = 0
x = 1

Keep in mind that the interactive command prompt (REPL) belongs to the global scope of the Main module.

Local viewport

Most of the code blocks form new local areas (for a complete list, see table above). If such a block is syntactically nested in another local area, the area it creates will be nested in all the local areas it belongs to. In turn, these areas are eventually nested in the global area of the module in which the code is calculated. Variables from external areas are visible in any internal area they contain (that is, they can be read and written to it), unless a local variable with the same name "obscures" the external one. This is true even if the external local variable is declared after the internal block (that is, later in the program text). When we say that a variable exists in a particular area, we mean that a variable with this name exists in any of the areas in which the current area is nested, or in the current area itself.

In some programming languages, variables must be explicitly declared before they can be used. In Julia, explicit declaration is also possible: if you write local x in any local scope, a new local variable will be declared in it, regardless of whether there is already a variable named x in the external scope. However, declaring each new variable in this way is tedious, so in Julia, as in many other languages, when assigning a value to a previously unknown variable, this variable is declared implicitly. If the current scope is global, then the new variable will be global. If the current area is local, then the new variable will be local to the most deeply nested local area and accessible only within it, but not from the outside. When assigning a value to an existing local variable, it is always the local variable that changes: you can only shade a local variable by explicitly declaring a new local variable in a nested scope using the keyword local. In particular, this applies to variables that are assigned values in internal functions, which may be unexpected for Python developers. In this language, an assignment in an internal function results in the creation of a new local variable, unless the variable is explicitly declared as non-local.

In general, such a mechanism is quite natural to understand, but, like many natural phenomena, it has its own subtleties that are not obvious at first glance.

When the expression x = <value> is used in the local scope, its value is determined in Julia according to the following rules, taking into account where the assignment expression is located and what x already refers to in this part of the code.

  1. An existing local variable: if x is already a local variable, then the value is assigned to this existing variable `x'.

  2. Strict area: if the local variable x no longer exists and the assignment takes place inside the strict scope structure (i.e. inside the let block, function or macro body, inclusion or generator), a new local variable named x is created in the assignment scope.

  3. Non-strict area: if the local variable x _ does not exist yet_ and all the constructions of the fields that the assignment includes are non-strict (loops, 'try` and catch blocks or struct blocks), the result depends on whether the global variable x is defined.

    • If the global variable x is not defined, a new local variable named x is created in the assignment scope.

    • If the global variable x _ is defined, the assignment is considered ambiguous.

      • In non-interactive contexts_ (files, eval) an ambiguity warning is displayed, and a new local variable is created.

      • In interactive contexts (REPL, notebooks), the value is assigned to the global variable x.

As you can see, in non-interactive contexts, the rules for strict and non-strict scopes are the same, except that if a global variable is obscured by an implicit local variable (i.e., not declared as local x), a warning is displayed. In interactive contexts, more complex rules apply for convenience. They will be discussed in more detail in further examples.

Now that you know the rules, let’s look at some examples. It is assumed that each example is executed in a new REPL session, so that in each code fragment, values are assigned only to global variables in this block.

Let’s start with a simple and clear situation - assignments inside a strict scope, in this case inside the body of a function, when a local variable with that name does not yet exist.:

julia> function greet()
           x = "hello" # Новая локальная переменная
           println(x)
       end
greet (generic function with 1 method)

julia> greet()
hello

julia> x # Глобальная
ERROR: UndefVarError: `x` not defined in `Main`

Within the greet function, the assignment x = "hello" creates a new local variable x in the function scope. In this case, two facts are important: the assignment takes place in the local area, and the local variable x does not exist yet. Since x is a local variable, it doesn’t matter if there is a global variable named x'. Here, for example, we perform the assignment `x = 123 before defining and calling `greet'.

julia> x = 123 # Глобальная
123

julia> function greet()
           x = "hello" # Новая локальная переменная
           println(x)
       end
greet (generic function with 1 method)

julia> greet()
hello

julia> x # Глобальная
123

Since the variable x in greet is local, calling greet does not affect the value of the global variable x (and it does not matter if it exists). According to the strict scope rule, the existence of a global variable named x is ignored: the assignment of a value to the variable x in the strict scope occurs at the local level (unless the variable x is declared global).

In the next obvious situation that we will consider, a local variable named x already exists. In this case, the operator x = <value> always assigns a value to this existing local variable `x'. This is true regardless of whether the assignment occurs in the same local scope, in an internal local scope in the body of the same function, or in the body of a function nested in another function, which is also known as https://en.wikipedia.org/wiki/Closure_ (computer_programming)[closing].

For example, we use the sum_to function, which calculates the sum of integers from one to `n'.

function sum_to(n)
    s = 0 # Новая локальная переменная
    for i = 1:n
        s = s + i # Значение присваивается существующей локальной переменной
    end
    return s # Та же локальная переменная
end

As in the previous example, the first assignment of the variable s at the beginning of the function sum_to leads to the declaration of a new local variable s in the body of the function. The 'for` loop has its own internal local scope within the function scope. In the place where the assignment s = s + i occurs, the local variable s already exists, so the assignment causes the value of the existing variable s to change, rather than creating a new local variable. You can check this by calling `sum_to' in the REPL.

julia> function sum_to(n)
           s = 0 # Новая локальная переменная
           for i = 1:n
               s = s + i # Значение присваивается существующей локальной переменной
           end
           return s # Та же локальная переменная
       end
sum_to (generic function with 1 method)

julia> sum_to(10)
55

julia> s # Глобальная
ERROR: UndefVarError: `s` not defined in `Main`

Since the variable s is local to the function sum_to, calling the function does not affect the global variable s. In addition, you can see that the expression s = s + i in the for loop modifies the same variable s that was created as a result of initializing s = 0, since we get the correct sum of 55 for integers from 1 to 10.

Let’s focus a little on the fact that the for loop has its own scope, and write a slightly more detailed version of the function, which we’ll call sum_to_def'. In it, the sum of `s + i is stored in the variable t before changing s.

julia> function sum_to_def(n)
           s = 0 # Новая локальная переменная
           for i = 1:n
               t = s + i # Новая локальная переменная `t`
               s = t # Значение присваивается существующей локальной переменной `s`
           end
           return s, @isdefined(t)
       end
sum_to_def (generic function with 1 method)

julia> sum_to_def(10)
(55, false)

This version returns s as before, but also uses the macro @isdefined to return a boolean value indicating whether there is a local variable named t defined in the external local scope of the function. As can be seen from the result, the variable t is not defined outside the body of the for loop. The reason again lies in the strict domain rule.: since the assignment of a value to the variable t occurs inside a function that introduces a strict scope, it leads to the creation of a new local variable t in the local scope where it appears, that is, inside the body of the loop. Even if there was a global variable named t, there would be no difference — the strict scope rule is not affected by the contents of the global scope.

Note that the local area of the body of the for loop does not differ from the local area of the internal function. This means that this example could have been rewritten differently by implementing the body of the loop as a call to an internal auxiliary function. The behavior would not have changed.

julia> function sum_to_def_closure(n)
           function loop_body(i)
               t = s + i # Новая локальная переменная `t`
               s = t # Значение присваивается той же локальной переменной `s`, что и ниже
           end
           s = 0 # Новая локальная переменная
           for i = 1:n
               loop_body(i)
           end
           return s, @isdefined(t)
       end
sum_to_def_closure (generic function with 1 method)

julia> sum_to_def_closure(10)
(55, false)

This example illustrates a couple of important points.

  1. The internal function areas are similar to any other nested local area. In particular, if a variable is already local outside the internal function and is assigned a value in the internal function, the external local variable is changed.

  2. It doesn’t matter if the definition of an external local variable is located after the place where the variable is updated, the rule remains the same. Before determining the value of internal local variables, the entire external local domain is analyzed and its local variables are resolved.

This scheme means that code can usually be transferred to or from an internal function without changing its meaning, which simplifies the use of a number of standard idioms in a language that supports closures (see the section on do blocks).

Let’s move on to a number of more confusing cases where the lax domain rule applies. To do this, we will extract the bodies of the greet and sum_to_def functions into the context of a non-strict scope. First, let’s put the body of the greet function in the for loop, which forms a non-strict scope, and execute it in the REPL.

julia> for i = 1:3
           x = "hello" # Новая локальная переменная
           println(x)
       end
hello
hello
hello

julia> x
ERROR: UndefVarError: `x` not defined in `Main`

Since the global variable x is not defined at the time of execution of the for loop, the first provision of the rule applies for a non-strict scope and the variable x is created as a local for the for loop. Therefore, after the loop is executed, the global variable x remains undefined. Next, let’s extract the body of the function sum_to_def into the global scope and fix its argument as `n = 10'.

s = 0
for i = 1:10
    t = s + i
    s = t
end
s
@isdefined(t)

What does this code do? Hint: This is a trick question. The correct answer is that it all depends on the circumstances. If this code is entered interactively, it is executed in the same way as in the function body. However, if the code is executed from a file, an ambiguity warning is displayed and the error "variable not defined" occurs. Let’s check how it works in the REPL first.

julia> s = 0 # Глобальная
0

julia> for i = 1:10
           t = s + i # Новая локальная переменная `t`
           s = t # Значение присваивается глобальной переменной `s`
       end

julia> s # Глобальная
55

julia> @isdefined(t) # Глобальная
false

In REPL, execution in the function body is simulated by determining whether an assignment within a loop results in changing the value of a global variable or creating a new local variable based on whether a global variable with that name is defined. If a global variable with that name exists, the assignment causes it to be changed. If there is no global variable, a new local variable is created as a result of the assignment. This example demonstrates both cases.

  • There is no global variable named t, therefore, as a result of the assignment t = s + i, a new variable t is created, local to the 'for` loop.

  • A global variable named s exists, so the expression s = t assigns a value to it.

The second fact explains why the value of the global variable s changes as a result of the execution of the loop, and the first is why the variable t is still undefined after the execution of the loop. Now let’s try to execute the same code as if it were contained in a file.

julia> code = """
       s = 0 # Глобальная
       for i = 1:10
           t = s + i # Новая локальная переменная `t`
           s = t # Новая локальная переменная `s` с предупреждением
       end
       s, # Глобальная
       @isdefined(t) # Глобальная
       """;

julia> include_string(Main, code)
┌ Warning: Assignment to `s` in soft scope is ambiguous because a global variable by the same name exists: `s` will be treated as a new local. Disambiguate by using `local s` to suppress this warning or `global s` to assign to the existing global variable.
└ @ string:4
ERROR: LoadError: UndefVarError: `s` not defined in local scope

Here we use the function include_string to calculate the code' as the file content. You could also save the `code to a file, and then call include for that file — the result would be the same. As you can see, the code in this case is executed in a completely different way than in the REPL. Let’s figure out what’s going on here.

  • Before executing the loop, the global variable s with the value 0 is defined.

  • The assignment of s = t occurs in a non-strict domain — in the for loop outside the body of any function or other construct forming a strict domain.

  • Therefore, the second provision of the rule applies for a non-strict domain and the assignment is ambiguous, resulting in a warning.

  • Execution continues, and the s variable becomes local to the body of the 'for` loop.

  • Since the variable s is local to the for loop, it is not defined when calculating t = s + i and an error occurs.

  • This completes the execution, but if it had continued until s and @isdefined(t), the values 0 and `false' would have been returned.

This example demonstrates a number of important aspects related to scopes: within a scope, each variable can have only one value, which does not depend on the order of expressions. The presence of the expression s = t in the loop makes the variable s local to it. This means that it is also local when used on the right side of the expression t = s + i, although this expression is evaluated first. One might think that the s in the first line of the loop would be a global variable, and the `s' in the second line would be a local one, but this is impossible, since these two lines are in the same block of the scope, and within a certain scope each variable can have only one meaning.

Learn more about non-strict scopes

We have reviewed all the rules for local areas, but before concluding this section, it is worth saying a few words about why a non-strict area acts differently in interactive and non-interactive contexts. Two obvious questions arise.

  1. Why is the behavior not the same everywhere as in the REPL?

  2. Why is the behavior not the same everywhere as in files? Maybe the warning can just be ignored?

In versions of Julia up to and including 0.6, all global scopes worked as they do now in REPL: when the assignment of x = <value> occurred in a loop (either in the try/catch block or in the struct body), but outside the function body (either in the let block or inclusion), the decision on whether the variable x should be local to the loop was made depending on the presence of a global variable named `x'. The advantage of this approach is that it is intuitive and convenient, as it is as similar as possible to the behavior inside the function body. In particular, it makes it easy to transfer code from the function body to the REPL and vice versa when debugging the function. But there are also disadvantages. Firstly, this approach is difficult to explain and understand: it often confused users and caused complaints. Quite fair. Secondly, and more importantly, it creates difficulties when writing large-scale programs. In a small piece of code like the following, it’s not difficult to understand what’s going on.

s = 0
for i = 1:10
    s += i
end

Obviously, the goal is to change the existing global variable `s'. What else could it mean? However, in reality, the code is not always so short and clear. For example, in real programs, you can often find this:

x = 123

# гораздо дальше
# возможно, в другом файле

for i = 1:10
    x = "hello"
    println(x)
end

# гораздо дальше
# возможно, еще в одном файле
# или, может быть, в предыдущем, где `x = 123`

y = x + 234

It’s not so easy to figure out what’s going on here. Since x + "hello" will cause a method error, it seems likely that the intention was to make the variable x local to the 'for` loop. However, based on the values of the execution time and existing methods, it is impossible to determine the scope of variables. The behavior adopted in versions of Julia up to and including 0.6 was especially dangerous for the following reason: someone could first write a for loop, and it worked fine, but then someone else could add a new global variable in a completely different place, possibly in a different file, and the meaning of the code changed. It either just started to fail, or, even worse, it was executed, but with the wrong result. In well-designed programming languages, this effect https://en.wikipedia.org/wiki/Action_at_a_distance_ (computer_programming)[creepy ranged attacks] should not be allowed.

Therefore, in Julia 1.0, we simplified the rules regarding scopes: in any local scope, assigning a value to a name that is not yet a local variable leads to the creation of a new local variable. As a result, the concept of a non-strict area has completely disappeared, which made it possible to prevent possible negative effects of long-range action. We have identified and eliminated many errors that have become our punishment for abandoning non-strict areas. And finally, they could rest on their laurels! But no, it’s not that simple. After all, I often had to do this now.:

s = 0
for i = 1:10
    global s += i
end

Do you see the global annotation? It’s horrible. Of course, this state of affairs could not be tolerated. But seriously, the mandatory use of global in such top-level code is fraught with two main problems.

  1. Now it is not so convenient to copy the code from the function body to the REPL for debugging - you have to add global annotations, and then delete them when transferring the code back.

  2. Inexperienced developers can write such code without the keyword global and not understand why it does not work, - the error "variable s is not defined" is returned, which does not shed much light on the cause of the problem.

Starting with Julia 1.5, this code works without the global annotation in interactive contexts such as REPL or Jupyter notebooks (as in Julia 0.6), and in files and other non-interactive contexts, such an unambiguous warning is displayed.

Assigning a value to the variable s in a non-strict scope is ambiguous because there is a global variable with the same name. The s will be interpreted as a new local variable. To resolve the ambiguity, use local s to suppress this warning or global s to assign a value to an existing global variable.

This solves both problems and preserves the advantages of writing large-scale programs implemented in version 1.0: global variables do not have a hidden effect on the meaning of deleted code; debugging by copying to REPL works, and novice developers have no problems; when someone forgets to add the global annotation or accidentally obscures an existing global variable. local in a non-strict area, a clear warning is issued.

An important feature of this approach is that the code that runs in the file without displaying a warning will work the same way in a new REPL session. Conversely, if, after saving the code from the REPL session in a file, it is executed differently than in the REPL, a warning will be received.

Let blocks

The 'let` operator creates a block with a strict scope (see above) and introduces new variable bindings each time it is executed. The value of the variable must be assigned immediately.

julia> var1 = let x
           for i in 1:5
               (i == 4) && (x = i; break)
           end
           x
       end
4

While an existing value location can be overwritten with a new value when assigned, let always creates a new location. This difference usually does not play a role and manifests itself only if variables continue to exist outside their scope due to closure. The let syntax allows for a comma-separated list of variable assignments and names.:

julia> x, y, z = -1, -1, -1;

julia> let x = 1, z
           println("x: $x, y: $y") # x — локальная переменная, y — глобальная
           println("z: $z") # выдает ошибку, так как переменной z еще не присвоено значение, но она является локальной
       end
x: 1, y: -1
ERROR: UndefVarError: `z` not defined in local scope

Assignment operators are evaluated in order, with the right side of the operator being evaluated within the scope before introducing a new variable on the left side. For this reason, the expression let x = x will make sense, since the x in the left and right parts are different variables that are stored separately. Here is an example that requires the let behavior.

julia> Fs = Vector{Any}(undef, 2); i = 1;

julia> while i <= 2
           Fs[i] = ()->i
           global i += 1
       end

julia> Fs[1]()
3

julia> Fs[2]()
3

Two closures are created and saved here, returning the variable i'. However, it is always the same variable `i, so closures work the same way. Using let, you can create a new binding for `i'.

julia> Fs = Vector{Any}(undef, 2); i = 1;

julia> while i <= 2
           let i = i
               Fs[i] = ()->i
           end
           global i += 1
       end

julia> Fs[1]()
1

julia> Fs[2]()
2

Since the 'begin` construction does not introduce a new scope, it may be useful to use let without arguments to simply introduce a new scope block without creating new bindings immediately.

julia> let
           local x = 1
           let
               local x = 2
           end
           x
       end
1

Since let introduces a new scope block, the internal local variable x is different from the external local x. This particular example is equivalent to the following code.

julia> let x = 1
           let x = 2
           end
           x
       end
1

Cycles and inclusions

In cycles and inclusions the variables entered in their bodies are re-placed in memory at each iteration, as if the body of the loop were enclosed in a `let' block. For example:

julia> Fs = Vector{Any}(undef, 2);

julia> for j = 1:2
           Fs[j] = ()->j
       end

julia> Fs[1]()
1

julia> Fs[2]()
2

The for loop iteration or inclusion variable is always a new variable.:

julia> function f()
           i = 0
           for i = 1:3
               # пусто
           end
           return i
       end;

julia> f()
0

However, sometimes it is useful to reuse an existing local variable as an iteration variable. It is convenient to use the keyword outer for this purpose.

julia> function f()
           i = 0
           for outer i = 1:3
               # пусто
           end
           return i
       end;

julia> f()
3

Constants

Variables are often used to assign names to certain immutable values. Values for such variables are assigned only once. You can use a keyword to inform the compiler about this assignment. const.

julia> const e  = 2.71828182845904523536;

julia> const pi = 3.14159265358979323846;

Multiple variables can be declared in a single const statement.

julia> const a, b = 1, 2
(1, 2)

The declaration const should only be used in the global scope in relation to global variables. It is difficult for the compiler to optimize code with global variables, since their values (or even types) can change at any moment. If the global variable does not change, declaring const solves this performance problem.

The situation is different with local constants. The compiler can automatically determine if a local variable is a constant, so you don’t need to declare local constants. Moreover, this feature is not supported yet.

Special top-level assignments, such as those performed by the keywords function and struct, are constant by default.

Note that const only affects the binding of a variable; a variable can be bound to a mutable object (for example, an array) that can still be modified. In addition, when trying to assign a value to a variable declared as a constant, the following scenarios are possible.

  • If the type of the new value differs from the type of the constant, an error occurs.

julia> const x = 1.0
1.0

julia> x = 1
ERROR: invalid redefinition of constant x
  • If the new value and the constant are of the same type, a warning is displayed.

julia> const y = 1.0
1.0

julia> y = 2.0
WARNING: redefinition of constant y. This may fail, cause incorrect answers, or produce other errors.
2.0
  • If the assignment does not change the value of the variable, the message is not displayed.

julia> const z = 100
100

julia> z = 100
100

The last rule applies to immutable objects, even if the binding of the variable changes, for example:

julia> const s1 = "1"
"1"

julia> s2 = "1"
"1"

julia> pointer.([s1, s2], 1)
2-element Array{Ptr{UInt8},1}:
 Ptr{UInt8} @0x00000000132c9638
 Ptr{UInt8} @0x0000000013dd3d18

julia> s1 = s2
"1"

julia> pointer.([s1, s2], 1)
2-element Array{Ptr{UInt8},1}:
 Ptr{UInt8} @0x0000000013dd3d18
 Ptr{UInt8} @0x0000000013dd3d18

However, for mutable objects, the warning is displayed as usual.

julia> const a = [1]
1-element Vector{Int64}:
 1

julia> a = [1]
WARNING: redefinition of constant a. This may fail, cause incorrect answers, or produce other errors.
1-element Vector{Int64}:
 1

Please note: although it is sometimes possible to change the value of the const variable, it is strongly discouraged to do so. This feature is intended solely for convenience when working interactively. Changing constants can lead to various problems or unexpected results. For example, if a method refers to a constant and has already been compiled before changing it, the old value may be used.

julia> const x = 1
1

julia> f() = x
f (generic function with 1 method)

julia> f()
1

julia> x = 2
WARNING: redefinition of constant x. This may fail, cause incorrect answers, or produce other errors.
2

julia> f()
1

Typed global variables

Compatibility: Julia 1.8

Support for typed global variables was added in Julia 1.8.

Global bindings can be declared not only as having a constant value, but also as having a constant type. This can be done without assigning an actual value using the global x' syntax.::T or when assigned as `x::T = 123'.

julia> x::Float64 = 2.718
2.718

julia> f() = x
f (generic function with 1 method)

julia> Base.return_types(f)
1-element Vector{Any}:
 Float64

For any assignment of a value to a global variable, Julia first tries to convert it to the appropriate type using the function convert:

julia> global y::Int

julia> y = 1.0
1.0

julia> y
1

julia> y = 3.14
ERROR: InexactError: Int64(3.14)
Stacktrace:
[...]

The type does not have to be specific, but annotations with abstract types usually do not provide much performance benefit.

After assigning a value to a global variable or setting its type, you cannot change the binding type.

julia> x = 1
1

julia> global x::Int
ERROR: cannot set type for global x. It already has a value or is already set to a different type.
Stacktrace:
[...]