Introduction#

Julia’s typing system is core to the language, even though it might not always be explicitly visible.

Julia’s type system is designed to be powerful and expressive, yet clear, intuitive and unobtrusive. Many Julia programmers may never feel the need to write code that explicitly uses types. Some kinds of programming, however, become clearer, simpler, faster and more robust with declared types.

Abstract vs Concrete Types#

Concrete types:

  • Types of values (“objects”)

  • Specify data structure

Abstract types:

  • Cannot be instantiated

  • Define sets of concrete types (their descendants) by their shared “behaviors” (duck typing)

3 + 2.0
5.0
typeof(3)
Int64
typeof(2.0)
Float64
isconcretetype(Float64)
true
3 isa Int64
true
3 isa Float64
false
3 isa Number
true
isabstracttype(Number)
true
isabstracttype(Real)
true

Duck typing#

“If it walks like a duck and it quacks like a duck, then it must be a duck”

The abstract type Number indicates that one can do number-like things, e.g. +,-,*, and /, with corresponding values. In this category we have (concrete) things like Float64 and Int32 numbers.

An AbstractArray is a type that, e.g., allows indexing A[i]. Examples include regular arrays (Array), as well as ranges (UnitRange).

Inspecting the Type Tree#

supertype(Float64)
AbstractFloat
supertype(AbstractFloat)
Real
supertype(Real)
Number
supertype(Number)
Any
subtypes(AbstractFloat)
4-element Vector{Any}:
 BigFloat
 Float16
 Float32
 Float64

Everything is a subtype of Any:

Number <: Any
true
Float64 <: Any
true
Int32 <: Any
true
Int32 <: String
false

Let’s extract a branch of the type tree and visualize it

using AbstractTrees
AbstractTrees.children(x) = subtypes(x)
print_tree(Number)
Number
├─ Complex
└─ Real
   ├─ AbstractFloat
   │  ├─ BigFloat
   │  ├─ Float16
   │  ├─ Float32
   │  └─ Float64
   ├─ AbstractIrrational
   │  └─ Irrational
   ├─ Integer
   │  ├─ Bool
   │  ├─ Signed
   │  │  
├─ BigInt
   │  │  ├─ Int128
   │  │  ├─ Int16
   │  │  ├─ Int32
   │  │  ├─ Int64
   │  │  └─ Int8
   │  └─ Unsigned
   │     ├─ UInt128
   │     ├─ UInt16
   │     ├─ UInt32
   │     ├─ UInt64
   │     └─ UInt8
   └─ Rational

Note that concrete types are the leaves of the type tree whereas abstract types are nodes in the type graph.

Functions, Methods, and Dispatch#

Let’s define a function that calculates the absolute value of a number (like the built-in abs already does).

How would we practically calculate the absolute values of the numbers \(-4.32\) and \(1.0 + 1.0i\)?

Broadly you have two cases:

  • Real number

    • Drop the sign.

      • myabs(-4.32) = 4.32

  • Complex number:

    • Square root of z times the complex conjugate of z.

      • myabs(1.0 + 1.0im) = sqrt(2) 1.414

We see that the methods that we use depend on the type of the number.

While the single function represents the what (“calculate the absolute value”), there might be different methods describing the how.

We can use the :: operator to annotate function arguments with types and define different methods.

myabs(x::Float64) = sign(x) * x
myabs (generic function with 1 method)
myabs(-4.32)
4.32
myabs(1.0 + 1.0im)
MethodError: no method matching myabs(::ComplexF64)
Closest candidates are:
  myabs(::Float64) at In[21]:1

Stacktrace:
 [1] top-level scope
   @ In[23]:1
myabsthatdoesntexist(1.0 + 1.0im)
UndefVarError: myabsthatdoesntexist not defined

Stacktrace:
 [1] top-level scope
   @ In[24]:1
myabs(z::ComplexF64) = sqrt(real(z)^2 + imag(z)^2)
myabs (generic function with 2 methods)
myabs(1.0 + 1.0im)
1.4142135623730951
myabs(1.0 + 1.0im)  sqrt(2.0)
true
methods(myabs)
# 2 methods for generic function myabs:
  • myabs(x::Float64) in Main at In[21]:1
  • myabs(z::ComplexF64) in Main at In[25]:1

One can check which particular method is being used through the @which macro.

