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.
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.
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.
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.
Other resources
Good practices
Other resources for learning julia
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.
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
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.
10.0
Float64
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.
Multiple definitions for y.
Combine all definitions into a single reactive cell using a `begin ... end` block.
Multiple definitions for y.
Combine all definitions into a single reactive cell using a `begin ... end` block.
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!
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:
200
200
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.
1049580
UndefVarError: my_local_x not defined
- top-level scope@Local: 1
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.
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.
true
true
true
true
true
true
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.
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
Array{Float64, 3}
Matrix{Float64} (alias for Array{Float64, 2})
You can get the element type of an array with the following
Float64
Julia as one-based indexing. To access an element of an array simply use square brackets as the following:
0.152856
0.561577
0.59567
0.152784
0.288263
0.130376
0.736835
0.79133
0.589633
0.0601253
0.15285570069180276
And you can assign to this array
1
1.0
0.561577
0.59567
0.152784
0.288263
0.130376
0.736835
0.79133
0.589633
0.0601253
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.
0.0837568
0.4677
and you can assign to these strands:
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
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
Julia has a fully featured set of linear algebra operations built into the language which you can see later in this tutorial.
Other collection types
There are several other collection types which will be useful.
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
.
"world"
"Matthew"
"name"
"Matthew"
"hello"
"world"
Dict{Any, Any}
You can get the keys from a dictionary with the following
"name"
"hello"
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.
1
"1"
'1'
1.0
1.0
Tuple{Int64, String, Char, Float64, Float32}
Compared to an array
1
"1"
'1'
1.0
1.0
Vector{Any} (alias for Array{Any, 1})
Note: Unlike an array, you cannot assign to an element of a tuple
MethodError: no method matching setindex!(::Tuple{Int64, String, Char, Float64, Float32}, ::Float32, ::Int64)
- top-level scope@Local: 1
Named Tuples
Named tuples are just like tuples except each element has a name.
1
2
1
NamedTuple{(:a, :b), Tuple{Int64, Int64}}
Control Flow
Below are the basics of control flow. This includes Loops and If-Else statements
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.
i=2 and is less than 3
i=3 and is $\geq$ to 3
i=3
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.
The variable is nothing!
Loops
Like most languages, loops come in two varieties: for
and while
. See the examples below.
55
37
20.169
We can even loop over the elements of various collections:
41.846692926527496
34.027858340120076
"Matthew"
"world"
We cal also do list comprehensions.
0.781426
0.357762
0.445497
0.314865
0.43211
0.252897
0.589398
0.420082
0.847961
0.433182
0.054121
0.354865
0.263309
0.642517
0.756848
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).
1
1
Note we can't assign n using a struct, instead we need to make MyType a mutable struct
setfield!: immutable struct of type MyType cannot be changed
- setproperty!@Base.jl:43[inlined]
- top-level scope@Local: 1[inlined]
To make a mutable type
1
2
2
If you have a mutable type composed in a non-mutable type you can still modify the data internal to the mutable member
VectorWrapper
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
3
0.0
0.0
3.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
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
.
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
.
0.0658054
0.870748
0.24519
0.59577
0.0508563
2×2 Matrix{Float64}: 0.174815 0.72966 0.658998 0.0755602
Vector Addtion:
0.310996
1.46652
Scalar multiplication:
0.131611
1.7415
0.131611
1.7415
0.131611
1.7415
Inner product of vectors:
0.5349
0.5349
Outer product of vectors:
2×2 Matrix{Float64}: 0.0161348 0.0392049 0.213499 0.518766
2×2 Matrix{Float64}: 0.0161348 0.0392049 0.213499 0.518766
Matrix mulitplication of vectors:
0.646853
0.109159
Matrix element wise product with outer product operation
2×2 Matrix{Float64}:
0.0028206 0.0286062
0.140695 0.039198
Element wise vector multiplication (broadcasting):
0.0161348
0.518766
Broadcast scalar-vector addtion
0.116662
0.921604
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
.
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
true
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.
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.
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.
DimensionMismatch("A has dimensions (2,1) but B has dimensions (2,2)")
- gemm_wrapper!(::Matrix{Float64}, ::Char, ::Char, ::Matrix{Float64}, ::Matrix{Float64}, ::LinearAlgebra.MulAddMul{true, true, Bool, Bool})@matmul.jl:643
- mul!@matmul.jl:169[inlined]
- mul!@matmul.jl:275[inlined]
- *@matmul.jl:160[inlined]
- *@matmul.jl:63[inlined]
- top-level scope@Local: 1[inlined]
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
.
row_major (generic function with 1 method)
column_major (generic function with 1 method)
eachindex_iteration (generic function with 1 method)
Benchmarking
Below we are benchmarking the functions we wrote above. Note the median and mean times for each function.
run example:
check_test_arr (generic function with 1 method)
Column Major Sum
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.
Row Major Sum
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.
Each index
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.
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!).
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.
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.
eachindex_iteration_simd (generic function with 1 method)
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.
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.
2.56001323429547e7
2.560013234295313e7
2.56001323429547e7
2.5600132342951603e7
2.5600132342951216e7
This effect can be magnified with lower percisions
1.6777216f7
2.5601073472405612e7
2.5601174f7
2.5601074f7
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.
There is a global random number generator at Random.GLOBAL_RNG
, which we can seed using
We can generate random numbers via:
0.54734
0.505884
2×2×2×2 Array{Float32, 4}: [:, :, 1, 1] = 0.111083 0.231641 0.00690567 0.510265 [:, :, 2, 1] = 0.00109822 0.189613 0.814076 0.117714 [:, :, 1, 2] = 0.492186 0.513533 0.195057 0.676001 [:, :, 2, 2] = 0.383528 0.713411 0.654685 0.408814
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:
MersenneTwister(10)
0.112582
0.368314
2×2×2×2 Array{Float32, 4}: [:, :, 1, 1] = 0.543258 0.659525 0.176415 0.564665 [:, :, 2, 1] = 0.383382 0.686799 0.274749 0.425757 [:, :, 1, 2] = 0.130258 0.668391 0.612101 0.450317 [:, :, 2, 2] = 0.237482 0.393846 0.340622 0.938823
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.
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.
f (generic function with 1 method)
f (generic function with 2 methods)
f (generic function with 3 methods)
"default"
"Int"
"Float"
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.
greet (generic function with 1 method)
"Hello Matthew"
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
- top-level scope@Local: 1[inlined]
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.
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:
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.
my_func (generic function with 1 method)
Define type A
data_as_int (generic function with 1 method)
Define type B
data_as_int (generic function with 2 methods)
3080
3080
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.
Digging deeper
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
.
data_as_int (generic function with 3 methods)
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.
return_cast (generic function with 1 method)
recieve_assert (generic function with 1 method)
Because the float 3080.0
can be cast to an integer we do this without any loss of information.
3080
3080
3080
Unlike above 3128.2
can't be exactly cast, so we fail to return the correct value.
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.
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.
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
CodeInfo( 1 ─ %1 = Base.getfield(aab, :data)::Float64 │ Core.typeassert(%1, Main.workspace#5.Int)::Union{} │ π (%1, Union{}) └── unreachable )
Union{}