Frontmatter

If you are publishing this notebook on the web, you can set the parameters below to provide HTML metadata. This is useful for search engines and social media.

begin
import PlutoUI
PlutoUI.TableOfContents(title="Intro to Julia")
end
410 ms

Julia and Modern Machine Learning

Looking at the Julia's current surge in popularity in scientific fields, it makes sense to start considering Julia for doing researching in the machine learning.

I will be giving a high-level tutorial on what you need to know about Julia for our course. The goals of this tutorial are:

  • to get you set up to use Julia for the purposes of the UofA ML Undergrad Courses,

  • introduce some core aspects of how to use Julia,

  • point to key packages to take advantage of,

  • and give resources for learning more.

This tutorial is written in Pluto notebooks, a new take on what an interactive notebook can be built from the ground up in Julia.

md"""
# Julia and Modern Machine Learning

Looking at the Julia's current surge in popularity in scientific fields, it makes sense to start considering Julia for doing researching in the machine learning.

I will be giving a high-level tutorial on what you need to know about Julia for our course. The goals of this tutorial are:
- to get you set up to use Julia for the purposes of the UofA ML Undergrad Courses,
- introduce some core aspects of how to use Julia,
- point to key packages to take advantage of,
- and give resources for learning more.

This tutorial is written in Pluto notebooks, a new take on what an interactive notebook can be built from the ground up in Julia.


"""
16.5 ms

Why Julia

Julia is a modern language with numerics at its core. It is abstract and flexible like python, numerically focused like Matlab, and can be optimized to be as fast as c/c++/fortran. It is also the first language to seriously use multiple dispatch as a core design philosophy. While this may be hard to get used to when coming from OO languages, the Julia userbase has noticed some interesting properties suggesting its effectiveness in code reuse link. Julia also has built-in utilities for threading (go-style coroutines, and pthread style for loops), multi-processing, and efforts for language-wide auto differentiation tools with a unified and extensible approach. There is also support for GPU computations, including writing your own GPU kernels.

You may be asking yourself, why should we think about using Julia as Python is ubiquious in the field? I won't try and convince you in this notebook, but here are a few reasons I have for using Julia:

  • Mulitple dispatch can often make code easier to use and extend as compared to OOP.

  • Arrays are well thought about in the base language, so there is uniformity in design principles across numeric arrays, arrays for generic data, and arrays on specialized hardware (like GPUs). Core linear algebra is also a priority and a part of Base.

  • Solves the two language problem: code can be effiecient or easy to read (and often both!) all in julia, so there is little need to turn to c or fortran for really efficient code. Julia is also a part of the exclusive petaflop club: https://www.avenga.com/magazine/julia-programming-language/.

  • Threads and Multi-processing are both a part of the base language and easy to use.

These are only a few reasons, and more extensive lists can be found elsewhere. If you have further questions you can ask Matt Schlegel about why he uses Julia in his ML/RL research and doesn't see a return to Python.

md"""
### Why Julia

Julia is a modern language with numerics at its core. It is abstract and flexible like python, numerically focused like Matlab, and can be optimized to be as fast as c/c++/fortran. It is also the first language to seriously use multiple dispatch as a core design philosophy. While this may be hard to get used to when coming from OO languages, the Julia userbase has noticed some interesting properties suggesting its effectiveness in code reuse [link](https://www.youtube.com/watch?v=kc9HwsxE1OY). Julia also has built-in utilities for threading (go-style coroutines, and pthread style for loops), multi-processing, and efforts for language-wide auto differentiation tools with a unified and extensible approach. There is also support for GPU computations, including writing your own GPU kernels.


You may be asking yourself, why should we think about using Julia as Python is ubiquious in the field? I won't try and convince you in this notebook, but here are a few reasons I have for using Julia:

* Mulitple dispatch can often make code easier to use and extend as compared to OOP.
* Arrays are well thought about in the base language, so there is uniformity in design principles across numeric arrays, arrays for generic data, and arrays on specialized hardware (like GPUs). Core linear algebra is also a priority and a part of Base.
* Solves the two language problem: code can be effiecient or easy to read (and often both!) all in julia, so there is little need to turn to c or fortran for really efficient code. Julia is also a part of the exclusive petaflop club: https://www.avenga.com/magazine/julia-programming-language/.
* Threads and Multi-processing are both a part of the base language and easy to use.

These are only a few reasons, and more extensive lists can be found elsewhere. If you have further questions you can ask Matt Schlegel about why he uses Julia in his ML/RL research and doesn't see a return to Python.
"""
32.7 ms

What about Python?

Julia gives you the tools and flexibility to work at an abstract level (like Python) with the ability to work at a low level to optimize numerical code (like C/C++). While Python lets you write high-level abstract code, all the optimized numerical code is written in C/C++. This means operations not supported by NumPy will either need to be written in C or will be slow. Python can also be quite error-prone for new users, as certain operations are legal but not what you intended (e.g., dot product versus element-wise product) and certain language features can cause the code to become very slow (e.g., for loops). While some of these issues are being actively tackled by projects such as Numba and Cython, third-party package developers need to have explicit buy-in to these systems and develop code with these in mind. Chris Rackauckas has an excellent blog post discussing the core limitations to these approaches link.

This is not to say Python is not an excellent language, it is and has become quite popular becuase of the trade-offs it makes. It is hard to predict if Julia will become a language of choice over other common languages for data analysis (Python, R, Matlab), but it has the potential to become widely used and has a growing user base, and has the potential to kick the machine learning field out of its design run caused by monolithic highly optimized kernels.

md"""
### What about Python?

Julia gives you the tools and flexibility to work at an abstract level (like Python) with the ability to work at a low level to optimize numerical code (like C/C++). While Python lets you write high-level abstract code, all the optimized numerical code is written in C/C++. This means operations not supported by NumPy will either need to be written in C or will be slow. Python can also be quite error-prone for new users, as certain operations are legal but not what you intended (e.g., dot product versus element-wise product) and certain language features can cause the code to become very slow (e.g., for loops). While some of these issues are being actively tackled by projects such as Numba and Cython, third-party package developers need to have explicit buy-in to these systems and develop code with these in mind. Chris Rackauckas has an excellent blog post discussing the core limitations to these approaches [link](https://www.stochasticlifestyle.com/why-numba-and-cython-are-not-substitutes-for-julia/).

This is not to say Python is not an excellent language, it is and has become quite popular becuase of the trade-offs it makes. It is hard to predict if Julia will become a language of choice over other common languages for data analysis (Python, R, Matlab), but it has the potential to become widely used and has a growing user base, and has the potential to kick the machine [learning field out of its design run](https://dl.acm.org/doi/10.1145/3317550.3321441) caused by monolithic highly optimized kernels.
"""
428 μs

Design patterns

While I won't go into detail about design patterns which emerge from Julia's multiple dispatch and typing system, you should read this blog by Christopher Rackauckas (who is an active user of the language doing research in applying ML/AI methods to Scientific pursuits).

I've noticed after introducing several students to Julia the hardest hurdle is understanding how to organize code. This is not new, and when faced with any new paradigm it can be daunting to try and understand how things are organized. Unlike other modern paradigms like OOP there is very little in the way of patterns that need to be known (think gang of four). This is a property of a multiple dispatch language and the seperation of functions/methods from types. You can see more of the neat properties of multiple dispatch in this video.