@which myabs(-4.32)
myabs(x::Float64) in Main at In[21]:1
@which myabs(1.0 + 1.0im)
myabs(z::ComplexF64) in Main at In[25]:1

Note that we should better loosen our type restrictions:

myabs(-3)
MethodError: no method matching myabs(::Int64)
Closest candidates are:
  myabs(::Float64) at In[21]:1
  myabs(::ComplexF64) at In[25]:1

Stacktrace:
 [1] top-level scope
   @ In[31]:1
myabs(1 + 1im)
MethodError: no method matching myabs(::Complex{Int64})
Closest candidates are:
  myabs(::Float64) at In[21]:1
  myabs(::ComplexF64) at In[25]:1

Stacktrace:
 [1] top-level scope
   @ In[32]:1
myabs(x::Real) = sign(x) * x
myabs(z::Complex) = sqrt(real(z * conj(z)))
myabs (generic function with 4 methods)
myabs(-3)
3

As we will understand later, type annotations in function signatures virtually never affect performance!

One should therefore generally make them as generic as possible.

Multiple Dispatch#

Which method gets executed when you call a generic function f for a given set of input arguments?

Answer: Julia always chooses the most specific method by considering all input argument types.

(Since methods belong to generic functions rather than objects no function argument is special.)

f(a, b::Any) = "fallback"
f(a::Number, b::Number) = "a and b are both numbers"
f(a::Number, b) = "a is a number"
f(a, b::Number) = "b is a number"
f(a::Integer, b::Integer) = "a and b are both integers"
f (generic function with 5 methods)
methods(f)
# 5 methods for generic function f:
  • f(a::Integer, b::Integer) in Main at In[35]:5
  • f(a::Number, b::Number) in Main at In[35]:2
  • f(a::Number, b) in Main at In[35]:3
  • f(a, b::Number) in Main at In[35]:4
  • f(a, b) in Main at In[35]:1
f(1.5, 2)
"a and b are both numbers"
f(1, "Hamburg!")
"a is a number"
f(1, 2)
"a and b are both integers"
f("Hello", "World!")
"fallback"
@which f(1, 2)
f(a::Integer, b::Integer) in Main at In[35]:5
@which f(1, "Hamburg!")
f(a::Number, b) in Main at In[35]:3

It happens rarely, but it can happen that there is no unique most specific method:

f(x::Int, y::Any) = println("int")
f(x::Any, y::String) = println("string")
f(3, "test")
MethodError: f(::Int64, ::String) is ambiguous. Candidates:
  f(x::Int64, y) in Main at In[43]:1
  f(a::Number, b) in Main at In[35]:3
  f(x, y::String) in Main at In[43]:2
Possible fix, define
  f(::Int64, ::String)

Stacktrace:
 [1] top-level scope
   @ In[43]:3

Spoiler! This is an awesome feature:

Built-in Julia Functions#

(Most of) Julia’s built-in functions are not special by any means.

methods(+)
# 206 methods for generic function +:
@which true + false
+(x::Bool, y::Bool) in Base at bool.jl:162
@which "Hello" * "World!"
*(s1::Union{AbstractChar, AbstractString}, ss::Union{AbstractChar, AbstractString}...) in Base at strings/basic.jl:260

We can easily modify or add methods to them as well.

import Base: + # we have to import functions to override/add methods
+(x::String, y::String) = x * "_" * y

# alternative
Base.:+(x::String, y::String) = x * "_" * y
"Hello" + "Hamburg!"
"Hello_Hamburg!"

(Side note: as we neither own the + function nor the String type the above is type piracy and should generally be avoided! 😉)

Type Parameters#

Types can have type parameters. They are crucial for achieving high performance while being generic at the same time (more on this later).

The most prominent example is Julia’s regular array type.

M = rand(2, 2)
2×2 Matrix{Float64}:
 0.143278  0.420948
 0.624822  0.817864
typeof(M)
Matrix{Float64} (alias for Array{Float64, 2})

Here, Array is a parametric datatype. The type parameters are

  • Float64 (element type)

  • 2 (dimensionality)

Hence Array{Float64, 2} means that we have a matrix than can hold 64-bit floating point numbers.

This generalizes as expected. Here, a vector of Strings:

M = fill("Hamburg", 2)
2-element Vector{String}:
 "Hamburg"
 "Hamburg"
