for Pythonistas¶
TL;DR: Julia looks and feels a lot like Python, only much faster. It's dynamic, expressive, extensible, with batteries included, in particular for Data Science.
This notebook is an introduction to Julia for Python programmers.
It will go through the most important Python features (such as functions, basic types, list comprehensions, exceptions, generators, modules, packages, and so on) and show you how to code them in Julia.
You can either run this notebook in Google Colab, or using Jupyter on your own machine.
Setup¶
Let's start by installing some libraries needed in this notebook (I'll explain this code later in this notebook). This will take about one minute on Colab.
using Pkg
if haskey(ENV, "COLAB_GPU") # only install libraries if we are running on Colab
pkg"add BenchmarkTools; precompile;" # ~15s
pkg"add PyCall; precompile;" # ~40s
pkg"add PyPlot; precompile;" # 15s
end
Updating registry at `~/.julia/registries/General.toml` Resolving package versions... Installed BenchmarkTools ─ v1.6.2 ┌ Warning: CUDA version 12.4.0 in /usr/lib64-nvidia/libcuda.so.1 not supported with this version of Reactant (min supported: 12.6) └ @ Main ~/.julia/packages/Reactant_jll/M7uc2/.pkg/platform_augmentation.jl:103 Updating `~/.julia/environments/v1.11/Project.toml` [6e4b80f9] + BenchmarkTools v1.6.2 Updating `~/.julia/environments/v1.11/Manifest.toml` [6e4b80f9] + BenchmarkTools v1.6.2 [9abbd945] + Profile v1.11.0 ┌ Warning: CUDA version 12.4.0 in /usr/lib64-nvidia/libcuda.so.1 not supported with this version of Reactant (min supported: 12.6) └ @ Main ~/.julia/packages/Reactant_jll/M7uc2/.pkg/platform_augmentation.jl:103 Precompiling project... 2549.6 ms ✓ BenchmarkTools 1 dependency successfully precompiled in 10 seconds. 488 already precompiled. ┌ Warning: CUDA version 12.4.0 in /usr/lib64-nvidia/libcuda.so.1 not supported with this version of Reactant (min supported: 12.6) └ @ Main ~/.julia/packages/Reactant_jll/M7uc2/.pkg/platform_augmentation.jl:103 Resolving package versions... Installed PyCall ─ v1.96.4 ┌ Warning: CUDA version 12.4.0 in /usr/lib64-nvidia/libcuda.so.1 not supported with this version of Reactant (min supported: 12.6) └ @ Main ~/.julia/packages/Reactant_jll/M7uc2/.pkg/platform_augmentation.jl:103 Updating `~/.julia/environments/v1.11/Project.toml` [438e738f] + PyCall v1.96.4 Updating `~/.julia/environments/v1.11/Manifest.toml` [438e738f] + PyCall v1.96.4 Building PyCall → `~/.julia/scratchspaces/44cfe95a-1eb2-52ea-b672-e2afdf69b78f/9816a3826b0ebf49ab4926e2b18842ad8b5c8f04/build.log` ┌ Warning: CUDA version 12.4.0 in /usr/lib64-nvidia/libcuda.so.1 not supported with this version of Reactant (min supported: 12.6) └ @ Main ~/.julia/packages/Reactant_jll/M7uc2/.pkg/platform_augmentation.jl:103 Precompiling project... 17651.9 ms ✓ PyCall 1 dependency successfully precompiled in 19 seconds. 489 already precompiled. ┌ Warning: CUDA version 12.4.0 in /usr/lib64-nvidia/libcuda.so.1 not supported with this version of Reactant (min supported: 12.6) └ @ Main ~/.julia/packages/Reactant_jll/M7uc2/.pkg/platform_augmentation.jl:103 Resolving package versions... Installed PyPlot ─ v2.11.6 ┌ Warning: CUDA version 12.4.0 in /usr/lib64-nvidia/libcuda.so.1 not supported with this version of Reactant (min supported: 12.6) └ @ Main ~/.julia/packages/Reactant_jll/M7uc2/.pkg/platform_augmentation.jl:103 Updating `~/.julia/environments/v1.11/Project.toml` [d330b81b] + PyPlot v2.11.6 Updating `~/.julia/environments/v1.11/Manifest.toml` [d330b81b] + PyPlot v2.11.6 ┌ Warning: CUDA version 12.4.0 in /usr/lib64-nvidia/libcuda.so.1 not supported with this version of Reactant (min supported: 12.6) └ @ Main ~/.julia/packages/Reactant_jll/M7uc2/.pkg/platform_augmentation.jl:103 Precompiling project... 2968.0 ms ✓ PyPlot 1 dependency successfully precompiled in 4 seconds. 490 already precompiled. ┌ Warning: CUDA version 12.4.0 in /usr/lib64-nvidia/libcuda.so.1 not supported with this version of Reactant (min supported: 12.6) └ @ Main ~/.julia/packages/Reactant_jll/M7uc2/.pkg/platform_augmentation.jl:103
If you prefer to run this notebook on your machine instead of Google Colab:
Download this notebook (File > Download .ipynb)
Install Julia
Run the following command in a terminal to install
IJulia(the Jupyter kernel for Julia), and a few packages we will use:julia -e 'using Pkg pkg"add IJulia; precompile;" pkg"add BenchmarkTools; precompile;" pkg"add PyCall; precompile;" pkg"add PyPlot; precompile;"'
Next, go to the directory containing this notebook:
cd /path/to/notebook/directory
Start Jupyter Notebook:
julia -e 'using IJulia; IJulia.notebook()'
Or replace
notebook()withjupyterlab()if you prefer JupyterLab.If you do not already have Jupyter installed, IJulia will propose to install it. If you agree, it will automatically install a private Miniconda (just for Julia), and install Jupyter and Python inside it.
Lastly, open this notebook and skip directly to the next section.
Checking the Installation¶
The versioninfo() function should print your Julia version and some other info about the system (if you ever ask for help or file an issue about Julia, you should always provide this information).
versioninfo()
Julia Version 1.11.5 Commit 760b2e5b739 (2025-04-14 06:53 UTC) Build Info: Official https://julialang.org/ release Platform Info: OS: Linux (x86_64-linux-gnu) CPU: 2 × Intel(R) Xeon(R) CPU @ 2.00GHz WORD_SIZE: 64 LLVM: libLLVM-16.0.6 (ORCJIT, skylake-avx512) Threads: 2 default, 0 interactive, 1 GC (on 2 virtual cores) Environment: LD_LIBRARY_PATH = /usr/lib64-nvidia JULIA_NUM_THREADS = auto
Getting Help¶
To get help on any module, function, variable, or just about anything else, just type ? followed by what you're interested in. For example:
?versioninfo
search: versioninfo varinfo
versioninfo(io::IO=stdout; verbose::Bool=false)
Print information about the version of Julia in use. The output is controlled with boolean keyword arguments:
verbose: print all additional information
!!! warning The output of this function may contain sensitive information. Before sharing the output, please review the output and remove any data that should not be shared publicly.
See also: VERSION.
This works in interactive mode only: in Jupyter, Colab and in the Julia shell (called the REPL).
Here are a few more ways to get help and inspect objects in interactive mode:
| Julia | Python | |
|---|---|---|
?obj |
help(obj) |
|
dump(obj) |
print(repr(obj)) |
|
names(FooModule) |
dir(foo_module) |
|
methodswith(SomeType) |
dir(SomeType) |
|
@which func |
func.__module__ |
|
apropos("bar") |
Search for "bar" in docstrings of all installed packages |
|
typeof(obj) |
type(obj) |
|
obj isa SomeTypeor isa(obj, SomeType) |
isinstance(obj, SomeType) |
If you ever ask for help or file an issue about Julia, you should generally provide the output of versioninfo().
And of course, you can also learn and get help here:
- Learning: https://julialang.org/learning/
- Documentation: https://docs.julialang.org/
- Questions & Discussions:
A First Look at Julia¶
This section will give you an idea of what Julia looks like and what some of its major qualities are: it's expressive, dynamic, flexible, and most of all, super fast.
Estimating π¶
Let's write our first function. It will estimate π using the equation: $π = 4 \times \left(1 - \dfrac{1}{3} + \dfrac{1}{5} - \dfrac{1}{7} + \dfrac{1}{9}-\dfrac{1}{11}+\dots\right)$
There are much better ways to estimate π, but this one is easy to implement.
function estimate_pi(n)
s = 1.0
for i in 1:n
s += (isodd(i) ? -1 : 1) / (2i + 1)
end
4s
end
p = estimate_pi(100_000_000)
println("π ≈ $p")
println("Error is $(p - π)")
π ≈ 3.141592663589326 Error is 9.999532757376528e-9
Compare this with the equivalent Python 3 code:
# PYTHON
import math
def estimate_pi(n):
s = 1.0
for i in range(1, n + 1):
s += (-1 if i % 2 else 1) / (2 * i + 1)
return 4 * s
p = estimate_pi(100_000_000)
print(f"π ≈ {p}") # f-strings are available in Python 3.6+
print(f"Error is {p - math.pi}")
Pretty similar, right? But notice the small differences:
| Julia | Python | |
|---|---|---|
function |
def |
|
for i in X...end |
for i in X:... |
|
1:n |
range(1, n+1) |
|
cond ? a : b |
a if cond else b |
|
2i + 1 |
2 * i + 1 |
|
4s |
return 4 * s |
|
println(a, b) |
print(a, b, sep="") |
|
print(a, b) |
print(a, b, sep="", end="") |
|
"$p" |
f"{p}" |
|
"$(p - π)" |
f"{p - math.pi}" |
This example shows that:
- Julia can be just as concise and readable as Python.
- Indentation in Julia is not meaningful like it is in Python. Instead, blocks end with
end. - Many math features are built in Julia and need no imports.
- There's some mathy syntactic sugar, such as
2i(but you can write2 * iif you prefer). - In Julia, the
returnkeyword is optional at the end of a function. The result of the last expression is returned (4sin this example). - Julia loves Unicode and does not hesitate to use Unicode characters like
π. However, there are generally plain-ASCII equivalents (e.g.,π == pi).
Typing Unicode Characters¶
Typing Unicode characters is easy: for latex symbols like π, just type \pi<tab>. For emojis like 😃, type \:smiley:<tab>.
This works in the REPL, in Jupyter, but unfortunately not in Colab (yet?). As a workaround, you can run the following code to print the character you want, then copy/paste it:
using REPL.REPLCompletions: latex_symbols, emoji_symbols
latex_symbols["\\pi"]
"π"
emoji_symbols["\\:smiley:"]
"😃"
In Julia, using Foo.Bar: a, b corresponds to running from foo.bar import a, b in Python.
| Julia | Python | |
|---|---|---|
using Foo |
from foo import *; import foo |
|
using Foo.Bar |
from foo.bar import *; from foo import bar |
|
using Foo.Bar: a, b |
from foo.bar import a, b |
|
using Foo: Bar |
from foo import bar |
Running Python code in Julia¶
Julia lets you easily run Python code using the PyCall module. We installed it earlier, so we just need to import it:
using PyCall
Now that we have imported PyCall, we can use the pyimport() function to import a Python module directly in Julia! For example, let's check which Python version we are using:
sys = pyimport("sys")
sys.version
"3.12.12 (main, Oct 10 2025, 08:52:57) [GCC 11.4.0]"
In fact, let's run the Python code we discussed earlier (this will take about 15 to 20 seconds to run, because Python is so slow...):
py"""
import math
def estimate_pi(n):
s = 1.0
for i in range(1, n + 1):
s += (-1 if i % 2 else 1) / (2 * i + 1)
return 4 * s
p = estimate_pi(100_000_000)
print(f"π ≈ {p}") # f-strings are available in Python 3.6+
print(f"Error is {p - math.pi}")
"""
π ≈ 3.141592663589326 Error is 9.999532757376528e-09
As you can see, running arbitrary Python code is as simple as using py-strings (py"..."). Note that py-strings are not part of the Julia language itself: they are defined by the PyCall module (we will see how this works later).
We can look at the value of p:
py"p"
3.141592663589326
Let's compare this to the value we calculated above using Julia:
py"p" - p
0.0
Perfect, they are exactly equal!
As you can see, it's very easy to mix Julia and Python code. So if there's a module you really love in Python, you can keep using it as long as you want! For example, let's use NumPy:
np = pyimport("numpy")
a = np.random.rand(2, 3)
2×3 Matrix{Float64}:
0.204074 0.0920921 0.584858
0.489169 0.89381 0.676898
Notice that PyCall automatically converts some Python types to Julia types, including NumPy arrays. That's really quite convenient! Note that Julia supports multi-dimensional arrays (analog to NumPy arrays) out of the box. Array{Float64, 2} means that it's a 2-dimensional array of 64-bit floats.
PyCall also converts Julia arrays to NumPy arrays when needed:
exp_a = np.exp(a)
2×3 Matrix{Float64}:
1.22639 1.09647 1.79474
1.63096 2.44442 1.96776
If you want to use some Julia variable in a py-string, for example exp_a, you can do so by writing $exp_a like this:
py"""
import numpy as np
result = np.log($exp_a)
"""
py"result"
2×3 Matrix{Float64}:
0.204074 0.0920921 0.584858
0.489169 0.89381 0.676898
If you want to keep using Matplotlib, it's best to use the PyPlot module (which we installed earlier), rather than trying to use pyimport("matplotlib"), as PyPlot provides a more straightforward interface with Julia, and it plays nicely with Jupyter and Colab:
using PyPlot
x = range(-5π, 5π, length=100)
plt.plot(x, sin.(x) ./ x) # we'll discuss this syntax in the next section
plt.title("sin(x) / x")
plt.grid("True")
plt.show()
That said, Julia has its own plotting libraries, such as the Plots library, which you may want to check out.
As you can see, Julia's range() function acts much like NumPy's linspace() function, when you use the length argument. However, it acts like Python's range() function when you use the step argument instead (except the upper bound is inclusive). Julia's range() function returns an object which behaves just like an array, except it doesn't actually use any RAM for its elements, it just stores the range parameters. If you want to collect all of the elements into an array, use the collect() function (similar to Python's list() function):
println(collect(range(10, 80, step=20)))
println(collect(10:20:80)) # 10:20:80 is equivalent to the previous range
println(collect(range(10, 80, length=5))) # similar to NumPy's linspace()
step = (80-10)/(5-1) # 17.5
println(collect(10:step:80)) # equivalent to the previous range
[10, 30, 50, 70] [10, 30, 50, 70] [10.0, 27.5, 45.0, 62.5, 80.0] [10.0, 27.5, 45.0, 62.5, 80.0]
The equivalent Python code is:
# PYTHON
print(list(range(10, 80+1, 20)))
# there's no short-hand for range() in Python
print(np.linspace(10, 80, 5))
step = (80-10)/(5-1) # 17.5
print([i*step + 10 for i in range(5)])
| Julia | Python | |
|---|---|---|
np = pyimport("numpy") |
import numpy as np |
|
using PyPlot |
from pylab import * |
|
1:10 |
range(1, 11) |
|
1:2:10or range(1, 10, step=2) |
range(1, 11, 2) |
|
1.2:0.5:10.3or range(1.2, 10.3, step=0.5) |
np.arange(1.2, 10.3, 0.5) |
|
range(1, 10, length=3) |
np.linspace(1, 10, 3) |
|
collect(1:5)or [i for i in 1:5] |
list(range(1, 6))or [i for i in range(1, 6)] |
Loop Fusion¶
Did you notice that we wrote sin.(x) ./ x (not sin(x) / x)? This is equivalent to [sin(i) / i for i in x].
a = sin.(x) ./ x
b = [sin(i) / i for i in x]
@assert a == b
This is not just syntactic sugar: it's actually a very powerful Julia feature. Indeed, notice that the array only gets traversed once. Even if we chained more than two dotted operations, the array would still only get traversed once. This is called loop fusion.
In contrast, when using NumPy arrays, sin(x) / x first computes a temporary array containing sin(x) and then it computes the final array. Two loops and two arrays instead of one. NumPy is implemented in C, and has been heavily optimized, but if you chain many operations, it still ends up being slower and using more RAM than Julia.
However, all the extra dots can sometimes make the code a bit harder to read. To avoid that, you can write @. before an expression: every operation will be "dotted" automatically, like this:
a = @. sin(x) / x
b = sin.(x) ./ x
@assert a == b
Note: Julia's @assert statement starts with an @ sign, just like @., which means that they are macros. In Julia, macros are very powerful metaprogramming tools: a macro is evaluated at parse time, and it can inspect the expression that follows it and then transform it, or even replace it. In practice, you will often use macros, but you will rarely define your own. I'll come back to macros later.
Julia is fast!¶
Let's compare the Julia and Python implementations of the estimate_pi() function:
@time estimate_pi(100_000_000);
0.126690 seconds
To get a more precise benchmark, it's preferable to use the BenchmarkTools module. Just like Python's timeit module, it provides tools to benchmark code by running it multiple times. This provides a better estimate of how long each call takes:
using BenchmarkTools
@benchmark estimate_pi(100_000_000)
BenchmarkTools.Trial: 40 samples with 1 evaluation per sample. Range (min … max): 126.203 ms … 128.595 ms ┊ GC (min … max): 0.00% … 0.00% Time (median): 126.876 ms ┊ GC (median): 0.00% Time (mean ± σ): 127.012 ms ± 637.152 μs ┊ GC (mean ± σ): 0.00% ± 0.00% ▃ ▃ █▃█ █ ▃ ▃ ▇▁█▇▇▇█▇▁▇▁███▁▇▇█▇▇▇▁▇▇█▁▇▇▁▁▁█▁▁▁▁▁▇▁▁▁▁▁▁▁▁▁▁▁▁▁▇▇▁▁▁▁▁▇▇▇ ▁ 126 ms Histogram: frequency by time 129 ms < Memory estimate: 0 bytes, allocs estimate: 0.
If this output is too verbose for you, simply use @btime instead:
@btime estimate_pi(100_000_000)
126.264 ms (0 allocations: 0 bytes)
3.141592663589326
Now let's time the Python version. Since the call is so slow, we just run it once (it will take about 15 seconds):
py"""
from timeit import timeit
duration = timeit("estimate_pi(100_000_000)", number=1, globals=globals())
"""
py"duration"
21.100875977999976
It looks like Julia is over 150 times faster than Python in this case! To be fair, PyCall does add some overhead, but even if you run this code in a separate Python shell, you will see that Julia crushes (pure) Python when it comes to speed.
So why is Julia so much faster than Python? Well, Julia compiles the code on the fly as it runs it.
Okay, let's summarize what we learned so far: Julia is a dynamic language that looks and feels a lot like Python, you can even execute Python code super easily, and pure Julia code runs much faster than pure Python code, because it is compiled on the fly. I hope this convinces you to read on!
Next, let's continue to see how Python's main constructs can be implemented in Julia.
Numbers¶
i = 42 # 64-bit integer
f = 3.14 # 64-bit float
c = 3.4 + 4.5im # 128-bit complex number
bi = BigInt(2)^1000 # arbitrarily long integer
bf = BigFloat(1) / 7 # arbitrary precision
r = 15//6 * 9//20 # rational number
9//8
And the equivalent Python code:
# PYTHON
i = 42
f = 3.14
c = 3.4 + 4.5j
bi = 2**1000 # integers are seemlessly promoted to long integers
from decimal import Decimal
bf = Decimal(1) / 7
from fractions import Fraction
r = Fraction(15, 6) * Fraction(9, 20)
Dividing integers gives floats, like in Python:
5 / 2
2.5
For integer division, use ÷ or div():
5 ÷ 2
2
div(5, 2)
2
The % operator is the remainder, not the modulo like in Python. These differ only for negative numbers:
57 % 10
7
(-57) % 10
-7
| Julia | Python | |
|---|---|---|
3.4 + 4.5im |
3.4 + 4.5j |
|
BigInt(2)^1000 |
2**1000 |
|
BigFloat(3.14) |
from decimal import DecimalDecimal(3.14) |
|
9//8 |
from fractions import FractionFraction(9, 8) |
|
5/2 == 2.5 |
5/2 == 2.5 |
|
5÷2 == 2or div(5, 2) == 2 |
5//2 == 2 |
|
57%10 == 7 |
57%10 == 7 |
|
(-57)%10 == -7 |
(-57)%10 == 3 |
Strings¶
Julia strings use double quotes " or triple quotes """, but not single quotes ':
s = "ångström" # Julia strings are UTF-8 encoded by default
println(s)
ångström
s = "Julia strings
can span
several lines\n\n
and they support the \"usual\" escapes like
\x41, \u5bb6, and \U0001f60a!"
println(s)
Julia strings
can span
several lines
and they support the "usual" escapes like
A, 家, and 😊!
Use repeat() instead of * to repeat a string, and use * instead of + for concatenation:
s = repeat("tick, ", 10) * "BOOM!"
println(s)
tick, tick, tick, tick, tick, tick, tick, tick, tick, tick, BOOM!
The equivalent Python code is:
# PYTHON
s = "tick, " * 10 + "BOOM!"
print(s)
Use join(a, s) instead of s.join(a):
s = join([i for i in 1:4], ", ")
println(s)
1, 2, 3, 4
You can also specify a string for the last join:
s = join([i for i in 1:4], ", ", " and ")
"1, 2, 3 and 4"
split() works as you might expect:
split(" one three four ")
3-element Vector{SubString{String}}:
"one"
"three"
"four"
split("one,,three,four!", ",")
4-element Vector{SubString{String}}:
"one"
""
"three"
"four!"
occursin("sip", "Mississippi")
true
replace("I like coffee", "coffee" => "tea")
"I like tea"
Triple quotes work a bit like in Python, but they also remove indentation and ignore the first line feed:
s = """
1. the first line feed is ignored if it immediately follows \"""
2. triple quotes let you use "quotes" easily
3. indentation is ignored
- up to left-most character
- ignoring the first line (the one with \""")
4. the final line feed it n̲o̲t̲ ignored
"""
println("<start>")
println(s)
println("<end>")
<start>
1. the first line feed is ignored if it immediately follows """
2. triple quotes let you use "quotes" easily
3. indentation is ignored
- up to left-most character
- ignoring the first line (the one with """)
4. the final line feed it n̲o̲t̲ ignored
<end>
String Interpolation¶
String interpolation uses $variable and $(expression):
total = 1 + 2 + 3
s = "1 + 2 + 3 = $total = $(1 + 2 + 3)"
println(s)
1 + 2 + 3 = 6 = 6
This means you must escape the $ sign:
s = "The car costs \$10,000"
println(s)
The car costs $10,000
Raw Strings¶
Raw strings use raw"..." instead of r"...":
s = raw"In a raw string, you only need to escape quotes \", but not
$ or \. There is one exception, however: the backslash \
must be escaped if it's just before quotes like \\\"."
println(s)
In a raw string, you only need to escape quotes ", but not
$ or \. There is one exception, however: the backslash \
must be escaped if it's just before quotes like \".
s = raw"""
Triple quoted raw strings are possible too: $, \, \t, "
- They handle indentation and the first line feed like regular
triple quoted strings.
- You only need to escape triple quotes like \""", and the
backslash before quotes like \\".
"""
println(s)
Triple quoted raw strings are possible too: $, \, \t, "
- They handle indentation and the first line feed like regular
triple quoted strings.
- You only need to escape triple quotes like """, and the
backslash before quotes like \".
Characters¶
Single quotes are used for individual Unicode characters:
a = 'å' # Unicode code point (single quotes)
'å': Unicode U+00E5 (category Ll: Letter, lowercase)
To be more precise:
- A Julia "character" represents a single Unicode code point (sometimes called a Unicode scalar).
- Multiple code points may be required to produce a single grapheme, i.e., something that readers would recognize as a single character. Such a sequence of code points is called a "Grapheme cluster".
For example, the character é can be represented either using the single code point \u00E9, or the grapheme cluster e + \u0301:
s = "café"
println(s, " has ", length(s), " code points")
café has 4 code points
s = "cafe\u0301"
println(s, " has ", length(s), " code points")
café has 5 code points
for c in "cafe\u0301"
display(c)
end
'c': ASCII/Unicode U+0063 (category Ll: Letter, lowercase)
'a': ASCII/Unicode U+0061 (category Ll: Letter, lowercase)
'f': ASCII/Unicode U+0066 (category Ll: Letter, lowercase)
'e': ASCII/Unicode U+0065 (category Ll: Letter, lowercase)
'́': Unicode U+0301 (category Mn: Mark, nonspacing)
Julia represents any individual character like 'é' using 32-bits (4 bytes):
sizeof('é')
4
But strings are represented using the UTF-8 encoding. In this encoding, code points 0 to 127 are represented using one byte, but any code point above 127 is represented using 2 to 6 bytes:
sizeof("a")
1
sizeof("é")
2
sizeof("家")
3
sizeof("🏳️🌈") # this is a grapheme with 4 code points of 4 + 3 + 3 + 4 bytes
14
[sizeof(string(c)) for c in "🏳️🌈"]
4-element Vector{Int64}:
4
3
3
4
You can iterate through graphemes instead of code points:
using Unicode
for g in graphemes("e\u0301🏳️🌈")
println(g)
end
é 🏳️🌈
String Indexing¶
Characters in a string are indexed based on the position of their starting byte in the UTF-8 representation. For example, the character ê in the string "être" is located at index 1, but the character 't' is located at index 3, since the UTF-8 encoding of ê is 2 bytes long:
s = "être"
println(s[1])
println(s[3])
println(s[4])
println(s[5])
ê t r e
If you try to get the character at index 2, you get an exception:
try
s[2]
catch ex
ex
end
StringIndexError("être", 2)
By the way, notice the exception-handling syntax (we'll discuss exceptions later):
| Julia | Python | |
|---|---|---|
try...catch ex...end |
try:...except Exception as ex:... |
You can get a substring easily, using valid character indices:
s[1:3]
"êt"
You can iterate through a string, and it will return all the code points:
for c in s
println(c)
end
ê t r e
Or you can iterate through the valid character indices:
for i in eachindex(s)
println(i, ": ", s[i])
end
1: ê 3: t 4: r 5: e
Benefits of representing strings as UTF-8:
- All Unicode characters are supported.
- UTF-8 is fairly compact (at least for Latin scripts).
- It plays nicely with C libraries which expect ASCII characters only, since ASCII characters correspond to the Unicode code points 0 to 127, which UTF-8 encodes exactly like ASCII.
Drawbacks:
- UTF-8 uses a variable number of bytes per character, which makes indexing harder.
- However, If the language tried to hide this by making
s[5]search for the 5th character from the start of the string, then code likefor i in 1:length(s); s[i]; endwould be unexpectedly inefficient, since at each iteration there would be a search from the beginning of the string, leading to O(n2) performance instead of O(n).
- However, If the language tried to hide this by making
findfirst(isequal('t'), "être")
3
findlast(isequal('p'), "Mississippi")
10
findnext(isequal('i'), "Mississippi", 2)
2
findnext(isequal('i'), "Mississippi", 2 + 1)
5
findprev(isequal('i'), "Mississippi", 5 - 1)
2
Other useful string functions: ncodeunits(str), codeunit(str, i), thisind(str, i), nextind(str, i, n=1), prevind(str, i, n=1).
Regular Expressions¶
To create a regular expression in Julia, use the r"..." syntax:
regex = r"c[ao]ff?(?:é|ee)"
r"c[ao]ff?(?:é|ee)"
The expression r"..." is equivalent to Regex("...") except the former is evaluated at parse time, while the latter is evaluated at runtime, so unless you need to construct a Regex dynamically, you should prefer r"...".
occursin(regex, "A bit more coffee?")
true
m = match(regex, "A bit more coffee?")
m.match
"coffee"
m.offset
12
m = match(regex, "A bit more tea?")
isnothing(m) && println("I suggest coffee instead")
I suggest coffee instead
regex = r"(.*)#(.+)"
line = "f(1) # nice comment"
m = match(regex, line)
code, comment = m.captures
println("code: ", repr(code))
println("comment: ", repr(comment))
code: "f(1) " comment: " nice comment"
m[2]
" nice comment"
m.offsets
2-element Vector{Int64}:
1
7
m = match(r"(?<code>.+)#(?<comment>.+)", line)
m[:comment]
" nice comment"
replace("Want more bread?", r"(?<verb>more|some)" => s"a little")
"Want a little bread?"
replace("Want more bread?", r"(?<verb>more|less)" => s"\g<verb> and \g<verb>")
"Want more and more bread?"
Control Flow¶
if statement¶
Julia's if statement works just like in Python, with a few differences:
- Julia uses
elseifinstead of Python'selif. - Julia's logic operators are just like in C-like languages:
&&meansand,||meansor,!meansnot, and so on.
a = 1
if a == 1
println("One")
elseif a == 2
println("Two")
else
println("Other")
end
One
Julia also has ⊻ for exclusive or (you can type \xor<tab> to get the ⊻ character):
@assert false ⊻ false == false
@assert false ⊻ true == true
@assert true ⊻ false == true
@assert true ⊻ true == false
Oh, and notice that true and false are all lowercase, unlike Python's True and False.
Since && is lazy (like and in Python), cond && f() is a common shorthand for if cond; f(); end. Think of it as "cond then f()":
a = 2
a == 1 && println("One")
a == 2 && println("Two")
Two
Similarly, cond || f() is a common shorthand for if !cond; f(); end. Think of it as "cond else f()":
a = 1
a == 1 || println("Not one")
a == 2 || println("Not two")
Not two
All expressions return a value in Julia, including if statements. For example:
a = 1
result = if a == 1
"one"
else
"two"
end
result
"one"
When an expression cannot return anything, it returns nothing:
a = 1
result = if a == 2
"two"
end
isnothing(result)
true
nothing is the single instance of the type Nothing:
typeof(nothing)
Nothing
for loops¶
You can use for loops just like in Python, as we saw earlier. However, it's also possible to create nested loops on a single line:
for a in 1:2, b in 1:3, c in 1:2
println((a, b, c))
end
(1, 1, 1) (1, 1, 2) (1, 2, 1) (1, 2, 2) (1, 3, 1) (1, 3, 2) (2, 1, 1) (2, 1, 2) (2, 2, 1) (2, 2, 2) (2, 3, 1) (2, 3, 2)
The corresponding Python code would look like this:
# PYTHON
from itertools import product
for a, b, c in product(range(1, 3), range(1, 4), range(1, 3)):
print((a, b, c))
The continue and break keywords work just like in Python. Note that in single-line nested loops, break will exit all loops, not just the inner loop:
for a in 1:2, b in 1:3, c in 1:2
println((a, b, c))
(a, b, c) == (2, 1, 1) && break
end
(1, 1, 1) (1, 1, 2) (1, 2, 1) (1, 2, 2) (1, 3, 1) (1, 3, 2) (2, 1, 1)
Julia does not support the equivalent of Python's for/else construct. You need to write something like this:
found = false
for person in ["Joe", "Jane", "Wally", "Jack", "Julia"] # try removing "Wally"
println("Looking at $person")
person == "Wally" && (found = true; break)
end
found || println("I did not find Wally.")
Looking at Joe Looking at Jane Looking at Wally
true
The equivalent Python code looks like this:
# PYTHON
for person in ["Joe", "Jane", "Wally", "Jack", "Julia"]: # try removing "Wally"
print(f"Looking at {person}")
if person == "Wally":
break
else:
print("I did not find Wally.")
| Julia | Python | |
|---|---|---|
if cond1...elseif cond2...else...end |
if cond1:...elif cond2:...else:... |
|
&& |
and |
|
\|\| |
or |
|
! |
not |
|
⊻ (type \xor<tab>) |
^ |
|
true |
True |
|
false |
False |
|
cond && f() |
if cond: f() |
|
cond \|\| f() |
if not cond: f() |
|
for i in 1:5 ... end |
for i in range(1, 6): ... |
|
for i in 1:5, j in 1:6 ... end |
from itertools import productfor i, j in product(range(1, 6), range(1, 7)):... |
|
while cond ... end |
while cond: ... |
|
continue |
continue |
|
break |
break |
Now lets looks at data structures, starting with tuples.
Tuples¶
Julia has tuples, very much like Python. They can contain anything:
t = (1, "Two", 3, 4, 5)
(1, "Two", 3, 4, 5)
Let's look at one element:
t[1]
1
Hey! Did you see that? Julia is 1-indexed, like Matlab and other math-oriented programming languages, not 0-indexed like Python and most programming languages. I found it easy to get used to, and in fact I quite like it, but your mileage may vary.
Moreover, the indexing bounds are inclusive. In Python, to get the 1st and 2nd elements of a list or tuple, you would write t[0:2] (or just t[:2]), while in Julia you write t[1:2].
t[1:2]
(1, "Two")
Note that end represents the index of the last element in the tuple. So you must write t[end] instead of t[-1]. Similarly, you must write t[end - 1], not t[-2], and so on.
t[end]
5
t[end - 1:end]
(4, 5)
Like in Python, tuples are immutable:
try
t[2] = 2
catch ex
ex
end
MethodError(setindex!, ((1, "Two", 3, 4, 5), 2, 2), 0x0000000000006870)
The syntax for empty and 1-element tuples is the same as in Python:
empty_tuple = ()
one_element_tuple = (42,)
(42,)
You can unpack a tuple, just like in Python (it's called "destructuring" in Julia):
a, b, c, d, e = (1, "Two", 3, 4, 5)
println("a=$a, b=$b, c=$c, d=$d, e=$e")
a=1, b=Two, c=3, d=4, e=5
It also works with nested tuples, just like in Python:
(a, (b, c), (d, e)) = (1, ("Two", 3), (4, 5))
println("a=$a, b=$b, c=$c, d=$d, e=$e")
a=1, b=Two, c=3, d=4, e=5
However, consider this example:
a, b, c = (1, "Two", 3, 4, 5)
println("a=$a, b=$b, c=$c")
a=1, b=Two, c=3
In Python, this would cause a ValueError: too many values to unpack. In Julia, the extra values in the tuple are just ignored.
If you want to capture the extra values in the variable c, you need to do so explicitly:
t = (1, "Two", 3, 4, 5)
a, b = t[1:2]
c = t[3:end]
println("a=$a, b=$b, c=$c")
a=1, b=Two, c=(3, 4, 5)
Or more concisely:
(a, b), c = t[1:2], t[3:end]
println("a=$a, b=$b, c=$c")
a=1, b=Two, c=(3, 4, 5)
The corresponding Python code is:
# PYTHON
t = (1, "Two", 3, 4, 5)
a, b, *c = t
print(f"a={a}, b={b}, c={c}")
Named Tuples¶
Julia supports named tuples:
nt = (name="Julia", category="Language", stars=5)
(name = "Julia", category = "Language", stars = 5)
nt.name
"Julia"
dump(nt)
@NamedTuple{name::String, category::String, stars::Int64}
name: String "Julia"
category: String "Language"
stars: Int64 5
The corresponding Python code is:
# PYTHON
from collections import namedtuple
Rating = namedtuple("Rating", ["name", "category", "stars"])
nt = Rating(name="Julia", category="Language", stars=5)
print(nt.name) # prints: Julia
print(nt) # prints: Rating(name='Julia', category='Language', stars=5)
Structs¶
Julia supports structs, which hold multiple named fields, a bit like named tuples:
struct Person
name
age
end
Structs have a default constructor, which expects all the field values, in order:
p = Person("Mary", 30)
Person("Mary", 30)
p.age
30
You can create other constructors by creating functions with the same name as the struct:
function Person(name)
Person(name, -1)
end
function Person()
Person("no name")
end
p = Person()
Person("no name", -1)
This creates two constructors: the second calls the first, which calls the default constructor. Notice that you can create multiple functions with the same name but different arguments. We will discuss this later.
These two constructors are called "outer constructors", since they are defined outside of the definition of the struct. You can also define "inner constructors":
struct Person2
name
age
function Person2(name)
new(name, -1)
end
end
function Person2()
Person2("no name")
end
p = Person2()
Person2("no name", -1)
This time, the outer constructor calls the inner constructor, which calls the new() function. This new() function only works in inner constructors, and of course it creates an instance of the struct.
When you define inner constructors, they replace the default constructor:
try
Person2("Bob", 40)
catch ex
ex
end
MethodError(Person2, ("Bob", 40), 0x0000000000006875)
Structs usually have very few inner constructors (often just one), which do the heavy duty work, and the checks. Then they may have multiple outer constructors which are mostly there for convenience.
By default, structs are immutable:
try
p.name = "Someone"
catch ex
ex
end
ErrorException("setfield!: immutable struct of type Person2 cannot be changed")
However, it is possible to define a mutable struct:
mutable struct Person3
name
age
end
p = Person3("Lucy", 79)
p.age += 1
p
Person3("Lucy", 80)
Structs look a lot like Python classes, with instance variables and constructors, but where are the methods? We will discuss this later, in the "Methods" section.
Arrays¶
Let's create a small array:
a = [1, 4, 9, 16]
4-element Vector{Int64}:
1
4
9
16
Indexing and assignments work as you would expect:
a[1] = 10
a[2:3] = [20, 30]
a
4-element Vector{Int64}:
10
20
30
16
Element Type¶
Since we used only integers when creating the array, Julia inferred that the array is only meant to hold integers (NumPy arrays behave the same way). Let's try adding a string:
try
a[3] = "Three"
catch ex
ex
end
MethodError(convert, (Int64, "Three"), 0x0000000000006876)
Nope! We get a MethodError exception, telling us that Julia could not convert the string "Three" to a 64-bit integer (we will discuss exceptions later). If we want an array that can hold any type, like Python's lists can, we must prefix the array with Any, which is Julia's root type (like object in Python):
a = Any[1, 4, 9, 16]
a[3] = "Three"
a
4-element Vector{Any}:
1
4
"Three"
16
Prefixing with Float64, or String or any other type works as well:
Float64[1, 4, 9, 16]
4-element Vector{Float64}:
1.0
4.0
9.0
16.0
An empty array is automatically an Any array:
a = []
Any[]
You can use the eltype() function to get an array's element type (the equivalent of NumPy arrays' dtype):
eltype([1, 4, 9, 16])
Int64
If you create an array containing objects of different types, Julia will do its best to use a type that can hold all the values as precisely as possible. For example, a mix of integers and floats results in a float array:
[1, 2, 3.0, 4.0]
4-element Vector{Float64}:
1.0
2.0
3.0
4.0
This is similar to NumPy's behavior:
# PYTHON
np.array([1, 2, 3.0, 4.0]) # => array([1., 2., 3., 4.])
A mix of unrelated types results in an Any array:
[1, 2, "Three", 4]
4-element Vector{Any}:
1
2
"Three"
4
If you want to live in a world without type constraints, you can prefix all you arrays with Any, and you will feel like you're coding in Python. But I don't recommend it: the compiler can perform a bunch of optimizations when it knows exactly the type and size of the data the program will handle, so it will run much faster. So when you create an empty array but you know the type of the values it will contain, you might as well prefix it with that type (you don't have to, but it will speed up your program).
Push and Pop¶
To append elements to an array, use the push!() function. By convention, functions whose name ends with a bang ! may modify their arguments:
a = [1]
push!(a, 4)
push!(a, 9, 16)
4-element Vector{Int64}:
1
4
9
16
This is similar to the following Python code:
# PYTHON
a = [1]
a.append(4)
a.extend([9, 16]) # or simply a += [9, 16]
And pop!() works like in Python:
pop!(a)
16
Equivalent to:
# PYTHON
a.pop()
There are many more functions you can call on an array. We will see later how to find them.
Multidimensional Arrays¶
Importantly, Julia arrays can be multidimensional, just like NumPy arrays:
M = [1 2 3 4
5 6 7 8
9 10 11 12]
3×4 Matrix{Int64}:
1 2 3 4
5 6 7 8
9 10 11 12
Another syntax for this is:
M = [1 2 3 4; 5 6 7 8; 9 10 11 12]
3×4 Matrix{Int64}:
1 2 3 4
5 6 7 8
9 10 11 12
You can index them much like NumPy arrays:
M[2:3, 3:4]
2×2 Matrix{Int64}:
7 8
11 12
You can transpose a matrix using the "adjoint" operator ':
M'
4×3 adjoint(::Matrix{Int64}) with eltype Int64:
1 5 9
2 6 10
3 7 11
4 8 12
As you can see, Julia arrays are closer to NumPy arrays than to Python lists.
Arrays can be concatenated vertically using the vcat() function:
M1 = [1 2
3 4]
M2 = [5 6
7 8]
vcat(M1, M2)
4×2 Matrix{Int64}:
1 2
3 4
5 6
7 8
Alternatively, you can use the [M1; M2] syntax:
[M1; M2]
4×2 Matrix{Int64}:
1 2
3 4
5 6
7 8
To concatenate arrays horizontally, use hcat():
hcat(M1, M2)
2×4 Matrix{Int64}:
1 2 5 6
3 4 7 8
Or you can use the [M1 M2] syntax:
[M1 M2]
2×4 Matrix{Int64}:
1 2 5 6
3 4 7 8
As you can see, the semicolon ; is used to concatenate along the 1st axis, while the space is used to concatenate along the 2nd axis.
Since Julia 1.7, it's possible to use ;; instead of the space to concatenate along the 2nd axis, ;;; to concatenate along the 3rd axis, ;;;; along the 4th axis, and so on. For example, the following code creates a 4x3x2 array in Julia 1.7+ (however, it creates a vector with numbers 1 to 24 in Julia 1.6 and earlier):
[1;2;3;4 ;; 5;6;7;8 ;; 9;10;11;12 ;;; 13;14;15;16 ;; 17;18;19;20 ;; 21;22;23;24]
4×3×2 Array{Int64, 3}:
[:, :, 1] =
1 5 9
2 6 10
3 7 11
4 8 12
[:, :, 2] =
13 17 21
14 18 22
15 19 23
16 20 24
Note that Julia arrays are column-major, as opposed to row-major like NumPy arrays. This means that in memory, the elements are listed by column first (as well as in the syntax in the previous cell). For example, the elements of the previous array are stored in memory in the following order: 1, 2, 3, ..., 24 (whereas NumPy would store them as 1, 13, 5, 17, 9, 21, 2, 14, 6, 18, and so on).
For this reason, the fastest way to iterate over a Julia array using nested for loops is to use the outer for loop for the last dimension, and the inner for loop for the first dimension (the opposite is true with NumPy).
For this reason, in Julia Machine Learning frameworks like Flux, the batch dimension is typically the last one, instead of the first one in Python frameworks like TensorFlow or PyTorch.
You can combine horizontal and vertical concatenation:
M3 = [9 10 11 12]
[M1 M2; M3]
3×4 Matrix{Int64}:
1 2 5 6
3 4 7 8
9 10 11 12
Equivalently, you can call the hvcat() function. The first argument specifies the number of arguments to concatenate in each block row:
hvcat((2, 1), M1, M2, M3)
3×4 Matrix{Int64}:
1 2 5 6
3 4 7 8
9 10 11 12
hvcat() is useful to create a single cell matrix:
hvcat(1, 42)
1×1 Matrix{Int64}:
42
Or a column vector (i.e., an n×1 matrix = a matrix with a single column):
hvcat((1, 1, 1), 10, 11, 12) # a column vector with values 10, 11, 12
hvcat(1, 10, 11, 12) # equivalent to the previous line
3×1 Matrix{Int64}:
10
11
12
If you are using Julia 1.7+, you can use this syntax instead to create a single cell matrix:
[42;;]
1×1 Matrix{Int64}:
42
Or a column vector:
[10;11;12;;]
3×1 Matrix{Int64}:
10
11
12
Alternatively, you can transpose a row vector (but hvcat() and the Julia 1.7 syntax are a bit faster):
[10 11 12]'
3×1 adjoint(::Matrix{Int64}) with eltype Int64:
10
11
12
The REPL and IJulia call display() to print the result of the last expression in a cell (except when it is nothing). It is fairly verbose:
display([1, 2, 3, 4])
4-element Vector{Int64}:
1
2
3
4
The println() function is more concise, but be careful not to confuse vectors, column vectors and row vectors (printed with commas, semi-colons and spaces, respectively):
println("Vector: ", [1, 2, 3, 4])
println("Column vector: ", hvcat(1, 1, 2, 3, 4))
println("Row vector: ", [1 2 3 4])
println("Matrix: ", [1 2 3; 4 5 6])
Vector: [1, 2, 3, 4] Column vector: [1; 2; 3; 4;;] Row vector: [1 2 3 4] Matrix: [1 2 3; 4 5 6]
Before Julia 1.7, the column vector was printed as [1; 2; 3; 4], but evaluating [1; 2; 3; 4] would give you a regular vector. That's because [x;y] concatenates x and y vertically, and if x and y are scalars or vectors, you just get a regular vector. Since Julia 1.7, the column vector is printed as [1; 2; 3; 4;;].
| Julia | Python | |
|---|---|---|
a = [1, 2, 3] |
a = [1, 2, 3]or import numpy as npnp.array([1, 2, 3]) |
|
a[1] |
a[0] |
|
a[end] |
a[-1] |
|
a[2:end-1] |
a[1:-1] |
|
push!(a, 5) |
a.append(5) |
|
pop!(a) |
a.pop() |
|
M = [1 2 3] |
np.array([[1, 2, 3]]) |
|
M = [1 2 3]' |
np.array([[1, 2, 3]]).T |
|
M = hvcat(1, 1, 2, 3)or M = [1; 2; 3;;] since Julia 1.7 |
np.array([[1], [2], [3]]) |
|
M = [1 2 34 5 6]or M = [1 2 3; 4 5 6]or M = [1;4 ;; 2;5 ;; 3;6] since Julia 1.7 |
M = np.array([[1,2,3], [4,5,6]]) |
|
M[1:2, 2:3] |
M[0:2, 1:3] |
|
[M1; M2] |
np.r_[M1, M2] |
|
[M1 M2] |
np.c_[M1, M2] |
|
[M1 M2; M3] |
np.r_[np.c_[M1, M2], M3] |
|
[42;;;] since Julia 1.7 |
np.array([[[42]]]) |
Comprehensions¶
List comprehensions are available in Julia, just like in Python (they're usually just called "comprehensions" in Julia):
a = [x^2 for x in 1:4]
4-element Vector{Int64}:
1
4
9
16
You can filter elements using an if clause, just like in Python:
a = [x^2 for x in 1:5 if x ∉ (2, 4)]
3-element Vector{Int64}:
1
9
25
a ∉ bis equivalent to!(a in b)(ora not in bin Python). You can type∉with\notin<tab>a ∈ bis equivalent toa in b. You can type it with\in<tab>
In Julia, comprehensions can contain nested loops, just like in Python:
a = [(i,j) for i in 1:3 for j in 1:i]
6-element Vector{Tuple{Int64, Int64}}:
(1, 1)
(2, 1)
(2, 2)
(3, 1)
(3, 2)
(3, 3)
Here's the corresponding Python code:
# PYTHON
a = [(i, j) for i in range(1, 4) for j in range(1, i+1)]
Julia comprehensions can also create multi-dimensional arrays (note the different syntax: there is only one for):
a = [row * col for row in 1:3, col in 1:5]
3×5 Matrix{Int64}:
1 2 3 4 5
2 4 6 8 10
3 6 9 12 15
Dictionaries¶
The syntax for dictionaries is a bit different than Python:
d = Dict("tree"=>"arbre", "love"=>"amour", "coffee"=>"café")
println(d["tree"])
arbre
println(get(d, "unknown", "pardon?"))
pardon?
keys(d)
KeySet for a Dict{String, String} with 3 entries. Keys:
"coffee"
"tree"
"love"
values(d)
ValueIterator for a Dict{String, String} with 3 entries. Values:
"café"
"arbre"
"amour"
haskey(d, "love")
true
"love" in keys(d) # this is slower than haskey()
true
The equivalent Python code is of course:
d = {"tree": "arbre", "love": "amour", "coffee": "café"}
d["tree"]
d.get("unknown", "pardon?")
d.keys()
d.values()
"love" in d
"love" in d.keys()
Dict comprehensions work as you would expect:
d = Dict(i=>i^2 for i in 1:5)
Dict{Int64, Int64} with 5 entries:
5 => 25
4 => 16
2 => 4
3 => 9
1 => 1
Note that the items (aka "pairs" in Julia) are shuffled, since dictionaries are hash-based, like in Python (although Python sorts them by key for display).
You can easily iterate through the dictionary's pairs like this:
for (k, v) in d
println("$k maps to $v")
end
5 maps to 25 4 maps to 16 2 maps to 4 3 maps to 9 1 maps to 1
The equivalent code in Python is:
# PYTHON
d = {i: i**2 for i in range(1, 6)}
for k, v in d.items():
print(f"{k} maps to {v}")
And you can merge dictionaries like this:
d1 = Dict("tree"=>"arbre", "love"=>"amour", "coffee"=>"café")
d2 = Dict("car"=>"voiture", "love"=>"aimer")
d = merge(d1, d2)
Dict{String, String} with 4 entries:
"car" => "voiture"
"coffee" => "café"
"tree" => "arbre"
"love" => "aimer"
Notice that the second dictionary has priority in case of conflict (it's "love" => "aimer", not "love" => "amour").
In Python, this would be:
# PYTHON
d1 = {"tree": "arbre", "love": "amour", "coffee": "café"}
d2 = {"car": "voiture", "love": "aimer"}
d = {**d1, **d2}
Or if you want to update the first dictionary instead of creating a new one:
merge!(d1, d2)
Dict{String, String} with 4 entries:
"car" => "voiture"
"coffee" => "café"
"tree" => "arbre"
"love" => "aimer"
In Python, that's:
# PYTHON
d1.update(d2)
In Julia, each pair is an actual Pair object:
p = "tree" => "arbre"
println(typeof(p))
k, v = p
println("$k maps to $v")
Pair{String, String}
tree maps to arbre
Note that any object for which a hash() method is implemented can be used as a key in a dictionary. This includes all the basic types like integers, floats, as well as string, tuples, etc. But it also includes arrays! In Julia, you have the freedom to use arrays as keys (unlike in Python), but make sure not to mutate these arrays after insertion, or else things will break! Indeed, the pairs will be stored in memory in a location that depends on the hash of the key at insertion time, so if that key changes afterwards, you won't be able to find the pair anymore:
a = [1, 2, 3]
d = Dict(a => "My array")
println("The dictionary is: $d")
println("Indexing works fine as long as the array is unchanged: ", d[a])
a[1] = 10
println("This is the dictionary now: $d")
try
println("Key changed, indexing is now broken: ", d[a])
catch ex
ex
end
The dictionary is: Dict([1, 2, 3] => "My array") Indexing works fine as long as the array is unchanged: My array This is the dictionary now: Dict([10, 2, 3] => "My array")
KeyError([10, 2, 3])
However, it's still possible to iterate through the keys, the values or the pairs:
for pair in d
println(pair)
end
[10, 2, 3] => "My array"
| Julia | Python | |
|---|---|---|
Dict("tree"=>"arbre", "love"=>"amour") |
{"tree": "arbre", "love": "amour"} |
|
d["arbre"] |
d["arbre"] |
|
get(d, "unknown", "default") |
d.get("unknown", "default") |
|
keys(d) |
d.keys() |
|
values(d) |
d.values() |
|
haskey(d, k) |
k in d |
|
Dict(i=>i^2 for i in 1:4) |
{i: i**2 for i in 1:4} |
|
for (k, v) in d |
for k, v in d.items(): |
|
merge(d1, d2) |
{**d1, **d2} |
|
merge!(d1, d2) |
d1.update(d2) |
Sets¶
Let's create a couple sets:
odd = Set([1, 3, 5, 7, 9, 11])
prime = Set([2, 3, 5, 7, 11])
Set{Int64} with 5 elements:
5
7
2
11
3
The order of sets is not guaranteed, just like in Python.
Use in or ∈ (type \in<tab>) to check whether a set contains a given value:
5 ∈ odd
true
5 in odd
true
Both of these expressions are equivalent to:
in(5, odd)
true
Now let's get the union of these two sets:
odd ∪ prime
Set{Int64} with 7 elements:
5
7
1
11
2
9
3
∪ is the union symbol, not a U. To type this character, type \cup<tab> (it has the shape of a cup). Alternatively, you can just use the union() function:
union(odd, prime)
Set{Int64} with 7 elements:
5
7
1
11
2
9
3
Now let's get the intersection using the ∩ symbol (type \cap<tab>):
odd ∩ prime
Set{Int64} with 4 elements:
5
7
11
3
Or use the intersect() function:
intersect(odd, prime)
Set{Int64} with 4 elements:
5
7
11
3
Next, let's get the set difference and the symetric difference between these two sets:
setdiff(odd, prime) # values in odd but not in prime
Set{Int64} with 2 elements:
9
1
symdiff(odd, prime) # values that are not in the intersection
Set{Int64} with 3 elements:
2
9
1
Lastly, set comprehensions work just fine:
Set([i^2 for i in 1:4])
Set{Int64} with 4 elements:
4
16
9
1
The equivalent Python code is:
# PYTHON
odds = {1, 3, 5, 7, 9, 11}
primes = {2, 3, 5, 7, 11}
5 in primes
odds | primes # union
odds.union(primes)
odds & primes # intersection
odds.intersection(primes)
odds - primes # set difference
odds.difference(primes)
odds ^ primes # symmetric difference
odds.symmetric_difference(primes)
{i**2 for i in range(1, 5)}
Note that you can store any hashable object in a Set (i.e., any instance of a type for which the hash() method is implemented). This includes arrays, unlike in Python. Just like for dictionary keys, you can add arrays to sets, but make sure not to mutate them after insertion.
|Julia|Python
|-----|------
|Set([1, 3, 5, 7]) | {1, 3, 5, 7}
|5 in odd | 5 in odd
|Set([i^2 for i in 1:4]) | {i**2 for i in range(1, 5)}
|odd ∪ primes | odd | primes
union(odd, primes) |
odd.union(primes) |
|
|---|---|---|
insersect(odd, primes) |
odd.intersection(primes) |
|
setdiff(odd, primes) |
odd - primes or odd.difference(primes) |
|
symdiff(odd, primes) |
odd ^ primes or odd.symmetric_difference(primes) |
Enums¶
To create an enum, use the @enum macro:
@enum Fruit apple=1 banana=2 orange=3
This creates the Fruit enum, with 3 possible values. It also binds the names to the values:
banana
banana::Fruit = 2
Or you can get a Fruit instance using the value:
Fruit(2)
banana::Fruit = 2
And you can get all the instances of the enum easily:
instances(Fruit)
(apple, banana, orange)
| Julia | Python | |
|---|---|---|
@enum Fruit apple=1 banana=2 orange=3 |
from enum import Enumclass Fruit(Enum):APPLE = 1BANANA = 2ORANGE = 3 |
|
Fruit(2) === banana |
Fruit(2) is Fruit.BANANA |
|
instances(Fruit) |
dir(Fruit) |
Object Identity¶
In the previous example, Fruit(2) and banana refer to the same object, not just two objects that happen to be equal. You can verify using the === operator, which is the equivalent of Python's is operator:
banana === Fruit(2)
true
You can also check this by looking at their objectid(), which is the equivalent of Python's id() function:
objectid(banana)
0x000000000c17feaa
objectid(Fruit(2))
0x000000000c17feaa
a = [1, 2, 4]
b = [1, 2, 4]
@assert a == b # a and b are equal
@assert a !== b # but they are not the same object
| Julia | Python | |
|---|---|---|
a === b |
a is b |
|
a !== b |
a is not b |
|
objectid(obj) |
id(obj) |
Other Collections¶
For the Julia equivalent of Python's other collections, namely defaultdict, deque, OrderedDict, and Counter, check out these libraries:
Now let's looks at various iteration constructs.
Iteration Tools¶
Generator Expressions¶
Just like in Python, a generator expression resembles a list comprehension, but without the square brackets, and it returns a generator instead of a list. Here's a much shorter implementation of the estimate_pi() function using a generator expression:
function estimate_pi2(n)
4 * sum((isodd(i) ? -1 : 1)/(2i+1) for i in 0:n)
end
@assert estimate_pi(100) == estimate_pi2(100)
That's very similar to the corresponding Python code:
# PYTHON
def estimate_pi2(n):
return 4 * sum((-1 if i%2==1 else 1)/(2*i+1) for i in range(n+1))
assert estimate_pi(100) == estimate_pi2(100)
zip, enumerate, collect¶
The zip() function works much like in Python:
for (i, s) in zip(10:13, ["Ten", "Eleven", "Twelve"])
println(i, ": ", s)
end
10: Ten 11: Eleven 12: Twelve
Notice that the parentheses in for (i, s) are required in Julia, as opposed to Python.
The enumerate() function also works like in Python, except of course it is 1-indexed:
for (i, s) in enumerate(["One", "Two", "Three"])
println(i, ": ", s)
end
1: One 2: Two 3: Three
To pull the values of a generator into an array, use collect():
collect(1:5)
5-element Vector{Int64}:
1
2
3
4
5
A shorter syntax for that is:
[1:5;]
5-element Vector{Int64}:
1
2
3
4
5
The equivalent Python code is:
# PYTHON
list(range(1, 6))
Generators¶
In Python, you can easily write a generator function to create an object that will behave like an iterator. For example, let's create a generator for the Fibonacci sequence (where each number is the sum of the two previous numbers):
def fibonacci(n):
a, b = 1, 1
for i in range(n):
yield a
a, b = b, a + b
for f in fibonacci(10):
print(f)
This is also quite easy in Julia:
function fibonacci(n)
Channel() do ch
a, b = 1, 1
for i in 1:n
put!(ch, a)
a, b = b, a + b
end
end
end
for f in fibonacci(10)
println(f)
end
1 1 2 3 5 8 13 21 34 55
The Channel type is part of the API for tasks and coroutines. We'll discuss these later.
Now let's take a closer look at functions.
Functions¶
Arguments¶
Julia functions supports positional arguments and default values:
function draw_face(x, y, width=3, height=4)
println("x=$x, y=$y, width=$width, height=$height")
end
draw_face(10, 20, 30)
x=10, y=20, width=30, height=4
However, unlike in Python, positional arguments must not be named when the function is called:
try
draw_face(10, 20, width=30)
catch ex
ex
end
MethodError(Core.kwcall, ((width = 30,), Main.draw_face, 10, 20), 0x000000000000688b)
Julia also supports a variable number of arguments (called "varargs") using the syntax arg..., which is the equivalent of Python's *arg:
function copy_files(target_dir, paths...)
println("target_dir=$target_dir, paths=$paths")
end
copy_files("/tmp", "a.txt", "b.txt")
target_dir=/tmp, paths=("a.txt", "b.txt")
Keyword arguments are supported, after a semicolon ;:
function copy_files2(paths...; confirm=false, target_dir)
println("paths=$paths, confirm=$confirm, $target_dir")
end
copy_files2("a.txt", "b.txt"; target_dir="/tmp")
paths=("a.txt", "b.txt"), confirm=false, /tmp
Notes:
target_dirhas no default value, so it is a required argument.- The order of the keyword arguments does not matter.
You can have another vararg in the keyword section. It corresponds to Python's **kwargs:
function copy_files3(paths...; confirm=false, target_dir, options...)
println("paths=$paths, confirm=$confirm, $target_dir")
verbose = options[:verbose]
println("verbose=$verbose")
end
copy_files3("a.txt", "b.txt"; target_dir="/tmp", verbose=true, timeout=60)
paths=("a.txt", "b.txt"), confirm=false, /tmp
verbose=true
The options vararg acts like a dictionary (we will discuss dictionaries later). The keys are symbols, e.g., :verbose. Symbols are like strings, less flexible but faster. They are typically used as keys or identifiers.
| Julia | Python (3.8+ if / is used) |
|
|---|---|---|
function foo(a, b=2, c=3)...endfoo(1, 2) # positional only |
def foo(a, b=2, c=3, /):...foo(1, 2) # pos only because of / |
|
function foo(;a=1, b, c=3)...endfoo(c=30, b=2) # keyword only |
def foo(*, a=1, b, c=3):...foo(c=30, b=2) # kw only because of * |
|
function foo(a, b=2; c=3, d)...endfoo(1; d=4) # pos only; then keyword only |
def foo(a, b=2, /, *, c=3, d):...foo(1, d=4) # pos only then kw only |
|
function foo(a, b=2, c...)...endfoo(1, 2, 3, 4) # positional only |
def foo(a, b=2, /, *c):...foo(1, 2, 3, 4) # positional only |
|
function foo(a, b=1, c...; d=1, e, f...)...endfoo(1, 2, 3, 4, e=5, x=10, y=20) |
def foo(a, b=1, /, *c, d=1, e, **f):...foo(1, 2, 3, 4, e=5, x=10, y=20) |
Concise Functions¶
In Julia, the following definition:
square(x) = x^2
square (generic function with 1 method)
is equivalent to:
function square(x)
x^2
end
square (generic function with 1 method)
For example, here's a shorter way to define the estimate_pi() function in Julia:
estimate_pi3(n) = 4 * sum((isodd(i) ? -1 : 1)/(2i+1) for i in 0:n)
estimate_pi3 (generic function with 1 method)
To define a function on one line in Python, you need to use a lambda (but this is generally frowned upon, since the resulting function's name is "<lambda>"):
# PYTHON
square = lambda x: x**2
assert square.__name__ == "<lambda>"
This leads us to anonymous functions.
Anonymous Functions¶
You can define anonymous functions (lambdas) using the in -> out syntax:
map(x -> x^2, 1:4)
4-element Vector{Int64}:
1
4
9
16
Here is the equivalent Python code:
list(map(lambda x: x**2, range(1, 5)))
Notes:
map()returns an array in Julia, instead of an iterator like in Python.- You could use a comprehension instead:
[x^2 for x in 1:4].
| Julia | Python | |
|---|---|---|
x -> x^2 |
lambda x: x**2 |
|
(x,y) -> x + y |
lambda x,y: x + y |
|
() -> println("yes") |
lambda: print("yes") |
In Python, lambda functions must be simple expressions. They cannot contain multiple statements. In Julia, they can be as long as you want. Indeed, you can create a multi-statement block using the syntax (stmt_1; stmt_2; ...; stmt_n). The return value is the output of the last statement. For example:
map(x -> (println("Number $x"); x^2), 1:4)
Number 1 Number 2 Number 3 Number 4
4-element Vector{Int64}:
1
4
9
16
This syntax can span multiple lines:
map(x -> (
println("Number $x");
x^2
), 1:4)
Number 1 Number 2 Number 3 Number 4
4-element Vector{Int64}:
1
4
9
16
But in this case, it's probably clearer to use the begin ... end syntax instead:
map(x -> begin
println("Number $x")
x^2
end, 1:4)
Number 1 Number 2 Number 3 Number 4
4-element Vector{Int64}:
1
4
9
16
Notice that this syntax allows you to drop the semicolons ; at the end of each line in the block.
Yet another way to define an anonymous function is using the function (args) ... end syntax:
map(function (x)
println("Number $x")
x^2
end, 1:4)
Number 1 Number 2 Number 3 Number 4
4-element Vector{Int64}:
1
4
9
16
Lastly, if you're passing the anonymous function as the first argument to a function (as is the case in this example), it's usually much preferable to define the anonymous function immediately after the function call, using the do syntax, like this:
map(1:4) do x
println("Number $x")
x^2
end
Number 1 Number 2 Number 3 Number 4
4-element Vector{Int64}:
1
4
9
16
This syntax lets you easily define constructs that feel like language extensions:
function my_foreach(func, collection)
for i in collection
func(i)
end
end
my_foreach(1:4) do i
println("The square of $i is $(i^2)")
end
The square of 1 is 1 The square of 2 is 4 The square of 3 is 9 The square of 4 is 16
In fact, Julia has a similar foreach() function.
The do syntax could be used to write a Domain Specific Language (DSL), for example an infrastructure automation DSL:
function spawn_server(startup_func, server_type)
println("Starting $server_type server")
server_id = 1234
println("Configuring server $server_id...")
startup_func(server_id)
end
# This is the DSL part
spawn_server("web") do server_id
println("Creating HTML pages on server $server_id...")
end
Starting web server Configuring server 1234... Creating HTML pages on server 1234...
It's also quite nice for event-driven code:
handlers = []
on_click(handler) = push!(handlers, handler)
click(event) = foreach(handlers) do handler handler(event) end
on_click() do event
println("Mouse clicked at $event")
end
on_click() do event
println("Beep.")
end
click((x=50, y=20))
click((x=120, y=10))
Mouse clicked at (x = 50, y = 20) Beep. Mouse clicked at (x = 120, y = 10) Beep.
It can also be used to create context managers, for example to automatically close an object after it has been used, even if an exception is raised:
function with_database(func, name)
println("Opening connection to database $name")
db = "a db object for database $name"
try
func(db)
finally
println("Closing connection to database $name")
end
end
with_database("jobs") do db
println("I'm working with $db")
#error("Oops") # try uncommenting this line (database will still be closed)
end
Opening connection to database jobs I'm working with a db object for database jobs Closing connection to database jobs
The equivalent code in Python would look like this:
# PYTHON
class Database:
def __init__(self, name):
self.name = name
def __enter__(self):
print(f"Opening connection to database {self.name}")
return f"a db object for database {self.name}"
def __exit__(self, type, value, traceback):
print(f"Closing connection to database {self.name}")
with Database("jobs") as db:
print(f"I'm working with {db}")
#raise Exception("Oops") # try uncommenting this line
Or you could use contextlib:
from contextlib import contextmanager
@contextmanager
def database(name):
print(f"Opening connection to database {name}")
db = f"a db object for database {name}"
try:
yield db
finally:
print(f"Closing connection to database {name}")
with database("jobs") as db:
print(f"I'm working with {db}")
#raise Exception("Oops") # try uncommenting this line
Piping¶
If you are used to the Object Oriented syntax "a b c".upper().split(), you may feel that writing split(uppercase("a b c")) is a bit backwards. If so, the piping operation |> is for you:
"a b c" |> uppercase |> split
3-element Vector{SubString{String}}:
"A"
"B"
"C"
If you want to pass more than one argument to some of the functions, you can use anonymous functions:
"a b c" |> uppercase |> split |> tokens->join(tokens, ", ", " and ")
"A, B and C"
The dotted version of the pipe operator works as you might expect, applying the ith function of the right array to the ith value in the left array:
[π/2, "hello", 4] .|> [sin, length, x->x^2]
3-element Vector{Real}:
1.0
5
16
Composition¶
Julia also lets you compose functions like mathematicians do, using the composition operator ∘ (\circ<tab> in the REPL or Jupyter, but not Colab):
f = exp ∘ sin ∘ sqrt
f(2.0) == exp(sin(sqrt(2.0)))
true
Methods¶
Earlier, we discussed structs, which look a lot like Python classes, with instance variables and constructors, but they did not contain any methods (just the inner constructors). In Julia, methods are defined separately, like regular functions:
struct Person
name
age
end
function greetings(greeter)
println("Hi, my name is $(greeter.name), I am $(greeter.age) years old.")
end
p = Person("Alice", 70)
greetings(p)
Hi, my name is Alice, I am 70 years old.
Since the greetings() method in Julia is not bound to any particular type, we can use it with any other type we want, as long as that type has a name and an age (i.e., if it quacks like a duck):
struct City
name
country
age
end
using Dates
c = City("Auckland", "New Zealand", year(now()) - 1840)
greetings(c)
Hi, my name is Auckland, I am 185 years old.
You could code this the same way in Python if you wanted to:
# PYTHON
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
class City:
def __init__(self, name, country, age):
self.name = name
self.country = country
self.age = age
def greetings(greeter):
print(f"Hi there, my name is {greeter.name}, I am {greeter.age} years old.")
p = Person("Lucy", 70)
greetings(p)
from datetime import date
c = City("Auckland", "New Zealand", date.today().year - 1840)
greetings(c)
However, many Python programmers would use inheritance in this case:
class Greeter:
def __init__(self, name, age):
self.name = name
self.age = age
def greetings(self):
print(f"Hi there, my name is {self.name}, I am {self.age} years old.")
class Person(Greeter):
def __init__(self, name, age):
super().__init__(name, age)
class City(Greeter):
def __init__(self, name, country, age):
super().__init__(name, age)
self.country = country
p = Person("Lucy", 70)
p.greetings()
from datetime import date
c = City("Auckland", "New Zealand", date.today().year - 1840)
c.greetings()
Extending a Function¶
One nice thing about having a class hierarchy is that you can override methods in subclasses to get specialized behavior for each class. For example, in Python you could override the greetings() method like this:
# PYTHON
class Developer(Person):
def __init__(self, name, age, language):
super().__init__(name, age)
self.language = language
def greetings(self):
print(f"Hi there, my name is {self.name}, I am {self.age} years old.")
print(f"My favorite language is {self.language}.")
d = Developer("Amy", 40, "Julia")
d.greetings()
Notice that the expression d.greetings() will call a different method if d is a Person or a Developer. This is called "polymorphism": the same method call behaves differently depending on the type of the object. The language chooses which actual method implementation to call, based on the type of d: this is called method "dispatch". More specifically, since it only depends on a single variable, it is called "single dispatch".
The good news is that Julia can do single dispatch as well:
struct Developer
name
age
language
end
function greetings(dev::Developer)
println("Hi, my name is $(dev.name), I am $(dev.age) years old.")
println("My favorite language is $(dev.language).")
end
d = Developer("Amy", 40, "Julia")
greetings(d)
Hi, my name is Amy, I am 40 years old. My favorite language is Julia.
Notice that the dev argument is followed by ::Developer, which means that this method will only be called if the argument has that type.
We have extended the greetings function, so that it now has two different implementations, called methods, each for different argument types: namely, greetings(dev::Developer) for arguments of type Developer, and greetings(greeter) for values of any other type.
You can easily get the list of all the methods of a given function:
methods(greetings)
- greetings(dev::Developer) in Main at In[203]:7
- greetings(greeter) in Main at In[201]:6
You can also get the list of all the methods which take a particular type as argument:
methodswith(Developer)
- greetings(dev::Developer) in Main at In[203]:7
When you call the greetings() function, Julia automatically dispatches the call to the appropriate method, depending on the type of the argument. If Julia can determine at compile time what the type of the argument will be, then it optimizes the compiled code so that there's no choice to be made at runtime. This is called static dispatch, and it can significantly speed up the program. If the argument's type can't be determined at compile time, then Julia makes the choice at runtime, just like in Python: this is called dynamic dispatch.
Multiple Dispatch¶
Julia actually looks at the types of all the positional arguments, not just the first one. This is called multiple dispatch. For example:
multdisp(a::Int64, b::Int64) = 1
multdisp(a::Int64, b::Float64) = 2
multdisp(a::Float64, b::Int64) = 3
multdisp(a::Float64, b::Float64) = 4
multdisp(10, 20) # try changing the arguments to get each possible output
1
Julia always chooses the most specific method it can, so the following method will only be called if the first argument is neither an Int64 nor a Float64:
multdisp(a::Any, b::Int64) = 5
multdisp(10, 20)
1
Julia will raise an exception if there is some ambiguity as to which method is the most specific:
ambig(a::Int64, b) = 1
ambig(a, b::Int64) = 2
try
ambig(10, 20)
catch ex
ex
end
MethodError(Main.ambig, (10, 20), 0x00000000000068b5)
To solve this problem, you can explicitely define a method for the ambiguous case:
ambig(a::Int64, b::Int64) = 3
ambig(10, 20)
3
So you can have polymorphism in Julia, just like in Python. This means that you can write your algorithms in a generic way, without having to know the exact types of the values you are manipulating, and it will work fine, as long as these types act in the general way you expect (i.e., if they "quack like ducks"). For example:
function how_can_i_help(greeter)
greetings(greeter)
println("How can I help?")
end
how_can_i_help(p) # called on a Person
how_can_i_help(d) # called on a Developer
Hi, my name is Alice, I am 70 years old. How can I help? Hi, my name is Amy, I am 40 years old. My favorite language is Julia. How can I help?
Calling super()?¶
You may have noticed that the greetings(dev::Developer) method could be improved, since it currently duplicates the implementation of the base method greetings(greeter). In Python, you would get rid of this duplication by calling the base class's greetings() method, using super():
# PYTHON
class Developer(Person):
def __init__(self, name, age, language):
super().__init__(name, age)
self.language = language
def greetings(self):
super().greetings() # <== THIS!
print(f"My favorite language is {self.language}.")
d = Developer("Amy", 40, "Julia")
d.greetings()
In Julia, you can do something pretty similar, although you have to implement your own super() function, as it is not part of the language:
super(dev::Developer) = Person(dev.name, dev.age)
function greetings(dev::Developer)
greetings(super(dev))
println("My favorite language is $(dev.language).")
end
greetings(d)
Hi, my name is Amy, I am 40 years old. My favorite language is Julia.
However, this implementation creates a new Person instance when calling super(dev), copying the name and age fields. That's okay for small objects, but it's not ideal for larger ones. Instead, you can explicitely call the specific method you want by using the invoke() function:
function greetings(dev::Developer)
invoke(greetings, Tuple{Any}, dev)
println("My favorite language is $(dev.language).")
end
greetings(d)
Hi, my name is Amy, I am 40 years old. My favorite language is Julia.
The invoke() function expects the following arguments:
- The first argument is the function to call.
- The second argument is the type of the desired method's arguments tuple:
Tuple{TypeArg1, TypeArg2, etc.}. In this case we want to call the base function, which takes a singleAnyargument (theAnytype is implicit when no type is specified). - Lastly, it takes all the arguments to be passed to the method. In this case, there's just one:
dev.
As you can see, we managed to get the same advantages Object-Oriented programming offers, without defining classes or using inheritance. This takes a bit of getting used to, but you might come to prefer this style of generic programming. Indeed, OO programming encourage you to bundle data and behavior together, but this is not always a good idea. Let's look at one example:
# PYTHON
class Rectangle:
def __init__(self, height, width):
self.height = height
self.width = width
def area(self):
return self.height * self.width
class Square(Rectangle):
def __init__(self, length):
super().__init__(length, length)
It makes sense for the Square class to be a subclass of the Rectangle class, since a square is a special type of rectangle. It also makes sense for the Square class to inherit from all of the Rectangle class's behavior, such as the area() method. However, it does not really make sense for rectangles and squares to have the same memory representation: a Rectangle needs two numbers (height and width), while a Square only needs one (length).
It's possible to work around this issue like this:
# PYTHON
class Rectangle:
def __init__(self, height, width):
self.height = height
self.width = width
def area(self):
return self.height * self.width
class Square(Rectangle):
def __init__(self, length):
self.length = length
@property
def width(self):
return self.length
@property
def height(self):
return self.length
That's better: now, each square is only represented using a single number. We've inherited the behavior, but not the data.
In Julia, you could code this like so:
struct Rectangle
width
height
end
width(rect::Rectangle) = rect.width
height(rect::Rectangle) = rect.height
area(rect) = width(rect) * height(rect)
struct Square
length
end
width(sq::Square) = sq.length
height(sq::Square) = sq.length
height (generic function with 2 methods)
area(Square(5))
25
Notice that the area() function relies on the getters width() and height(), rather than directly on the fields width and height. This way, the argument can be of any type at all, as long as it has these getters.
Abstract Types¶
One nice thing about the class hierarchy we defined in Python is that it makes it clear that a square is a kind of rectangle. Any new function you define that takes a Rectangle as an argument will automatically accept a Square as well, but no other non-rectangle type. In contrast, our area() function currently accepts anything at all.
In Julia, a concrete type like Square cannot extend another concrete type like Rectangle. However, any type can extend from an abstract type. Let's define some abstract types to create a type hierarchy for our Square and Rectangle types.
abstract type AbstractShape end
abstract type AbstractRectangle <: AbstractShape end # <: means "subtype of"
abstract type AbstractSquare <: AbstractRectangle end
The <: operator means "subtype of".
Now we can attach the area() function to the AbstractRectangle type, instead of any type at all:
area(rect::AbstractRectangle) = width(rect) * height(rect)
area (generic function with 2 methods)
Now we can define the concrete types, as subtypes of AbstractRectangle and AbstractSquare:
struct Rectangle_v2 <: AbstractRectangle
width
height
end
width(rect::Rectangle_v2) = rect.width
height(rect::Rectangle_v2) = rect.height
struct Square_v2 <: AbstractSquare
length
end
width(sq::Square_v2) = sq.length
height(sq::Square_v2) = sq.length
height (generic function with 4 methods)
In short, the Julian approach to type hierarchies looks like this:
- Create a hierarchy of abstract types to represent the concepts you want to implement.
- Write functions for these abstract types. Much of your implementation can be coded at that level, manipulating abstract concepts.
- Lastly, create concrete types, and write the methods needed to give them the behavior that is expected by the generic algorithms you wrote.
This pattern is used everywhere in Julia's standard libraries. For example, here are the supertypes of Float64 and Int64:
Base.show_supertypes(Float64)
Float64 <: AbstractFloat <: Real <: Number <: Any
Base.show_supertypes(Int64)
Int64 <: Signed <: Integer <: Real <: Number <: Any
Note: Julia implicitly runs using Core and using Base when starting the REPL. However, the show_supertypes() function is not exported by the Base module, thus you cannot access it by just typing show_supertypes(Float64). Instead, you have to specify the module name: Base.show_supertypes(Float64).
And here is the whole hierarchy of Number types:
function show_hierarchy(root, indent=0)
println(repeat(" ", indent * 4), root)
for subtype in subtypes(root)
show_hierarchy(subtype, indent + 1)
end
end
show_hierarchy(Number)
Number
Base.MultiplicativeInverses.MultiplicativeInverse
Base.MultiplicativeInverses.SignedMultiplicativeInverse
Base.MultiplicativeInverses.UnsignedMultiplicativeInverse
Complex
Real
AbstractFloat
BigFloat
Core.BFloat16
Float16
Float32
Float64
AbstractIrrational
Irrational
FixedPointNumbers.FixedPoint
FixedPointNumbers.Fixed
FixedPointNumbers.Normed
Integer
Bool
Signed
BigInt
Int128
Int16
Int32
Int64
Int8
Unsigned
UInt128
UInt16
UInt32
UInt64
UInt8
Rational
Iterator Interface¶
You will sometimes want to provide a way to iterate over your custom types. In Python, this requires defining the __iter__() method which should return an object which implements the __next__() method. In Julia, you must define at least two functions:
iterate(::YourIteratorType), which must return eithernothingif there are no values in the sequence, or(first_value, iterator_state).iterate(::YourIteratorType, state), which must return eithernothingif there are no more values, or(next_value, next_iterator_state).
For example, let's create a simple iterator for the Fibonacci sequence:
struct FibonacciIterator end
import Base.iterate
iterate(f::FibonacciIterator) = (1, (1, 1))
function iterate(f::FibonacciIterator, state)
new_state = (state[2], state[1] + state[2])
(new_state[1], new_state)
end
iterate (generic function with 273 methods)
Now we can iterate over a FibonacciIterator instance:
for f in FibonacciIterator()
println(f)
f > 10 && break
end
1 1 2 3 5 8 13
Indexing Interface¶
You can also create a type that will be indexable like an array (allowing syntax like a[5] = 3). In Python, this requires implementing the __getitem__() and __setitem__() methods. In Julia, you must implement the getindex(::YourType, i), setindex!(::YourType, v, i), firstindex(::YourType) and lastindex(::YourType) methods.
import Base.getindex, Base.firstindex
struct MySquares end
getindex(::MySquares, i) = i^2
firstindex(::MySquares) = 0
S = MySquares()
S[10]
100
S[begin]
0
getindex(S::MySquares, r::UnitRange) = [S[i] for i in r]
getindex (generic function with 221 methods)
S[1:4]
4-element Vector{Int64}:
1
4
9
16
For more details on these interfaces, and to learn how to build full-blown array types with broadcasting and more, check out this page.
Creating a Number Type¶
Let's create a MyRational struct and try to make it mimic the built-in Rational type:
struct MyRational <: Real
num # numerator
den # denominator
end
MyRational(2, 3)
MyRational(2, 3)
It would be more convenient and readable if we could type 2 ⨸ 3 to create a MyRational:
function ⨸(num, den)
MyRational(num, den)
end
⨸ (generic function with 1 method)
2 ⨸ 3
MyRational(2, 3)
I chose ⨸ because it's a symbol that Julia's parser treats as a binary operator, but which is otherwise not used by Julia (see the full list of parsed symbols and their priorities). This particular symbol will have the same priority as multiplication and division.
If you want to know how to type it and check that it is unused, type ?⨸ (copy/paste the symbol):
?⨸
"⨸" can be typed by \odiv<tab> search: ⨸
No documentation found for private symbol.
⨸ is a Function.
# 1 method for generic function "⨸" from Main:
[1] ⨸(num, den)
@ In[230]:1
Now let's make it possible to add two MyRational values. We want it to be possible for our MyRational type to be used in existing algorithms which rely on +, so we must create a new method for the Base.+ function:
import Base.+
function +(r1::MyRational, r2::MyRational)
(r1.num * r2.den + r1.den * r2.num) ⨸ (r1.den * r2.den)
end
+ (generic function with 205 methods)
2 ⨸ 3 + 3 ⨸ 5
MyRational(19, 15)
It's important to import Base.+ first, or else you would just be defining a new + function in the current module (Main), which would not be called by existing algorithms.
You can easily implement *, ^ and so on, in much the same way.
Let's change the way MyRational values are printed, to make them look a bit nicer. For this, we must create a new method for the Base.show(io::IO, x) function:
import Base.show
function show(io::IO, r::MyRational)
print(io, "$(r.num) ⨸ $(r.den)")
end
2 ⨸ 3 + 3 ⨸ 5
19 ⨸ 15
We can expand the show() function so it can provide an HTML representation for MyRational values. This will be called by the display() function in Jupyter or Colab:
function show(io::IO, ::MIME"text/html", r::MyRational)
print(io, "<sup><b>$(r.num)</b></sup>⁄<sub><b>$(r.den)</b></sub>")
end
2 ⨸ 3 + 3 ⨸ 5
Next, we want to be able to perform any operation involving MyRational values and values of other Number types. For example, we may want to multiply integers and MyRational values. One option is to define a new method like this:
import Base.*
function *(r::MyRational, i::Integer)
(r.num * i) ⨸ r.den
end
2 ⨸ 3 * 5
Since multiplication is commutative, we need the reverse method as well:
function *(i::Integer, r::MyRational)
r * i # this will call the previous method
end
5 * (2 ⨸ 3) # we need the parentheses since * and ⨸ have the same priority
It's cumbersome to have to define these methods for every operation. There's a better way, which we will explore in the next two sections.
Conversion¶
It is possible to provide a way for integers to be automatically converted to MyRational values:
import Base.convert
MyRational(x::Integer) = MyRational(x, 1)
convert(::Type{MyRational}, x::Integer) = MyRational(x)
convert(MyRational, 42)
The Type{MyRational} type is a special type which has a single instance: the MyRational type itself. So this convert() method only accepts MyRational itself as its first argument (and we don't actually use the first argument, so we don't even need to give it a name in the function declaration).
Now integers will be automatically converted to MyRational values when you assign them to an array whose element type if MyRational:
a = [2 ⨸ 3] # the element type is MyRational
a[1] = 5 # convert(MyRational, 5) is called automatically
push!(a, 6) # convert(MyRational, 6) is called automatically
println(a)
MyRational[5 ⨸ 1, 6 ⨸ 1]
Conversion will also occur automatically in these cases:
r::MyRational = 42: assigning an integer torwhereris a local variable with a declared type ofMyRational.s.b = 42ifsis a struct andbis a field of typeMyRational(also when callingnew(42)on that struct, assumingbis the first field).return 42if the return type is declared asMyRational(e.g.,function f(x)::MyRational ... end).
However, there is no automatic conversion when calling functions:
function for_my_rationals_only(x::MyRational)
println("It works:", x)
end
try
for_my_rationals_only(42)
catch ex
ex
end
MethodError(Main.for_my_rationals_only, (42,), 0x00000000000068dc)
Promotion¶
The Base functions +, -, *, /, ^, etc. all use a "promotion" algorithm to convert the arguments to the appropriate type. For example, adding an integer and a float promotes the integer to a float before the addition takes place. These functions use the promote() function for this. For example, given several integers and a float, all integers get promoted to floats:
promote(1, 2, 3, 4.0)
(1.0, 2.0, 3.0, 4.0)
This is why a sum of integers and floats results in a float:
1 + 2 + 3 + 4.0
10.0
The promote() function is also called when creating an array. For example, the following array is a Float64 array:
a = [1, 2, 3, 4.0]
4-element Vector{Float64}:
1.0
2.0
3.0
4.0
What about the MyRational type? Rather than create new methods for the promote() function, the recommended approach is to create a new method for the promote_rule() function. It takes two types and returns the type to convert to:
promote_rule(Float64, Int64)
Float64
Let's implement a new method for this function, to make sure that any subtype of the Integer type will be promoted to MyRational:
import Base.promote_rule
promote_rule(::Type{MyRational}, ::Type{T}) where {T <: Integer} = MyRational
promote_rule (generic function with 153 methods)
This method definition uses parametric types: the type T can be any type at all, as long as it is a subtype of the Integer abstract type. If you tried to define the method promote_rule(::Type{MyRational}, ::Type{Integer}), it would expect the type Integer itself as the second argument, which would not work, since the promote_rule() function will usually be called with concrete types like Int64 as its arguments.
Let's check that it works:
promote(5, 2 ⨸ 3)
(5 ⨸ 1, 2 ⨸ 3)
Yep! Now whenever we call +, -, etc., with an integer and a MyRational value, the integer will get automatically promoted to a MyRational value:
5 + 2 ⨸ 3
Under the hood:
- this called
+(5, 2 ⨸ 3),- which called the
+(::Number, ::Number)method (thanks to multiple dispatch),- which called
promote(5, 2 ⨸ 3),- which called
promote_rule(Int64, MyRational),- which called
promote_rule(::MyRational, ::T) where {T <: Integer},- which returned
MyRational,
- which returned
- which called
- which called
- then the
+(::Number, ::Number)method calledconvert(MyRational, 5),- which called
MyRational(5),- which returned
MyRational(5, 1),
- which returned
- which called
- and finally
+(::Number, ::Number)called+(MyRational(5, 1), MyRational(2, 3)),- which returned
MyRational(17, 3).
- which returned
- which called
- which called the
The benefit of this approach is that we only need to implement the +, -, etc. functions for pairs of MyRational values, not with all combinations of MyRational values and integers.
If your head hurts, it's perfectly normal. 😉 Writing a new type that is easy to use, flexible and plays nicely with existing types takes a bit of planning and work, but the point is that you will not write these every day, and once you have, they will make your life much easier.
Now let's handle the case where we want to execute operations with MyRational values and floats. In this case, we naturally want to promote the MyRational value to a float. We first need to define how to convert a MyRational value to any subtype of AbstractFloat:
convert(::Type{T}, x::MyRational) where {T <: AbstractFloat} = T(x.num / x.den)
convert (generic function with 273 methods)
This convert() works with any type T which is a subtype of AbstractFloat. It just computes x.num / x.den and converts the result to type T. Let's try it:
convert(Float64, 3 ⨸ 2)
1.5
Now let's define a promote_rule() method which will work for any type T which is a subtype of AbstractFloat, and which will give priority to T over MyRational:
promote_rule(::Type{MyRational}, ::Type{T}) where {T <: AbstractFloat} = T
promote_rule (generic function with 154 methods)
promote(1 ⨸ 2, 4.0)
(0.5, 4.0)
Now we can combine floats and MyRational values easily:
2.25 ^ (1 ⨸ 2)
1.5
Parametric Types and Functions¶
Julia's Rational type is actually a parametric type which ensures that the numerator and denominator have the same type T, subtype of Integer. Here's a new version of our rational struct which enforces the same constraint:
struct MyRational2{T <: Integer}
num::T
den::T
end
To instantiate this type, we can specify the type T:
MyRational2{BigInt}(2, 3)
MyRational2{BigInt}(2, 3)
Alternatively, we can use the MyRational2 type's default constructor, with two integers of the same type:
MyRational2(2, 3)
MyRational2{Int64}(2, 3)
If we want to be able to construct a MyRational2 with integers of different types, we must write an appropriate constructor which handles the promotion rule:
function MyRational2(num::Integer, den::Integer)
MyRational2(promote(num, den)...)
end
MyRational2
This constructor accepts two integers of potentially different types, and promotes them to the same type. Then it calls the default MyRational2 constructor which expects two arguments of the same type. The syntax f(args...) is analog to Python's f(*args).
Let's see if this works:
MyRational2(2, BigInt(3))
MyRational2{BigInt}(2, 3)
Great!
Note that all parametrized types such as MyRational2{Int64} or MyRational2{BigInt} are subtypes of MyRational2. So if a function accepts a MyRational2 argument, you can pass it an instance of any specific, parametrized type:
function for_any_my_rational2(x::MyRational2)
println(x)
end
for_any_my_rational2(MyRational2{BigInt}(1, 2))
for_any_my_rational2(MyRational2{Int64}(1, 2))
MyRational2{BigInt}(1, 2)
MyRational2{Int64}(1, 2)
A more explicit (but verbose) syntax for this function is:
function for_any_my_rational2(x::MyRational2{T}) where {T <: Integer}
println(x)
end
for_any_my_rational2 (generic function with 1 method)
It's useful to think of types as sets. For example, the Int64 type represents the set of all 64-bit integer values, so 42 isa Int64:
- When
xis an instance of some typeT, it is an element of the setTrepresents, andx isa T. - When
Uis a subtype ofV,Uis a subset ofV, andU <: V.
The MyRational2 type itself (without any parameter) represents the set of all values of MyRational2{T} for all subtypes T of Integer. In other words, it is the union of all the MyRational2{T} types. This is called a UnionAll type, and indeed the type MyRational2 itself is an instance of the UnionAll type:
@assert MyRational2{BigInt}(2, 3) isa MyRational2{BigInt}
@assert MyRational2{BigInt}(2, 3) isa MyRational2
@assert MyRational2 === (MyRational2{T} where {T <: Integer})
@assert MyRational2{BigInt} <: MyRational2
@assert MyRational2 isa UnionAll
If we dump the MyRational2 type, we can see that it is a UnionAll instance, with a parameter type T, constrained to a subtype of the Integer type (since the upper bound ub is Integer):
dump(MyRational2)
UnionAll
var: TypeVar
name: Symbol T
lb: Union{}
ub: Integer <: Real
body: MyRational2{T<:Integer} <: Any
num::T
den::T
There's a lot more to learn about Julia types. When you feel ready to explore this in more depth, check out this page. You can also take a look at the source code of Julia's rationals.
Writing/Reading Files¶
The do syntax we saw earlier is helpful when using the open() function:
open("test.txt", "w") do f
write(f, "This is a test.\n")
write(f, "I repeat, this is a test.\n")
end
open("test.txt") do f
for line in eachline(f)
println("[$line]")
end
end
[This is a test.] [I repeat, this is a test.]
The open() function automatically closes the file at the end of the block. Notice that the line feeds \n at the end of each line are not returned by the eachline() function. So the equivalent Python code is:
# PYTHON
with open("test.txt", "w") as f:
f.write("This is a test.\n")
f.write("I repeat, this is a test.\n")
with open("test.txt") as f:
for line in f.readlines():
line = line.rstrip("\n")
print(f"[{line}]")
Alternatively, you can read the whole file into a string:
open("test.txt") do f
s = read(f, String)
end
"This is a test.\nI repeat, this is a test.\n"
Or more concisely:
s = read("test.txt", String)
"This is a test.\nI repeat, this is a test.\n"
The Python equivalent is:
# PYTHON
with open("test.txt") as f:
s = f.read()
Exceptions¶
Julia's exceptions behave very much like in Python:
a = [1]
try
push!(a, 2)
#throw("Oops") # try uncommenting this line
push!(a, 3)
catch ex
println(ex)
push!(a, 4)
finally
push!(a, 5)
end
println(a)
[1, 2, 3, 5]
The equivalent Python code is:
# PYTHON
a = [1]
try:
a.append(2)
#raise Exception("Oops") # try uncommenting this line
a.append(3)
except Exception as ex:
print(ex)
a.append(4)
finally:
a.append(5)
print(a)
There is a whole hierarchy of standard exceptions which can be thrown, just like in Python. For example:
choice = 1 # try changing this value (from 1 to 4)
try
choice == 1 && open("/foo/bar/i_dont_exist.txt")
choice == 2 && sqrt(-1)
choice == 3 && push!(a, "Oops")
println("Everything worked like a charm")
catch ex
if ex isa SystemError
println("Oops. System error #$(ex.errnum) ($(ex.prefix))")
elseif ex isa DomainError
println("Oh no, I could not compute sqrt(-1)")
else
println("I got an unexpected error: $ex")
end
end
Oops. System error #2 (opening file "/foo/bar/i_dont_exist.txt")
Compare this with Python's equivalent code:
# PYTHON
choice = 3 # try changing this value (from 1 to 4)
try:
if choice == 1:
open("/foo/bar/i_dont_exist.txt")
if choice == 2:
math.sqrt(-1)
if choice == 3:
#a.append("Ok") # this would actually work
raise TypeError("Oops") # so let's fail manually
print("Everything worked like a charm")
except OSError as ex:
print(f"Oops. OS error (#{ex.errno} ({ex.strerror})")
except ValueError:
print("Oh no, I could not compute sqrt(-1)")
except Exception as ex:
print(f"I got an unexpected error: {ex}")
A few things to note here:
- Julia only allows a single
catchblock which handles all possible exceptions. obj isa SomeClassis a shorthand forisa(obj, SomeClass)which is equivalent to Python'sisinstance(obj, SomeClass).
| Julia | Python | |
|---|---|---|
try...catch exif ex isa SomeError...else...endfinally...end |
try:...except SomeException as ex:...except Exception as ex:...finally:... |
|
throw any_value |
raise SomeException(...) |
|
obj isa SomeTypeor isa(obj, SomeType) |
isinstance(obj, SomeType) |
Note that Julia does not support the equivalent of Python's try / catch / else construct. You need to write something like this:
catch_exception = true
try
println("Try something")
#error("ERROR: Catch me!") # try uncommenting this line
catch_exception = false
#error("ERROR: Don't catch me!") # try uncommenting this line
println("No error occurred")
catch ex
if catch_exception
println("I caught this exception: $ex")
else
throw(ex)
end
finally
println("The end")
end
println("After the end")
Try something No error occurred The end After the end
The equivalent Python code is shorter, but it's fairly uncommon:
# PYTHON
try:
print("Try something")
raise Exception("Catch me!") # try uncommenting this line
except Exception as ex:
print(f"I caught this exception: {ex}")
else:
raise Exception("Don't catch me!") # try uncommenting this line
print("No error occured")
finally:
print("The end")
print("After the end")
Docstrings¶
It's good practice to add docstrings to every function you export. The docstring is placed just before the definition of the function:
"Compute the square of number x"
square(x::Number) = x^2
square
You can retrieve a function's docstring using the @doc macro:
@doc square
Compute the square of number x
The docstring is displayed when asking for help:
?square
search: square Square MySquares Square_v2 super surf sqrt quote Base.acquire
Compute the square of number x
Docstrings follow the Markdown format.
A typical docstring starts with the signature of the function, indented by 4 spaces, so it will get syntax highlighted as Julia code.
It also includes an Examples section with Julia REPL outputs:
"""
cube(x::Number)
Compute the cube of `x`.
# Examples
```julia-repl
julia> cube(5)
125
julia> cube(im)
0 - 1im
```
"""
cube(x) = x^3
cube
Instead of using julia-repl code blocks for the examples, you can use jldoctest to mark these examples as doctests (similar to Python's doctests).
The help gets nicely formatted:
?cube
search: cube clabel Number Tue June code
When there are several methods for a given function, it is common to give general information about the function in the first method (usually the most generic), and only add docstrings to other methods if they add useful information (without repeating the general info).
Alternatively, you may attach the general information to the function itself:
"""
foo(x)
Compute the foo of the bar
"""
function foo end # declares the foo function
# foo(x::Number) behaves normally, no need for a docstring
foo(x::Number) = "baz"
"""
foo(x::String)
For strings, compute the qux of the bar instead.
"""
foo(x::String) = "qux"
foo
?foo
search: foo for floor cool Bool fdio
foo(x)
Compute the foo of the bar
foo(x::String)
For strings, compute the qux of the bar instead.
Macros¶
We have seen a few macros already: @which, @assert, @time, @benchmark, @btime and @doc. You guessed it: all macros start with an @ sign.
What is a macro? It is a function which can fully inspect the expression that follows it, and apply any transformation to that code at parse time, before compilation.
This makes it possible for anyone to effectively extend the language in any way they please. Whereas C/C++ macros just do simple text replacement, Julia macros are powerful meta-programming tools.
On the flip side, this also means that each macro has its own syntax and behavior.
A personal opinion: in my experience, languages that provide great flexibility typically attract a community of programmers with a tinkering mindset, who will love to experiment with all the fun features the language has to offer. This is great for creativity, but it can also be a nuisance if the community ends up producing too much experimental code, without much care for code reliability, API stability, or even for simplicity. By all means, let's be creative, let's experiment, but with great power comes great responsibility: let's also value reliability, stability and simplicity.
That said, to give you an idea of what macro definitions look like in Julia, here's a simple toy macro that replaces a + b expressions with a - b, and leaves other expressions alone.
macro addtosub(x)
if x.head == :call && x.args[1] == :+ && length(x.args) == 3
Expr(:call, :-, x.args[2], x.args[3])
else
x
end
end
@addtosub 10 + 2
8
In this macro definition, :call, :+ and :- are symbols. These are similar to strings, only more efficient and less flexible. They are typically used as identifiers, such as keys in dictionaries.
If you're curious, the macro works because the parser converts 10 + 2 to Expr(:call, :+, 10, 2) and passes this expression to the macro (before compilation). The if statement checks that the expression is a function call, where the called function is the + function, with two arguments. If so, then the macro returns a new expression, corresponding to a call to the - function, with the same arguments. So a + b becomes a - b.
For more info, check out this page.
Special Prefixed Strings¶
py"..." strings are defined by the PyCall module. Writing py"something" is equivalent to writing @py_str "something". In other words, anyone can write a macro that defines a new kind of prefixed string. For example, if you write the @ok_str macro, it will be called when you write ok"something".
Another example is the Pkg module which defines the @pkg_str macro: this is why you can use pkg"..." to interact with the Pkg module. This is how pkg"add PyCall; precompile;" worked (at the end of the very first cell). This downloaded, installed and precompiled the PyCall module.
Modules¶
In Python, a module must be defined in a dedicated file. In Julia, modules are independent from the file system. You can define several modules per file, or define one module across multiple files, it's up to you. Let's create a simple module containing two submodules, each containing a variable and a function:
module ModA
pi = 3.14
square(x) = x^2
module ModB
e = 2.718
cube(x) = x^3
end
module ModC
root2 = √2
relu(x) = max(0, x)
end
end
Main.ModA
The default module is Main, so whatever we define is put in this module (except when defining a package, as we will see). This is why the ModA's full name is Main.ModA.
We can now access the contents of these modules by providing the full paths:
Main.ModA.ModC.root2
1.4142135623730951
Since our code runs in the Main module, we can leave out the Main. part:
ModA.ModC.root2
1.4142135623730951
Alternatively, you can use import:
import Main.ModA.ModC.root2
root2
1.4142135623730951
Or we can use import with a relative path. In this case, we need to prefix ModA with a dot . to indicate that we want the module ModA located in the current module:
import .ModA.ModC.root2
root2
1.4142135623730951
Alternatively, we can import the submodule:
import .ModA.ModC
ModC.root2
1.4142135623730951
When you want to import more than one name from a module, you can use this syntax:
import .ModA.ModC: root2, relu
This is equivalent to this more verbose syntax:
import .ModA.ModC.root2, .ModA.ModC.relu
Nested modules do not automatically have access to names in enclosing modules. To import names from a parent module, use ..x. From a grand-parent module, use ...x, and so on.
module ModD
d = 1
module ModE
try
println(d)
catch ex
println(ex)
end
end
module ModF
f = 2
module ModG
import ..f
import ...d
println(f)
println(d)
end
end
end
UndefVarError(:d, Main.ModD.ModE) 2 1
Main.ModD
Instead of import, you can use using. It is analog to Python's from foo import *. It only gives access to names which were explicitly exported using export (similar to the way from foo import * in Python only imports names listed in the module's __all__ list):
module ModH
h1 = 1
h2 = 2
export h1
end
Main.ModH
using .ModH
println(h1)
try
println(h2)
catch ex
ex
end
1
UndefVarError(:h2, Main)
Note that using Foo not only imports all exported names (like Python's from foo import *), it also imports Foo itself (similarly, using Foo.Bar imports Bar itself):
ModH
Main.ModH
Even if a name is not exported, you can always access it using its full path, or using import:
ModH.h2
2
import .ModH.h2
h2
2
You can also import individual names like this:
module ModG
g1 = 1
g2 = 2
export g2
end
using .ModG: g1, g2
println(g1)
println(g2)
1 2
Notice that this syntax gives you access to any name you want, whether or not it was exported. In other words, whether a name is exported or not only affects the using Foo syntax.
Importantly, when you want to expand a function which is defined in a module, you must import the function using import, or you must specify the function's path:
module ModH
double(x) = x * 2
triple(x) = x * 3
end
import .ModH: double
double(x::AbstractString) = repeat(x, 2)
ModH.triple(x::AbstractString) = repeat(x, 3)
println(double(2))
println(double("Two"))
println(ModH.triple(3))
println(ModH.triple("Three"))
4 TwoTwo 9 ThreeThreeThree
WARNING: replacing module ModH.
You must never extend a function imported with using, unless you provide the function's path:
module ModI
quadruple(x) = x * 4
export quadruple
end
using .ModI
ModI.quadruple(x::AbstractString) = repeat(x, 4) # OK
println(quadruple(4))
println(quadruple("Four"))
#quadruple(x::AbstractString) = repeat(x, 4) # uncomment to see the error
16 FourFourFourFour
There is no equivalent of Python's import foo as x (yet), but you can do something like this:
import .ModI: quadruple
x = quadruple
quadruple (generic function with 2 methods)
In general, a module named Foo will be defined in a file named Foo.jl (along with its submodules). However, if the module becomes too big for a single file, you can split it into multiple files and include these files in Foo.jl using the include() function.
For example, let's create three files: Awesome.jl, great.jl and amazing/Fantastic.jl, where:
Awesome.jldefines theAwesomemodule and includes the other two filesgreat.jljust defines a functionamazing/Fantastic.jldefines theFantasticsubmodule
code_awesome = """
module Awesome
include("great.jl")
include("amazing/Fantastic.jl")
end
"""
code_great = """
great() = "This is great!"
"""
code_fantastic = """
module Fantastic
fantastic = true
end
"""
open(f->write(f, code_awesome), "Awesome.jl", "w")
open(f->write(f, code_great), "great.jl", "w")
mkdir("amazing")
open(f->write(f, code_fantastic), "amazing/Fantastic.jl", "w")
38
If we try to execute import Awesome now, it won't work since Julia does not search in the current directory by default. Let's change this:
pushfirst!(LOAD_PATH, ".")
4-element Vector{String}:
"."
"@"
"@v#.#"
"@stdlib"
Now when we import the Awesome module, Julia will look for a file named Awesome.jl in the current directory, or for Awesome/src/Awesome.jl, or for Awesome.jl/src/Awesome.jl. If it does not find any of these, it will look in the other places listed in the LOAD_PATH array (we will discuss this in more details in the "Package Management" section).
import Awesome
println(Awesome.great())
println("Is fantastic? ", Awesome.Fantastic.fantastic)
[ Info: Precompiling Awesome [top-level]
This is great! Is fantastic? true
Let's restore the original LOAD_PATH:
popfirst!(LOAD_PATH)
"."
In short:
| Julia | Python | |
|---|---|---|
import Foo |
import foo |
|
import Foo.Bar |
from foo import bar |
|
import Foo.Bar: a, b |
from foo.bar import a, b |
|
import Foo.Bar.a, Foo.Bar.b |
from foo.bar import a, b |
|
import .Foo |
import .foo |
|
import ..Foo.Bar |
from ..foo import bar |
|
import ...Foo.Bar |
from ...foo import bar |
|
import .Foo: a, b |
from .foo import a, b |
|
using Foo |
from foo import *; import foo |
|
using Foo.Bar |
from foo.bar import *; from foo import bar |
|
using Foo.Bar: a, b |
from foo.bar import a, b |
Extending function Foo.f() |
Result | |
|---|---|---|
import Foo.f # or Foo: f f(x::Int64) = ... |
OK | |
import FooFoo.f(x::Int64) = ... |
OK | |
using FooFoo.f(x::Int64) = ... |
OK | |
import Foo.f # or Foo: fFoo.f(x::Int64) = ... |
ERROR: Foo not defined |
|
using Foof(x::Int64) = ... |
ERROR: Foo.f must be explicitly imported |
|
using Foo: ff(x::Int64) = ... |
ERROR: Foo.f must be explicitly imported |
Scopes¶
Julia has two types of scopes: global and local.
Every module has its own global scope, independent from all other global scopes. There is no overarching global scope.
Modules, macros and types (including structs) can only be defined in a global scope.
Most code blocks, including function, struct, for, while, etc., have their own local scope. For example:
for q in 1:3
println(q)
end
try
println(q) # q is not available here
catch ex
ex
end
1 2 3
UndefVarError(:q, Main)
A local scope inherits from its parent scope:
z = 5
for i in 1:3
w = 10
println(i * w * z) # i and w are local, z is from the parent scope
end
50 100 150
An inner scope can assign to a variable in the parent scope, if the parent scope is not global:
for i in 1:3
s = 0
for j in 1:5
s = j # variable s is from the parent scope
end
println(s)
end
5 5 5
You can force a variable to be local by using the local keyword:
for i in 1:3
s = 0
for j in 1:5
local s = j # variable s is local now
end
println(s)
end
0 0 0
To assign to a global variable, you must declare the variable as global in the local scope:
for i in 1:3
global p
p = i
end
p
3
There is one exception to this rule: when executing code directly in the REPL (since Julia 1.5) or in IJulia, you do not need to declare a variable as global if the global variable already exists:
s = 0
for i in 1:3
s = i # implicitly global s: only in REPL Julia 1.5+ or IJulia
end
s
3
In functions, assigning to a variable which is not explicitly declared as global always makes it local (even in the REPL and IJulia):
s, t = 1, 2 # globals
function foo()
s = 10 * t # s is local, t is global
end
println(foo())
println(s)
20 1
Just like in Python, functions can capture variables from the enclosing scope (not from the scope the function is called from):
t = 1
foo() = t # foo() captures t from the global scope
function bar()
t = 5 # this is a new local variable
println(foo()) # foo() still uses t from the global scope
end
bar()
1
function quz()
global t
t = 5 # we change the global t
println(foo()) # and this affects foo()
end
quz()
5
Closures work much like in Python:
function create_multiplier(n)
function mul(x)
x * n # variable n is captured from the parent scope
end
end
mul2 = create_multiplier(2)
mul2(5)
10
An inner function can modify variables from its parent scope:
function create_counter()
c = 0
inc() = c += 1 # this inner function modifies the c from the outer function
end
cnt = create_counter()
println(cnt())
println(cnt())
1 2
Consider the following code, and see if you can figure out why it prints the same result multiple times:
funcs = []
i = 1
while i ≤ 5
push!(funcs, ()->i^2)
global i += 1
end
for fn in funcs
println(fn())
end
36 36 36 36 36
The answer is that there is a single variable i, which is captured by all 5 closures. By the time these closures are executed, the value of i is 6, so the square is 36, for every closure.
If we use a for loop, we don't have this problem, since a new local variable is created at every iteration:
funcs = []
for i in 1:5
push!(funcs, ()->i^2)
end
for fn in funcs
println(fn())
end
1 4 9 16 25
Any local variable created within a for loop, a while loop or a comprehension also get a new copy at each iteration. So we could code the above example like this:
funcs = []
i = 1
while i ≤ 5 # since we are in a while loop...
global i
local j = i # ...and j is created here, it's a new `j` at each iteration
push!(funcs, ()->j^2)
i += 1
end
for fn in funcs
println(fn())
end
1 4 9 16 25
Another way to get the same result is to use a let block, which also creates a new local variable every time it is executed:
funcs = []
i = 0
while i < 5
let i=i
push!(funcs, ()->i^2)
end
global i += 1
end
for fn in funcs
println(fn())
end
0 1 4 9 16
This let i=i block defines a new local variable i at every iteration, and initializes it with the value of i from the parent scope. Therefore each closure captures a different local variable i.
Variables in a let block are initialized from left to right, so they can access variables on their left:
a = 1
let a=a+1, b=a
println("a=$a, b=$b")
end
a=2, b=2
In this example, the local variable a is initialized with the value of a + 1, where a comes from the parent scope (i.e., it's the global a in this case). However, b is initialized with the value of the local a, since it now hides the variable a from the parent scope.
Default values in function arguments also have this left-to-right scoping logic:
a = 1
foobar(a=a+1, b=a) = println("a=$a, b=$b")
foobar()
foobar(5)
a=2, b=2 a=5, b=5
In this example, the first argument's default value is a + 1, where a comes from the parent scope (i.e., the global a in this case). However, the second argument's default value is a, where a in this case is the value of the first argument (not the parent scope's a).
Note that if blocks and begin blocks do not have their own local scope, they just use the parent scope:
a = 1
if true
a = 2 # same `a` as above
end
a
2
a = 1
begin
a = 2 # same `a` as above
end
a
2
Package Management¶
Basic Workflow¶
The simplest way to write a Julia program is to create a .jl file somewhere and run it using julia. You would usually do this with your favorite editor, but in this notebook we must do this programmatically. For example:
code = """
println("Hello world")
"""
open(f->write(f, code), "my_program1.jl", "w")
23
Then let's run the program using a shell command:
;julia my_program1.jl
Hello world
If you need to use a package which is not part of the standard library, such as PyCall, you first need to install it using Julia's package manager Pkg:
using Pkg
Pkg.add("PyCall")
Resolving package versions... ┌ Warning: CUDA version 12.4.0 in /usr/lib64-nvidia/libcuda.so.1 not supported with this version of Reactant (min supported: 12.6) └ @ Main ~/.julia/packages/Reactant_jll/M7uc2/.pkg/platform_augmentation.jl:103 No Changes to `~/.julia/environments/v1.11/Project.toml` No Changes to `~/.julia/environments/v1.11/Manifest.toml` ┌ Warning: CUDA version 12.4.0 in /usr/lib64-nvidia/libcuda.so.1 not supported with this version of Reactant (min supported: 12.6) └ @ Main ~/.julia/packages/Reactant_jll/M7uc2/.pkg/platform_augmentation.jl:103
Alternatively, in interactive mode, you can enter the Pkg mode by typing ], then type a command:
]add PyCall
Resolving package versions... ┌ Warning: CUDA version 12.4.0 in /usr/lib64-nvidia/libcuda.so.1 not supported with this version of Reactant (min supported: 12.6) └ @ Main ~/.julia/packages/Reactant_jll/M7uc2/.pkg/platform_augmentation.jl:103 No Changes to `~/.julia/environments/v1.11/Project.toml` No Changes to `~/.julia/environments/v1.11/Manifest.toml` ┌ Warning: CUDA version 12.4.0 in /usr/lib64-nvidia/libcuda.so.1 not supported with this version of Reactant (min supported: 12.6) └ @ Main ~/.julia/packages/Reactant_jll/M7uc2/.pkg/platform_augmentation.jl:103
You can also precompile the new package to avoid the compilation delay when the package is first used:
]add PyCall; precompile;
Resolving package versions... ┌ Warning: CUDA version 12.4.0 in /usr/lib64-nvidia/libcuda.so.1 not supported with this version of Reactant (min supported: 12.6) └ @ Main ~/.julia/packages/Reactant_jll/M7uc2/.pkg/platform_augmentation.jl:103 No Changes to `~/.julia/environments/v1.11/Project.toml` No Changes to `~/.julia/environments/v1.11/Manifest.toml` ┌ Warning: CUDA version 12.4.0 in /usr/lib64-nvidia/libcuda.so.1 not supported with this version of Reactant (min supported: 12.6) └ @ Main ~/.julia/packages/Reactant_jll/M7uc2/.pkg/platform_augmentation.jl:103 ┌ Warning: CUDA version 12.4.0 in /usr/lib64-nvidia/libcuda.so.1 not supported with this version of Reactant (min supported: 12.6) └ @ Main ~/.julia/packages/Reactant_jll/M7uc2/.pkg/platform_augmentation.jl:103
One last alternative is to use pkg"..." strings to run commands in your programs:
pkg"add PyCall; precompile;"
Resolving package versions... ┌ Warning: CUDA version 12.4.0 in /usr/lib64-nvidia/libcuda.so.1 not supported with this version of Reactant (min supported: 12.6) └ @ Main ~/.julia/packages/Reactant_jll/M7uc2/.pkg/platform_augmentation.jl:103 No Changes to `~/.julia/environments/v1.11/Project.toml` No Changes to `~/.julia/environments/v1.11/Manifest.toml` ┌ Warning: CUDA version 12.4.0 in /usr/lib64-nvidia/libcuda.so.1 not supported with this version of Reactant (min supported: 12.6) └ @ Main ~/.julia/packages/Reactant_jll/M7uc2/.pkg/platform_augmentation.jl:103 ┌ Warning: CUDA version 12.4.0 in /usr/lib64-nvidia/libcuda.so.1 not supported with this version of Reactant (min supported: 12.6) └ @ Main ~/.julia/packages/Reactant_jll/M7uc2/.pkg/platform_augmentation.jl:103
Now you can import PyCall in any of your Julia programs:
code = """
using PyCall
py"print('1 + 2 =', 1 + 2)"
"""
open(f->write(f, code), "my_program2.jl", "w")
41
;julia my_program2.jl
1 + 2 = 3
You can also add packages by providing their URL (typically on github). This is useful when you want to use a package which is not in the official Julia Package registry, or when you want the very latest version of a package:
]add https://github.com/JuliaLang/Example.jl
Cloning git-repo `https://github.com/JuliaLang/Example.jl` Updating git-repo `https://github.com/JuliaLang/Example.jl` Resolving package versions... ┌ Warning: CUDA version 12.4.0 in /usr/lib64-nvidia/libcuda.so.1 not supported with this version of Reactant (min supported: 12.6) └ @ Main ~/.julia/packages/Reactant_jll/M7uc2/.pkg/platform_augmentation.jl:103 Updating `~/.julia/environments/v1.11/Project.toml` [7876af07] + Example v0.5.5 `https://github.com/JuliaLang/Example.jl#master` Updating `~/.julia/environments/v1.11/Manifest.toml` [7876af07] + Example v0.5.5 `https://github.com/JuliaLang/Example.jl#master` ┌ Warning: CUDA version 12.4.0 in /usr/lib64-nvidia/libcuda.so.1 not supported with this version of Reactant (min supported: 12.6) └ @ Main ~/.julia/packages/Reactant_jll/M7uc2/.pkg/platform_augmentation.jl:103 Precompiling project... 732.5 ms ✓ Example 1 dependency successfully precompiled in 2 seconds. 491 already precompiled.
You can install a specific package version like this:
]add PyCall@1.96.2
Resolving package versions... Installed PyCall ─ v1.96.2 ┌ Warning: CUDA version 12.4.0 in /usr/lib64-nvidia/libcuda.so.1 not supported with this version of Reactant (min supported: 12.6) └ @ Main ~/.julia/packages/Reactant_jll/M7uc2/.pkg/platform_augmentation.jl:103 Updating `~/.julia/environments/v1.11/Project.toml` ⌃ [438e738f] ↓ PyCall v1.96.4 ⇒ v1.96.2 Updating `~/.julia/environments/v1.11/Manifest.toml` ⌃ [438e738f] ↓ PyCall v1.96.4 ⇒ v1.96.2 Info Packages marked with ⌃ have new versions available and may be upgradable. Building PyCall → `~/.julia/scratchspaces/44cfe95a-1eb2-52ea-b672-e2afdf69b78f/1cb97fa63a3629c6d892af4f76fcc4ad8191837c/build.log` ┌ Warning: CUDA version 12.4.0 in /usr/lib64-nvidia/libcuda.so.1 not supported with this version of Reactant (min supported: 12.6) └ @ Main ~/.julia/packages/Reactant_jll/M7uc2/.pkg/platform_augmentation.jl:103 Precompiling project... 17943.0 ms ✓ PyCall 1 dependency successfully precompiled in 19 seconds. 491 already precompiled. 1 dependency precompiled but a different version is currently loaded. Restart julia to access the new version. Otherwise, loading dependents of this package may trigger further precompilation to work with the unexpected version.
If you only specify version 1 or version 1.96, Julia will get the latest version with that prefix. For example, ]add PyCall@1.96 would install the latest version 1.96.x.
You can also update a package to its latest version:
]update PyCall
Updating registry at `~/.julia/registries/General.toml` ┌ Warning: CUDA version 12.4.0 in /usr/lib64-nvidia/libcuda.so.1 not supported with this version of Reactant (min supported: 12.6) └ @ Main ~/.julia/packages/Reactant_jll/M7uc2/.pkg/platform_augmentation.jl:103 Updating `~/.julia/environments/v1.11/Project.toml` [438e738f] ↑ PyCall v1.96.2 ⇒ v1.96.4 Updating `~/.julia/environments/v1.11/Manifest.toml` [438e738f] ↑ PyCall v1.96.2 ⇒ v1.96.4 ┌ Warning: CUDA version 12.4.0 in /usr/lib64-nvidia/libcuda.so.1 not supported with this version of Reactant (min supported: 12.6) └ @ Main ~/.julia/packages/Reactant_jll/M7uc2/.pkg/platform_augmentation.jl:103 Precompiling project... 17768.6 ms ✓ PyCall 1 dependency successfully precompiled in 19 seconds. 491 already precompiled. 1 dependency precompiled but a different version is currently loaded. Restart julia to access the new version. Otherwise, loading dependents of this package may trigger further precompilation to work with the unexpected version. Info We haven't cleaned this depot up for a bit, running Pkg.gc()... Active manifest files: 1 found Active artifact files: 106 found Active scratchspaces: 5 found Deleted no artifacts, repos, packages or scratchspaces
You can update all packages to their latest versions (uncomment if you want to run this, but know that it may take a long time, depending on the number of libraries installed):
# ]update
If you don't want a particular package to be updated the next time you call ]update, you can pin it:
]pin PyCall
Resolving package versions... ┌ Warning: CUDA version 12.4.0 in /usr/lib64-nvidia/libcuda.so.1 not supported with this version of Reactant (min supported: 12.6) └ @ Main ~/.julia/packages/Reactant_jll/M7uc2/.pkg/platform_augmentation.jl:103 Updating `~/.julia/environments/v1.11/Project.toml` [438e738f] ~ PyCall v1.96.4 ⇒ v1.96.4 ⚲ Updating `~/.julia/environments/v1.11/Manifest.toml` [438e738f] ~ PyCall v1.96.4 ⇒ v1.96.4 ⚲ ┌ Warning: CUDA version 12.4.0 in /usr/lib64-nvidia/libcuda.so.1 not supported with this version of Reactant (min supported: 12.6) └ @ Main ~/.julia/packages/Reactant_jll/M7uc2/.pkg/platform_augmentation.jl:103
To unpin the package:
]free PyCall
Updating `~/.julia/environments/v1.11/Project.toml` [438e738f] ~ PyCall v1.96.4 ⚲ ⇒ v1.96.4 Updating `~/.julia/environments/v1.11/Manifest.toml` [438e738f] ~ PyCall v1.96.4 ⚲ ⇒ v1.96.4 ┌ Warning: CUDA version 12.4.0 in /usr/lib64-nvidia/libcuda.so.1 not supported with this version of Reactant (min supported: 12.6) └ @ Main ~/.julia/packages/Reactant_jll/M7uc2/.pkg/platform_augmentation.jl:103
You can also run the tests defined in a package:
]test Example
┌ Warning: CUDA version 12.4.0 in /usr/lib64-nvidia/libcuda.so.1 not supported with this version of Reactant (min supported: 12.6) └ @ Main ~/.julia/packages/Reactant_jll/M7uc2/.pkg/platform_augmentation.jl:103 Testing Example Status `/tmp/jl_FKYY3l/Project.toml` [7876af07] Example v0.5.5 `https://github.com/JuliaLang/Example.jl#master` [8dfed614] Test v1.11.0 Status `/tmp/jl_FKYY3l/Manifest.toml` [7876af07] Example v0.5.5 `https://github.com/JuliaLang/Example.jl#master` [2a0f44e3] Base64 v1.11.0 [b77e0a4c] InteractiveUtils v1.11.0 [56ddb016] Logging v1.11.0 [d6f4376e] Markdown v1.11.0 [9a3f8284] Random v1.11.0 [ea8e919c] SHA v0.7.0 [9e88b42a] Serialization v1.11.0 [8dfed614] Test v1.11.0 Precompiling project for configuration --code-coverage=none --color=yes --check-bounds=yes --warn-overwrite=yes --depwarn=yes --inline=yes --startup-file=no --track-allocation=none... 411.7 ms ✓ Example 1 dependency successfully precompiled in 1 seconds. 6 already precompiled. Testing Running tests... Testing Example tests passed
Of course, you can remove a package:
]rm Example
Updating `~/.julia/environments/v1.11/Project.toml` [7876af07] - Example v0.5.5 `https://github.com/JuliaLang/Example.jl#master` Updating `~/.julia/environments/v1.11/Manifest.toml` [7876af07] - Example v0.5.5 `https://github.com/JuliaLang/Example.jl#master`
Lastly, you can check which packages are installed using ]status (or ]st for short):
]st
Status `~/.julia/environments/v1.11/Project.toml` [79e6a3ab] Adapt v4.4.0 [6e4b80f9] BenchmarkTools v1.6.2 [336ed68f] CSV v0.10.15 [052768ef] CUDA v5.9.1 ⌃ [a93c6f00] DataFrames v1.8.0 [7073ff75] IJulia v1.31.0 [b2108857] Lux v1.23.0 [ee78f7c6] Makie v0.24.6 [91a5bcdd] Plots v1.41.1 [438e738f] PyCall v1.96.4 [d330b81b] PyPlot v2.11.6 [3c362404] Reactant v0.2.171 ⌃ [76a88914] CUDA_Runtime_jll v0.19.1+0 Info Packages marked with ⌃ have new versions available and may be upgradable.
For more Pkg commands, type ]help.
| Julia (in interactive mode) | Python (in a terminal) | |
|---|---|---|
]status |
pip freezeor conda list |
|
]add Foo |
pip install fooor conda install foo |
|
]add Foo@1.2 |
pip install foo==1.2or conda install foo=1.2 |
|
]update Foo |
pip install --upgrade fooor conda update foo |
|
]pin Foo |
foo==<version> in requirements.txtor foo=<version> in environment.yml |
|
]free Foo |
foo in requirements.txtor foo in environment.yml |
|
]test Foo |
python -m unittest foo |
|
]rm Foo |
pip uninstall fooor conda remove foo |
|
]help |
pip --help |
This workflow is fairly simple, but it means that all of your programs will be using the same version of each package. This is analog to installing packages using pip install without using virtual environments.
Projects¶
If you want to have multiple projects, each with different libraries and library versions, you should define projects. These are analog to Python virtual environments.
A project is just a directory containing a Project.toml file and a Manifest.toml file:
my_project/
Project.toml
Manifest.toml
Project.tomlis similar to arequirements.txtfile (for pip) orenvironment.yml(for conda): it lists the dependencies of the project, and compatibility constraints (e.g.,SomeDependency = 2.5).Manifest.tomlis an automatically generated file which lists the exact versions and unique IDs (UUIDs) of all the packages that Julia found, based onProject.toml. It includes all the implicit dependencies of the project's packages. This is useful to reproduce an environment precisely. Analog to the output ofpip --freeze.
By default, the active project is located in ~/.julia/environments/v#.# (where #.# is the Julia version you are using, such as 1.4). You can set a different project when starting Julia:
# BASH
julia --project=/path/to/my_project
Or you can set the JULIA_PROJECT environment variable:
# BASH
export JULIA_PROJECT=/path/to/my_project
julia
Or you can just activate a project directly in Julia (this is analog to running source my_project/env/bin/activate when using virtualenv):
Pkg.activate("my_project")
Activating new project at `/content/my_project`
The my_project directory does not exist yet, but it gets created automatically, along with the Project.toml and Manifest.toml files, when you first add a package:
]add PyCall
Resolving package versions... Updating `/content/my_project/Project.toml` [438e738f] + PyCall v1.96.4 Updating `/content/my_project/Manifest.toml` [8f4d0f93] + Conda v1.10.2 ⌅ [682c06a0] + JSON v0.21.4 [1914dd2f] + MacroTools v0.5.16 [69de0a69] + Parsers v2.8.3 ⌅ [aea7be01] + PrecompileTools v1.2.1 [21216c6a] + Preferences v1.5.0 [438e738f] + PyCall v1.96.4 [81def892] + VersionParsing v1.3.0 [0dad84c5] + ArgTools v1.1.2 [56f22d72] + Artifacts v1.11.0 [ade2ca70] + Dates v1.11.0 [f43a241f] + Downloads v1.6.0 [7b1f6079] + FileWatching v1.11.0 [b27032c2] + LibCURL v0.6.4 [8f399da3] + Libdl v1.11.0 [37e2e46d] + LinearAlgebra v1.11.0 [a63ad114] + Mmap v1.11.0 [ca575930] + NetworkOptions v1.2.0 [de0858da] + Printf v1.11.0 [9a3f8284] + Random v1.11.0 [ea8e919c] + SHA v0.7.0 [9e88b42a] + Serialization v1.11.0 [fa267f1f] + TOML v1.0.3 [cf7118a7] + UUIDs v1.11.0 [4ec0a83e] + Unicode v1.11.0 [e66e0078] + CompilerSupportLibraries_jll v1.1.1+0 [deac9b47] + LibCURL_jll v8.6.0+0 [29816b5a] + LibSSH2_jll v1.11.0+1 [c8ffd9c3] + MbedTLS_jll v2.28.6+0 [14a3606d] + MozillaCACerts_jll v2023.12.12 [4536629a] + OpenBLAS_jll v0.3.27+1 [83775a58] + Zlib_jll v1.2.13+1 [8e850b90] + libblastrampoline_jll v5.11.0+0 [8e850ede] + nghttp2_jll v1.59.0+0 Info Packages marked with ⌅ have new versions available but compatibility constraints restrict them from upgrading. To see why use `status --outdated -m`
You can also add a package via its URL:
]add https://github.com/JuliaLang/Example.jl
Updating git-repo `https://github.com/JuliaLang/Example.jl` Resolving package versions... Updating `/content/my_project/Project.toml` [7876af07] + Example v0.5.5 `https://github.com/JuliaLang/Example.jl#master` Updating `/content/my_project/Manifest.toml` [7876af07] + Example v0.5.5 `https://github.com/JuliaLang/Example.jl#master`
Let's also add a package with a specific version:
]add Example@0.3
Resolving package versions... Installed Example ─ v0.3.3 Updating `/content/my_project/Project.toml` ⌃ [7876af07] ~ Example v0.5.5 `https://github.com/JuliaLang/Example.jl#master` ⇒ v0.3.3 Updating `/content/my_project/Manifest.toml` ⌃ [7876af07] ~ Example v0.5.5 `https://github.com/JuliaLang/Example.jl#master` ⇒ v0.3.3 Info Packages marked with ⌃ have new versions available and may be upgradable. Precompiling project... 325.6 ms ✓ Example 1 dependency successfully precompiled in 0 seconds. 26 already precompiled.
Now the Project.toml and Manifest.toml files were created:
;find my_project
my_project my_project/Project.toml my_project/Manifest.toml
Notice that the packages we added to the project were not placed in the my_project directory itself. They were saved in the ~/.julia/packages directory, the compiled files were placed in ~/.julia/compiled director, logs were written to ~/.julia/logs and so on.
If several projects use the same package, it will only be downloaded and built once (well, once per version). The ~/.julia/packages directory can hold multiple versions of the same package, so it's fine if different projects use different versions of the same package. There will be no conflict, no "dependency hell".
The Project.toml just says that the project depends on PyCall and Example, and it specifies the UUID of this package:
print(read("my_project/Project.toml", String))
[deps] Example = "7876af07-990d-54b4-ab0e-23690620f79a" PyCall = "438e738f-606a-5dbb-bf0a-cddfbfd45ab0"
UUIDs are useful to avoid name conflicts. If several people name their package CoolStuff, then the UUID will clarify which one we are referring to.
The Manifest.toml file is much longer, since it contains all the packages which PyCall and Example depend on, along with their versions (except for the standard library packages), and the dependency graph. This file should never be modified manually:
print(read("my_project/Manifest.toml", String))
# This file is machine-generated - editing it directly is not advised julia_version = "1.11.5" manifest_format = "2.0" project_hash = "a4c0ea484367d5308a288ac3bbb28ae05c93b2ac" [[deps.ArgTools]] uuid = "0dad84c5-d112-42e6-8d28-ef12dabb789f" version = "1.1.2" [[deps.Artifacts]] uuid = "56f22d72-fd6d-98f1-02f0-08ddc0907c33" version = "1.11.0" [[deps.CompilerSupportLibraries_jll]] deps = ["Artifacts", "Libdl"] uuid = "e66e0078-7015-5450-92f7-15fbd957f2ae" version = "1.1.1+0" [[deps.Conda]] deps = ["Downloads", "JSON", "VersionParsing"] git-tree-sha1 = "b19db3927f0db4151cb86d073689f2428e524576" uuid = "8f4d0f93-b110-5947-807f-2305c1781a2d" version = "1.10.2" [[deps.Dates]] deps = ["Printf"] uuid = "ade2ca70-3891-5945-98fb-dc099432e06a" version = "1.11.0" [[deps.Downloads]] deps = ["ArgTools", "FileWatching", "LibCURL", "NetworkOptions"] uuid = "f43a241f-c20a-4ad4-852c-f6b1247861c6" version = "1.6.0" [[deps.Example]] git-tree-sha1 = "276fa06109ac5c80035cff711b0a18ad5b3117cc" uuid = "7876af07-990d-54b4-ab0e-23690620f79a" version = "0.3.3" [[deps.FileWatching]] uuid = "7b1f6079-737a-58dc-b8bc-7a2ca5c1b5ee" version = "1.11.0" [[deps.JSON]] deps = ["Dates", "Mmap", "Parsers", "Unicode"] git-tree-sha1 = "31e996f0a15c7b280ba9f76636b3ff9e2ae58c9a" uuid = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" version = "0.21.4" [[deps.LibCURL]] deps = ["LibCURL_jll", "MozillaCACerts_jll"] uuid = "b27032c2-a3e7-50c8-80cd-2d36dbcbfd21" version = "0.6.4" [[deps.LibCURL_jll]] deps = ["Artifacts", "LibSSH2_jll", "Libdl", "MbedTLS_jll", "Zlib_jll", "nghttp2_jll"] uuid = "deac9b47-8bc7-5906-a0fe-35ac56dc84c0" version = "8.6.0+0" [[deps.LibSSH2_jll]] deps = ["Artifacts", "Libdl", "MbedTLS_jll"] uuid = "29816b5a-b9ab-546f-933c-edad1886dfa8" version = "1.11.0+1" [[deps.Libdl]] uuid = "8f399da3-3557-5675-b5ff-fb832c97cbdb" version = "1.11.0" [[deps.LinearAlgebra]] deps = ["Libdl", "OpenBLAS_jll", "libblastrampoline_jll"] uuid = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" version = "1.11.0" [[deps.MacroTools]] git-tree-sha1 = "1e0228a030642014fe5cfe68c2c0a818f9e3f522" uuid = "1914dd2f-81c6-5fcd-8719-6d5c9610ff09" version = "0.5.16" [[deps.MbedTLS_jll]] deps = ["Artifacts", "Libdl"] uuid = "c8ffd9c3-330d-5841-b78e-0817d7145fa1" version = "2.28.6+0" [[deps.Mmap]] uuid = "a63ad114-7e13-5084-954f-fe012c677804" version = "1.11.0" [[deps.MozillaCACerts_jll]] uuid = "14a3606d-f60d-562e-9121-12d972cd8159" version = "2023.12.12" [[deps.NetworkOptions]] uuid = "ca575930-c2e3-43a9-ace4-1e988b2c1908" version = "1.2.0" [[deps.OpenBLAS_jll]] deps = ["Artifacts", "CompilerSupportLibraries_jll", "Libdl"] uuid = "4536629a-c528-5b80-bd46-f80d51c5b363" version = "0.3.27+1" [[deps.Parsers]] deps = ["Dates", "PrecompileTools", "UUIDs"] git-tree-sha1 = "7d2f8f21da5db6a806faf7b9b292296da42b2810" uuid = "69de0a69-1ddd-5017-9359-2bf0b02dc9f0" version = "2.8.3" [[deps.PrecompileTools]] deps = ["Preferences"] git-tree-sha1 = "5aa36f7049a63a1528fe8f7c3f2113413ffd4e1f" uuid = "aea7be01-6a6a-4083-8856-8a6e6704d82a" version = "1.2.1" [[deps.Preferences]] deps = ["TOML"] git-tree-sha1 = "0f27480397253da18fe2c12a4ba4eb9eb208bf3d" uuid = "21216c6a-2e73-6563-6e65-726566657250" version = "1.5.0" [[deps.Printf]] deps = ["Unicode"] uuid = "de0858da-6303-5e67-8744-51eddeeeb8d7" version = "1.11.0" [[deps.PyCall]] deps = ["Conda", "Dates", "Libdl", "LinearAlgebra", "MacroTools", "Serialization", "VersionParsing"] git-tree-sha1 = "9816a3826b0ebf49ab4926e2b18842ad8b5c8f04" uuid = "438e738f-606a-5dbb-bf0a-cddfbfd45ab0" version = "1.96.4" [[deps.Random]] deps = ["SHA"] uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" version = "1.11.0" [[deps.SHA]] uuid = "ea8e919c-243c-51af-8825-aaa63cd721ce" version = "0.7.0" [[deps.Serialization]] uuid = "9e88b42a-f829-5b0c-bbe9-9e923198166b" version = "1.11.0" [[deps.TOML]] deps = ["Dates"] uuid = "fa267f1f-6049-4f14-aa54-33bafae1ed76" version = "1.0.3" [[deps.UUIDs]] deps = ["Random", "SHA"] uuid = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" version = "1.11.0" [[deps.Unicode]] uuid = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5" version = "1.11.0" [[deps.VersionParsing]] git-tree-sha1 = "58d6e80b4ee071f5efd07fda82cb9fbe17200868" uuid = "81def892-9a0e-5fdd-b105-ffc91e053289" version = "1.3.0" [[deps.Zlib_jll]] deps = ["Libdl"] uuid = "83775a58-1f1d-513f-b197-d71354ab007a" version = "1.2.13+1" [[deps.libblastrampoline_jll]] deps = ["Artifacts", "Libdl"] uuid = "8e850b90-86db-534c-a0d3-1478176c7d93" version = "5.11.0+0" [[deps.nghttp2_jll]] deps = ["Artifacts", "Libdl"] uuid = "8e850ede-7688-5339-a07c-302acd2aaf8d" version = "1.59.0+0"
Note that Manifest.toml contains the precise version of the Example package that was installed, but the Project.toml file does not specify that version 0.3 is required. That's because Julia cannot know whether your project is supposed to work only with any version 0.3.x, or whether it could work with other versions as well. So if you want to specify a version constraint for the Example package, you must add it manually in Project.toml. You would normally use your favorite editor to do this, but in this notebook we'll update Project.toml programmatically:
append_config = """
[compat]
Example = "0.3"
"""
open(f->write(f, append_config), "my_project/Project.toml", "a")
26
Here is the updated Project.toml file:
print(read("my_project/Project.toml", String))
[deps] Example = "7876af07-990d-54b4-ab0e-23690620f79a" PyCall = "438e738f-606a-5dbb-bf0a-cddfbfd45ab0" [compat] Example = "0.3"
Now if we try to replace Example 0.3 with version 0.2, we get an error:
try
pkg"add Example@0.2"
catch ex
ex
end
Resolving package versions...
Pkg.Resolve.ResolverError("empty intersection between Example@0.2 and project compatibility 0.3", nothing)
Now you can run a program based on this project, and it will have the possibility to use all the packages which have been added to this project, with their specific versions. If you import a package which was not explicitly added to this project, Julia will fallback to the default project:
code = """
import PyCall # found in the project
import PyPlot # not found, so falls back to default project
println("Success!")
"""
open(f->write(f, code), "my_program3.jl", "w")
117
;julia --project=my_project my_program3.jl
Success!
Packages¶
Falling back to the default project is fine, as long as you run the code on your own machine, but if you want to share your code with other people, it would be brittle to count on packages installed in their default project. Instead, if you plan to share your code, you should clearly specify which packages it depends on, and use only these packages. Such a shareable project is called a package.
A package is a regular project (as defined above), but with a few extras:
- the
Project.tomlfile must specify aname, aversionand auuid. - there must be a
src/PackageName.jlfile containing a module namedPackageName. - you generally want to specify the
authorsanddescription, and maybe also thelicense,repository(e.g., the package's github URL), and somekeywords, but all of these are optional.
It is very easy to create a new package using the ]generate command. To define the authors field, Pkg will look up the user.name and user.email git config entries, so let's define them before we generate the package:
;git config --global user.name "Alice Bob"
;git config --global user.email "alice.bob@example.com"
]generate MyPackages/Hello
Generating project Hello:
MyPackages/Hello/Project.toml
MyPackages/Hello/src/Hello.jl
This generated the MyPackages/Hello/Project.toml file (along with the enclosing directories) and the MyPackages/Hello/src/Hello.jl file. Let's take a look at the Project.toml file:
print(read("MyPackages/Hello/Project.toml", String))
name = "Hello" uuid = "f5fd85bb-ea0c-4917-a7c1-fa0842e52507" authors = ["Alice Bob <alice.bob@example.com>"] version = "0.1.0"
Notice that the project has no dependencies yet, but it has a name, a unique UUID, and a version (plus an author).
Note: if Pkg does not find a your name or email in the git config, it falls back to environment variables (GIT_AUTHOR_NAME, GIT_COMMITTER_NAME, USER, USERNAME, NAME and GIT_AUTHOR_EMAIL, GIT_COMMITTER_EMAIL, EMAIL).
And let's look at the src/Hello.jl file:
print(read("MyPackages/Hello/src/Hello.jl", String))
module Hello
greet() = print("Hello World!")
end # module Hello
Let's try to use the greet() function from the Hello package:
try
import Hello
Hello.greet()
catch ex
ex
end
ArgumentError("Package Hello not found in current path.\n- Run `import Pkg; Pkg.add(\"Hello\")` to install the Hello package.")
Julia could not find the Hello package. When you're working on a package, don't forget to activate it first!
]activate MyPackages/Hello
Activating project at `/content/MyPackages/Hello`
import Hello
Hello.greet()
[ Info: Precompiling Hello [f5fd85bb-ea0c-4917-a7c1-fa0842e52507]
Hello World!
It works!
If the Hello package depends on other packages, we must add them:
]add PyCall Example
Resolving package versions... Installed Example ─ v0.5.5 Compat entries added for PyCall, Example Updating `/content/MyPackages/Hello/Project.toml` [7876af07] + Example v0.5.5 [438e738f] + PyCall v1.96.4 Updating `/content/MyPackages/Hello/Manifest.toml` [8f4d0f93] + Conda v1.10.2 [7876af07] + Example v0.5.5 ⌅ [682c06a0] + JSON v0.21.4 [1914dd2f] + MacroTools v0.5.16 [69de0a69] + Parsers v2.8.3 ⌅ [aea7be01] + PrecompileTools v1.2.1 [21216c6a] + Preferences v1.5.0 [438e738f] + PyCall v1.96.4 [81def892] + VersionParsing v1.3.0 [0dad84c5] + ArgTools v1.1.2 [56f22d72] + Artifacts v1.11.0 [ade2ca70] + Dates v1.11.0 [f43a241f] + Downloads v1.6.0 [7b1f6079] + FileWatching v1.11.0 [b27032c2] + LibCURL v0.6.4 [8f399da3] + Libdl v1.11.0 [37e2e46d] + LinearAlgebra v1.11.0 [a63ad114] + Mmap v1.11.0 [ca575930] + NetworkOptions v1.2.0 [de0858da] + Printf v1.11.0 [9a3f8284] + Random v1.11.0 [ea8e919c] + SHA v0.7.0 [9e88b42a] + Serialization v1.11.0 [fa267f1f] + TOML v1.0.3 [cf7118a7] + UUIDs v1.11.0 [4ec0a83e] + Unicode v1.11.0 [e66e0078] + CompilerSupportLibraries_jll v1.1.1+0 [deac9b47] + LibCURL_jll v8.6.0+0 [29816b5a] + LibSSH2_jll v1.11.0+1 [c8ffd9c3] + MbedTLS_jll v2.28.6+0 [14a3606d] + MozillaCACerts_jll v2023.12.12 [4536629a] + OpenBLAS_jll v0.3.27+1 [83775a58] + Zlib_jll v1.2.13+1 [8e850b90] + libblastrampoline_jll v5.11.0+0 [8e850ede] + nghttp2_jll v1.59.0+0 Info Packages marked with ⌅ have new versions available but compatibility constraints restrict them from upgrading. To see why use `status --outdated -m` Precompiling project... 334.5 ms ✓ Example 1 dependency successfully precompiled in 0 seconds. 27 already precompiled.
You must not use any package which has not been added to the project. If you do, you will get a warning.
Once you are happy with your package, you can deploy it to github (or anywhere else). Then you can add it to your own projects just like any other package.
If you want to make your package available to the world via the official Julia registry, you just need to send a Pull Request to https://github.com/JuliaRegistries/General. However, it's highly recommended to automate this using the Registrator.jl github app.
If you want to use other registries (including private registries), check out this page.
Also check out the PkgTemplate package, which provides more sophisticated templates for creating new packages, for example with continuous integration, code coverage tests, etc.
Fixing Issues in a Dependency¶
Sometimes you may run into an issue inside one of the packages your project depends on. When this happens, you can use Pkg's dev command to fix the issue. For example, let's pretend the Example package has a bug:
]dev Example
Cloning git-repo `https://github.com/JuliaLang/Example.jl.git` Resolving package versions... Updating `/content/MyPackages/Hello/Project.toml` [7876af07] ~ Example v0.5.5 ⇒ v0.5.5 `~/.julia/dev/Example` Updating `/content/MyPackages/Hello/Manifest.toml` [7876af07] ~ Example v0.5.5 ⇒ v0.5.5 `~/.julia/dev/Example`
This command cloned the repo into ~/.julia/dev/Example:
;ls -l "~/.julia/dev"
total 4 drwxr-xr-x 7 root root 4096 Oct 18 08:34 Example
It also updated the Hello package's Manifest.toml file to ensure the package now uses the Example clone. You can see this using ]status:
]st
Project Hello v0.1.0 Status `/content/MyPackages/Hello/Project.toml` [7876af07] Example v0.5.5 `~/.julia/dev/Example` [438e738f] PyCall v1.96.4
So you would now go ahead and edit the clone and fix the bug. Of course, you would also want to send a PR to the package's owners so the source package gets fixed. Once that happens, you can go back to the official Example package easily:
]free Example
Resolving package versions... Updating `/content/MyPackages/Hello/Project.toml` [7876af07] ~ Example v0.5.5 `~/.julia/dev/Example` ⇒ v0.5.5 Updating `/content/MyPackages/Hello/Manifest.toml` [7876af07] ~ Example v0.5.5 `~/.julia/dev/Example` ⇒ v0.5.5
]st
Project Hello v0.1.0 Status `/content/MyPackages/Hello/Project.toml` [7876af07] Example v0.5.5 [438e738f] PyCall v1.96.4
Instantiating a Project¶
If you want to run someone else's project and you want to make sure you are using the exact same package versions, you can clone the project, and assuming it has a Manifest.toml file, you can activate the project and run ]instantiate to install all the appropriate packages. For example, let's instantiate the Registrator.jl project:
;git clone https://github.com/JuliaRegistries/Registrator.jl
Cloning into 'Registrator.jl'...
]activate Registrator.jl
Activating project at `/content/Registrator.jl`
]instantiate
Updating registry at `~/.julia/registries/General.toml` Installed AutoHashEquals ─ v0.2.0 Installed TimeToLive ───── v0.3.0 Installed Mustache ─────── v1.0.21 Installed Mocking ──────── v0.8.1 Installed JSON3 ────────── v1.14.3 Installed RegistryTools ── v2.3.0 Installed TimeZones ────── v1.22.1 Installed TZJData ──────── v1.5.0+2025b Installed Pidfile ──────── v1.3.0 Installed AssetRegistry ── v0.1.0 Installed GitForge ─────── v0.4.4 Installed SodiumSeal ───── v0.1.1 Installed StructTypes ──── v1.11.0 Installed Hiccup ───────── v0.2.2 Installed GitHub ───────── v5.10.0 Installed Mux ──────────── v1.0.2 Updating `/content/Registrator.jl/Project.toml` ⌅ [15f4f7f2] + AutoHashEquals v0.2.0 [8f6bce27] + GitForge v0.4.4 [bc5e4493] + GitHub v5.10.0 [cd3eb016] + HTTP v1.10.19 ⌅ [682c06a0] + JSON v0.21.4 [739be429] + MbedTLS v1.1.9 [78c3b35d] + Mocking v0.8.1 [ffc61752] + Mustache v1.0.21 [a975b10e] + Mux v1.0.2 [d1eb7eb1] + RegistryTools v2.3.0 [37f0c46e] + TimeToLive v0.3.0 [5c2747f8] + URIs v1.6.1 [c2297ded] + ZMQ v1.5.0 [2a0f44e3] ~ Base64 ⇒ v1.11.0 [ade2ca70] ~ Dates ⇒ v1.11.0 [8ba89e20] ~ Distributed ⇒ v1.11.0 [7b1f6079] ~ FileWatching ⇒ v1.11.0 [76f85450] ~ LibGit2 ⇒ v1.11.0 [56ddb016] ~ Logging ⇒ v1.11.0 [44cfe95a] ~ Pkg ⇒ v1.11.0 [9e88b42a] ~ Serialization ⇒ v1.11.0 [6462fe0b] ~ Sockets ⇒ v1.11.0 [cf7118a7] ~ UUIDs ⇒ v1.11.0 Updating `/content/Registrator.jl/Manifest.toml` [bf4720bc] + AssetRegistry v0.1.0 ⌅ [15f4f7f2] + AutoHashEquals v0.2.0 [d1d4a3ce] + BitFlags v0.1.9 [944b1d66] + CodecZlib v0.7.8 [34da2185] + Compat v4.18.1 [f0e56b4a] + ConcurrentUtilities v2.5.0 [9a962f9c] + DataAPI v1.16.0 [e2d170a0] + DataValueInterfaces v1.0.0 [460bff9d] + ExceptionUnwrapping v0.1.11 [e2ba6199] + ExprTools v0.1.10 [8f6bce27] + GitForge v0.4.4 [bc5e4493] + GitHub v5.10.0 [cd3eb016] + HTTP v1.10.19 [9fb69e20] + Hiccup v0.2.2 [842dd82b] + InlineStrings v1.4.5 [82899510] + IteratorInterfaceExtensions v1.0.0 [692b3bcd] + JLLWrappers v1.7.1 ⌅ [682c06a0] + JSON v0.21.4 [0f8b85d8] + JSON3 v1.14.3 [e6f89c97] + LoggingExtras v1.2.0 [1914dd2f] + MacroTools v0.5.16 [739be429] + MbedTLS v1.1.9 [78c3b35d] + Mocking v0.8.1 [ffc61752] + Mustache v1.0.21 [a975b10e] + Mux v1.0.2 [4d8831e6] + OpenSSL v1.5.0 [bac558e1] + OrderedCollections v1.8.1 [69de0a69] + Parsers v2.8.3 [fa939f87] + Pidfile v1.3.0 ⌅ [aea7be01] + PrecompileTools v1.2.1 [21216c6a] + Preferences v1.5.0 [d1eb7eb1] + RegistryTools v2.3.0 [6c6a2e73] + Scratch v1.3.0 [777ac1f9] + SimpleBufferStream v1.2.0 [2133526b] + SodiumSeal v0.1.1 [856f2bd8] + StructTypes v1.11.0 [dc5dba14] + TZJData v1.5.0+2025b [3783bdb8] + TableTraits v1.0.1 [bd369af6] + Tables v1.12.1 [37f0c46e] + TimeToLive v0.3.0 [f269a46b] + TimeZones v1.22.1 [3bb67fe8] + TranscodingStreams v0.11.3 [5c2747f8] + URIs v1.6.1 [c2297ded] + ZMQ v1.5.0 [458c3c95] + OpenSSL_jll v3.5.4+0 [8f1865be] + ZeroMQ_jll v4.3.6+0 [a9144af2] + libsodium_jll v1.0.21+0 [0dad84c5] + ArgTools v1.1.2 [56f22d72] + Artifacts v1.11.0 [2a0f44e3] + Base64 v1.11.0 [ade2ca70] + Dates v1.11.0 [8ba89e20] + Distributed v1.11.0 [f43a241f] + Downloads v1.6.0 [7b1f6079] + FileWatching v1.11.0 [b77e0a4c] + InteractiveUtils v1.11.0 [b27032c2] + LibCURL v0.6.4 [76f85450] + LibGit2 v1.11.0 [8f399da3] + Libdl v1.11.0 [56ddb016] + Logging v1.11.0 [d6f4376e] + Markdown v1.11.0 [a63ad114] + Mmap v1.11.0 [ca575930] + NetworkOptions v1.2.0 [44cfe95a] + Pkg v1.11.0 [de0858da] + Printf v1.11.0 [9a3f8284] + Random v1.11.0 [ea8e919c] + SHA v0.7.0 [9e88b42a] + Serialization v1.11.0 [6462fe0b] + Sockets v1.11.0 [fa267f1f] + TOML v1.0.3 [a4e569a6] + Tar v1.10.0 [8dfed614] + Test v1.11.0 [cf7118a7] + UUIDs v1.11.0 [4ec0a83e] + Unicode v1.11.0 [deac9b47] + LibCURL_jll v8.6.0+0 [e37daf67] + LibGit2_jll v1.7.2+0 [29816b5a] + LibSSH2_jll v1.11.0+1 [c8ffd9c3] + MbedTLS_jll v2.28.6+0 [14a3606d] + MozillaCACerts_jll v2023.12.12 [83775a58] + Zlib_jll v1.2.13+1 [8e850ede] + nghttp2_jll v1.59.0+0 [3f19e933] + p7zip_jll v17.4.0+2 Info Packages marked with ⌅ have new versions available but compatibility constraints restrict them from upgrading. To see why use `status --outdated -m` Precompiling project... 570.2 ms ✓ AutoHashEquals 1007.4 ms ✓ TimeToLive 1145.6 ms ✓ TZJData 1530.9 ms ✓ Pidfile 2052.0 ms ✓ Mustache 2869.3 ms ✓ StructTypes 1031.6 ms ✓ Mocking 1772.9 ms ✓ Hiccup 1388.8 ms ✓ SodiumSeal 1157.3 ms ✓ AssetRegistry 3183.4 ms ✓ RegistryTools 5552.9 ms ✓ GitHub 5689.4 ms ✓ Mux 19007.2 ms ✓ TimeZones 21304.3 ms ✓ JSON3 3401.9 ms ✓ GitForge 3162.4 ms ✓ Registrator 17 dependencies successfully precompiled in 33 seconds. 60 already precompiled.
Usually, that's all you need to know about projects and packages, but let's look at bit under the hood, so you can handle less common cases.
Load Path¶
When you import a package, Julia searches for it in the environments listed in the LOAD_PATH array. An environment can be a project or a directory containing a bunch of packages directly. By default, the LOAD_PATH array contains three elements:
LOAD_PATH
3-element Vector{String}:
"@"
"@v#.#"
"@stdlib"
Here's what these elements mean:
"@"represents the active project, if any: that's the project activated via--project,JULIA_PROJECT,]activateorPkg.activate()."@v#.#"represents the default shared project for the version of Julia we are running. That's why it is used by default when there is no active project."@stdlib"represents the standard library. This is not a project: it's a directory containing many packages.
If you want to see the actual paths, you can call Base.load_path():
Base.load_path()
3-element Vector{String}:
"/content/Registrator.jl/Project.toml"
"/root/.julia/environments/v1.11/Project.toml"
"/usr/local/share/julia/stdlib/v1.11"
You can change the load path if you want to. For example, if you want Julia to look only in the active project and in the standard library, without looking in the default project, then you can set the JULIA_LOAD_PATH environment variable to "@:@stdlib".
If you try to run my_program3.jl this way, it will successfully import PyCall, but it will fail to import PyPlot, since it is not listed in Project.toml (however, it would successfully import any package from the standard library):
try
withenv("JULIA_LOAD_PATH"=>"@:@stdlib", "LD_PRELOAD"=>"") do
run(`julia --project=my_project my_program3.jl`)
end
catch ex
ex
end
ERROR: LoadError: ArgumentError: Package PyPlot not found in current path.
- Run `import Pkg; Pkg.add("PyPlot")` to install the PyPlot package.
Stacktrace:
[1] macro expansion
@ ./loading.jl:2296 [inlined]
[2] macro expansion
@ ./lock.jl:273 [inlined]
[3] __require(into::Module, mod::Symbol)
@ Base ./loading.jl:2271
[4] #invoke_in_world#3
@ ./essentials.jl:1089 [inlined]
[5] invoke_in_world
@ ./essentials.jl:1086 [inlined]
[6] require(into::Module, mod::Symbol)
@ Base ./loading.jl:2260
in expression starting at /content/my_program3.jl:2
ProcessFailedException(Base.Process[Process(`julia --project=my_project my_program3.jl`, ProcessExited(1))])
You can also modify the LOAD_PATH array programmatically, for example to make all the packages in the my_packages/ directory available to the project:
push!(LOAD_PATH, "my_packages")
4-element Vector{String}:
"@"
"@v#.#"
"@stdlib"
"my_packages"
Now any package added to this directory will be directly available to us:
]generate my_packages/Hello2
Generating project Hello2:
my_packages/Hello2/Project.toml
my_packages/Hello2/src/Hello2.jl
using Hello2
Hello2.greet()
[ Info: Precompiling Hello2 [a952cb70-f232-429f-a988-7379953cea39]
Hello World!
This is a convenience for development, as we didn't have to push this package to a repository or even add it to the project. However, it's just for development: once you're happy with your package, make sure to push it to a repo, and add it to the project normally.
Depots¶
As we saw earlier, new packages you add to a project are placed in the ~/.julia/packages directory, logs are placed in ~/.julia/logs, and so on.
A directory like ~/.julia which contains Pkg related content is called a depot. Julia installs all new packages in the default depot, which is the first directory in the DEPOT_PATH array (this array can be modified manually in Julia, or set via the JULIA_DEPOT_PATH environment variable):
DEPOT_PATH
3-element Vector{String}:
"/root/.julia"
"/usr/local/local/share/julia"
"/usr/local/share/julia"
The default depot needs to be writeable for the current user, since that's where new packages will be written to (as well as logs and other stuff). The other depots can be read-only: they're typically used for private package registries.
You can occasionally run the ]gc command, which will remove all unused package versions (Pkg will use the logs to located existing projects).
In summary: when some code runs using Foo or import Foo, the LOAD_PATH is used to determine which specific package Foo refers to, while the DEPOT_PATH is used to determine where it is. The exception is when the LOAD_PATH contains directories which directly contain packages: for these packages, the DEPOT_PATH is not used.
Parallel Computing¶
Julia supports coroutines (aka green threads), multithreading (without a [GIL](https://en.wikipedia.org/wiki/Global_interpreter_lock#:~:text=A%20global%20interpreter%20lock%20(GIL,on%20a%20multi%2Dcore%20processor.) like CPython!), multiprocessing and distributed computing.
Coroutines¶
Let's go back to the fibonacci() generator function:
function fibonacci(n)
Channel() do ch
a, b = 1, 1
for i in 1:n
put!(ch, a)
a, b = b, a + b
end
end
end
for f in fibonacci(10)
println(f)
end
1 1 2 3 5 8 13 21 34 55
Under the hood, Channel() do ... end creates a Channel object, and spawns an asynchronous Task to execute the code in the do ... end block. The task is scheduled to execute immediately, but when it calls the put!() function on the channel to yield a value, it blocks until another task calls the take!() function to grab that value. You do not see the take!() function explicitly in this code example, since it is executed automatically in the for loop, in the main task. To demonstrate this, we can just call the take!() function 10 times to get all the items from the channel:
ch = fibonacci(10)
for i in 1:10
println(take!(ch))
end
1 1 2 3 5 8 13 21 34 55
This channel is bound to the task, therefore it is automatically closed when the task ends. So if we try to get one more element, we will get an exception:
try
take!(ch)
catch ex
ex
end
InvalidStateException("Channel is closed.", :closed)
Here is a more explicit version of the fibonacci() function:
function fibonacci(n)
function generator_func(ch, n)
a, b = 1, 1
for i in 1:n
put!(ch, a)
a, b = b, a + b
end
end
ch = Channel()
task = @task generator_func(ch, n) # creates a task without starting it
bind(ch, task) # the channel will be closed when the task ends
schedule(task) # start running the task asynchronously
ch
end
fibonacci (generic function with 1 method)
And here is a more explicit version of the for loop:
ch = fibonacci(10)
for value in ch
println(value)
end
1 1 2 3 5 8 13 21 34 55
Note that asynchronous tasks (also called "coroutines" or "green threads") are not actually run in parallel: they cooperate to alternate execution. Some functions, such as put!(), take!(), and many I/O functions, interrupt the current task's execution, at which point it lets Julia's scheduler decide which task should resume its execution. This is just like Python's coroutines.
For more details on coroutines and tasks, see the manual.
Multithreading¶
Julia also supports multithreading. You can specify the number of O.S. threads upon startup, by setting the JULIA_NUM_THREADS environment variable (or setting the -t argument in Julia 1.5+).
ENV["JULIA_NUM_THREADS"]
"auto"
The actual number of threads started by Julia may be lower than that, as it is limited to the number of available cores on the machine (thanks to hyperthreading, each physical core may run two threads). Here is the number of threads that were actually started:
using Base.Threads
nthreads()
2
Now let's run 10 tasks across these threads:
@threads for i in 1:10
println("thread #", threadid(), " is starting task #$i")
sleep(rand()) # pretend we're actually working
println("thread #", threadid(), " is finished")
end
thread #1 is starting task #1 thread #2 is starting task #6 thread #2 is finished thread #2 is starting task #7 thread #1 is finished thread #1 is starting task #2 thread #2 is finished thread #2 is starting task #8 thread #1 is finished thread #2 is starting task #3 thread #1 is finished thread #2 is starting task #9 thread #1 is finished thread #2 is starting task #4 thread #2 is finished thread #2 is starting task #5 thread #2 is finished thread #1 is finished thread #2 is starting task #10 thread #2 is finished
Here is a multithreaded version of the estimate_pi() function. Each thread computes part of the sum, and the parts are added at the end:
function parallel_estimate_pi(n)
s = zeros(nthreads())
nt = n ÷ nthreads()
@threads for t in 1:nthreads()
for i in (1:nt) .+ nt*(t - 1)
@inbounds s[t] += (isodd(i) ? -1 : 1) / (2i + 1)
end
end
return 4.0 * (1.0 + sum(s))
end
@btime parallel_estimate_pi(100_000_000)
126.719 ms (16 allocations: 1.22 KiB)
3.1415926635894196
The @inbounds macro is an optimization: it tells the Julia compiler not to add any bounds check when accessing the array. It's safe in this case since the s array has one element per thread, and t varies from 1 to nthreads(), so there is no risk for s[t] to be out of bounds.
Let's compare this with the single-threaded implementation:
@btime estimate_pi(100_000_000)
126.156 ms (0 allocations: 0 bytes)
3.141592663589326
If you are running this notebook on Colab, the parallel implementation is probably no faster than the single-threaded one. That's because the Colab Runtime only has a single CPU, so there is no benefit from multithreading (plus there is a bit of overhead for managing threads). However, on my 8-core machine, using 16 threads, the parallel implementation is about 6 times faster than the single-threaded one.
Julia has a mapreduce() function which makes it easy to implement functions like parallel_estimate_pi():
function parallel_estimate_pi2(n)
4.0 * mapreduce(i -> (isodd(i) ? -1 : 1) / (2i + 1), +, 0:n)
end
parallel_estimate_pi2 (generic function with 1 method)
@btime parallel_estimate_pi2(100_000_000)
65.007 ms (0 allocations: 0 bytes)
3.141592663589793
The mapreduce() function is well optimized, so it's about twice as fast as parallel_estimate_pi().
You can also spawn a task using Threads.@spawn. It will get executed on any one of the running threads (it will not start a new thread):
task = Threads.@spawn begin
println("Thread starting")
sleep(1)
println("Thread stopping")
42 # result
end
println("Hello!")
println("The result is: ", fetch(task))
Hello! Thread starting Thread stopping The result is: 42
The fetch() function waits for the thread to finish, and fetches the result. You can also just call wait() if you don't need the result.
Last but not least, you can use channels to synchronize and communicate across tasks, even if they are running across separate threads:
ch = Channel()
task1 = Threads.@spawn begin
for i in 1:5
sleep(rand())
put!(ch, i^2)
end
println("Finished sending!")
close(ch)
end
task2 = Threads.@spawn begin
foreach(v->println("Received $v"), ch)
println("Finished receiving!")
end
wait(task2)
Received 1 Received 4 Received 9 Received 16 Finished sending! Received 25 Finished receiving!
For more details about multithreading, check out this page.
Multiprocessing & Distributed Programming¶
Julia can spawn multiple Julia processes upon startup if you specify the number of processes via the -p argument. You can also spawn extra processes from Julia itself:
using Distributed
addprocs(4)
workers() # array of worker process ids
4-element Vector{Int64}:
2
3
4
5
The main process has id 1:
myid()
1
The @everywhere macro lets you run any code on all workers:
@everywhere println("Hi! I'm worker $(myid())")
Hi! I'm worker 1
From worker 4: Hi! I'm worker 4
From worker 3: Hi! I'm worker 3
From worker 2: Hi! I'm worker 2
From worker 5: Hi! I'm worker 5
You can also execute code on a particular worker by using @spawnat <worker id> <statement>:
@spawnat 3 println("Hi! I'm worker $(myid())")
Future(3, 1, 14, ReentrantLock(nothing, 0x00000000, 0x00, Base.GenericCondition{SpinLock}(Base.IntrusiveLinkedList{Task}(nothing, nothing), SpinLock(0)), (46, 0, -1)), nothing)
If you specify :any instead of a worker id, Julia chooses the worker for you:
@spawnat :any println("Hi! I'm worker $(myid())")
Future(2, 1, 15, ReentrantLock(nothing, 0x00000000, 0x00, Base.GenericCondition{SpinLock}(Base.IntrusiveLinkedList{Task}(nothing, nothing), SpinLock(0)), (139267361548128, 139267510191424, 139267510189264)), nothing)
The output of @spawnat is a Future object. You can call fetch() on this object to wait for the result:
result = @spawnat 3 1+2+3+4
fetch(result)
From worker 3: Hi! I'm worker 3
10
If you import some package in the main process, it is not automatically imported in the workers. For example, the following code fails because the worker does not know what pyimport is:
using PyCall
result = @spawnat 4 (np = pyimport("numpy"); np.log(10))
try
fetch(result)
catch ex
ex
end
From worker 2: Hi! I'm worker 2
RemoteException(4, CapturedException(UndefVarError(:pyimport, Main), Any[(#118 at macros.jl:83, 1), (#invokelatest#2 at essentials.jl:1055 [inlined], 1), (invokelatest at essentials.jl:1052, 1), (#107 at process_messages.jl:283, 1), (run_work_thunk at process_messages.jl:70, 1), (run_work_thunk at process_messages.jl:79, 1), (#100 at process_messages.jl:88, 1)]))
You must use @everywhere or @spawnat to import the packages you need in each worker:
@everywhere using PyCall
result = @spawnat 4 (np = pyimport("numpy"); np.log(10))
fetch(result)
2.302585092994046
Similarly, if you define a function in the main process, it is not automatically available in the workers. You must define the function in every worker:
@everywhere addtwo(n) = n + 2
result = @spawnat 4 addtwo(40)
fetch(result)
42
You can pass a Future to @everywhere or @spawnat, as long as you wrap it in a fetch() function:
M = @spawnat 2 rand(5)
result = @spawnat 3 fetch(M) .* 10.0
fetch(result)
5-element Vector{Float64}:
1.5598359450571153
9.77255628698287
5.983511443658351
8.084261249368886
9.873546141395915
In this example, worker 2 creates a random array, then worker 3 fetches this array and multiplies each element by 10, then the main process fetches the result and displays it.
GPU¶
Julia has excellent GPU support. As you may know, GPUs are devices which can run thousands of threads in parallel. Each thread is slower and more limited than on a CPU, but there are so many of them that plenty of tasks can be executed much faster on a GPU than on a CPU, provided these tasks can be parallelized.
Let's check which GPU device is installed:
;nvidia-smi
Sat Oct 18 08:36:45 2025
+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 550.54.15 Driver Version: 550.54.15 CUDA Version: 12.4 |
|-----------------------------------------+------------------------+----------------------+
| GPU Name Persistence-M | Bus-Id Disp.A | Volatile Uncorr. ECC |
| Fan Temp Perf Pwr:Usage/Cap | Memory-Usage | GPU-Util Compute M. |
| | | MIG M. |
|=========================================+========================+======================|
| 0 Tesla T4 Off | 00000000:00:04.0 Off | 0 |
| N/A 48C P8 10W / 70W | 0MiB / 15360MiB | 0% Default |
| | | N/A |
+-----------------------------------------+------------------------+----------------------+
+-----------------------------------------------------------------------------------------+
| Processes: |
| GPU GI CI PID Type Process name GPU Memory |
| ID ID Usage |
|=========================================================================================|
| No running processes found |
+-----------------------------------------------------------------------------------------+
If you're running on the Colab, your runtime will generally have an Nvidia Tesla T4 16GB GPU, but others are sometimes available (especially if you have the Pro version).
If no GPU is detected, go to Runtime > Change runtime type, set Hardware accelerator to GPU, then go to Runtime > Factory reset runtime, then run the first cells to reinstall the required packages, and come back here. If you're running on your own machine, make sure you have a compatible GPU card installed, with the appropriate drivers.
Now let's create a large matrix and time how long it takes to square it on the CPU:
using BenchmarkTools
M = rand(2^11, 2^11)
function benchmark_matmul_cpu(M)
M * M
return
end
benchmark_matmul_cpu(M) # warm up
@btime benchmark_matmul_cpu($M)
246.308 ms (3 allocations: 32.00 MiB)
Notes:
- For benchmarking, we wrapped the operation in a function which returns
nothing. - Why do we have a "warm up" line? Well, since Julia compiles code on the fly the first time it is executed, it's good practice to execute the operation we want to benchmark at least once before starting the benchmark, or else the benchmark will include the compilation time.
- We used
$Minstead ofMon the last line. This is a feature of the@btimemacro: it evaluatesMbefore benchmarking takes place, to avoid the extra delay that is incurred when benchmarking with global variables.
In Colab, GPU computation should happen only on the main process, not on remote workers, so before using CUDA, let's remove all extra workers:
rmprocs(workers())
Task (done) @0x00007ea9544d5780
Now let's benchmark this same operation on the GPU:
using CUDA
# Copy the data to the GPU. Creates a CuArray:
M_on_gpu = cu(M)
# Alternatively, create a new random matrix directly on the GPU:
#M_on_gpu = CUDA.CURAND.rand(2^11, 2^11)
function benchmark_matmul_gpu(M)
CUDA.@sync M * M
return
end
benchmark_matmul_gpu(M_on_gpu) # warm up
@btime benchmark_matmul_gpu($M_on_gpu)
┌ Warning: You are using CUDA 12.5.0 with a driver for CUDA 13.x. │ It is recommended to upgrade your driver, or switch to automatic installation of CUDA. └ @ CUDA ~/.julia/packages/CUDA/G7Cnt/src/initialization.jl:137
2.431 ms (72 allocations: 1.64 KiB)
That's much faster (over 100x faster in my test on Colab with an NVidia Tesla T4 GPU).
Importantly:
- Before the GPU can work on some data, it needs to be copied to the GPU (or generated there directly).
- the
CUDA.@syncmacro waits for the GPU operation to complete. Without it, the operation would happen in parallel on the GPU, while execution would continue on the CPU. So we would just be timing how long it takes to start the operation, not how long it takes to complete. - In general, you don't need
CUDA.@sync, since many operations (includingcu()) call it implicitly, and it's usually a good idea to let the CPU and GPU work in parallel. Typically, the GPU will be working on the current batch of data while the CPU works on preparing the next batch.
Of course, the speed up will vary depending on the matrix size and the GPU type. Moreover, copying the data from the CPU to the GPU is often the slowest part of the operation, but we only benchmarked the matrix multiplication itself. Let's see what we get if we include the data transfer in the benchmark:
That's still much faster than on the CPU.
Let's check how much RAM we have left on the GPU:
CUDA.memory_status()
Effective GPU memory usage: 50.59% (7.458 GiB/14.741 GiB) Memory pool usage: 4.828 GiB (7.344 GiB reserved)
Julia's Garbage Collector will free CUDA arrays like any other object, when there's no more reference to it. However, CUDA.jl uses a memory pool to make allocations faster on the GPU, so don't be surprised if the allocated memory on the GPU does not go down immediately. Moreover, IJulia keeps a reference to the output of each cell, so if you let any cell output a CuArray, it will only be released when you execute Out[<cell number>]=0. If you want to force the Garbage Collector to run, you an run GC.gc(). To reclaim memory from the memory pool, use CUDA.reclaim():
GC.gc()
CUDA.reclaim()
Many other operations are implemented for CuArray (+, -, etc.) and dotted operations (.+, exp.(), etc). Importantly, loop fusion also works on the GPU. For example, if we want to compute M .* M .+ M, without loop fusion the GPU would first compute M .* M and create a temporary array, then it would add M to that array, like this:
function benchmark_without_fusion(M)
P = M .* M
CUDA.@sync P .+ M
return
end
benchmark_without_fusion(M_on_gpu) # warm up
@btime benchmark_without_fusion($M_on_gpu)
422.275 μs (228 allocations: 6.34 KiB)
Instead, loop fusion ensures that the array is only traversed once, without the need for a temporary array:
function benchmark_with_fusion(M)
CUDA.@sync M .* M .+ M
return
end
benchmark_with_fusion(M_on_gpu) # warm up
@btime benchmark_with_fusion($M_on_gpu)
219.786 μs (144 allocations: 3.86 KiB)
That's much faster (about twice as fast in my test on Colab). 😃
Lastly, you can actually write your own GPU kernels in Julia! In other words, rather than using GPU operations implemented in the CUDA.jl package (or others), you can write Julia code that will be compiled for the GPU, and executed there. This can occasionally be useful to speed up some algorithms where the standard kernels don't suffice. For example, here's a GPU kernel which implements u .+= v, where u and v are two (large) vectors:
function worker_gpu_add!(u, v)
index = (blockIdx().x - 1) * blockDim().x + threadIdx().x
index ≤ length(u) && (@inbounds u[index] += v[index])
return
end
function gpu_add!(u, v)
numblocks = ceil(Int, length(u) / 256)
@cuda threads=256 blocks=numblocks worker_gpu_add!(u, v)
return u
end
gpu_add! (generic function with 1 method)
This code example is adapted from the CUDA.jl package's documentation, which I highly encourage you to check out if you're interested in writing your own kernels. Here are the key parts to understand this example, starting from the end:
- The
gpu_add!()function first calculatesnumblocks, the number of blocks of threads to start, then it uses the@cudamacro to spawnnumblocksblocks of GPU threads, each with 256 threads, and each thread runsworker_gpu_add!(u, v). - The
worker_gpu_add!()function computesu[index] += v[index]for a single value ofindex: in other words, each thread will just update a single value in the vector! Let's see how the index is computed:- The
@cudamacro spawned many blocks of 256 threads each. These blocks are organized in a grid, which is one-dimensional by default, but it can be up to three-dimensional. Therefore each thread and each block have an(x, y, z)coordinate in this grid. See this diagram from the Nvidia blog post:
. threadIdx().xreturns the current GPU thread'sxcoordinate within its block (one difference with the diagram is that Julia is 1-indexed).blockIdx().xreturns the current block'sxcoordinate in the grid.blockDim().xreturns the block size along thexaxis (in this example, it's 256).gridDim().xreturns the number of blocks in the grid, along thexaxis (in this example it'snumblocks).- So the
indexthat each thread must update in the array is(blockIdx().x - 1) * blockDim().x + threadIdx().x.
- The
- As explained earlier, the
@inboundsmacro is an optimization that tells Julia that the index is guaranteed to be inbounds, so there's no need for it to check.
Now writing your own GPU kernel won't seem like something only top experts with advanced C++ skills can do: you can do it too!
Let's check that the kernel works as expected:
u = rand(2^20)
v = rand(2^20)
u_on_gpu = cu(u)
v_on_gpu = cu(v)
u .+= v
gpu_add!(u_on_gpu, v_on_gpu)
@assert Array(u_on_gpu) ≈ u
Yes, it works well!
Note: the ≈ operator checks whether the operands are approximately equal within the float precision limit.
Let's benchmark our custom kernel:
function benchmark_custom_assign_add!(u, v)
CUDA.@sync gpu_add!(u, v)
return
end
benchmark_custom_assign_add!(u_on_gpu, v_on_gpu)
@btime benchmark_custom_assign_add!($u_on_gpu, $v_on_gpu)
59.037 μs (12 allocations: 240 bytes)
Let's see how this compares to CUDA.jl's implementation:
function benchmark_assign_add!(u, v)
CUDA.@sync u .+= v
return
end
benchmark_assign_add!(u_on_gpu, v_on_gpu)
@btime benchmark_assign_add!($u_on_gpu, $v_on_gpu)
67.150 μs (86 allocations: 2.12 KiB)
How about that? Our custom kernel is a bit faster than CUDA.jl's kernel! But to be fair, our kernel would not work with huge vectors, since there's a limit to the number of blocks & threads you can spawn (see Table 15 in CUDA's documentation). To support such huge vectors, we need each worker to run a loop like this:
function worker_gpu_add!(u, v)
index = (blockIdx().x - 1) * blockDim().x + threadIdx().x
stride = blockDim().x * gridDim().x
for i = index:stride:length(u)
@inbounds u[i] += v[i]
end
return
end
worker_gpu_add! (generic function with 1 method)
This way, if @cuda is executed with a smaller number of blocks than needed to have one thread per array item, the workers will loop appropriately.
This should get you started! For more info, check out CUDA.jl's documentation.
Command Line Arguments¶
Command line arguments are available via ARGS:
ARGS
1-element Vector{String}:
"/root/.local/share/jupyter/runt" ⋯ 21 bytes ⋯ "0e2-4016-bd46-622109a66ce5.json"
Unlike Python's sys.argv, the first element of this array is not the program name. If you need the program name, use PROGRAM_FILE instead:
PROGRAM_FILE
""
You can get the current module, directory, file or line number:
@__MODULE__, @__DIR__, @__FILE__, @__LINE__
(Main, "/content", "In[410]", 1)
The equivalent of Python's if __name__ == "__main__" is:
if abspath(PROGRAM_FILE) == @__FILE__
println("Starting of the program")
end
Memory Management¶
Let's check how many megabytes of RAM are available:
free() = println("Available RAM: ", Sys.free_memory() ÷ 10^6, " MB")
free()
Available RAM: 11086 MB
If a variable holds a large object that you don't need anymore, you can either wait until the variable falls out of scope, or set it to nothing. Either way, the memory will only be freed when the Garbage Collector does its magic, which may not be immediate. In general, you don't have to worry about that, but if you want, you can always call the GC directly:
function use_ram()
M = rand(10000, 10000) # use 400+MB of RAM
println("sum(M)=$(sum(M))")
end # M will be freed by the GC eventually after this
use_ram()
M = rand(10000, 10000) # use 400+MB of RAM
println("sum(M)=$(sum(M))")
M = nothing
GC.gc() # rarely needed
sum(M)=5.0005098957713865e7 sum(M)=5.0006538077012256e7
free()
Available RAM: 11133 MB
Thanks!¶
I hope you enjoyed this introduction to Julia! I recommend you join the friendly and helpful Julia community on Slack or Discourse.
Cheers!
Aurélien Geron