md"""
# Design patterns

While I won't go into detail about design patterns which emerge from Julia's multiple dispatch and typing system, you should read [this blog](https://www.stochasticlifestyle.com/type-dispatch-design-post-object-oriented-programming-julia/) by Christopher Rackauckas (who is an active user of the language doing research in applying ML/AI methods to Scientific pursuits).

I've noticed after introducing several students to Julia the hardest hurdle is understanding how to organize code. This is not new, and when faced with any new paradigm it can be daunting to try and understand how things are organized. Unlike other modern paradigms like OOP there is very little in the way of patterns that need to be known ([think gang of four](https://en.wikipedia.org/wiki/Design_Patterns)). This is a property of a multiple dispatch language and the seperation of functions/methods from types. You can see more of the neat properties of multiple dispatch in [this video](https://www.youtube.com/watch?v=kc9HwsxE1OY).

"""
420 μs

Other resources

md"""
# Other resources
"""
163 μs
md"""
### Good practices

- [Style Guide](https://docs.julialang.org/en/v1/manual/style-guide/)
- [Differences from other Languages](https://docs.julialang.org/en/v1/manual/noteworthy-differences/)
- [Performance Tips](https://docs.julialang.org/en/v1/manual/performance-tips/)

"""
471 μs

Useful packages and projects

  • Pluto: reactive notebooks written in and for julia

  • IJulia: jupyter kernel

  • Revise: for command line development

  • Julia for VSCode: an editor outside of pluto/jupyter

  • Plots: an extensive plotting package

  • Flux: neural network package

and many more...

md"""
### Useful packages and projects

- [Pluto](https://github.com/fonsp/Pluto.jl): reactive notebooks written in and for julia
- [IJulia](https://github.com/JuliaLang/IJulia.jl): jupyter kernel
- [Revise](https://timholy.github.io/Revise.jl/stable/): for command line development
- [Julia for VSCode](https://www.julia-vscode.org): an editor outside of pluto/jupyter
- [Plots](http://docs.juliaplots.org/latest/): an extensive plotting package
- [Flux](https://fluxml.ai): neural network package

and many more...

"""
745 μs
md"""
### Other resources for learning julia

- [Julia for Beginners](https://www.youtube.com/watch?v=ub3tqCWZmo4)
- [Documentation](https://docs.julialang.org/en/v1/)
- [Tutorials on the Julia website](https://julialang.org/learning/tutorials/)

"""
481 μs

Hurdles and known sharp edges

So far we've discussed several positive aspects of julia, but like with any language there are edges where the design of the language can be frustrating. While we won't discuss these here, a great list has been compiled here.

md"""
### Hurdles and known sharp edges

So far we've discussed several positive aspects of julia, but like with any language there are edges where the design of the language can be frustrating. While we won't discuss these here, a great list has been compiled [here](https://viralinstruction.com/posts/badjulia/).
"""
271 μs

Basics

In this section, we will be going over the basics of using the julia language. This should be enough for most of what you will need for the machine learning course.

  • variables

  • variable scope

  • types

  • arrays and other collections

  • loops

  • structs and data

  • linear algebra/mathematical operations

  • Style guide and other tips/recommendations

md"""
# Basics


In this section, we will be going over the basics of using the julia language. This should be enough for most of what you will need for the machine learning course.


- variables
- variable scope
- types
- arrays and other collections
- loops
- structs and data
- linear algebra/mathematical operations
- Style guide and other tips/recommendations


"""
649 μs

Variables

Variables in julia are created and assigned like most dynamic languages. In julia, every variable is given a type, and if that variable remains the same type throughout its scope it is considered type-stable. You can inspect the typeof of a variable using typeof, as seen below.

md"""
## Variables

Variables in julia are created and assigned like most dynamic languages. In julia, every variable is given a type, and if that variable remains the same type throughout its scope it is considered type-stable. You can inspect the typeof of a variable using `typeof`, as seen below.


"""
5.6 ms
x
10.0
x = 10.0
9.0 μs
Float64
typeof(x)
9.8 μs

Variable Scope in Julia/Pluto

Variables are scopped in Julia/Pluto in a similar way to Python/Jupyter. Variables defined in a cell are global and available across all the cells. This can sometimes cause problems in Jupyter notebooks where variables can be overwritten unknowingly. In Pluto, you are unable to re-use a variable by accident (see below for y), Pluto doesn't know which definition you mean and throws an error for both.

md"""
## Variable Scope in Julia/Pluto

Variables are scopped in Julia/Pluto in a similar way to Python/Jupyter. Variables defined in a cell are global and available across all the cells. This can sometimes cause problems in Jupyter notebooks where variables can be overwritten unknowingly. In Pluto, you are unable to re-use a variable by accident (see below for `y`), Pluto doesn't know which definition you mean and throws an error for both.
"""
284 μs

Multiple definitions for y.

Combine all definitions into a single reactive cell using a `begin ... end` block.

y = 11
---

Multiple definitions for y.

Combine all definitions into a single reactive cell using a `begin ... end` block.

y = 12
---

While a win for reproducibility and simplicity, this can be annoying when you have throw-away variables you wan to use/name. Fortunately, there is a way around this!

md"""
While a win for reproducibility and simplicity, this can be annoying when you have throw-away variables you wan to use/name. Fortunately, there is a way around this!
"""
239 μs

Begin and Let Blocks

The begin and let blocks are both used to make multi-statement cells in our notebook.

The begin block makes all the variables available at the global scope:

md"""
### Begin and Let Blocks

The begin and let blocks are both used to make multi-statement cells in our notebook.

The begin block makes all the variables available at the global scope:
"""
287 μs
200
begin
my_global_x = 10
# do other stuff
my_global_x *= 20
end
13.8 μs
200
my_global_x
11.0 μs

A let block on the other hand keeps all its variables local. Notice how my_local_x is not available to the cell right after the let block.

md"""
A let block on the other hand keeps all its variables local. Notice how `my_local_x` is not available to the cell right after the let block.
"""
305 μs
1049580
let
my_local_x = 1020
# do other stuff
my_local_x *= 1029
end
18.5 μs

UndefVarError: my_local_x not defined

  1. top-level scope@Local: 1
my_local_x
---

Both of these work within functions and other scopes. You can also consider most scopes (i.e. functions, loops, control flow statements) as let blocks.

md"""
Both of these work within functions and other scopes. You can also consider most scopes (i.e. functions, loops, control flow statements) as let blocks.
"""
219 μs

Types

Types are an important part of Julia's ecosystem. There are many primative types, which are represented as a collection of bits, including Int64, Float32, Float64, and many more. You can see a full list here.

Types are placed into a type hierarchy, where leaf nodes of the hierarchy are objects you will be interacting with. For example, Float64 is an AbstractFloat which is a Number. You can inspect the type of a variable with the functions isa and <:. Below you can see their use, note how isa works with the variable while <: works with a type. You will likely not have to use these a lot in this course.

md"""
## Types

Types are an important part of Julia's ecosystem. There are many primative types, which are represented as a collection of bits, including `Int64`, `Float32`, `Float64`, and many more. You can see a full list [here](https://docs.julialang.org/en/v1/manual/types/#Primitive-Types).

Types are placed into a type hierarchy, where leaf nodes of the hierarchy are objects you will be interacting with. For example, `Float64` is an `AbstractFloat` which is a `Number`. You can inspect the type of a variable with the functions `isa` and `<:`. Below you can see their use, note how `isa` works with the variable while `<:` works with a type. You will likely not have to use these a lot in this course.
"""
434 μs
x isa Float64, x isa AbstractFloat, x isa Number
19.0 μs
typeof(x) <: Float64, typeof(x) <: AbstractFloat, typeof(x) <: Number
19.5 μs

