Julia Module 6 – The type system and multiple dispatch
The type system: abstract and concrete types
Walk Julia’s type tree with supertype, subtypes and <:, tell abstract from concrete types, and see why Vector{Int} is no Vector{Real} but fits Vector{<:Real}.
What you will learn
- Navigate the Number hierarchy with supertype, subtypes and the <: operator
- Distinguish abstract from concrete types, and explain why a Vector{Real} is concrete but slow
- Write signatures with AbstractVector{<:Real}, and explain why type parameters are invariant
- Use a Union for a result that may be nothing, and handle both cases where it is called
- Predict what a :: annotation does in an expression, a signature and a local variable
Before you start
What you’ll learn · Before you start
What you will learn
- Navigate the Number hierarchy with supertype, subtypes and the <: operator
- Distinguish abstract from concrete types, and explain why a Vector{Real} is concrete but slow
- Write signatures with AbstractVector{<:Real}, and explain why type parameters are invariant
- Use a Union for a result that may be nothing, and handle both cases where it is called
- Predict what a :: annotation does in an expression, a signature and a local variable
Before you start
On this page
Every integer is a real number, yet a function declared for a Vector{Real} refused [1, 2, 3] with a
MethodError. And a Vector{Real} of a million numbers took 16,000,040 bytes where a Vector{Float64} of the same
numbers took 8,000,040. Both facts come from one design: Julia’s types form a tree, and the types inside it, the
abstract ones, say what a value is but never how it is stored.
This lesson walks the tree of number types, separates abstract from concrete types, explains why a vector’s type
parameter does not follow the tree and what to write instead, uses a Union for a result that may be missing, and
sorts out the three jobs of ::. The runs below use Julia 1.13.1.
Every value has one concrete type, and abstract types group them
typeof(42) is Int64 and typeof(2.5) is Float64: each value has exactly one type, and it is always a
concrete type: A type that values can have, such as Int64, Float64 or Vector{Real}. typeof always returns one. Concrete types are final: they cannot have subtypes, so values sit at the leaves of the type tree.. Above them sit the
abstract type: A type that no value has directly, such as Integer, Real or Number. It groups other types in the type tree, so that isa tests and method signatures can treat them alike; only abstract types may have subtypes., which no value has directly. supertype(Int64) is Signed,
supertype(Signed) is Integer, and so on up to Any, the type at the top of every chain, as the lesson on
variables showed with isa. This lesson walks the other way, down from Number with subtypes:
# The tree of number types below Number, each marked abstract, concrete or parametric.
using InteractiveUtils # subtypes is a REPL helper that a script loads
exported(T) = Base.isexported(Base, nameof(T)) # leave out Base's internal helper types
function kind(T)
isabstracttype(T) && return "abstract"
isconcretetype(T) && return "concrete"
return "parametric" # Complex, Rational: a family of concrete types
end
const hidden = Any[] # the internal types the walk leaves out
function tree(T, depth = 0)
println(" "^depth, T, " (", kind(T), ")")
for S in subtypes(T)
exported(S) ? tree(S, depth + 1) : push!(hidden, S)
end
end
tree(Number)
println("left out, not exported by Base: ", join(string.(hidden), ", "))
println("Int64 <: Signed <: Integer <: Real <: Number: ", Int64 <: Signed <: Integer <: Real <: Number)
println("supertype(Float64) = ", supertype(Float64), ", subtypes(Integer) = ", subtypes(Integer))
println("true + true = ", true + true, ", because Bool <: Integer is ", Bool <: Integer) from session import files, run
# The number types below Number, walked with subtypes.
files("number_tree.jl")
run("""
julia number_tree.jl
""") Output
$ julia number_tree.jl
Number (abstract)
Complex (parametric)
Real (abstract)
AbstractFloat (abstract)
BigFloat (concrete)
Float16 (concrete)
Float32 (concrete)
Float64 (concrete)
AbstractIrrational (abstract)
Irrational (parametric)
Integer (abstract)
Bool (concrete)
Signed (abstract)
BigInt (concrete)
Int128 (concrete)
Int16 (concrete)
Int32 (concrete)
Int64 (concrete)
Int8 (concrete)
Unsigned (abstract)
UInt128 (concrete)
UInt16 (concrete)
UInt32 (concrete)
UInt64 (concrete)
UInt8 (concrete)
Rational (parametric)
left out, not exported by Base: Base.MultiplicativeInverses.MultiplicativeInverse, Core.BFloat16
Int64 <: Signed <: Integer <: Real <: Number: true
supertype(Float64) = AbstractFloat, subtypes(Integer) = Any[Bool, Signed, Unsigned]
true + true = 2, because Bool <: Integer is true
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 number_tree_session.py
The printed tree holds every number type that Base exports, and the line after it names the two internal types the
walk left out. AbstractFloat gathers the four floating-point types, Signed the five fixed-size signed integers
and BigInt, and Integer gathers Signed, Unsigned and Bool: a Bool is an integer, which is why
true + true is 2. The subtype: A type that sits below another in the type tree. T <: S asks whether T is a subtype of S: Int64 <: Real is true, and every type is a subtype of itself and of Any. operator <: asks whether one type sits below another, and it chains:
Int64 <: Signed <: Integer <: Real <: Number printed true. subtypes returns a vector of the types one level
down, and in a script it comes from InteractiveUtils, which the REPL loads by itself. The same tree, drawn with
each abstract type around its subtypes:
The number types of Base, each abstract type drawn around its subtypes
Text description of the diagram
Number, Real and Integer are drawn as boxes around their subtypes. Each smaller box names a type, and for an abstract type it lists the concrete types below it, the types values have.
- Number, abstract, holds Complex and Real.
- Complex{T} is parametric: a family of concrete types such as ComplexF64 and ComplexF32.
- Real, abstract, holds AbstractFloat, Integer, Rational and AbstractIrrational.
- AbstractFloat, abstract, has the concrete types Float16, Float32, Float64 and BigFloat below it.
- Integer, abstract, holds the concrete type Bool and the abstract types Signed and Unsigned.
- Signed has Int8, Int16, Int32, Int64, Int128 and BigInt below it; Unsigned has UInt8, UInt16, UInt32, UInt64 and UInt128.
- Rational{T} is parametric, and AbstractIrrational has Irrational below it, the type of constants such as π and ℯ.
Three kinds of type appear in it, and this program tests each one:
# Abstract types have no values of their own, and concrete types have no subtypes.
for T in (Real, Float64, Complex, Complex{Float64}, Vector{Real})
println(rpad(string(T), 18), "isabstracttype ", rpad(isabstracttype(T), 7), "isconcretetype ", isconcretetype(T))
end
x = AbstractFloat(1) # calling an abstract type converts to a concrete one
println("AbstractFloat(1) = ", x, ", a ", typeof(x))
try
eval(:(struct Precise <: Float64 end))
catch err
println(sprint(showerror, err))
end from session import files, run
# Which types are abstract and which concrete, and the one rule concrete types follow.
files("kinds.jl")
run("""
julia kinds.jl
""") Output
$ julia kinds.jl
Real isabstracttype true isconcretetype false
Float64 isabstracttype false isconcretetype true
Complex isabstracttype false isconcretetype false
ComplexF64 isabstracttype false isconcretetype true
Vector{Real} isabstracttype false isconcretetype true
AbstractFloat(1) = 1.0, a Float64
invalid subtyping in definition of Precise: can only subtype abstract types.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 kinds_session.py
Real is abstract and Float64 concrete. Complex is neither: it is a family of concrete types, one for each type
parameter, and Complex{Float64}, printed ComplexF64, is one member. The manual states two rules that the run
shows. Abstract types cannot be instantiated: AbstractFloat(1) made no value of type AbstractFloat but returned
1.0, a Float64, because Base defines that call as a conversion to a concrete floating-point type. And concrete
types are final, they may only have abstract supertypes, so struct Precise <: Float64 end stopped with
invalid subtyping. A tree built this way has values only at its leaves.
Checkpoint
Check: The type tree: supertype, subtypes and <:
A vector of an abstract type is concrete, and stores every value elsewhere
isconcretetype(Vector{Real}) is true, which surprises most people the first time. The vector type is concrete,
because values of it exist; its element type, Real, is abstract. What that costs shows up when the same million
numbers go into a Vector{Float64} and a Vector{Real}:
# The same million numbers in a Vector{Float64} and in a Vector{Real}.
function total(v)
s = 0.0
for x in v
s += x
end
return s
end
xf = [Float64(k) for k in 1:1_000_000]
xr = Real[Float64(k) for k in 1:1_000_000]
total(xf); total(xr) # the first calls compile
println("isconcretetype(Vector{Real}): ", isconcretetype(Vector{Real}), ", eltype: ", eltype(xr), ", typeof(xr[1]): ", typeof(xr[1]))
println("isbitstype: Float64 ", isbitstype(Float64), ", Real ", isbitstype(Real))
println("sizeof, the slots only: Float64 ", sizeof(xf), " bytes, Real ", sizeof(xr), " bytes")
println("summarysize, with values: Float64 ", Base.summarysize(xf), " bytes, Real ", Base.summarysize(xr), " bytes")
println("one pass of total: Float64 ", @allocated(total(xf)), " bytes allocated, Real ", @allocated(total(xr)), " bytes")
println("type the compiler infers: Float64 ", only(Base.return_types(total, (Vector{Float64},))), ", Real ", only(Base.return_types(total, (Vector{Real},)))) from session import files, run
# What a Vector{Real} stores, compared with a Vector{Float64} of the same values.
files("storage.jl")
run("""
julia storage.jl
""") Output
$ julia storage.jl
isconcretetype(Vector{Real}): true, eltype: Real, typeof(xr[1]): Float64
isbitstype: Float64 true, Real false
sizeof, the slots only: Float64 8000000 bytes, Real 8000000 bytes
summarysize, with values: Float64 8000040 bytes, Real 16000040 bytes
one pass of total: Float64 0 bytes allocated, Real 16000000 bytes
type the compiler infers: Float64 Float64, Real Any
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 storage_session.py
Step through what each vector keeps in its slots:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Two vectors. xf and xr hold the same numbers, 1.0 to 1,000,000.0. Both have 1,000,000 slots of 8 bytes, so sizeof printed 8,000,000 bytes for each.
- Float64 in place. Every Float64 is 8 bytes, so a slot of xf holds the number itself. summarysize, which also counts what the slots point to, printed 8,000,040 bytes.
- Real cannot fit. A slot of xr must take any Real: a Float64, a 16-byte Int128, a BigFloat of any size. No one size fits them all, so each slot holds the address of the value instead.
- One object per value. Each value sits in an object of its own, with a tag that names its type. summarysize printed 16,000,040 bytes for xr, almost twice as much as for xf.
- The loop over xf. The compiler knows every element of xf is a Float64, so it picks the + method once and the loop runs on the values in order: 0 bytes allocated, and the result inferred as Float64.
- The loop over xr. For xr each x is read through its address and its tag decides the + method while the loop runs. One pass allocated 16,000,000 bytes, and the result was inferred only as Any.
Both vectors have a million slots of 8 bytes, so sizeof gave 8,000,000 bytes for each. A Float64 fits a slot, and
isbitstype(Float64) is true: it is plain data with no references. A Real could be any real number, a 16-byte
Int128 or a BigFloat of any size, so the manual explains that an array of an abstract element type must hold
pointers to individually allocated objects. Base.summarysize, which follows the pointers, counted 16,000,040 bytes
for the Vector{Real}.
An abstract element type to make code general
Wrong: readings = Real[] so that a function can take integers and floats. Every reading is then a separate object.
Right: give the container a concrete element type, Float64[], and make the function general instead, with no
annotation or an abstract one on its argument: Julia compiles a separate version for each concrete type it is called
with.
The loop pays again. Over xf, the compiler knew every element was a Float64, chose + once and allocated
nothing. Over xr, the type of each element was found while the loop ran, every s += x made a new object, and one
pass allocated 16,000,000 bytes. Base.return_types shows the cause: the result was inferred as Float64 for the
first vector and only as Any for the second.
Key idea
An abstract type in a container’s braces makes every element a separate object reached through a pointer; keep abstract types in function signatures and concrete types in the braces of your data.
Checkpoint
Check: Abstract element types and storage
A vector of integers is not a vector of reals: type parameters are invariant
Two functions compute the same mean. The first is declared for Vector{Real}, the second for
AbstractVector{<:Real}, and each is called with six vectors of real numbers:
# A Vector{Real} parameter accepts only a Vector{Real}; AbstractVector{<:Real} accepts every vector of reals.
mean_strict(v::Vector{Real}) = sum(v) / length(v)
mean_wide(v::AbstractVector{<:Real}) = sum(v) / length(v)
inputs = (("[1, 2, 3]", [1, 2, 3]), ("[1.5, 2.5]", [1.5, 2.5]), ("Real[1, 2.5]", Real[1, 2.5]),
("Float32[1, 2]", Float32[1, 2]), ("1:4", 1:4), ("view(w, 2:3)", view([1.0, 2.0, 3.0], 2:3)))
for (code, v) in inputs
strict = try
mean_strict(v)
catch err
err isa MethodError ? "MethodError" : rethrow()
end
println(rpad(code, 15), rpad(string(strict), 13), mean_wide(v))
end
println("Vector{Int} <: Vector{Real}: ", Vector{Int} <: Vector{Real})
println("Vector{Int} <: Vector{<:Real}: ", Vector{Int} <: Vector{<:Real})
println("UnitRange{Int} <: AbstractVector{<:Real}: ", UnitRange{Int} <: AbstractVector{<:Real})
println("Tuple{Int} <: Tuple{Real}: ", Tuple{Int} <: Tuple{Real})
# Why: a Vector{Real} must take any real number, and a Vector{Int} cannot.
r = Real[1, 2]
push!(r, 2.5)
println(r)
i = [1, 2]
try
push!(i, 2.5)
catch err
println(sprint(showerror, err))
end from session import files, run
# Two signatures, six vectors of real numbers.
files("invariance.jl")
run("""
julia invariance.jl
""") Output
$ julia invariance.jl
[1, 2, 3] MethodError 2.0
[1.5, 2.5] MethodError 2.0
Real[1, 2.5] 1.75 1.75
Float32[1, 2] MethodError 1.5
1:4 MethodError 2.5
view(w, 2:3) MethodError 2.5
Vector{Int} <: Vector{Real}: false
Vector{Int} <: Vector{<:Real}: true
UnitRange{Int} <: AbstractVector{<:Real}: true
Tuple{Int} <: Tuple{Real}: true
Real[1, 2, 2.5]
InexactError: Int64(2.5)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 invariance_session.py
mean_strict accepted one input of the six, Real[1, 2.5]. The manual calls Julia’s type parameters
invariant: Said of a parametric type whose parameters do not pass their subtype order on to it, as Julia's are: Int64 <: Real is true, but Vector{Int64} <: Vector{Real} is false. Tuple types are the exception.: although Int64 <: Real, Vector{Int64} <: Vector{Real} is
false, because the two types have different representations in memory. The last lines show the promise a
Vector{Real} makes and a Vector{Int64} cannot keep: any real number may be stored in it. push!(r, 2.5) worked
on the Vector{Real}, while push!(i, 2.5) on [1, 2] stopped with InexactError: Int64(2.5).
Vector{<:Real} is the set of every Vector whose element type is a subtype of Real, a
UnionAll type: A type with a parameter left open, written with where or <:, such as Vector{T} where T<:Real, also written Vector{<:Real}. It is the set of every type it covers and is never a value's own type. More in “Parametric types and generic numeric code” that is short for Vector{T} where T<:Real, so
Vector{Int} <: Vector{<:Real} printed true. AbstractVector{<:Real} is wider still: it also holds ranges such as 1:4 and views,
which are vectors of reals but not Vectors. The sets nest like this:
The vector types each signature accepts
Text description of the diagram
Each box is the set of types a signature with that type accepts.
- The outer box, AbstractVector{<:Real}, holds every vector whose element type is a subtype of Real.
- Inside it, Vector{<:Real} holds the Vector types: Vector{Int64}, Vector{Float64}, Vector{Float32} and Vector{Real}.
- Vector{Real} is one of them, a single concrete type, so a parameter declared Vector{Real} accepts only that one box.
- AbstractVector{<:Real} also holds vectors of reals that are not a Vector at all: ranges such as 1:4, a UnitRange{Int64}, and views such as view(w, 2:3), a SubArray.
Tuples are the exception the run also printed: they are covariant: Said of a type whose parameters pass their subtype order on to it, as Julia's tuple types do: Tuple{Int64} <: Tuple{Real} is true. Other parametric types are invariant, so Vector{Int64} <: Vector{Real} is false. More in “Tuples, named tuples and pairs”, so
Tuple{Int} <: Tuple{Real} is true. A tuple can never be written to, so it can break no promise about what may be
stored in it.
The usual signature for numeric vectors
Write v::AbstractVector{<:Real}, or leave the argument untyped. It costs no speed: the call mean_wide([1, 2, 3])
runs code compiled for Vector{Int64}, and mean_wide(1:4) code compiled for UnitRange{Int64}.
Checkpoint
Check: Invariant type parameters and <:Real
A Union holds one of a few types, such as a result or nothing
A lookup sometimes has no answer. rainfall(city) below reads a table of invented yearly totals and returns
nothing for a city it does not have, so its result is one of two types:
# A lookup that returns a number, or nothing when it has no data.
const RAINFALL_MM = Dict("Pune" => 722.0, "Chennai" => 1400.0) # invented yearly totals
rainfall(city) = get(RAINFALL_MM, city, nothing)
println(typeof(rainfall("Pune")), " ", typeof(rainfall("Delhi")))
println("inferred: ", only(Base.return_types(rainfall, (String,))))
println(Union{Int, Nothing}, " ", 1 isa Union{Int, Nothing}, " ", nothing isa Union{Int, Nothing})
for city in ("Pune", "Delhi") # the caller handles both cases
mm = rainfall(city)
if mm === nothing
println(city, ": no data")
else
println(city, ": ", mm / 10, " cm")
end
end
println("something(rainfall(\"Delhi\"), 0.0) = ", something(rainfall("Delhi"), 0.0))
try # a caller that forgot the second case
rainfall("Delhi") + 1
catch err
println(first(split(sprint(showerror, err), '\n')))
end
# A small Union is stored compactly in an array: the values in one block, plus one type byte each.
gaps = Union{Missing, Float64}[isodd(k) ? missing : Float64(k) for k in 1:1000]
boxed = Any[isodd(k) ? missing : Float64(k) for k in 1:1000]
println("1,000 values: Union{Missing, Float64} ", Base.summarysize(gaps), " bytes, Any ", Base.summarysize(boxed), " bytes") from session import files, run
# A result or nothing, and the cost of a small Union in an array.
files("unions.jl")
run("""
julia unions.jl
""") Output
$ julia unions.jl
Float64 Nothing
inferred: Union{Nothing, Float64}
Union{Nothing, Int64} true true
Pune: 72.2 cm
Delhi: no data
something(rainfall("Delhi"), 0.0) = 0.0
MethodError: no method matching +(::Nothing, ::Int64)
1,000 values: Union{Missing, Float64} 9040 bytes, Any 12040 bytes
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 unions_session.py
The compiler inferred Union{Nothing, Float64} for the result. A Union: A type whose values have one of the listed member types: Union{Nothing, Float64} holds nothing or a Float64. The compiler handles unions of a few types efficiently, in code and in arrays. is the type of a
value that has one of the listed types: 1 isa Union{Int, Nothing} and nothing isa Union{Int, Nothing} are both
true, and Julia prints the members in its own order, Union{Nothing, Int64}. Follow both calls through the caller:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The return type. rainfall returns a Float64 for a city in the table and nothing for any other, so the compiler inferred Union{Nothing, Float64} for its result.
- Pune. For Pune the table has 722.0, so mm is a Float64. The caller tests mm === nothing, which is false.
- The value. Only now does the caller divide: 722.0 / 10 printed as 72.2 cm. Inside this branch mm is known to be a Float64.
- Delhi. Delhi is not in the table, so get returned its default, nothing. The test is true and the caller reports that there is no data.
- A caller that forgot. rainfall("Delhi") + 1 has no test. The nothing reaches +, and the run stopped with MethodError: no method matching +(::Nothing, ::Int64), far from the lookup that caused it.
The caller tests mm === nothing before it does arithmetic, and inside each branch the type is settled. The manual
notes that the compiler generates efficient code for unions of a small number of types, up to 4 by default, so this
costs almost nothing. something(x, default) returns the first argument that is not nothing, which suits a
default. The try block shows what happens without a test: the nothing travelled to +, which threw
MethodError: no method matching +(::Nothing, ::Int64), far from the lookup that produced it.
A small union is also cheap in an array. The last line stored 1,000 values, half of them missing: the
Union{Missing, Float64} vector took 9,040 bytes, the floats in one block plus a byte per element for its type, while
the Vector{Any} took 12,040 and kept every float as a separate object. That is how vectors with missing data stay
fast, as the manual’s page on missing values describes.
Returning a sentinel number
Wrong: get(table, city, -1.0), so that a missing city looks like rainfall of −1 mm and is averaged in silently.
Right: return nothing (or missing for a gap in data), so a caller that forgets to check fails at once instead of
computing a wrong answer.
Checkpoint
Check: Union types for a result or nothing
A type annotation asserts, restricts or converts, depending on where it stands
x::T looks the same everywhere, and does a different job in each of three places:
# The same :: in three places does three different things.
# 1. In an expression it asserts: the value is returned, or a TypeError is thrown.
n = 3
println((n::Int) * 2)
try
(2.5::Int) * 2
catch err
println(sprint(showerror, err))
end
# 2. In a signature it restricts which arguments the method accepts.
half(x::Real) = x / 2
println(half(3), " ", half(3//4))
try
half("3")
catch err
println(first(split(sprint(showerror, err), '\n')))
end
# 3. On a local variable it converts every value assigned to it.
function running_total(xs)
t::Float64 = 0
for x in xs
t += x
end
return t
end
println(running_total([1, 2, 3]))
try
running_total([1, 2im])
catch err
println(sprint(showerror, err))
end
# An annotation in a signature is not needed for speed: both methods are compiled for Int64 and infer Float64.
plain(x) = x / 2
println(only(Base.return_types(plain, (Int,))), " ", only(Base.return_types(half, (Int,))))
# An assertion helps where the compiler cannot know the type, such as a value read from a Vector{Any}.
first_plus(v) = v[1] + 1.0
first_plus_asserted(v) = (v[1]::Float64) + 1.0
println(only(Base.return_types(first_plus, (Vector{Any},))), " ", only(Base.return_types(first_plus_asserted, (Vector{Any},))))
# Two related types: the closest common supertype, and the type both values convert to.
println("typejoin(Int, Float64) = ", typejoin(Int, Float64), ", promote_type(Int, Float64) = ", promote_type(Int, Float64)) from session import files, run
# A type annotation in an expression, in a signature and on a local variable.
files("annotations.jl")
run("""
julia annotations.jl
""") Output
$ julia annotations.jl 6 TypeError: in typeassert, expected Int64, got a value of type Float64 1.5 3//8 MethodError: no method matching half(::String) 6.0 InexactError: Float64(1.0 + 2.0im) Float64 Float64 Any Float64 typejoin(Int, Float64) = Real, promote_type(Int, Float64) = Float64
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 annotations_session.py
Where ::Int stands |
What it does | What went wrong in the run |
|---|---|---|
In an expression, (n::Int) * 2 |
Asserts: returns the value, or throws | (2.5::Int) threw TypeError: in typeassert |
In a signature, half(x::Real) |
Restricts the arguments the method accepts | half("3") threw a MethodError |
On a local variable, t::Float64 = 0 |
Converts every value assigned to it | t += 2im threw InexactError |
A type assertion: The expression x::T, which returns x when x isa T and throws a TypeError otherwise. It documents an assumption and stops the program at the line where the assumption fails. in an expression checks a value and returns it unchanged, so it
documents an assumption and stops the program where the assumption fails. In a signature the annotation decides which
method a call may use, the subject of the lessons that follow. On a local variable it converts:
running_total([1, 2, 3]) returned 6.0, a Float64, from integer inputs, and a complex value with a non-zero imaginary part could not
be converted at all.
An annotation in a signature does not make a function faster. The style guide says a generic definition is not
slower, because Julia compiles specialised versions as needed: plain(x) = x / 2 and half(x::Real) = x / 2 were
both inferred to return Float64 for an Int64 argument. An assertion helps speed only where the compiler cannot
know a type, as for a value read from a Vector{Any}: v[1] + 1.0 was inferred as Any, and
(v[1]::Float64) + 1.0 as Float64, which the performance tips call annotating values taken from untyped
locations. The last line separates two functions that relate types. typejoin(Int, Float64) is Real, the closest
common supertype, while promote_type(Int, Float64) is Float64, the type both values are
converted to for arithmetic; array literals such as [1, 2.5] use the second.
Checkpoint
Check: What a :: annotation does
Practise: walk the tree yourself
Find the concrete types below a type
Write concrete_below(T), which walks subtypes recursively and returns every concrete type below T, sorted by
name. Check on your machine that it finds 12 types below Integer and 6 below Signed, and that Rational and
Complex, which are neither abstract nor concrete, never appear in its result. Then write
mean_or_nothing(v::AbstractVector{<:Real}), which returns nothing for an empty vector, and check what
Base.return_types infers for it.
The self-check below goes through the tree, storage, invariance, unions and annotations, beginning wherever your answers so far were weakest; each choice carries the reason it is right or the slip behind it:
Practice that adapts to you
Practise this lesson
13 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): how does an abstract type differ from a concrete type in Julia?
A concrete type is a type that values have: typeof always returns one, such as Int64, Float64 or
Vector{Real}, and it fixes how a value is laid out in memory. An abstract type, such as Integer, Real or
Number, has no values of its own; it groups types so that isa tests and method signatures can treat them alike.
Concrete types are final: they can only have abstract supertypes, so values sit at the leaves of the type tree. A
type can be concrete and still contain an abstract type: Vector{Real} is concrete, but each element is a pointer to
a separately stored value, which made a million numbers take 16 MB instead of 8. Use abstract types in signatures and
concrete element types in data.
Two design questions grow out of this lesson: a library function that rejects the data people pass it, and a lookup
whose nothing crashes a batch job hours later. Write down what you would check first in each case, then open its
first step:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to senior
A library function is declared fit(x::Vector{Float64}, y::Vector{Float64}). Users report MethodErrors when they pass ranges, views, Float32 data and vectors of integers. How do you widen the signature, how far do you widen it, and what does the change cost?
-
Mid to staff
A station lookup returns nothing for unknown stations. A nightly batch job fails after three hours with MethodError: no method matching +(::Nothing, ::Float64) inside a summing loop. How do you find the cause, and how do you redesign the lookup and its callers so the failure comes early or never?
Key takeaways
- Every value has one concrete type; abstract types such as
IntegerandRealgroup types, are never instantiated, and are the only types that may have subtypes. supertype,subtypes(fromInteractiveUtilsin a script) and<:walk the tree, andBool <: Integeris whytrue + trueis 2.- A
Vector{Real}is a concrete type whose slots hold pointers to separate objects: 16,000,040 bytes for a million numbers against 8,000,040 for aVector{Float64}, and 16,000,000 bytes allocated by one pass of a loop. - Type parameters are invariant, so
Vector{Int}is not aVector{Real}; writeAbstractVector{<:Real}to accept every vector of reals, ranges and views included, at no cost in speed. - A
Unionsuch asUnion{Nothing, Float64}types a result that may be absent, and the caller tests it with=== nothingor supplies a default withsomething. ::asserts in an expression, restricts in a signature and converts on a local variable; an annotation in a signature adds no speed, and an assertion helps only on a value from an untyped place such as aVector{Any}.
Terms in this lesson
- abstract type
- A type that no value has directly, such as Integer, Real or Number. It groups other types in the type tree, so that isa tests and method signatures can treat them alike; only abstract types may have subtypes.
- concrete type
- A type that values can have, such as Int64, Float64 or Vector{Real}. typeof always returns one. Concrete types are final: they cannot have subtypes, so values sit at the leaves of the type tree.
- invariant
- Said of a parametric type whose parameters do not pass their subtype order on to it, as Julia's are: Int64 <: Real is true, but Vector{Int64} <: Vector{Real} is false. Tuple types are the exception.
- subtype
- A type that sits below another in the type tree. T <: S asks whether T is a subtype of S: Int64 <: Real is true, and every type is a subtype of itself and of Any.
- type assertion
- The expression x::T, which returns x when x isa T and throws a TypeError otherwise. It documents an assumption and stops the program at the line where the assumption fails.
- Union
- A type whose values have one of the listed member types: Union{Nothing, Float64} holds nothing or a Float64. The compiler handles unions of a few types efficiently, in code and in arrays.
References
- Types: abstract types, parametric types, type unions and UnionAll types (The Julia Project)
- Essentials: supertype, isabstracttype, isconcretetype, isbitstype, typejoin and something (The Julia Project)
- Interactive utilities: subtypes and supertypes (The Julia Project)
- Performance tips: avoid containers with abstract type parameters (The Julia Project)
- Style guide: avoid writing overly-specific types (The Julia Project)
- Missing values: arrays with missing values (The Julia Project)
Report a problem with this lesson
Kept only in this browser. Your Learn progress