Types
Last updated on 2024-05-27 | Edit this page
Overview
Questions
- What data types does Julia offer out of the box?
- How can we define our own types?
Objectives
- SOME OBJECTIVE
Even though Julia is by default dynamic, types play an important role. We need to specify them to make use of the method dispatch feature, where one of Julia’s distinguishing features is that it offers multiple dispatch. We also need it to write high performance code.
Method Dispatch Primer
Method dispatch is the process of choosing which method of a function (more on that in a later episode) to execute based on the argument count and argument types.
For example, adding two integers requires different code than adding to floating point numbers, and again different code than adding an integer to a floating point number.
Types and Type Hierarchy
Every value in Julia has a type. We can determine the type of a value
using the function typeof
.
JULIA
> typeof(1)
Int64
> typeof(2.0)
Float64
> typeof(typeof)
typeof(typeof) (singleton type of function typeof, subtype of Function)
We can see that the default type for integers as well as floats is the respective 64-bit version. We can also see that functions are types just like any other. This allows to write functional code, which is part of idiomatic Julia.
All types in Julia form a hierarchy with Any
at the top.
We can explore that hierarchy using the functions supertype
and supertypes
.
We determine the type of the value represented by the number literal
1
.
The Julia REPL offers a special variable, ans
, that
always holds the result of the previous evaluation. We use it with
supertype
to climb up the type hierarchy:
> supertype(ans)
Signed
> supertype(ans)
Integer
> supertype(ans)
Real
> supertype(ans)
Number
> supertype(ans)
Any
> supertype(ans)
Any
supertype
returns Any
for input
Any
which happens to be at the top of the type hierarchy.
It is an ancestor of every data type and so every value is of type
Any
.
We can use supertypes
to get the full inheritance chain
of a type as a tuple of types.
> supertypes(Int64)
(Int64, Signed, Integer, Real, Number, Any)
Number Types
Julia provides various common number types.
There are four floating point number types:
JULIA
> Float16(1.0) # This might be broken
Float16(0.0)
> Float32(1.0)
1.0f0
> Float64(1.0)
1.0
> supertypes(BigFloat)
(BigFloat, AbstractFloat, Real, Number, Any)
BigFloat
is an arbitrary precision floating point number
type.
And signed and unsigned integer types for 8, 16, 32, 64, and 128 bits, for example:
As for floating point numbers, there is an arbitrary precision integer type:
Bool is a number type in Julia:
There are types for complex and rational numbers that are
parameterised on subtypes of Real
and Integer
respectively:
JULIA
> typeof(3//7)
Rational{Int64}
> Complex{Bool}(1, 0)
Complex(true, false)
> Complex(3//7, -1//5)
3//7 - 1//5*im
> im*im
-1 * 0im
Finally, Julia has a type to represent the exact values of irrational constants:
Strings
There are types for strings and characters:
Indexing in Julia
What does the following expression evaluate to:
[2, 3, 5][2]
(Named) Tuples
Julia features a type for tuples, similar to tuples in C++ and Python or lists in many Lisp dialects. Tuples is a fixed length ordered container whose elements may have any type. The syntax is the same as in Python:
We can use tuples to return multiple values from a function:
The elements of tuples can be accessed through decomposition and indexing:
In addition to plain tuples, there are named tuples, where every element has its own name:
Dictionaries
Close to named tuples, but without the limitation of fixed length are dictionaries:
JULIA
> person = Dict("first name" => "Albert", "last name" => "Einstein", "age" => 44)
Dict{String, Any} with 3 entries:
"first name" => "Albert"
"age" => 44
"last name" => "Einstein"
As seen from this example: key types can be mixed. However, unlike tuples, the order of keys is not preserved and you cannot get the entries by index:
JULIA
> person[1]
ERROR: KeyError: key 1 not found
Stacktrace:
[1] getindex(h::Dict{String, Any}, key::Int64)
@ Base ./dict.jl:498
[2] top-level scope
@ REPL[77]:1
But this works:
Also, dictionaries are not fixed in length:
Arrays
Arrays and associated features are arguably one of the most important defining traits of the Julia programming language.
Julia supports arrays of arbitrary dimensions.
We have 0-dimensional arrays (scalars):
1-dimensional arrays are called vectors:
2-dimensional arrays are called matrices:
And any higher dimensional arrays are just called arrays:
Initialising arrays can be done with and without comma:
JULIA
> [1, 2]
2-element Vector{Int64}:
1
2
> typeof([1, 2])
Vector{Int64} (alias for Array{Int64, 1})
> [1 2]
1×2 Matrix{Int64}:
1 2
> typeof([1 2])
Matrix{Int64} (alias for Array{Int64, 2})
One is effectively the transpose of the other:
This has further consequences for multidimensional arrays:
JULIA
# all commas
> [[0, 1], [2,3]]
2-element Vector{Vector{Int64}}:
[0, 1]
[2, 3]
> typeof([[0, 1], [2, 3]])
Vector{Vector{Int64}} (alias for Array{Array{Int64, 1}, 1})
# inner commas
> [[0, 1] [2, 3]]
2×2 Matrix{Int64}:
0 2
1 3
> typeof([[0, 1] [2, 3]])
Matrix{Int64} (alias for Array{Int64, 2})
# outer commas
> [[0 1], [2 3]]
2-element Vector{Matrix{Int64}}:
[0 1]
[2 3]
> typeof([[0 1], [2 3]])
Vector{Matrix{Int64}} (alias for Array{Array{Int64, 2}, 1})
# no commas
> [[0 1] [2 3]]
1×4 Matrix{Int64}:
0 1 2 3
> typeof([[0 1] [2 3]])
Matrix{Int64} (alias for Array{Int64, 2})
# ... which is the same as this:
> [0 1 2 3]
1×4 Matrix{Int64}:
0 1 2 3
> typeof([0 1 2 3])
Matrix{Int64} (alias for Array{Int64, 2})
# ... but is not the same as:
> [[0 1 2 3]]
1-element Vector{Matrix{Int64}}:
[0 1 2 3]
> typeof([[0 1 2 3]])
Vector{Matrix{Int64}} (alias for Array{Array{Int64, 2}, 1})
# and finally, it is also different from this:
> [0, 1, 2, 3]
4-element Vector{Int64}:
0
1
2
3
> typeof([0, 1, 2, 3])
Vector{Int64} (alias for Array{Int64, 1})
Working with arrays
Julia supports matrix operations:
JULIA
> A = [[0, 1] [1, 0]]
2×2 Matrix{Int64}:
0 1
1 0
> b = [1, 2]
2-element Vector{Int64}:
1
2
> b+b
2-element Vector{Int64}:
2
4
> A-A
2×2 Matrix{Int64}:
0 0
0 0
> A * b
2-element Vector{Int64}:
2
1
> A*A
2×2 Matrix{Int64}:
1 0
0 1
Julia also supports a special syntax, the dot-operator, to element-wise apply functions to arrays:
JULIA
> [1, 4, 9, 16, 25] .> 7
5-element BitVector:
0
0
1
1
1
> sqrt.([1, 4, 9, 16, 25])
5-element Vector{Float64}:
1.0
2.0
3.0
4.0
5.0
With the @.
notation we can turn every function call
into an expression that is applied element-wise:
JULIA
> @. [1 1] * [1 1] + [1 1]
1×2 Matrix{Int64}:
2 2
> [1 1] * [1 1] + [1 1]
ERROR: DimensionMismatch("matrix A has dimensions (1,2), matrix B has dimensions (1,2)")
Stacktrace:
[1] _generic_matmatmul!(C::Matrix{Int64}, tA::Char, tB::Char, A::Matrix{Int64}, B::Matrix{Int64}, _add::LinearAlgebra.MulAddMul{true, true, Bool, Bool})
@ LinearAlgebra /usr/share/julia/stdlib/v1.7/LinearAlgebra/src/matmul.jl:810
[2] generic_matmatmul!(C::Matrix{Int64}, tA::Char, tB::Char, A::Matrix{Int64}, B::Matrix{Int64}, _add::LinearAlgebra.MulAddMul{true, true, Bool, Bool})
@ LinearAlgebra /usr/share/julia/stdlib/v1.7/LinearAlgebra/src/matmul.jl:798
[3] mul!
@ /usr/share/julia/stdlib/v1.7/LinearAlgebra/src/matmul.jl:302 [inlined]
[4] mul!
@ /usr/share/julia/stdlib/v1.7/LinearAlgebra/src/matmul.jl:275 [inlined]
[5] *(A::Matrix{Int64}, B::Matrix{Int64})
@ LinearAlgebra /usr/share/julia/stdlib/v1.7/LinearAlgebra/src/matmul.jl:153
[6] top-level scope
@ REPL[2]:1
Element Types
We can determine the element types of an array type (and some other
container types) using the function eltype
:
Generic Array types
There are more types that behave similar to arrays. With strings we already saw one of them. Another are ranges:
JULIA
> 1:10
1:10
> typeof(ans)
UnitRange{Int64}
> eltype(ans)
Int64
> (1:10)[9]
9
> collect(1:10)
10-element Vector{Int64}:
1
2
3
4
5
6
7
8
9
10
> collect(1:3:10)
4-element Vector{Int64}:
1
4
7
10
> typeof(1:3:10)
StepRange{Int64}
> range(-0.1, 0.1, length=3)
-0.1:0.1:0.1
> typeof(range(-0.1, 0.1, length=3))
StepRangeLen{Float64, Base.TwicePrecision{Float64},
Base.TwicePrecision{Float64}, Int64}
> collect(range(-0.1, 0.1, length=3))
3-element Vector{Float64}:
-0.1
0.0
0.1
Structs
Structs are what allows us to define our own types in Julia. In our first example we will created a parameterized type for 2-dimensional points:
Mutability
Instances of structs are by default immutable.
JULIA
> p = Point(1, 2)
Point{Int64}(1, 2)
> p.x = 3
ERROR: setfield!: immutable struct of type Point cannot be changed
Stacktrace:
[1] setproperty!(x::Point{Int64}, f::Symbol, v::Int64)
@ Base ./Base.jl:43
[2] top-level scope
@ REPL[3]:1
Putting the keyword mutable
in front of the definition
changes that:
> mutable struct MPoint{X}
x::X
y::Y
end
> mp = MPoint(1, 2)
MPoint{Int64}(1, 2)
> mp.x = 3
3
Constructors
We already used the default constructor of a struct. It takes as many arguments as the struct has fields and assigns them to the fields in the order they are specified.
We can also define further or alternate constructors; either as part of the struct definition, inner constructor, or separately, outer constructor.
We will start with a separate, parameterless constructor for our
Point
struct:
In this case we get an additional constructor.
When we define constructors as part of the struct definition, the default constructor does not exist.
JULIA
> struct Foo
x
Foo() = new(0)
end
> Foo()
Foo(0)
> Foo(1)
ERROR: MethodError: no method matching Foo(::Int64)
Closest candidates are:
Foo() at REPL[11]:3
Stacktrace:
[1] top-level scope
@ REPL[3]:1
Note, that we need to use a preexisting constructor to define an
outer constructor, while we use the keyword new
as if we
were calling the default constructor when defining inner
constructors.
More Types
The Julia language provides a lot more types; for example
CartesianIndex
, IOStream
, or
Atomic{T}
. We again refer to the documentation.