Arrays

Every array will have two parametric types associated with it. You can create an array of Float64 with three dimensions as below. When we inspect the type you will see Array{Float64, 3} the first component in the curly braces is the element type of the array, and the second is the number of the dimensions the array has. A vector has 1 dimension, and a matrix has 2 dimensions.

If you change x_arr to be a matrix by removing the 2 you will see the type change to Matrix{Float64} (alias for Array{Float64, 2}). Matrix is a convenient name for 2d arrays, but their underlying type is exactly the same.

md"""
### Arrays

Every array will have two parametric types associated with it. You can create an array of `Float64` with three dimensions as below. When we inspect the type you will see `Array{Float64, 3}` the first component in the curly braces is the element type of the array, and the second is the number of the dimensions the array has. A vector has 1 dimension, and a matrix has 2 dimensions.

If you change x_arr to be a matrix by removing the `2` you will see the type change to `Matrix{Float64} (alias for Array{Float64, 2})`. `Matrix` is a convenient name for 2d arrays, but their underlying type is exactly the same.
"""
14.8 ms
x_arr
4×3×2 Array{Float64, 3}:
[:, :, 1] =
 0.0837568  0.497223  0.74077
 0.862406   0.458732  0.222258
 0.122359   0.202465  0.217
 0.789485   0.695932  0.938246

[:, :, 2] =
 0.4677    0.66969   0.383242
 0.769984  0.115417  0.125273
 0.113963  0.822641  0.973848
 0.523283  0.79622   0.270056
x_arr = rand(4, 3, 2)
15.1 μs
Array{Float64, 3}
typeof(x_arr)
9.9 μs
Matrix{Float64} (alias for Array{Float64, 2})
typeof(zeros(10, 3))
21.1 μs

You can get the element type of an array with the following

md"""
You can get the element type of an array with the following
"""
181 μs
Float64
eltype(x_arr)
8.9 μs

Julia as one-based indexing. To access an element of an array simply use square brackets as the following:

md"""
Julia as one-based indexing. To access an element of an array simply use square brackets as the following:
"""
185 μs
x_vec
x_vec = rand(10)
13.2 μs
0.15285570069180276
x_vec[1]
9.9 μs

And you can assign to this array

md"""
And you can assign to this array
"""
200 μs
1
x_vec[1] = 1
10.8 μs
x_vec
7.6 μs

Finally, you can get a slice from a multi-dimensional array using :. Note how this returned vector is a column vector, and not a 1×1×2 strand.

md"""
Finally, you can get a slice from a multi-dimensional array using `:`. Note how this returned vector is a column vector, and not a `1×1×2` strand.
"""
229 μs
x_arr[1, 1, :]
16.8 μs

and you can assign to these strands:

md"""
and you can assign to these strands:
"""
195 μs
4×3×2 Array{Float64, 3}:
[:, :, 1] =
 1.0       0.497223  0.74077
 0.862406  0.458732  0.222258
 0.122359  0.202465  0.217
 0.789485  0.695932  0.938246

[:, :, 2] =
 1.0       0.66969   0.383242
 0.769984  0.115417  0.125273
 0.113963  0.822641  0.973848
 0.523283  0.79622   0.270056
begin
x_arr[1, 1, :] .= 1
x_arr
end
17.6 μs
4×3×2 Array{Float64, 3}:
[:, :, 1] =
 1.0       2.0       0.74077
 0.862406  0.458732  0.222258
 0.122359  0.202465  0.217
 0.789485  0.695932  0.938246

[:, :, 2] =
 1.0       3.0       0.383242
 0.769984  0.115417  0.125273
 0.113963  0.822641  0.973848
 0.523283  0.79622   0.270056
begin
x_arr[1, 2, :] .= [2, 3]
x_arr
end
21.2 μs

Julia has a fully featured set of linear algebra operations built into the language which you can see later in this tutorial.

md"""
Julia has a fully featured set of linear algebra operations built into the language which you can see later in this tutorial.
"""
342 μs

Other collection types

There are several other collection types which will be useful.

md"""
### Other collection types

There are several other collection types which will be useful.
"""
249 μs

Dictionaries

A dictionary is a collection which encodes key=>value pairs using a hash table. Dictionaries created with Dict() accept any types for the keys and values. You can create dicts with more specifict types using Dict{String, Int}() which will have keys as String and values as Int.

md"""
#### Dictionaries

A dictionary is a collection which encodes key=>value pairs using a hash table. Dictionaries created with `Dict()` accept any types for the keys and values. You can create dicts with more specifict types using `Dict{String, Int}()` which will have keys as `String` and values as `Int`.
"""
5.9 ms
d
d = Dict()
17.3 μs
"world"
d["hello"] = "world"
20.5 μs
"Matthew"
d["name"] = "Matthew"
13.8 μs
d
9.9 μs
Dict{Any, Any}
typeof(d)
10.4 μs

You can get the keys from a dictionary with the following

md"You can get the keys from a dictionary with the following"
187 μs
keys(d)
9.4 μs

Tuples

Tuples encode a finite set of elements of any type. They are constructed as seen below, and have types which depend on the types of all of its elements.

md"""
#### Tuples

Tuples encode a finite set of elements of any type. They are constructed as seen below, and have types which depend on the types of all of its elements.
"""
239 μs
tpl
tpl = (1, "1", '1', 1.0, 1f0)
12.4 μs
Tuple{Int64, String, Char, Float64, Float32}
typeof(tpl)
15.2 μs

Compared to an array

md"Compared to an array"
188 μs
arr
arr = [1, "1", '1', 1.0, 1f0]
16.5 μs
Vector{Any} (alias for Array{Any, 1})
typeof(arr)
10.7 μs

Note: Unlike an array, you cannot assign to an element of a tuple

md"**Note**: Unlike an array, you cannot assign to an element of a tuple"
227 μs

MethodError: no method matching setindex!(::Tuple{Int64, String, Char, Float64, Float32}, ::Float32, ::Int64)

  1. top-level scope@Local: 1
tpl[end] = 2f0
---

Named Tuples

Named tuples are just like tuples except each element has a name.

md"""
#### Named Tuples

Named tuples are just like tuples except each element has a name.
"""
261 μs
ntpl
ntpl = (a=1, b=2)
15.7 μs
1
ntpl.a
12.2 μs
NamedTuple{(:a, :b), Tuple{Int64, Int64}}
typeof(ntpl)
10.6 μs

Control Flow

Below are the basics of control flow. This includes Loops and If-Else statements

md"""
## Control Flow

Below are the basics of control flow. This includes Loops and If-Else statements
"""
285 μs

If-Else Statements

If-else statments work much like they do in other languages. Below is an example of if statements and if-else statements. If there is an if statement w/o an else it returns nothing by default. Like all julia blocks, the last statement will be returned automatically.

md"""
### If-Else Statements

If-else statments work much like they do in other languages. Below is an example of if statements and if-else statements. If there is an if statement w/o an else it returns nothing by default. Like all julia blocks, the last statement will be returned automatically.
"""
266 μs

i=2 and is less than 3

