# 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
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.
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.
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
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}
.
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.
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.
Julia functions can accept an arbitrary number of arguments using the splatting operator ...
. These arguments are gathered into a tuple.
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.
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:
calculate()
uses all default values: a=1
, b=2
, c=3
.calculate(5)
overrides a
, leaving b
and c
as defaults.calculate(5, 4)
overrides a
and b
, leaving c
as the default.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.
If a function has many optional arguments, consider using keyword arguments to improve readability and avoid confusion about the order of arguments.
!
ConventionIn 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.
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:
square(x)
is applied to each element of vec
using the .
operator.In Julia, functions automatically return the last evaluated expression. However, you can use the return
keyword to explicitly specify the output if needed.
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.
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.
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.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.
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:
divide(10, 0)
raises an error, the program catches it and prints a custom message instead of stopping execution.e
holds the error, which can be printed or used for further handling.finally
for CleanupIn 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.
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
finally
block ensures that the file is always closed after reading, even if an error occurs (e.g., file not found, read error).open
operation is successful, the finally
block will still execute and close the file, ensuring proper resource management.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).finally
:In Julia, scoping rules determine the visibility and lifetime of variables. Understanding scope and closures is essential for writing efficient and error-free code.
Scope in Julia refers to the region of code where a variable is accessible. There are two primary scopes: global and local.
julia> access_global() = 10
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.
for
LoopsIn 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.
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.
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.”
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.
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
.
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 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
.
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
.
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:
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
.
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
t
with three elements: a string, an integer, and a float.try-catch
block.nt
with fields name
, age
, and height
, and initialize it with your details.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)
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
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]