In this tutorial, we'll go over a few Julia basics. We will only need the Julia REPL for this tutorial, which you can open simply by starting Julia.
⚠ Note
On Windows, you can hit the Windows button on your keyboard to open the start menu and type Julia to find the executable. Alternatively, one can open the terminal and simply type julia on *nix systems, or julia.exe for Windows, into the command line.
Either method will open an interactive command-line interface called the Julia REPL which stands for Read-Evaluate-Print-Loop.
The code that we will need to execute in the REPL will be outlined in code blocks with solid borders like so:
println("Hello World")
and the output of these code blocks will be shown in code blocks with dashed borders as:
This will be a bit of a different Julia tutorial, than those that cover simple syntax and basic types. These are special cases, and since we physicists like to generalize as much as possible and only then specialize, we will apply the same techniques here.
First, I'll introduce you to the Julian way to conceptualize functions, methods, and types in the language while contrasting heavily against more standard languages like Python and C++. Then we'll discuss how, despite Julia being different from these more typically used languages in physics, the Julian way is a natural extension of how we normally understand relationships between different things. Then we'll show an example of the type system in practice with the Number type, and finally conclude by going over some of the details with concrete numerical examples.
Julia, unlike other programming languages used in physics like Python, Java, and C++, is not object-oriented (OO). In OO languages, the primary thing that is used by the programmer when writing complex software is the data object, usually called a class. This means that classes, and class-structure ultimately control everything about the way the code is run, including which specific functions are called as the code executes. In this sense, data owns functions in OO programming.
This is not the case for Julia. In Julia, functions are primary and
first-class,
and no data can own any function. This means that functions can be thrown around like any other data type, assigned to variables, and used as arguments to other functions. The thing that differentiates how code executes is through function methods which may do different things depending on the Types of arguments supplied to them.
This all may sound super abstract, but I promise, the concept is actually very similar to what you would've learned in elementary school with arithmetic. Take, for example, the addition operation. In Julia, the function is named +, just like how we're used to. And for any two numbers a and b, we know conceptually what it means to add them as a+b. But how did we learn how to compute it?
Well, for nonnegative integers, we basically increment a by 1 a total of b times. For example,
2+3=2+1+1+1=3+1+1=4+1=5.
Then of course we were probably taught to memorize particular increments so that for 2+33 we didn't have to do the above over and over again. Great! So that's (nonnegative) integer addition.
But what was the algorithm we learned for when we couldn't increment by nice units of 1? How, for instance, do we add 2 and 3.5, given that 2 is an integer but 3.5 is a decimal? Well, we promote2 to 2.0 and write both as 2+0/10 and 3+5/10, and then sum the terms according to which power of 10 appears as an overall multiplicative factor. For example,
2+3.5=2+100+3+105=(2+3)+101(0+5)=5+105.
Then, finally, we write 5+5/10 as 5.5.
What is important to recognize here is that the specific methods of addition (+) used in the two examples differ even though the functions are conceptually equivalent.
The takeaway is that I would argue the way we already think about things is very similar to how the Julia language is built. We have these generic conceptual beasts called functions that are each supposed to do particular things. Then, we have to implement new methods for our functions whenever they have different argument Types.
Because of this, all of the power of Julia's functions come from caring all about Types[1]. Indeed, in Julia one can develop hierarchical, or tree-like, relationships between different Types to be even more expressive in code. This allows functions as concepts to be defined for abstract types and then specialized down the tree for concrete types. Ultimately, we end up with very performant code written very similarly to how we, as scientists, would conceptualize the operations on paper!
As an example, we will first talk about Numbers in this generic way.
Starting from a physics-oriented perspective, we probably are most inclined to see how numbers are used in Julia. The parent-type of all numbers is a Number whose subtypes are:
using InteractiveUtils
subtypes(Number)
2-element Vector{Any}:
Complex
Real
⚠ Code Info
The line using InteractiveUtils is the Julian way to load the InteractiveUtils module. This is only necessary to bring the subtype function into scope for scripts. If you're still in the REPL, there is no need to load it directly.
The subtype function is built into Julia and is used to identify hierarchical relationships between DataTypes.
Likewise, there is a supertype function that can be used to traverse the hierarchy in the other way as
supertype(Real)
Number
Applying this function again to each of Number's subtypes, we find:
for type in subtypes(Number)
# First get the subtypes
subs = subtypes(type)
# Now iterate through them and print them nicely
println("subtypes($type):")
for sub in subs
println("|--> $sub")
end
println()
end
Wait! I thought we were looking at numbers, but instead we're already at for-loops, string interpolation, and printing!
Okay, I know I jumped the gun a little bit, but my reasons are twofold.
Firstly, and most importantly, I wanted to show that for the most part, Julia syntax can be read almost like English, much like Python can. If you haven't tried just reading it left-to-right, give it a go. If it helps, there are a few spoilers to keep in mind:
Whitespace does not matter in Julia (unlike Python). Scoping ends at the end keyword.
The # character denotes a comment in Julia. Everything to the left of it is read as real code, but everything to the right of it is ignored.
The function println (pronounced print line) prints its arguments together on a new line.
The $ character interpolates values in Julia. So if a variable x = 4, then "$x" will become the string "4".
Secondly, this was the simplest way I could think to show you just how many different possible Numbers that come built-in with Julia.
Okay, going back to the subtypes. One can see from the output that there are zero subtypes of a ComplexNumber in Julia[2], but there are four subtypes of Reals. There are Integers and Rationals, as well as types that are called AbstractFloats and AbstractIrrationals.
It turns out that all of these are still "abstract" in the sense that they do not have any particular representation in the computer. But, these begin to hint at the amount of numerical complexity that Julia built-in. It shows how the Julia team have made it a priority for Julia to stand out as a scientific language, and from personal experience it really speeds one's productivity to have such a rich numerical ecosystem available right out of the box. This is one of the reasons why Julia is so helpful as a scientific language.
We're not going to recurse all the way down Number's type hierarchy (although you certainly can if you want to!). A helpful figure that shows this hierarchy though is given below
Now that we've gone over Numbers in the abstract, it would ultimately be pretty helpful to have some concrete examples of their use.
First, let's check the type of Number that 1 is.
typeof(1)
Int64
On different systems, this type may change from Int64 to Int32 (though I doubt it for modern hardware). Both of these types are concrete, as opposed to the Integer type shown above which is abstract. The 32 or 64 refers to the number of bits used to store each integer, implying that it has a definite machine-code implementation, unlike the abstract Integer.
So since we spent all that time discussing addition, let's add integers:
What happened here? It looks like typeof(3.5) == Float64, then the sum 5.5 is also of that type. So what happened was Julia was smart enough to convert the Int64 into a Float64 and then add the two Float64 values; just like we did above by hand!
How did it do it? There are two conversion functions that are extremely helpful: convert and promote. They work like this:
In the first case, convert(type, value) will convert the value into the type, provided that it's possible. In the second case, promote(a, b) will find out which of the two possible conversions supercedes the other for values a and b, and will return a pair/Tuple of values represented as that superceding type. In this case, integers always get turned into floats. Why? If we try to convert 3.5 into an Int64 by typing it directly into the REPL, we get an error:
The reason for this error, as we know, is that a choice would have to be made about what do do with the decimal part of 3.5. In other high-performance languages, like C and C++, we're free to do this cast without a warning. But in Julia it's prohibited for numerical safety reasons. Instead, we can use the floor function to remove the decimal part the way either of those languages would:
@show floor(3.5)
@show floor(Int64, 3.5)
floor(3.5) = 3.0
floor(Int64, 3.5) = 3
The first case shows that by default, the floor function returns a type that is the same as that of its argument. For the method where the first argument is a type, the floor function will do the conversion we ask, because there is no decimal part any longer. Note that there are also ceiling and round functions available:
Notice that in round(3.5; digits = 1) there is a semicolon ; instead of a comma , separating arguments. This is Julia's convention for writing keyword arguments in functions.
All non-keyword arguments come first, separated by commas, then a semicolon appears, and then all keyword arguments follow, each also separated by commas.
z = 1.0 + 1.0im
r = 1
res = r + z
@show z
@show r
@show res
@show typeof(res)
@show abs(res)^2
z = 1.0 + 1.0im
r = 1
res = 2.0 + 1.0im
typeof(res) = ComplexF64
abs(res) ^ 2 = 5.000000000000001
In the above abs is the absolute value function – notice how it works for types of ComplexF64, the parametric type Complex{Float64} – and the carat operator ^ is the exponentiation function.
Interestingly, there is some floating-point error apparent in the answer due to the order of operations between abs and ^2. If we switch them, the error vanishes:
z = 1.0 + 1.0im
r = 1
res = r + z
@show abs(res^2)
abs(res ^ 2) = 5.0
and we get the expected result. As a word of warning, for those of you who don't know computers can lie, just like Julia did here. We know analytically that the order of operations should not matter between abs and ^2 (see the note below for proof), but in a computer, the situation is different due to the finite-precision of a floating-point number, i.e. rounding error.
⚠ Code Info
You may have caught on to the @show macro at this point. It is a macro, not a function, which means it has the ability of writing Julia code, whereas functions do not[3]. The @ sign differentiates a macro from a function at a call-site within code.
The @show macro essentially takes whatever follows, prints it verbatim with an equals sign, and then prints the result. So it's very convenient, but we could've used
println("abs(res ^ 2) = $res")
instead. (But one can imagine, or maybe has experienced, how annoying this can get with different expressions over and over!)
⚠ Note
Here we show that ∣w∣2=∣∣w2∣∣. Consider any nonzero complex number w=Reiθ. Then, we have
∣w∣2=(R2eiθe−iθ)2=R2,
and
∣∣w2∣∣=∣∣R2e2iθ∣∣=R4e2iθe−2iθ=R2.
(Of course, this ∣w∣2=∣∣w2∣∣ when w=0, too, but in this case we can't define the phase θ, so the proof above fails. 😉)
In this tutorial, we took the time to understand Julia from its very basics, with talking how functions are first-class objects and Julia distinguishes function methods by argument Types.
We saw how this conceptual framework is very natural and commensurate to how we, as people, normally associate functions and arguments.
We then saw how it is put into practice with the Numberabstract type, and then saw an example of the tree-like structure that follows naturally from type relationships in Julia.
Finally, we made use of concrete implementations of those abstractNumber types, learned about type conversions and got to see some neat mathematical functions in action. Furthermore, we got a taste for how mathematical functions may behave differently in a computer, even for simple examples such as taking the square-modulus of a complex number.
This tutorial, despite not going over all basic types in Julia, sets us up to better understand how the Julia language works, and gives us the feel for some syntax. Now, we'll be able to move on to more complicated examples with the foundation we've built.
For the sake of completeness, I want to emphasize that in the OO world, it's not like integer and decimal addition somehow are executed as the same machine code. This implication came from the elementary example of functions and methods, the main conclusion generalizes well to user-defined Types. In OO languages, it might generalize too all arguments of a function other than the first which is typically a self-reference to the object calling the function.
This is only true in the sense of what Julia considers a subtype for compilation purposes. I'll spare you the details for now, but there are indeed "conceptual subtypes" for Complex in the sense that one can have a complex number made out of integers or floating-point numbers. But because parametric types are
invariant
, Complex{Int64} is not a subtype of Complex{Real}, even though Int64 is a subtype of Real. This may seem pretty strange at first, but this choice was made for performance (and software development) reasons.
Technically speaking, I'm actually just wrong here. It's a little white lie that, at this point, hopefully you'll forgive. Functions in Julia can and do write Julia code. This is called
metaprogramming
and makes the Julia language very powerful. Without going too crazy, symbolic code expressions are data types in Julia that can be manipulated just like Numbers. One usually writes functions that do this manipulation, and then encapsulates them within a macro to tell the Julia compiler to evaluate it, construct the new code expressions, and then evaluate it in place.