let
i = 2
if i < 3
md"i=$(i) and is less than 3"
end
end
11.7 ms

i=3 and is $\geq$ to 3

let
i = 3
if i < 3
md"i=$(i) and is less than 3"
else
md"i=$(i) and is ``\geq`` to 3"
end
end
2.1 ms

i=3

let
i = 3
if i < 3
md"i=$(i) and is less than 3"
elseif i == 3
md"i=3"
else
md"i=$(i) and is ``>`` to 3"
end
end
550 μs

Another common pattern is to check for nothing. Unlike other languages a variable set to nothing is not assumed false and must be checked explicilty.

md"""
Another common pattern is to check for `nothing`. Unlike other languages a variable set to nothing is not assumed false and must be checked explicilty.
"""
196 μs

The variable is nothing!

let
my_var = nothing
if isnothing(my_var)
md"The variable is nothing!"
else
# Do Some work w/ my_var here
end
end
2.7 ms

Loops

Like most languages, loops come in two varieties: for and while. See the examples below.

md"""
### Loops

Like most languages, loops come in two varieties: `for` and `while`. See the examples below.

"""
283 μs
55
let
i = 0
for n in 1:10 # 1:10 creates a range containing all the numbers from 1 to 10.
i += n
end
i
end
24.2 μs
let
x = 0.0
i = 0
while x < 20.0
x += rand()
i += 1
end
i, x
end
33.3 μs

We can even loop over the elements of various collections:

md"""
We can even loop over the elements of various collections:
"""
219 μs
41.846692926527496
let
X = rand(10, 4, 2)
ret = 0.0
for x in eachindex(X)
ret += X[x]
end
ret
end
38.1 μs
34.027858340120076
let
X = rand(10, 4, 2)
ret = 0.0
for x in X
ret += x
end
ret
end
33.2 μs
let
ret = String[]
for k in keys(d)
push!(ret, d[k])
end
ret
end
57.9 μs

We cal also do list comprehensions.

md"""
We cal also do list comprehensions.
"""
281 μs
[rand(i) for i in 1:5]
33.9 μs

Structs and Data

Structs are how you can build your own composite types. For all of the assignments, the types will be provided for you. But it is good to know how these types are constructed.

For example:

struct MyType 
	n::Int
end

This types is named MyType with a member n which is typed as an integer. Each type comes with a default constructor, for example MyType(1).

You can access the data of a type using dot syntax (like c, python, and many other languages).

md"""
## Structs and Data

Structs are how you can build your own composite types. For all of the assignments, the types will be provided for you. But it is good to know how these types are constructed.

For example:

```julia
struct MyType
n::Int
end
```

This types is named `MyType` with a member `n` which is typed as an integer. Each type comes with a default constructor, for example `MyType(1)`.

You can access the data of a type using dot syntax (like c, python, and many other languages).

"""
544 μs
struct MyType
n::Int
end
952 μs
mt
mt = MyType(1)
16.1 μs
1
mt.n
12.5 μs

Note we can't assign n using a struct, instead we need to make MyType a mutable struct

md"**Note** we can't assign n using a struct, instead we need to make MyType a `mutable struct`"
228 μs

setfield!: immutable struct of type MyType cannot be changed

  1. setproperty!@Base.jl:43[inlined]
  2. top-level scope@Local: 1[inlined]
mt.n = 2
---

To make a mutable type

md"To make a mutable type"
184 μs
mutable struct MyMutableType
n::Int
end
959 μs
mmt
mmt = MyMutableType(1)
14.2 μs
2
mmt.n = 2
15.3 μs
mmt
7.9 μs

If you have a mutable type composed in a non-mutable type you can still modify the data internal to the mutable member

md"If you have a mutable type composed in a non-mutable type you can still modify the data internal to the mutable member"
190 μs
VectorWrapper
let
struct VectorWrapper
arr::Vector{Float64}
end
VectorWrapper(n) = VectorWrapper(zeros(n)) # create a new constructor
end
1.6 ms
vw
vw = VectorWrapper(10)
14.8 μs
3
vw.arr[3] = 3
15.2 μs
vw.arr
15.6 μs

Linear Algebra

Lets start simple, and perform some typical linear algebra operations using Julia to get a handle of the language. While these contain only a subset of what is baked into julia, you should be able to extrapolate to other operations you care about. In the code below only dot and svd are defined in LinearAlgebra.

md"""
## Linear Algebra

Lets start simple, and perform some typical linear algebra operations using Julia to get a handle of the language. While these contain only a subset of what is baked into julia, you should be able to extrapolate to other operations you care about. In the code below only `dot` and `svd` are defined in `LinearAlgebra`.

"""
441 μs
using LinearAlgebra
357 μs

An Aside on using

There are two main ways to include code from packages in your own package/notebook. The first is with the using keyword. This will bring in the functions and types that have been marked for export by the package authors. It has been common practice to export names which are stable, and keep private internals or apis still inprogress.

The second way is through the import keyword. Doing:

import LinearAlgebra

will keep all the names defined by LinearAlgebra contained in its own namespace and accessed as LinearAlgebra.svd

You can also import specific functions using

import LinearAlgebra: dot, svd

Currently there is no as keyword (although this is being discussed actively), but you can re-name packages with const LA = LinearAlgebra.


md"""
---
### **An Aside on `using`**

There are two main ways to include code from packages in your own package/notebook. The first is with the `using` keyword. This will bring in the functions and types that have been marked for export by the package authors. It has been common practice to export names which are stable, and keep private internals or apis still inprogress.

The second way is through the `import` keyword. Doing:
```julia
import LinearAlgebra
```
will keep all the names defined by `LinearAlgebra` contained in its own namespace and accessed as `LinearAlgebra.svd`

You can also import specific functions using
```julia
import LinearAlgebra: dot, svd
```

Currently there is no `as` keyword (although this is being discussed actively), but you can re-name packages with `const LA = LinearAlgebra`.

---
"""
547 μs
a, b, s, M = rand(2), rand(2), rand(), rand(2,2)
23.4 μs
Vector Addtion:
md"##### Vector Addtion:"
6.7 ms
a + b
19.1 μs
Scalar multiplication:
md"##### Scalar multiplication:"
169 μs
2a, 2*a, 2 .* a
32.8 μs
Inner product of vectors:
md"##### Inner product of vectors:"
179 μs
a' * b, dot(a, b) # dot is from LinearAlgebra
30.7 μs
Outer product of vectors:
md"##### Outer product of vectors:"
177 μs
a * b', a * transpose(b)
18.4 μs
Matrix mulitplication of vectors:
md"##### Matrix mulitplication of vectors:"
206 μs
M * a
27.5 μs
Matrix element wise product with outer product operation
md"##### Matrix element wise product with outer product operation"
171 μs
2×2 Matrix{Float64}:
 0.0028206  0.0286062
 0.140695   0.039198