typeof(M)
Vector{String} (alias for Array{String, 1})
eltype(M)
String

We can also nest parametric types. This is a vector of matrices of Float64s.

v = [rand(2, 2) for i in 1:3]
3-element Vector{Matrix{Float64}}:
 [0.4729470610240727 0.2454842781254145; 0.9946494078553325 0.7321556009235937]
 [0.3537497611269118 0.6223284386618653; 0.5429099854763516 0.09985402528424125]
 [0.34467500788821326 0.8186559161726774; 0.8342360583024825 0.19253273388995396]
typeof(v)
Vector{Matrix{Float64}} (alias for Array{Array{Float64, 2}, 1})
eltype(v)
Matrix{Float64} (alias for Array{Float64, 2})

Another example of a parametric type is the Tuple.

(1, 2.0, "3")
(1, 2.0, "3")
typeof((1, 2.0, "3"))
Tuple{Int64, Float64, String}

Type parameters in function signatures#

Naive approach:

myfunc(v::Vector{Real}) = "I'm a real vector!"
myfunc (generic function with 1 method)
myfunc([1.0, 2.0, 3.0])
MethodError: no method matching myfunc(::Vector{Float64})
Closest candidates are:
  myfunc(::Vector{Real}) at In[59]:1

Stacktrace:
 [1] top-level scope
   @ In[60]:1

Huh? What’s going on?

Note that although we have

Float64 <: Real
true

Parametric types have the following (perhaps somewhat counterintuitive) property

Vector{Float64} <: Vector{Real}
false
[1.0, 2.0, 3.0] isa Vector{Real}
false

How can we understand the behavior above?

The crucial point is that Vector{Real} is a concrete container type despite the fact that Real is an abstract type. Specifically, it describes a heterogeneous vector of values that individually can be of any type T <: Real.

This behaviour is explained in more detailed in the Julia documentation here however I’ll skip over the details as they are not too important.

isconcretetype(Vector{Real})
true
Real[1, 2.2, 13.0f0]
3-element Vector{Real}:
  1
  2.2
 13.0f0

As we have learned above, concrete types are the leafes of the type tree and cannot have any subtypes. Hence it is only consistent to have…

Vector{Float64} <: Vector{Real}
false

What we often actually mean when writing myfunc(v::Vector{Real}) = ... is

myfunc(v::Vector{T}) where T <: Real = "I'm a real vector!"
# Equivalent to: 
#   myfunc(v::Vector{<:Real}) = "I'm a real vector!"
myfunc (generic function with 2 methods)
myfunc([1.0, 2.0, 3.0])
"I'm a real vector!"

It works! But what does it mean exactly? First of all, we see that

Vector{Float64} <: Vector{T} where {T <: Real}
true

Here, Vector{T} where T <: Real describes the set of concrete Vector types whose elements are of any specific single type T that is a subtype of Real.

Think of it as representing

set(Vector{Float64}, Vector{Int64}, Vector{Int32}, Vector{AbstractFloat}, ...)

Vector{Int64} <: Vector{T} where T <: Real
true
Vector{AbstractFloat} <: Vector{T} where T <: Real
true
[1.0, 2.0, 3.0] isa Vector{T} where T <: Real
true

We can also use the where notation to write out our naive Vector{Real} from above in a more explicit way:

Vector{Real} === Vector{T where T <: Real}
true

Note that the crucial difference is the position of the where T<:Real piece, i.e. whether it is inside or outside of the curly braces.

Vector{T} where T <: Real
Vector{T} where T<:Real (alias for Array{T, 1} where T<:Real)
Vector{T where T <: Real}
Vector{Real} (alias for Array{Real, 1})

(More mathematically put: Whether where T is inside or outside of the curly braces indicates whether there is or is not a “degree of freedom” that spans the “one-dimensional” set above.)

Summary#

  • Concrete types describe data structures, i.e. concrete implementations.

  • Abstract types define the kind of a thing (What is it? What can I do with it?), i.e. an informal interface. This is also known as duck-typing.

  • A function (the what) can have multiple methods (the how).

  • Types in function signatures serve as filters. Avoid writing overly-specific types.

  • Multiple dispatch: Julia selects the method to run based on the types of all input arguments and chooses the most specialized one.

  • Types can have parameters, i.e. Vector{Float64}. We can use the notation T where T<:SomeSuperType to address sets of types.

References#