Structure your code

Now, you got familiar with the basic syntax of Julia — the foundation for writing simple scripts. However, as programs grow in size and complexity, writing code in a flat, linear way becomes difficult to manage, debug, and reuse. This is why in this page you will learn to move from basic syntax to structured code. Introducing functions allows you to break the code into reusable, logical blocks, making it easier to read, test, and maintain. Incorporating error handling ensures your program can manage unexpected situations gracefully instead of crashing, which is crucial for reliability. Finally, using modules helps organize code across multiple files, encourages reusability, and separates concerns—for example, keeping database code apart from user interface code. Together, these practices lead to cleaner, more robust, and scalable programs, which is the goal of any serious software development effort.

Functions

Julia offers flexible ways to define functions, with options for positional arguments, keyword arguments, optional arguments with default values, and variable-length arguments. Let’s explore each of these in detail.

Defining Functions

Functions in Julia can be defined using either the function keyword or the assignment syntax.

# Using the `function` keyword
function add(a, b)
    return a + b
end

# Using assignment syntax
multiply(a, b) = a * b

add(2, 3)
multiply(2, 3)
julia> add(2, 3) = 5
julia> multiply(2, 3) = 6
Warning

The function add has been defined without any specification on the kind of data which is accepted. We expect, however, the + operator not to make sense for any combination of data. 5+2 looks reasonable while 2+"hello" seems confusing. If, for example, you wish to use add only for integers (let say of the Int64 kind), you would annotate the function in the following way: function add(a::Int64, b::Int64).

If you wish add to take integers or real numbers, you should use the kind of data referred as Union to annote the arguments. It is created by specifying a list of data kinds within Union{}, here Union{Int64, Float64}.

Positional and Keyword Arguments

In Julia, functions can take both positional arguments and keyword arguments.

  • Positional Arguments: These are listed first in the parameter list and must be provided in the correct order when the function is called. Positional arguments can have default values, but it’s not required.

  • Keyword Arguments: Keyword arguments are specified after a semicolon (;) in the parameter list. These arguments must be provided by name when calling the function. Like positional arguments, keyword arguments can have default values, but they don’t have to.

function greet(name; punctuation = "!")
    return "Hello, " * name * punctuation
end

println(greet("Alice"))
println(greet("Alice", punctuation = "?"))
Hello, Alice!
Hello, Alice?

In this example, punctuation is a keyword argument with a default value of "!". You could also define a keyword argument without a default value if needed.

Note

In Julia, when you define a function with multiple positional arguments, those arguments are actually packed into a tuple when you call the function. This kind of data is reffered to as Tuple.

Keyword arguments are different from positional arguments because they are explicitly named when the function is called. Keyword arguments are passed as a kind of data reffered to as NamedTuple. It is a special kind of tuple where each element is paired with a name (as the key) and a corresponding value.

Variable Number of Arguments

Julia functions can accept an arbitrary number of arguments using the splatting operator .... These arguments are gathered into a tuple.

function sum_all(args...)
    total = 0
    for x in args
        total += x
    end
    return total
end

sum_all(1, 2, 3, 4)
julia> sum_all(1, 2, 3, 4) = 10

Default Values for Optional Arguments

In Julia, you can assign default values to both positional and keyword arguments. When the function is called without specifying a value for an argument with a default, the default value is used.

function power(base, exponent=2)
    return base ^ exponent
end

power(3)      # Outputs: 9 (since exponent defaults to 2)
power(3, 3)   # Outputs: 27
julia> power(3) = 9
julia> power(3, 3) = 27

Multiple Optional Positional Arguments

When a function has multiple optional positional arguments, Julia will use the default values for any arguments not provided, allowing flexible combinations.

function calculate(a=1, b=2, c=3)
    return a + b * c
end

calculate()        # Outputs: 7  (1 + 2 * 3)
calculate(5)       # Outputs: 11 (5 + 2 * 3)
calculate(5, 4)    # Outputs: 17 (5 + 4 * 3)
calculate(5, 4, 1) # Outputs: 9  (5 + 4 * 1)
julia> calculate() = 7
julia> calculate(5) = 11
julia> calculate(5, 4) = 17
julia> calculate(5, 4, 1) = 9

