In this section, we will discuss some basic concepts in Julia and start applying them to our ToDo project. Let us start by understanding the types of data that can be used in Julia.
Types
To achieve its high level of performance, Julia needs to know the types of data it will handle at either compile time or runtime. You can annotate a local function variable x
with a type Int16
explicitly, like in x::Int16 =
42
.
But you can just as well write x = 42
. If you then ask for the variable’s type with typeof(x)
, you get Int64
(or Int32
on 32-bit operating systems). So, you see, there is a difference: if you know Int16 is sufficient, you can save memory here, which can be important if there are many such cases.
Explicit typing is sometimes done for function arguments and can enhance performance. Types can also be added at a later stage of the project. Also, although Julia allows it, do not change a variable’s type: this is very bad for performance. To test whether a variable is of a certain type, use the isa
function: isa(x, Int64)
returns true
.
Julia has an abundance of built-in types, ranging from Char
, Bool
, Int8
to Int128
(and its unsigned counterparts, UInt8
and so on), Float16
to Float64
, String
, Array
, Dict
, and Set
.
Strings containing variables or expressions can be constructed by string interpolation: when x
has the value 108
, the string "The value of x is $x"
is evaluated to "The value of x is 108"
. An expression must be placed within parentheses, like "6 * 2 is $(6 * 2)"
, which evaluates to "6 * 2
is 12"
.
It is best practice not to use global variables as they cause bugs and have major performance issues. It is better to use constants, such as const var1 = 3
, which can’t be modified. In this case, Julia’s JIT compiler can generate much more efficient code.
As an alternative to global variables, you can use Refs
as is done in the Genie framework, like this:
const var = Ref{Float64}(0.0)
var[] = 20.0
That way, you make certain that the type of var
will not change.
Types follow a hierarchy, with the Any
type at the top, which, as the name says, allows any type for such a variable. In Figure 1.2, we show a part of this type tree:
Figure 1.2 – Part of Julia’s type hierarchy [Adapted from Type-hierarchy-for-julia-numbers.png made available at https://commons.wikimedia.org/wiki/File:Type-hierarchy-for-julia-numbers.png by Cormullion, licensed under the CC BY-SA 4.0 license (https://creativecommons.org/licenses/by-sa/4.0/deed.en)]
In the preceding figure, we see that the Integer
type has subtypes Unsigned
, Signed
, and Bool
.
A subtype (a kind of inheritance relationship) is indicated in code as follows:
Bool <: Integer
Types with subtypes are abstract types; we cannot create an instance of this type. The types that have no subtypes (the leaf nodes) are concrete types; only these can have data. For example, Bool
variables can have the values true
and false
. A variable b
declared as Integer
has in fact the type Int64
:
b :: Integer = 42
typeof(b) # => Int64
To describe a ToDo-item, we need several data items or fields. Let us have a look at what types of values each field can take using some examples:
- id: Here, we could add an integer of type
Int32
, such as 1
.
- description: Here, we can only use a String, such as
"
Getting groceries"
.
- completed: This field will take a
Bool
value, which is initially set to false
.
- created: This field takes the
Date
type. This type lives in the Dates
module, so to make it known to Julia, we have to say so in code: using Dates
.
- priority: This field could take an integer between
1
to 10
.
We could group all this data into an array-like type, called a Vector
. Because we have all kinds of items of different types, the type of the items is Any
. So, our Vector
would look as follows:
julia> todo1 = [1, "Getting groceries", false, Date("2022-04-01", "yyyy-mm-dd"), 5]
Running the preceding code would give us the following output:
5-element Vector{Any}:
1
"Getting groceries"
false
2022-04-01
5
To get the description, we have to use an index, todo1[2]
; the index is 2
because Julia array indices start from 1
.
A better way to group the data is using a struct:
julia> mutable struct ToDo
id::Int32
description::String
completed::Bool
created::Date
priority::Int8
end
Then, we can define the same todo
item as in the preceding code as a struct
instance:
julia> todo1 = ToDo(1, "Getting groceries", false, Date("2022-04-01", "yyyy-mm-dd"), 5)
Now, instead of using an index, we can directly ask for a particular field, for example, the todo’s description
:
julia> todo1.description
"Getting groceries"
Or we can indicate when the item is dealt with:
julia> todo1.completed = true
To nicely print out the data of a struct, use the show
(struct) or display
(struct) functions.
Another thing that we will see used a lot in Genie is symbols. These are names or expressions prefixed by a colon, for example, :customer
. Symbols are immutable and hashed by the language for fast comparison. A symbol is used to represent a variable in metaprogramming.
The :
quote operator prevents Julia from evaluating the code of the expression. Instead, that code will be evaluated when the expression is passed to eval
at runtime. The following code snippet shows this behavior:
ex = :(a + b * c + 1)
a = 1
b = 2
c = 3
println("ex is $ex") # => ex is a + b * c + 1
println("ex is $( eval(ex) )") # => ex is 8
See the Useful techniques in Julia web development section for how symbols can be used.
In this section, we have seen that the use of the appropriate types is very important in Julia: it can make your code more performant and readable.
Flow controls
Julia is equipped with all the standard flow controls, including the following:
You can see a concrete usage example of try/catch
in the echo server example in the Making a TCP echo server with TCP-IP Sockets section of Chapter 2, Using Julia Standard Web Packages. However, don’t overuse this feature; it can degrade performance (for those curious, this is because the runtime needs to add the stack trace to the exception, and afterward, needs to unwind it).
Let’s see an example of flow control in action. Here is how we compare the priorities of todos:
if todo2.priority > todo1.priority
println("Better do todo2 first")
else
println("Better do todo1 first")
end
So, you see, Julia has all the basic flow controls like any standard programming language.
Functions and methods
Functions are the basic tools in Julia. They are defined as follows:
function name(params)
# body code
end
Alternatively, we can use a one-liner:
name(params) = # body code
Functions are very powerful in Julia. They support optional arguments (which provide default values when no value is provided) and keyword arguments (here the argument’s arg1
value must be specified as func(arg1=value)
when the function is called). Functions can be nested inside other functions, passed as a parameter to a function, and returned as a value from a function. Neither argument types nor return types are required, but they can be specified using the ::
notation.
Values are not copied when they are passed to functions; instead, the arguments are new variable bindings for these values.
To better indicate that a function changes its argument, append !
to its name, for example:
julia> increase_priority!(todo) = todo.priority += 1
julia> todo1.priority
5
julia> increase_priority!(todo1)
6
julia> todo1.priority
6
In the preceding code, notice that we don’t need to indicate the type of the argument; todo
functions are by default generic, meaning that in principle, they can take any type. The JIT compiler will generate a different compiled version of the function each time it is called with arguments of a new type. A concrete version of a function for a specific combination of argument types is called a method in Julia. You can define different methods of a function (also called function overloading) by using a different number of arguments or arguments with different types with the same function name.
For example, here are two overloading methods for a move
function:
abstract type Vehicle end
function move(v::Vehicle, dist::Float64)
println("Moving by $dist meters")
end
function move(v::Vehicle, dist::LightYears)
println("Blazing across $dist light years")
end
The Julia runtime stores a list of all the methods in a virtual method table (vtable) on the function itself. Methods in Julia belong to a function, and not to a particular type as in object-oriented languages.
In practice, however, an error will be generated when the function cannot be applied for the supplied type. An example of such an error is as follows:
julia> increase_priority!("does this work?")
If you run the preceding code, you will get the following output:
ERROR: type String has no field priority
One could say that a function belongs to multiple types, or that a function is specialized or overloaded for different combinations of types. This key feature of Julia is called multiple dispatch, meaning that the execution can be dispatched on multiple argument types. Julia’s ability to compile code that reads like a high-level dynamic language into machine code that performs like C almost entirely derives from this ability, which neither Python, C++, nor Fortran implement.
A function can also take a variable number of arguments, indicated by three dots (…, called the splat
operator). For example, the validate function takes two arguments, a and b, and then a variable number of values (args…):
validate(a, b, args…)
The function can be called as validate(1, 2, 3, 4, 5)
, or as validate(1, 2, 3)
, or even validate(1, 2)
. The type of args…
is Vararg
; it can be type annotated as args::Vararg{Any}
.
If you see what seems to be a function call prefixed with an @
(such as @error
or @authenticated!
in Genie), you are looking at a macro call. A macro is code that is modified and expanded at parse-time, so before the code is actually compiled. For example, @show
is a macro that displays the expression to be evaluated and its result, and then returns the value of the result. You can see examples in action with @async
in the Making a TCP echo server with TCP-IP Sockets section in Chapter 2, Using Julia Standard Web Packages.
In this section, we saw that Julia has pretty much what you expect in any modern programming language: a complete type system, normal flow controls, exception handling, and versatile functions. We cannot review all the methods that Julia has to offer in this book, but detailed information regarding methods can be found in the Julia documentation: https://docs.julialang.org/en/v1/.
Now that you know some basic features that define the Julia language, let us explore some useful techniques that can help us further to take advantage of the speed of Julia.