M .* (a * b')
16.9 μs
Element wise vector multiplication (broadcasting):
md"##### Element wise vector multiplication (broadcasting):"
226 μs
a .* b
56.3 ms
Broadcast scalar-vector addtion
md"##### Broadcast scalar-vector addtion"
299 μs
a .+ s
49.2 ms
Much more

Along with these operations which are always available, there is a Base package LinearAlgebra

If you have a specfic linear algebra operation you want but can't find it in Base, you will need to explicitly load the LinearAlgebra package. This is already available to you in Base.

md"""
##### Much more

Along with these operations which are always available, there is a Base package `LinearAlgebra`

If you have a specfic linear algebra operation you want but can't find it in Base, you will need to explicitly load the `LinearAlgebra` package. This is already available to you in `Base`.

"""
496 μs
M_svd
SVD{Float64, Float64, Matrix{Float64}}
U factor:
2×2 Matrix{Float64}:
 -0.818394  -0.574657
 -0.574657   0.818394
singular values:
2-element Vector{Float64}:
 0.8261776892374674
 0.5660226433964752
Vt factor:
2×2 Matrix{Float64}:
 -0.631541  -0.775342
  0.775342  -0.631541
M_svd = svd(M)
145 μs
true
all((M_svd.U * diagm(M_svd.S) * M_svd.Vt) .≈ M)
250 ms

Style Guide and tips

We will inherit as much from this Style Guide as possible, but much of this style guide is written for when we are using julia outside of a Pluto notebook. Because of this, you should follow the style of the notebook you are completing. Because you will be primarily focused on writing functions, this should be straight forward.

Some tips:

  • Make sure your variables are type-stable (i.e. try to make sure you don't re-use variables unnecesairily).

  • Don't use any globals (all of your functions should be self contained).

  • Use for loops if you are unsure about how broadcasting or other mathematical operation is working (loops are perfectly fine in julia).

  • Make matrices (or multi-dim arrays) capital letters and vectors lower case.

  • Use meaningful variable names. In julia you can type \eta and press tab to get the unicode symbol η, and julia supports many symbols. This is useful when writing implementing algorithms so we can use all the same symbols which are used. We will provide you with a table of symbols that will be useful to know.

  • If you are unsure about how something works in julia or you want to look at how a function works, the best advice is to go look at the julia source. Most functions you will use are written in julia, and tend to be well documented and understandable. This is true in many packages as well.

md"""
## Style Guide and tips

We will inherit as much from this [Style Guide](https://docs.julialang.org/en/v1/manual/style-guide/) as possible, but much of this style guide is written for when we are using julia outside of a Pluto notebook. Because of this, you should follow the style of the notebook you are completing. Because you will be primarily focused on writing functions, this should be straight forward.

Some tips:
- Make sure your variables are type-stable (i.e. try to make sure you don't re-use variables unnecesairily).
- Don't use any globals (all of your functions should be self contained).
- Use for loops if you are unsure about how broadcasting or other mathematical operation is working (loops are perfectly fine in julia).
- Make matrices (or multi-dim arrays) capital letters and vectors lower case.
- Use meaningful variable names. In julia you can type `\eta` and press tab to get the unicode symbol `η`, and julia supports many symbols. This is useful when writing implementing algorithms so we can use all the same symbols which are used. We will provide you with a table of symbols that will be useful to know.
- If you are unsure about how something works in julia or you want to look at how a function works, the best advice is to go look at the julia source. Most functions you will use are written in julia, and tend to be well documented and understandable. This is true in many packages as well.
"""
765 μs

Advanced topics

The following are not necessairy for the course, but provide some insight into some of the core concepts of the julia language and its design philosophy.

md"""
# Advanced topics

The following are not necessairy for the course, but provide some insight into some of the core concepts of the julia language and its design philosophy.

"""
244 μs

Arrays are column major by default!

While this likely won't be of consequence for this course, it is important to note given Python and other languages have row major arrays.

md"""## Arrays are column major by default!

While this likely won't be of consequence for this course, it is important to note given Python and other languages have row major arrays.

"""
354 μs

DimensionMismatch("A has dimensions (2,1) but B has dimensions (2,2)")

  1. gemm_wrapper!(::Matrix{Float64}, ::Char, ::Char, ::Matrix{Float64}, ::Matrix{Float64}, ::LinearAlgebra.MulAddMul{true, true, Bool, Bool})@matmul.jl:643
  2. mul!@matmul.jl:169[inlined]
  3. mul!@matmul.jl:275[inlined]
  4. *@matmul.jl:160[inlined]
  5. *@matmul.jl:63[inlined]
  6. top-level scope@Local: 1[inlined]
a * M # this was meant to error
---

This can also make a difference for how we iterate through large arrays. Note the order of indicies in the loops below. eachindex_iteration provides a more general way to do what is provided in column_major.

md"This can also make a difference for how we iterate through large arrays. Note the order of indicies in the loops below. `eachindex_iteration` provides a more general way to do what is provided in `column_major`."
249 μs
row_major (generic function with 1 method)
function row_major(arr::Array{<:Number, 5})
s = zero(eltype(arr)) # This sets s = 0.0 using the same type as arr.
sze = size(arr)
for i in 1:sze[1]
for j in 1:sze[2]
for k in 1:sze[3]
for l in 1:sze[4]
for m in 1:sze[5]
s = s + arr[i, j, k, l, m]
end
end
end
end
end
s
end
2.2 ms
column_major (generic function with 1 method)
function column_major(arr::Array{<:Number, 5})
s = zero(eltype(arr))
sze = size(arr)
for m in 1:sze[5]
for l in 1:sze[4]
for k in 1:sze[3]
for j in 1:sze[2]
for i in 1:sze[1]
s += arr[i, j, k, l, m]
end
end
end
end
end
s
end
2.1 ms
eachindex_iteration (generic function with 1 method)
function eachindex_iteration(arr)
s = zero(eltype(arr))
for idx in eachindex(arr)
s = s + arr[idx]
end
s
end
708 μs

Benchmarking

Below we are benchmarking the functions we wrote above. Note the median and mean times for each function.

md"""
## Benchmarking

Below we are benchmarking the functions we wrote above. Note the median and mean times for each function.
"""
228 μs
let
import BenchmarkTools: @benchmark # This benchmarks a method
import PlutoUI: with_terminal, CheckBox
end
80.2 ms

run example:

md"run example: $(@bind run_example CheckBox())"
158 ms
test_arr = run_example ? rand(Float64, 1000, 100, 32, 4, 4) : nothing;
268 ms
check_test_arr (generic function with 1 method)
check_test_arr(func::Function) = isnothing(test_arr) ? md"Click run example." : func()
545 μs

Column Major Sum

md"**Column Major Sum**"
214 μs
BenchmarkTools.Trial: 70 samples with 1 evaluation.
 Range (min … max):  68.638 ms … 78.543 ms  ┊ GC (min … max): 0.00% … 0.00%
 Time  (median):     71.184 ms              ┊ GC (median):    0.00%
 Time  (mean ± σ):   71.802 ms ±  2.367 ms  ┊ GC (mean ± σ):  0.00% ± 0.00%

       ▄█▄▁▄▁█ █ ▄█▄ ▄▁▁ ▁   ▁  ▁           ▁          ▁       
  ▆▆▆▆▁███████▆█▆███▆███▆█▆▁▁█▁▁█▁▁▁▁▁▆▁▆▁▁▆█▆▁▁▁▁▆▆▆▁▁█▁▁▁▁▆ ▁
  68.6 ms         Histogram: frequency by time        77.8 ms <

 Memory estimate: 0 bytes, allocs estimate: 0.
check_test_arr() do
@benchmark column_major($test_arr)
end
12.3 s

Row Major Sum

md"**Row Major Sum**"
248 μs
BenchmarkTools.Trial: 8 samples with 1 evaluation.
 Range (min … max):  599.697 ms … 676.408 ms  ┊ GC (min … max): 0.00% … 0.00%
 Time  (median):     632.606 ms               ┊ GC (median):    0.00%
 Time  (mean ± σ):   629.711 ms ±  24.204 ms  ┊ GC (mean ± σ):  0.00% ± 0.00%

  █                 ▁      ▁▁ ▁ ▁                             ▁  
  █▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█▁▁▁▁▁▁██▁█▁█▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█ ▁
  600 ms           Histogram: frequency by time          676 ms <

 Memory estimate: 0 bytes, allocs estimate: 0.
check_test_arr() do
@benchmark row_major($test_arr)
end
13.6 s

Each index

md"**Each index**"
234 μs
BenchmarkTools.Trial: 70 samples with 1 evaluation.
 Range (min … max):  69.062 ms … 79.538 ms  ┊ GC (min … max): 0.00% … 0.00%
 Time  (median):     70.869 ms              ┊ GC (median):    0.00%
 Time  (mean ± σ):   71.495 ms ±  2.194 ms  ┊ GC (mean ± σ):  0.00% ± 0.00%

      ▂ ▆ ▄▆ █ ▄ ▂                                             
  ▄▁█▄█▆██████▁███▆▄▆▄▁▁▄▄▁▁▁▁▄▁▁▁▁▁▁▄▁▁▁▁▄▄▄▁▁▁▁▁▁▁▄▁▁▄▁▁▁▁▄ ▁
  69.1 ms         Histogram: frequency by time        78.6 ms <

 Memory estimate: 0 bytes, allocs estimate: 0.
check_test_arr() do
@benchmark eachindex_iteration($test_arr)
end
11.6 s
Comparing to sum

Now even though we can write a fastish version of the sum operation using eachindex it is still often better to use julia's built in functionality. For instance if we compare the performance with the provided sum function in julia, we see a huge boost in performance (3 times on my computer!).

md"""
##### Comparing to `sum`

Now even though we can write a fastish version of the sum operation using `eachindex` it is still often better to use julia's built in functionality. For instance if we compare the performance with the provided sum function in julia, we see a huge boost in performance (3 times on my computer!).
"""
302 μs
BenchmarkTools.Trial: 180 samples with 1 evaluation.
 Range (min … max):  25.732 ms … 35.159 ms  ┊ GC (min … max): 0.00% … 0.00%
 Time  (median):     27.262 ms              ┊ GC (median):    0.00%
 Time  (mean ± σ):   27.836 ms ±  1.753 ms  ┊ GC (mean ± σ):  0.00% ± 0.00%

       ▃▃▄▂▃█▃ ▁▃▃                                             
  ▄▆▄▆▅███████████▆▅▃▃▄▁▅▄▄▃▁▄▃▃▃▄▄▁▄▁▃▅▃▃▁▃▅▃▁▃▁▁▁▁▃▃▁▃▃▁▃▁▃ ▃
  25.7 ms         Histogram: frequency by time        33.5 ms <

 Memory estimate: 0 bytes, allocs estimate: 0.
check_test_arr() do
@benchmark sum($test_arr)
end
11.1 s

Why is this? I thought julia was supposed to compile to effecient code?

It does! and it often has performance comparable with c++ if you allow for similar compiler optimizations. Unfortunately, writing efficient code today often goes beyond being compilable, and involves giving the compiler clues about what operation can be used. The good thing for us is the majority of Base is written in julia! You can see the sum implementation for v1.6.2 (and more generally mapreduce) here. There is nothing stopping you from using the utilities used in this implementation in your own code!

If you look at the implementation, you will see the macro @simd. This tells the compiler that the loop can take advantage of simd instructions if your processor has them. We can even use this ourselves! This gets us most of the way to sum, with code that is effectively a for loop.

md"""
Why is this? I thought julia was supposed to compile to effecient code?

It does! and it often has performance comparable with c++ if you allow for similar compiler optimizations. Unfortunately, writing efficient code today often goes beyond being compilable, and involves giving the compiler clues about what operation can be used. The good thing for us is the majority of `Base` is written in julia! You can see the `sum` implementation for `v1.6.2` (and more generally `mapreduce`) [here](https://github.com/JuliaLang/julia/blob/1b93d53fc4bb59350ada898038ed4de2994cce33/base/reduce.jl#L504). There is nothing stopping you from using the utilities used in this implementation in your own code!

If you look at the implementation, you will see the macro `@simd`. This tells the compiler that the loop can take advantage of `simd` instructions if your processor has them. We can even use this ourselves! This gets us most of the way to sum, with code that is effectively a for loop.
"""
390 μs
eachindex_iteration_simd (generic function with 1 method)
function eachindex_iteration_simd(arr)
s = zero(eltype(arr))
indicies = eachindex(arr)
@simd for idx in indicies
@inbounds a1 = arr[idx]
s = s + a1
end
s
end
4.0 ms
BenchmarkTools.Trial: 173 samples with 1 evaluation.
 Range (min … max):  26.122 ms … 42.512 ms  ┊ GC (min … max): 0.00% … 0.00%
 Time  (median):     28.379 ms              ┊ GC (median):    0.00%
 Time  (mean ± σ):   28.980 ms ±  1.940 ms  ┊ GC (mean ± σ):  0.00% ± 0.00%

          ▄▇ █▄▇▃▄▃          ▂ ▂                               
  ▃▁▁▅▁▅▅▅█████████▆█▇▆▅▆█▁█▆█▅█▅▃▅▇▃▃▆▃▆▅▅▅▅▁▅▅▃▃▃▃▅▅▅▁▁▁▁▁▃ ▃
  26.1 ms         Histogram: frequency by time        33.5 ms <

 Memory estimate: 0 bytes, allocs estimate: 0.
check_test_arr() do
@benchmark $eachindex_iteration_simd($test_arr)
end
11.5 s

Why not @simd by default?

When using simd the order the loop is executed in can be arbitrary, unlike without simd. This has to do with how things are vectorized. This means the answer can vary with difference orders of the sum. It is also hard to analyze the internals of a loop to make sure they follow the requirements to make sure @simd is safe (see the pluto docs for @simd or type ?@simd in the julia repl).

You should try to default to julia implemented versions of operations. While the effect is small for Float64s it can be catastrophic for Float32s, where simd actually provides more accurate results. But @simd doesn't always provide more accurate results for arbitrary operations/algorithms.

md"""
### Why not `@simd` by default?

When using simd the order the loop is executed in can be arbitrary, unlike without simd. This has to do with how things are vectorized. This means the answer can vary with difference orders of the sum. It is also hard to analyze the internals of a loop to make sure they follow the requirements to make sure `@simd` is safe (see the pluto docs for `@simd` or type `?@simd` in the julia repl).

You should try to default to julia implemented versions of operations. While the effect is small for `Float64`s it can be catastrophic for `Float32s`, where simd actually provides more accurate results. But `@simd` doesn't always provide more accurate results for arbitrary operations/algorithms.
"""
336 μs
2.56001323429547e7
check_test_arr() do
column_major(test_arr)
end
71.9 ms
2.560013234295313e7
check_test_arr() do
row_major(test_arr)
end
642 ms
2.56001323429547e7
check_test_arr() do
eachindex_iteration(test_arr)
end
69.8 ms
2.5600132342951603e7
check_test_arr() do
eachindex_iteration_simd(test_arr)
end
26.6 ms
2.5600132342951216e7
check_test_arr() do
sum(test_arr)
end
27.3 ms

This effect can be magnified with lower percisions

md"This effect can be magnified with lower percisions"
180 μs
test_arr_32 = run_example ? rand(Float32, 1000, 100, 32, 4, 4) : nothing;
118 ms
1.6777216f7
check_test_arr() do
eachindex_iteration(test_arr_32)
end
86.5 ms
2.5601073472405612e7
check_test_arr() do
eachindex_iteration(Float64.(test_arr_32))
end
715 ms
2.5601174f7
check_test_arr() do
eachindex_iteration_simd(test_arr_32)
end
14.4 ms
2.5601074f7
check_test_arr() do
sum(test_arr_32)
end
13.1 ms

Random Numbers

Being considerate about your random number generators is one of the most important aspects of making experiments reproducible (i.e. setting your random seed). Julia lets you set the seed of a Global random number generator, as well as construct and manage your own.

md"""
## Random Numbers

Being considerate about your random number generators is one of the most important aspects of making experiments reproducible (i.e. setting your random seed). Julia lets you set the seed of a Global random number generator, as well as construct and manage your own.
"""
235 μs
using Random # Already in the language, you are just accessing the namespace
218 μs

There is a global random number generator at Random.GLOBAL_RNG, which we can seed using

md"""
There is a global random number generator at `Random.GLOBAL_RNG`, which we can seed using

"""
196 μs
Random.seed!(10)
74.1 ms

We can generate random numbers via:

md"""
We can generate random numbers via:
"""
199 μs
rand(2), rand(Float32, 2, 2, 2, 2)
42.9 ms

Note that we can generate specific types through the call, or just use a default type of Float64.

This random number generator is thread local, so when a new thread is created and uses the global rng each thread's global rng will be independent (as of 1.5.x I believe).

We can also use our own managed RNG:

md"""

Note that we can generate specific types through the call, or just use a default type of `Float64`.

This random number generator is thread local, so when a new thread is created and uses the global rng each thread's global rng will be independent (as of `1.5.x` I believe).

We can also use our own managed RNG:

"""
465 μs
rng
MersenneTwister(10)
rng = Random.MersenneTwister(10)
62.4 ms
rand(rng, 2), rand(rng, Float32, 2, 2, 2, 2)
93.7 ms

WARNING

Random number generators are not guaranteed to be consistent across julia versions, and there was a breaking change between 1.4 and 1.5. Because of this there is a package StableRNGs.jl, which provides stable rngs if needed. This is useful for testing, but if you can guarantee you are going to use a version you should just use RNGs.

md"""
### WARNING

Random number generators are not guaranteed to be consistent across julia versions, and there was a breaking change between 1.4 and 1.5. Because of this there is a package [StableRNGs.jl](https://github.com/JuliaRandom/StableRNGs.jl), which provides stable rngs if needed. This is useful for testing, but if you can guarantee you are going to use a version you should just use RNGs.

"""
337 μs

Multiple Dispatch

Multiple dispatch is the central design ideology of Julia (much like OOP is central to Python or Java). At first glance, it seems very similar to function overloading of other languages (i.e. C++), but it has much more utility because of the ability to dispatch on all argument types (not just one or two)! This will be useful later, for now I am only going to simple show how you can take advantage.

md"""
## Multiple Dispatch

Multiple dispatch is the central design ideology of Julia (much like OOP is central to Python or Java). At first glance, it seems very similar to function overloading of other languages (i.e. C++), but it has much more utility because of the ability to dispatch on all argument types (not just one or two)! This will be useful later, for now I am only going to simple show how you can take advantage.
"""
250 μs
f (generic function with 1 method)
f(x) = "default"
296 μs
f (generic function with 2 methods)
f(x::Integer) = "Int"
310 μs
f (generic function with 3 methods)
f(x::AbstractFloat) = "Float"
300 μs
"default"
f("Hello")
9.8 μs
"Int"
f(1)
10.6 μs
"Float"
f(200f0) # 200f0 is a single precision floating point number
9.8 μs

A method is Julia's term for a specialized version of a function. Above we wrote a function f and hand-made specialized methods for integers and floats. While this may seem like the compiler is only working on the specialized versions, this is incorrect! The compiler will create a specialized method automatically from the generic function, meaning you get the performance of a hand-specialized method. The overriden methods are useful for when there are code changes for different types (which we'll see later on).

If you specialize a function w/o a generic fallback version you will get an exception that there is no matching method.

md"""
A method is Julia's term for a specialized version of a function. Above we wrote a function `f` and hand-made specialized methods for integers and floats. While this may seem like the compiler is only working on the specialized versions, this is incorrect! The compiler will create a specialized method automatically from the generic function, meaning you get the performance of a hand-specialized method. The overriden methods are useful for when there are code changes for different types (which we'll see later on).

If you specialize a function w/o a generic fallback version you will get an exception that there is no matching method.
"""
316 μs
greet (generic function with 1 method)
function greet(s::String)
"Hello $(s)"
end
412 μs
"Hello Matthew"
greet("Matthew")
12.1 μs

MethodError: no method matching greet(::Int64)

Closest candidates are:

greet(!Matched::String) at ~/Documents/Teaching/mlbasics/demos/IntroductionToJulia.jl#==#08fc585c-27ee-4944-b08d-594e5b28e9e3:1

  1. top-level scope@Local: 1[inlined]
greet(1) # This should throw an exception! greet is not defined for integers!
---

Later in the series you will see how to take advantage of multiple dispatch to design an RL interface and use it to make design easier with composition.

md"""
Later in the series you will see how to take advantage of multiple dispatch to design an RL interface and use it to make design easier with composition.
"""
191 μs

Types and Data

Now that we have some of the fundamental building blocks of what makes julia tick, we can start thinking about custom types. First, lets just build a basic struct which contains some data we can act on. As a simple example, lets make a struct A which stores an integer (you can imagine this struct being an agent, environment, or really anything), with a simple function.

Note: The struct and functions are in a begin..end block. This has to do with how Pluto works, but not a standard pattern in Julia.

struct A
   	data::Int
end

function double(a::A)
    a.data * 2
end

This just returns double the data stored in A. Lets make another struct B which holds a string this time

struct B
   data::String
end

we can dispatch on double by specializing:

function double(b::B)
    ret = tryparse(Int, b.data)
    if ret == nothing
        0
    else
        2*ret
    end
end

This parses the data in b as an Int and doubles. If it is unable to parse (i.e. the data isn’t an Int) it returns 0.

Great!

Now we can use this in a more complex, but general function

function complicated_function(a_or_b, args...)
    # ... Stuff goes here ...
    data_doubled = double(a_or_b)
    # other stuff
end

Notice how I didn’t specialize the aorb parameter above and instead kept it generic. This means any struct which specializes double will slot in the correct function when complicated_function is compiled!

Now it should be pretty clear how you can use multiple dispatch to get the kind of generics you are wanting (even though these are contrived examples). We can abstract one more layer and make this even more usable using abstracts:

md"""
## Types and Data

Now that we have some of the fundamental building blocks of what makes julia tick, we can start thinking about custom types. First, lets just build a basic struct which contains some data we can act on. As a simple example, lets make a struct A which stores an integer (you can imagine this struct being an agent, environment, or really anything), with a simple function.

Note: The struct and functions are in a `begin..end` block. This has to do with how Pluto works, but not a standard pattern in Julia.

```julia
struct A
data::Int
end

function double(a::A)
a.data * 2
end
```

This just returns double the data stored in A. Lets make another struct B which holds a string this time

```julia
struct B
data::String
end
```

we can dispatch on `double` by specializing:

```julia
function double(b::B)
ret = tryparse(Int, b.data)
if ret == nothing
0
else
2*ret
end
end
7.0 ms
# create an abstract type, this is the
# root node of the type hierarchy.
abstract type AbstractAB end
792 μs

Any sub-type of this parent will have my_func methods defined.

Notice: that these depend on that sub-type defining data_as_int and there is no default method. Currently, julia does not have a way to enforce interfaces like this. See Digging Deeper for more details.

md"""
Any sub-type of this parent will have
`my_func` methods defined.

Notice: that these depend on that sub-type
defining `data_as_int` and there is no default
method. Currently, julia does not have a way to
enforce interfaces like this. See [**Digging Deeper**](#digging-deeper)
for more details.

"""
357 μs
my_func (generic function with 1 method)
my_func(aab::AbstractAB) = 20*(data_as_int(aab)^2 + 10)
426 μs

Define type A

md"Define type `A`"
185 μs
data_as_int (generic function with 1 method)
begin
# create a sub-type of AbstractAB
struct A <: AbstractAB
data::Int
end

# define its `data_as_int` method to conform to
# what is expected by AbstractAB
data_as_int(a::A) = a.data
end
1.3 ms

Define type B

md"Define type `B`"
190 μs
data_as_int (generic function with 2 methods)
begin
# create a sub-type of AbstractAB
struct B <: AbstractAB
data_str::String
end

function data_as_int(b::B)
ret = tryparse(Int, b.data_str)
if ret == nothing
0
else
ret
end
end
end
1.4 ms
my_func(A(12)), my_func(B("12"))
18.6 μs

Notice that we moved the complex and specialized code into more restrictive functions so the general functions can be reused. While here we used actual Abstract typing to make dispatch work the way we want, you can also build this exact same interface using duck typing!

Note: We can only inherit from abstract types in Julia, so in the above

struct SubA <: A
	# Stuff
end

is not valid. While this can be a little bit tiresome and a bit odd for new users, it adds clarity to the data each struct holds without needing to trace the type hierarchy.

md"""
Notice that we moved the complex and specialized code into more restrictive functions so the general functions can be reused. While here we used actual Abstract typing to make dispatch work the way we want, you can also build this exact same interface using duck typing!

**Note**: We can only inherit from abstract types in Julia, so in the above
```julia
struct SubA <: A
# Stuff
end
```
is not valid. While this can be a little bit tiresome and a bit odd for new users, it adds clarity to the data each struct holds without needing to trace the type hierarchy.
"""
408 μs

Digging deeper

html"""<h3 id="digging-deeper"> Digging deeper </h3>"""
2.3 ms

Below is another sub-type of AbstractAB which doesn't follow the interface guidelines of AbstractAB. Instead there is a bug where data_as_int returns Float64.

md"""
Below is another sub-type of `AbstractAB` which doesn't follow the interface guidelines of AbstractAB. Instead there is a bug where `data_as_int` returns `Float64`.
"""
210 μs
data_as_int (generic function with 3 methods)
begin
# create a sub-type of AbstractAB
struct C <: AbstractAB
data::Float64
end
# Notice: this will return a float.
data_as_int(c::C) = c.data
end
1.3 ms

Here we define two new functions, which are variants of the above my_func. These functions are special in that they guarantee the output of the function is an integer, but with different strategies.

  • return_cast: will cast the return type of the equation to an integer.

  • recieve_assert: will assert that what is returned by dataasint is an Integer.

md"""
Here we define two new functions, which are variants of the above `my_func`. These functions are special in that they guarantee the output of the function is an integer, but with different strategies.

- `return_cast`: will cast the return type of the equation to an integer.
- `recieve_assert`: will assert that what is returned by data_as_int is an Integer.
"""
418 μs
return_cast (generic function with 1 method)
return_cast(aab::AbstractAB)::Int = 20*(data_as_int(aab)^2 + 10)
471 μs
recieve_assert (generic function with 1 method)
recieve_assert(aab::AbstractAB) = 20*(data_as_int(aab)::Int^2 + 10)
427 μs

Because the float 3080.0 can be cast to an integer we do this without any loss of information.

md"Because the float `3080.0` can be cast to an integer we do this without any loss of information."
214 μs
return_cast.([A(12), B("12"), C(12.0)])
115 ms

Unlike above 3128.2 can't be exactly cast, so we fail to return the correct value.

md"Unlike above `3128.2` can't be exactly cast, so we fail to return the correct value."
205 μs

InexactError: Int64(3128.2)

  1. Int64@float.jl:812[inlined]
  2. convert@number.jl:7[inlined]
  3. return_cast(::Main.workspace#5.C)@Other: 1
  4. top-level scope@Local: 1[inlined]
return_cast(C(12.1))
---

Instead of failing based on runtime information, we can assert that the data_as_int method for the type of aab must return an integer.

md"Instead of failing based on runtime information, we can assert that the `data_as_int` method for the type of `aab` must return an integer."
204 μs

TypeError: in typeassert, expected Int64, got a value of type Float64

  1. recieve_assert(::Main.workspace#5.C)@Other: 1
  2. top-level scope@Local: 1[inlined]
recieve_assert(C(12.0))
---

We can dig further into the details of the compiled functions using @code_typed. Notice how return_cast will always do all the operations and then try to cast the float to an int. recieve_assert on the other had knows that dataasint doesn't return a type Int for type C so will always return a type assertion.

md"""
We can dig further into the details of the compiled functions using `@code_typed`. Notice how `return_cast` will always do all the operations and then try to cast the float to an int. `recieve_assert` on the other had knows that data_as_int doesn't return a type `Int` for type `C` so will always return a type assertion.
"""
299 μs
CodeInfo(
1 ─ %1  = Base.getfield(aab, :data)::Float64
│   %2  = Base.mul_float(%1, %1)::Float64
│   %3  = Base.add_float(%2, 10.0)::Float64
│   %4  = Base.mul_float(20.0, %3)::Float64
│   %5  = Base.le_float(-9.22337e18, %4)::Bool
└──       goto #3 if not %5
2 ─ %7  = Base.lt_float(%4, 9.22337e18)::Bool
└──       goto #4
3 ─       nothing::Nothing
4 ┄ %10 = φ (#2 => %7, #3 => false)::Bool
└──       goto #7 if not %10
5 ─ %12 = Base.trunc_llvm(%4)::Float64
│   %13 = Base.eq_float(%12, %4)::Bool
└──       goto #7 if not %13
6 ─ %15 = Base.fptosi(Int64, %4)::Int64
└──       goto #8
7 ┄ %17 = Base.InexactError(:Int64, Int64, %4)::Any
│         Base.throw(%17)::Union{}
└──       unreachable
8 ─       goto #9
9 ─       return %15
)
Int64
let
@code_typed return_cast(C(12.1))
end
241 ms
CodeInfo(
1 ─ %1 = Base.getfield(aab, :data)::Float64
│        Core.typeassert(%1, Main.workspace#5.Int)::Union{}
│        π (%1, Union{})
└──      unreachable
)
Union{}
let
@code_typed recieve_assert(C(12.1))
end
12.1 ms