Exploring Modules and Variable Scope in Julia Programming

Exploring Modules and Variable Scope in Julia Programming

Master the essentials of modules and variable scoping in Julia programming.

Julia is a relatively new, free, and open-source programming language. It has a syntax similar to that of other popular programming languages such as MATLAB and Python, but it boasts being able to achieve C-like speeds.

One way to organize Julia code is to split functionality into individual functions. When enough functions exist, it may become useful to group the functions together, along with any relevant global variables, constants, and type definitions. Julia provides modules for this purpose.

Modules form the backbone of Julia packages, helping to organize code and minimize namespace collisions.

In this post, we will learn about modules in Julia, and we will discuss how to create and use them. Because modules each have their own global scope, we will also learn about scoping rules for variables.

This post assumes you already have a basic understanding of variables and functions in Julia. You should also understand the difference between functions and methods. If you haven't yet, check out our earlier post on variables and functions as well as our post on multiple dispatch, which explains the difference between functions and methods.

Modules

The syntax for creating a module is

module ModuleName

# Code goes here.

end

Here's an example module:

module MyModule

using Statistics

const A = "A global constant"
const B = [1, 2, 3]

func(x) = println("A: ", A, "\nmean(B): ", mean(B), "\nx: ", x)

export A, func

end

Let's walk through this code.

  • First, the module loads another package:

    using Statistics
    

    Modules can load packages, just like we can do in the REPL. When a package is loaded in a module, the package is brought into the module's namespace, meaning the loaded symbols (i.e., names referring to functions, types, constants, etc.) are not visible outside of the module. For example:

    julia> module StatsModule
           using Statistics
           end
    Main.StatsModule
    
    julia> mean([1, 2, 3])
    ERROR: UndefVarError: `mean` not defined
    
  • Next, the module defines its own data and functionality:

    const A = "A global constant"
    const B = [1, 2, 3]
    
    func(x) = println("A: ", A, "\nmean(B): ", mean(B), "\nx: ", x)
    

    This is the code we want to organize into a module. Typically, there are more lines of code, and they are saved in one or more separate files that are just included by the module via

    include("mycode.jl")
    
  • Finally, the module specifies some exports:
    export A, func
    
    Modules can export symbols that are then made available when the module is loaded with using.

Using Modules

When a module is created, it can be referred to by its name, and any symbols in its namespace can be accessed by prepending the module name, e.g., MyModule.func. (This is called a qualified name.)

julia> MyModule
Main.MyModule

julia> MyModule.func(1)
A: A global constant
mean(B): 2.0
x: 1

If we want to make exported symbols available without using a qualified name, we can load the module with using:

julia> using .MyModule

julia> A
"A global constant"

(Here we note one difference between packages and modules: packages can be loaded with using PackageName, whereas modules need their name to be prepended with a period, as seen above.)

After loading a module with using, unexported symbols are not made directly available, but they can still be accessed via a qualified name:

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

julia> MyModule.B
3-element Vector{Int64}:
 1
 2
 3

If we want to make an unexported symbol available without using a qualified name, we can explicitly load it:

julia> using .MyModule: B

julia> B
3-element Vector{Int64}:
 1
 2
 3

import Statements

import is another keyword that can be used to load modules and packages.

import .MyModule will make available just the name MyModule, not any exported symbols:

julia> import .MyModule

julia> func(false) # Error, even though `func` is exported
ERROR: UndefVarError: `func` not defined

julia> MyModule.func(false) # Qualified names still work
A: A global constant
mean(B): 2.0
x: false

Using vs. import

import also allows methods to be added to a module's functions without using a qualified name:

julia> import .MyModule: func

julia> func() = println("Method 2")
func (generic function with 2 methods)

julia> func()
Method 2

julia> func("MyModule.func")
A: A global constant
mean(B): 2.0
x: MyModule.func

For comparison, below are two similar examples that use using:

  1. julia> using .MyModule: func
    
    julia> func() = println("Method 2")
    ERROR: error in method definition: function MyModule.func
    must be explicitly imported to be extended
    

    Here, we learn that we cannot add a method to a function from another module without importing the function, as we did earlier, or referring to the function with its qualified name, as shown below:

    julia> using .MyModule
    
    julia> MyModule.func() = println("Method 2")
    
    julia> func()
    Method 2
    
    julia> func("MyModule.func")
    A: A global constant
    mean(B): 2.0
    x: MyModule.func
    
  2. julia> using .MyModule
    
    julia> func() = println("Method 2")
    func (generic function with 1 method)
    
    julia> func()
    Method 2
    
    julia> func("MyModule.func")
    ERROR: MethodError: no method matching func(::String)
    
    julia> MyModule.func("MyModule.func")
    A: A global constant
    mean(B): 2.0
    x: MyModule.func
    

    Here, we see that, even though func is exported from MyModule, we created a different func function in the REPL because we did not import func or use a qualified name. As a result, future uses of func from MyModule must use its qualified name.