Here’s how the argument combinations work:

  1. calculate() uses all default values: a=1, b=2, c=3.
  2. calculate(5) overrides a, leaving b and c as defaults.
  3. calculate(5, 4) overrides a and b, leaving c as the default.
  4. calculate(5, 4, 1) overrides all arguments.

This flexibility makes it easy to call functions with varying levels of detail without explicitly specifying each parameter.

Tip

If a function has many optional arguments, consider using keyword arguments to improve readability and avoid confusion about the order of arguments.

Mutation and the Bang ! Convention

In Julia, functions that modify or mutate their arguments typically end with a !, following the “bang” convention. This is not enforced by the language but is a widely followed convention in Julia to indicate mutation.

function add_one!(array)
    for i in eachindex(array)
        array[i] += 1
    end
end

arr = [1, 2, 3]
add_one!(arr)
arr  # Outputs: [2, 3, 4]
julia> arr = [1, 2, 3]
julia> add_one!(arr) = nothing
julia> arr = [2, 3, 4]

In this example, add_one! modifies the elements of the array arr. By convention, the ! at the end of the function name indicates that the function mutates its input.

Broadcasting

Julia supports broadcasting, a powerful feature that applies a function element-wise to arrays or other collections. Broadcasting is denoted by a . placed before the function call or operator.

# Define a simple function
function square(x)
    return x^2
end

# Apply the function to a vector using broadcasting
vec = [1, 2, 3, 4]
squared_vec = square.(vec)

println("Original vector: ", vec)
println("Squared vector: ", squared_vec)
Original vector: [1, 2, 3, 4]
Squared vector: [1, 4, 9, 16]

In this example:

  • The function square(x) is applied to each element of vec using the . operator.
  • Broadcasting works seamlessly with both built-in and user-defined functions, making it easy to perform element-wise operations on arrays of any shape.

Return Values

In Julia, functions automatically return the last evaluated expression. However, you can use the return keyword to explicitly specify the output if needed.

function multiply(a, b)
    a * b  # Returns the result of a * b
end

In the special case where a function does not return any data, the default return value in Julia is a kind of data called Nothing. It’s similar to void in languages like C or Java.

Errors and Exception Handling

Julia provides a powerful framework for managing and handling errors, which helps in writing robust programs. Error handling in Julia involves various built-in error types and mechanisms, including throw for raising errors and try/catch blocks for handling exceptions.

Common Error Types in Julia

Julia has several built-in error types that are commonly used:

  • ArgumentError: Raised when a function receives an argument that is inappropriate or out of expected range.
  • BoundsError: Occurs when trying to access an index that is out of bounds for an array or collection.
  • DivideError: Raised when division by zero is attempted.
  • DomainError: Raised when a mathematical function is called with an argument outside its domain. For instance, taking the square root of a negative number.
  • MethodError: Occurs when a method is called with incorrect arguments or types.

Raising Errors with throw

In Julia, you can explicitly raise an error using the throw function. This is useful for defining custom error conditions in your code. To throw an error, call throw with an instance of an error type:

function divide(a, b)
    if b == 0
        throw(DivideError())
    end
    return a / b
end

divide(10, 0)  # Will raise a DivideError
DivideError: integer division error

Stacktrace:
 [1] divide(a::Int64, b::Int64)
   @ Main ./In[232]:3
 [2] top-level scope
   @ In[232]:8

In this example, the function divide will throw a DivideError if the second argument b is zero, making the function safer and more robust.

Handling Errors with try/catch

Julia provides try/catch blocks for managing exceptions gracefully. Code within a try block runs until an error is encountered. If an error is thrown, control passes to the catch block, where you can handle the error.

Here’s an example of using try/catch with the divide function:

try
    println(divide(10, 0))  # Will raise an error
catch e
    println("Error: ", e)  # Handles the error
end
Error: DivideError()

In this example:

  • If divide(10, 0) raises an error, the program catches it and prints a custom message instead of stopping execution.
  • The variable e holds the error, which can be printed or used for further handling.

Using finally for Cleanup

In Julia, finally is a block used in conjunction with try and catch to ensure that certain cleanup actions are executed regardless of whether an error occurs or not. This is useful for tasks like closing files, releasing resources, or resetting variables that need to be done after the execution of a try-catch block.

The code inside the finally block is always executed, even if an exception is thrown and caught. This makes it ideal for situations where you need to guarantee that some actions occur after the main code runs, like resource deallocation.

Syntax:

try
    # Code that might throw an error
catch exception
    # Code to handle the error
finally
    # Cleanup code that will always run
end

Example:

function safe_file_read(filename::String)
    file = nothing
    try
        file = open(filename, "r")
        data = read(file, String)
        return data
    catch e
        println("An error occurred: ", e)
    finally
        if file !== nothing
            close(file)
            println("File closed.")
        end
    end
end

# Test with a valid file
println(safe_file_read("example.txt"))

# Test with an invalid file
println(safe_file_read("nonexistent.txt"))
An error occurred: SystemError("opening file \"example.txt\"", 2, nothing)
nothing
An error occurred: SystemError("opening file \"nonexistent.txt\"", 2, nothing)
nothing

Explanation:

  • The finally block ensures that the file is always closed after reading, even if an error occurs (e.g., file not found, read error).
  • If the open operation is successful, the finally block will still execute and close the file, ensuring proper resource management.
  • If an exception is thrown in the try block (like a non-existent file), it will be caught and handled by the catch block, but the finally block will still execute to close the file (if opened).

Use Cases for finally:

  • Closing files or network connections.
  • Releasing resources (e.g., database connections, locks).
  • Resetting the program state to a known clean state.

Scoping and Closure

In Julia, scoping rules determine the visibility and lifetime of variables. Understanding scope and closures is essential for writing efficient and error-free code.

Variable Scope

Scope in Julia refers to the region of code where a variable is accessible. There are two primary scopes: global and local.

  • Global Scope: Variables defined at the top level of a module or script are in the global scope and can be accessed from anywhere in that file. However, modifying global variables from within functions is generally discouraged.
global_var = 10

function access_global()
    return global_var
end

access_global()  # Outputs: 10
julia> access_global() = 10
  • Local Scope: Variables defined within a function or a block (e.g., loops or conditionals) have local scope and cannot be accessed outside of that block.
function local_scope_example()
    local_var = 5
    return local_var
end

local_scope_example()
julia> local_scope_example() = 5

If you try to access local_var outside the function, you will get an error because it is not defined in the global scope.

local_var  # This would cause an error, as local_var is not accessible here
LoadError: UndefVarError: `local_var` not defined in `Main`
Suggestion: check for spelling errors or missing imports.
UndefVarError: `local_var` not defined in `Main`
Suggestion: check for spelling errors or missing imports.

Scope of Variables in for Loops

In Julia, a for loop does create a new local scope for its loop variable when inside a function or another local scope. This means that a variable used as the loop variable will not overwrite an existing global variable with the same name in that context.

Here’s an example:

i = 10  # Define a global variable `i`

for i = 1:3
    println(i)  # Prints 1, 2, and 3
end

println("Outside loop: i = ", i)  # Outputs: 10
1
2
3
Outside loop: i = 10

In this case, the initial value of i (10) is not affected by the loop because the for loop has its own local scope for i. After the loop completes, the global variable i retains its original value (10), demonstrating that the for loop did not alter it.

However, if this code were inside a function, i would be entirely scoped within that function’s local environment, meaning any loop variables would only affect other variables within the function itself.

Nested Scopes

Julia allows for nested functions, which can access variables in their enclosing scopes. This is known as lexical scoping.

function outer_function(x)
    y = 2
    function inner_function(z)
        return x + y + z
    end
    return inner_function
end

closure = outer_function(3)
closure(4)  # Outputs: 9 (3 + 2 + 4)
julia> closure(4) = 9

In this example, inner_function forms a closure over the variables x and y, retaining access to them even after outer_function has finished executing.

Closures

A closure is a function that captures variables from its surrounding lexical scope, allowing the function to use these variables even after the scope where they were defined has ended. Closures are especially useful for creating customized functions or “function factories.”