Adding methods to a module's function

Finally, import enables renaming symbols:

julia> import .MyModule as MM

julia> MM.A
"A global constant"

julia> import .MyModule: B as NEWNAME

julia> NEWNAME
3-element Vector{Int64}:
 1
 2
 3

Common Modules

Now that we know how modules work, let's learn about a few modules that every Julia programmer will come across: Main, Base and Core.

  • Main: It turns out that all Julia code executes within a module. When Julia starts, a module named Main is created, and code that runs that isn't explicitly contained in a module (e.g., code in the REPL) is executed within Main.
  • Base: Much of Julia's basic functionality, including functions like +, print, and getindex, is defined in a module named Base. This module is automatically loaded into all modules (with few exceptions).
  • Core: Code that is considered built-in to Julia, i.e., code Julia needs to be able to function, lives in a module named Core. This module also is automatically loaded into all modules (with even fewer exceptions).

Variable Scope

Variable scope refers to where in code a variable is accessible. It therefore has implications for when two pieces of code can use the same variable name without referring to the same thing.

There are three types of scopes in Julia: global scope, hard local scope, and soft local scope. We will discuss each of these in turn.

Global Scope

Symbols defined within a global scope can be accessed within the global scope and any local scopes contained in the global scope.

Each module defines its own global scope. Importantly, there is no universal global scope, meaning there is nowhere we can define, e.g., x and have x refer to the same thing everywhere, even across modules.

Also note that global scopes do not nest, in the sense that a nested module cannot refer to a containing module's global variable:

julia> module A
           a = 1
           module B
               b = a # `a` is undefined here, even though `B` is nested within `A`
           end
       end
ERROR: UndefVarError: `a` not defined

Hard Local Scope

Symbols defined within a hard local scope can be accessed within the local scope and any contained local scopes.

Functions, let blocks, and comprehensions each introduce a hard local scope.

In a hard local scope, variable assignment always assigns to a local variable unless the variable is explicitly declared as global using the global keyword:

julia> let
           x = 1 # Assigns to a local variable `x`
       end;

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

julia> let
           global x
           x = 1 # Assigns to a global variable `x`
       end;

julia> x
1

Furthermore, if there is a local variable, e.g., x, in an outer local scope, assignment to x in an inner local scope will assign to the same x in the outer local scope:

julia> let
           x = 0
           for i = 1:10
               s = x + i
               x = s # `x` is the existing local variable, not a new one
           end
           x # 55, not 0
       end
55

In particular, it will not create a new local named x unless x is explicitly declared local in the inner scope. This is called shadowing, where names can be reused to refer to different things:

julia> x = 1; # A global `x`

julia> let
           x = 2 # A local `x` shadowing the global `x`
           let
               local x
               x = 3 # An inner local `x` shadowing the outer local `x`
               @show x # Shows `x = 3`
           end
           @show x # Shows `x = 2`, not `x = 3`
       end;
x = 3
x = 2

julia> @show x; # Shows `x = 1`, not `x = 2` or `x = 3`
x = 1

Another example of shadowing:

julia> x = 1; # A global `x`

julia> function f(x)
           x + 1 # This `x` refers to the local `x`, not the global one
       end;

julia> f(3) # Computes `3 + 1`, not `1 + 1`
4

Soft Local Scope

for, while, and try blocks each introduce a soft local scope.

Soft local scope is the same as hard local scope except in interactive contexts (e.g., when running code in the REPL) and when assigning to a variable (let's call it x) while the following conditions are met:

  1. x is not already a local variable.
  2. All enclosing local scopes are soft. (To illustrate, nested for loops within the REPL would satisfy this condition, while a for loop in a function would not.)
  3. A global variable x is defined.

In this case, the global variable x is assigned (as opposed to creating a new local variable x, as would be done in a hard local scope). (See the Julia documentation for the rationale.)

Variable scope

Summary

In this post, we learned how to create and use modules in Julia for organizing code. We also learned about Julia's scoping rules for global, hard local, and soft local scopes.

How do you use modules in your code? Let us know in the comments below!

Have a better feel for how modules and variable scope work? Move on to the next post to learn how to learn new Julia packages! Or, feel free to take a look at our other Julia tutorial posts.

Additional Links