Example: Using a Global Variable vs. Capturing a Variable in a Closure

To illustrate the difference between referencing a global variable and capturing a variable in a closure, let’s first create a function that uses a global variable:

factor = 2

function multiply_by_global(x)
    return x * factor
end

multiply_by_global(5)  # Outputs: 10

# Update the global variable `factor`
factor = 3
multiply_by_global(5)  # Outputs: 15 (factor is now 3)
julia> factor = 2
julia> function multiply_by_global(x)
    return x * factor
end
julia> multiply_by_global(5) = 10
julia> factor = 3
julia> multiply_by_global(5) = 15

In this example, multiply_by_global uses the global variable factor, so whenever factor is updated, the result of calling multiply_by_global changes.

Example: Capturing a Variable in a Closure

Now, let’s use a closure to capture the factor variable inside a function. Here, the captured value of factor remains fixed at the time the closure was created, regardless of changes to the variable afterward.

function make_multiplier(factor)
    return (x) -> x * factor  # Returns a closure that captures `factor`
end

double = make_multiplier(2)   # `factor` is captured as 2 in this closure
triple = make_multiplier(3)   # `factor` is captured as 3 in this closure

double(5)  # Outputs: 10
triple(5)  # Outputs: 15

# Even if we change `factor` globally, it doesn't affect the closure
factor = 4
double(5)  # Still outputs: 10
triple(5)  # Still outputs: 15
julia> function make_multiplier(factor)
    return (x->begin
                x * factor
            end)
end
julia> double = make_multiplier(2)
julia> triple = make_multiplier(3)
julia> double(5) = 10
julia> triple(5) = 15
julia> factor = 4
julia> double(5) = 10
julia> triple(5) = 15

In this example, make_multiplier returns a function that captures the factor variable when the closure is created. This means that double will always multiply by 2, and triple will always multiply by 3, regardless of any subsequent changes to factor.

Summary

Using closures in Julia allows you to “lock in” the values of variables from an outer scope at the time of the closure’s creation. This differs from referencing global variables directly, where any changes to the variable are reflected immediately. Closures are particularly useful for creating function factories or callbacks that need to retain specific values independently of changes in the global scope.

Understanding scope is crucial for performance in Julia. Defining variables within a local scope, such as inside functions, can lead to more efficient code execution. Global variables can lead to performance penalties due to type instability.

In summary, scoping rules in Julia allow for clear management of variable accessibility and lifespan, while closures enable powerful programming patterns by capturing the context in which they are created. Understanding these concepts is key to writing effective Julia code.

Modules

Modules can be seen as “boxes” in which you organise related functions. Such a “box” can be reused in other programs.

Let B be a locally written module (not from a package). In this module, a function f which returns a Vector{Float64} with a elements. This function is made visible to the outside world of the module by exporting it: export f.

module B

# Use
using Random

# Public
export f

# Function
function f(a::Int)
    return rand(a)
end

end
WARNING: replacing module B.
Main.B

To have access to the function from “box” B, you should add using .B at the beginning of your program. To call the function f defined in B, you type B.f.

# Use
using .B

# Call
B.f(7)
7-element Vector{Float64}:
 0.38771251690737485
 0.02945387576728542
 0.7593501769146536
 0.9035575247095426
 0.3109283479360223
 0.905506784998895
 0.9630153446273761

The . is necessary because B is a module in the local environment. This is a short hand for:

using Pkg; Pkg.activate(".")
include("./B.jl")
using .B

With B.f we can see that modules avoid conflicts on function names by providing a seperate namespace. This means that a function f from the main program will indeed be seen as a different function than B.f.

Exercices

Exercise Instructions

  1. For each exercise, implement the required functions in a new Julia script or interactive session.
  2. Test your functions with different inputs to ensure they work as expected.
  3. Comment on your code to explain the logic behind each part, especially where you utilize control flow and scope.

Exercise 1: Temperature Converter

Write a function convert_temperature that takes a temperature value and a keyword argument unit that can either be "C" for Celsius or "F" for Fahrenheit. The function should convert the temperature to the other unit and return the converted value. Use a conditional statement to determine the conversion formula:

  • If the unit is "C", convert to Fahrenheit using the formula: F = C \times \frac{9}{5} + 32

  • If the unit is "F", convert to Celsius using the formula: C = (F - 32) \times \frac{5}{9}

Example Output:

println(convert_temperature(100, unit="C"))  # Outputs: 212.0
println(convert_temperature(32, unit="F"))    # Outputs: 0.0

If the unit provided is not "C" or "F", you can raise an error using the throw statement along with ArgumentError. This way, you can inform the user that the input is invalid.

function convert_temperature(value; unit)
    if unit == "C"
        return value * 9/5 + 32  # Convert Celsius to Fahrenheit
    elseif unit == "F"
        return (value - 32) * 5/9  # Convert Fahrenheit to Celsius
    else
        throw(ArgumentError("Unit must be 'C' or 'F'"))
    end
end

println(convert_temperature(100, unit="C"))  # Outputs: 212.0
println(convert_temperature(32, unit="F"))    # Outputs: 0.0
212.0
0.0

Exercise 2: Manipulating Tuples

  1. Create a tuple t with three elements: a string, an integer, and a float.
  2. Try to mutate the first element of the tuple and handle any errors using a try-catch block.
  3. Create a NamedTuple nt with fields name, age, and height, and initialize it with your details.
  • Remember that tuples are immutable, so you can’t modify their elements.
  • Use a try-catch block to catch errors if an operation fails.
# Create a tuple with three elements: a string, an integer, and a float
t = ("John", 25, 5.9)

# Attempt to mutate the first element of the tuple with error handling
try
    t[1] = "Alice"  # This will raise an error because tuples are immutable
catch e
    println("Error: ", e)
end

# Create a NamedTuple with fields: name, age, and height
nt = (name = "John", age = 25, height = 5.9)

println("NamedTuple: ", nt)
Error: MethodError(setindex!, (("John", 25, 5.9), "Alice", 1), 0x0000000000006a88)
NamedTuple: (name = "John", age = 25, height = 5.9)

Exercise 3: Factorial Function with Closure

Create a function make_factorial that returns a closure. This closure should compute the factorial of a number. The closure should capture a variable that keeps track of the number of times it has been called. When the closure is called, it should return the factorial of the number and the call count.

Example Output:

factorial_closure = make_factorial()
result, count = factorial_closure(5)
println(result)  # Outputs: 120
result, count = factorial_closure(3)
println(result)  # Outputs: 6
println("Function called ", count, " times")  # Outputs: 2 times

When returning the results from the closure, you can return a pair of values by creating a tuple. In Julia, tuples are created using parentheses, like this: (value1, value2).

function make_factorial()
    counter = 0 # Variable to keep track of calls
    function factorial(n::Int)
        y = 1
        for i  2:n
            y *= i 
        end
        counter += 1
        return y, counter
    end
    return factorial 
end

factorial_closure = make_factorial()
result, count = factorial_closure(5)
println(result)  # Outputs: 120
result, count = factorial_closure(3)
println(result)  # Outputs: 6
println("Function called ", count, " times")  # Outputs: 2 times
120
6
Function called 2 times

Exercise 4: Filter Even Numbers

Write a function filter_even that takes an array of integers as input and returns a new array containing only the even numbers from the input array. Use a loop and a conditional statement to check each number.

Additionally, implement a helper function is_even that checks if a number is even. Use the filter_even function to filter an array of numbers, and print the result.

Example Output:

numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
even_numbers = filter_even(numbers)
println(even_numbers)  # Outputs: [2, 4, 6, 8, 10]

To add elements to an array in Julia, use the push! function. This function takes two arguments: the array you want to modify and the element to add to that array.

function is_even(x)
    return x % 2 == 0
end

function filter_even(numbers)
    even_numbers = []
    for number in numbers
        if is_even(number)
            push!(even_numbers, number)
        end
    end
    return even_numbers
end

numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
even_numbers = filter_even(numbers)
println(even_numbers)  # Outputs: [2, 4, 6, 8, 10]
Any[2, 4, 6, 8, 10]
Back to top