Chapter 3: Types
Julia's type system
A type system describes a programming language's way of handling individual pieces of data and determining how to operate on them based on their type. Julia's type system is primarily dynamic, meaning that there is no need to tell Julia what type a particular value is. This is useful, in that you can write fairly complex applications without ever needing to specify types. You might, then, be tempted to disregard types as an advanced feature that you cannot be bothered right now. However, a good understanding of types is extremely helpful to mastering a functional language.
Julia's dynamic system is augmented by the ability to specify types where needed. This has two advantages. First, type specification leads to more efficient code. It will make your code more stable, much faster and much more robust. At the same time, unlike in a statically typed language, you do not need to get your head around types at the very beginning. Thus, you can treat this chapter not so much as a tutorial exercise but as a reference you can come back to every now and then.
It's important to understand that in Julia, types belong to values, not variables. It's also important to understand the hierarchy of types in Julia.
Types may be abstract or concrete. You can think of an abstract type as a type that is intended solely to act as supertypes of other types, rather than types of particular objects. Thus, no object that is not a type can have an abstract type as its type.
Concrete types are intended to be the types of actual objects. They are always subtypes of abstract types. This is because concrete types cannot have subtypes, and also because you can't create types that don't have supertypes (Any
is the default supertype for any type you create). Here is useful to mention an interesting property of Julia's type system: any two types always have a common ancestor type.
If this feels incredibly convoluted, just bear with it for now. It will make much more sense once you get around to its practical implementations.
Declaring and testing types
Julia's primary type operator is ::
(double-colons). It has three different uses, all fairly important, and it's crucial that you understand the different functions that ::
fulfills in the different contexts.
Declaring a (sub)type
In the context of a statement, such as a function, ::
appended to a variable means 'this variable is always to be of this type'. In the following, we will create a function that returns 32
as Int8
(for now, let's ignore that we don't know much about functions and we don't quite know what integer types exist – these will all be explained shortly!).
julia> function restrict_this_integer()
x::Int8 = 32
x
end
restrict_this_integer (generic function with 1 method)
julia> p = restrict_this_integer()
32
julia> typeof(p)
Int8
As we can see, the ::
within the function had the effect that the returned result would be represented as an 8-bit integer (Int8
). Recall that this only works in the context of a statement – thus simply entering x::Int8
will yield a typeassert
error, telling us that we have provided an integer literal, which Julia understands by default to be an Int64, to be assigned to a variable and shaped as an
Int8` – which clearly doesn't work.
Asserting a type
In every other context, ::
means 'I assert this value is of this particular type'. This is a great way to check a value for both abstract and concrete type.
For instance, you are provided a variable input_from_user
. How do you make sure it has the right kind of value?
julia> input_from_user = 128
128
julia> input_from_user::Integer
128
julia> input_from_user::Char
ERROR: type: typeassert: expected Char, got Int64
As you can see, if you specify the correct abstract type, you get the value returned, whereas in our second assertion, where we asserted that the value was of the type Char
(used to store individual characters), we got a typeassert
error, which we can catch later on and return to ensure that we get the right type of value.
Remember that a type hierarchy is like a Venn diagram. Every Int64
(a concrete type) is also an Ìnteger
(an abstract type). Therefore, asserting input_from_user::Int64
will also yield 128
, while asserting a different concrete type, such as Int32
, will yield a typeassert
error. Int
is just an alias for Int64
.
Specifying acceptable function inputs
While we have not really discussed function inputs, you should be familiar with the general idea of a function – values go in, results go out. In Julia, you have the possibility to make sure your function only accepts values that you want it to. Consider creating a function that adds up only floating point numbers:
function addition(x::Float64, y::Float64)
x + y
end
Calling it on two floating-point numbers will, of course, yield the expected result:
julia> addition(3.14, 2.71)
5.85
But giving it a simpler task will raise an error:
julia> addition(1, 1)
ERROR: `addition` has no method matching addition(::Int64, ::Int64)
The real meaning of this error is a little complex, and refers to one of the base features of Julia called multiple dispatch. In Julia, you can create multiple functions with the same name that process different types of inputs, so e.g. an add()
function can add up Int
and Float
inputs but concatenate String
type inputs. Multiple dispatch effectively creates a table for every possible type for which the function is defined and looks up the right function at call time (so you can use both abstract and concrete types without a performance penalty). What the error complaining about the lack of a method matching addition(::Int64, ::Int64)
means is that Julia cannot find a definition for the name addition
that would accept two Int64
values.
Getting the type of a value
To obtain the type of a value, use the typeof()
function:
julia> typeof(32)
Int64
typeof()
is notable for treating tuples differently from most other collections. Calling typeof()
on a tuple enumerates the types of each element, whereas calling it on, say, an Array
value returns the Array
notation of type (which looks for the largest common type among the values, up to Any
):
julia> typeof([1, 2, "a"])
Array{Any,1}
julia> typeof((1, 2, "a"))
(Int64,Int64,String)
Helpfully, the isa()
function tells us whether something is a particular type:
julia> isa("River", String)
true
And, of course, types have types (specifically, DataType
)!
julia> typeof("River")
String (constructor with 2 methods)
julia> typeof(ans)
DataType
Exploring the type hierarchy
The <:
operator can help you find out whether the left-side type is a subtype of the right-side type. Thus, we see that Int64
is a subtype of Integer
, but String
isn't!
julia> Int64 <: Integer
true
julia> String <: Integer
false
To reveal the supertype of a type, use the super()
function:
julia> super(String)
AbstractString
Composite types
Composite types, known to C coders as structs
, are more complex object structures that you can define to hold a set of values. For instance, to have a Type that would accommodate geographic coordinates, you would use a composite type. Composite types are created with the struct
keyword:
struct GeoCoordinates
lat::Float64
lon::Float64
end
We can then create a new value with this type:
julia> home = GeoCoordinates(51.7519, 1.2578)
GeoCoordinates(51.7519,1.2578)
julia> typeof(home)
GeoCoordinates (constructor with 2 methods)
The values of a composite object are, of course, accessible using the dot notation you might be used to from many other programming languages:
julia> home.lat
51.7519
It is not possible to assig a new value to home.lat
. So if we would instantiate the immutable GeoCoordinates
type with the values above, then attempt to change one of its values, we would get an error:
julia> home.lat = 51.75
ERROR: type GeoCoordinates is immutable
Creating your very own immutable
A mutable type is one which, once instantiated, can be changed. They are created the same way as composite types, except by using the mutable
keyword in front of struct
:
mutable struct GeoCoordinates
lat::Float64
lon::Float64
end
Once instantiated, you can change the values. However, these values have to comply with the type's definition in that they have to be convertible to the type specified (in our case, Float64
). So, for instance, an Int64
input would be acceptable, since you can convert an Int64
into a Float64
easily. On the other hand, a String
would not do, since you cannot convert it into an Int64
.
Type unions
Sometimes, it's useful to have a single alias for multiple types. To do so, you can create a type union using the constructor Union
:
julia> Numeric = Union{Int, Float64}
Union{Float64, Int64}
julia> 1::Numeric
1
julia> 3.14::Numeric
3.14
From start to finish: creating a custom type
When you hear LSD, you might be tempted of the groovy drug that turned the '70s weird. It also refers to one of the biggest problems of early computing in Britain – making computers make sense of Britain's odd pre-decimal currency system before it was abandoned in 1971. Under this system, there were 20 shillings (s
) in a pound (£
or L
) and twelve pence (d
) in a shilling (so, 240 pence in a pound). This made electronic book-keeping in its earliest era in Britain rather difficult. Let's see how Julia would solve the problem.
Type definition
First of all, we need a type definition. We also know that this would be a composite type, since we want it to hold three values (known in this context as 'fields') - one for each of pounds, shillings and pence. We also know that these would have to be integers.
struct LSD
pounds::Int
shillings::Int
pence::Int
end
You don't strictly need to define types, but the narrower the types you define for fields when you create a new type, the faster compilation is going to be - thus, pounds::Signed
is faster than pounds
, and pounds::Int
is faster than pounds::Signed
. At any rate, avoid not defining any data types, which Julia will understand as referring to the global supertype ::Any
, unless that indeed is what you want your field to embrace. Generally prefer using concrete types to abstract types.
Constructor function
We have a good start, but not quite there yet. Every type can have a constructor function, the function executed when a new instance of a type is created. A constructor function is inside the type definition and has the same name as the type:
function LSD(l,s,d)
if l < 0 || s < 0 || d < 0
error("No negative numbers, please! We're British!")
end
if d > 12 || s > 20
error("That's too many pence or shillings!")
end
new(l,s,d)
end
Don't worry if this looks a little strange – since we haven't dealt with functions yet, most of this is going to be alien to you. What the function LSD(l,s,d)
does is to, first, test whether any of l
, s
or d
are negative or whether there are more pence or shillings than there could be in a shilling or a pound, respectively. In both of these cases, it raises an error. If the values do comply, it creates the new instance of the LSD
composite type using the new(l,s,d)
keyword.
The full type definition, therefore, would look like this:
struct LSD
pounds::Int
shillings::Int
pence::Int
function LSD(l,s,d)
if l < 0 || s < 0 || d < 0
error("No negative numbers, please! We're British!")
end
if d > 12 || s > 20
error("That's too many pence or shillings!")
end
new(l,s,d)
end
end
As we can see, we can now create valid prices in the old LSD system:
julia> biscuits = LSD(0,1,3)
LSD(0,1,3)
And the constructor function makes sure we don't contravene the constraints we set up earlier
julia> sausages = LSD(1,25,31)
ERROR: That's too many pence or shillings!
in LSD at none:11
julia> national_debt = LSD(-1000000000,0,0)
ERROR: No negative numbers, please! We're British!
in LSD at none:8
We can, of course, use dot notation to access constituent values of the type, the names of which derive from the beginning of our definition:
julia> biscuits.pence
3
Type methods
Let's see how our new type deals with some simple maths:
julia> biscuits = LSD(0,1,3)
LSD(0,1,3)
julia> gravy = LSD(0,0,5)
LSD(0,0,5)
julia> biscuits + gravy
ERROR: `+` has no method matching +(::LSD, ::LSD)
Ooops, that's not great. What the error message means is that the function +
(addition) has no 'method' for two instances of type LSD
(as you remember, ::
is short for 'type of'). A 'method', in Julia, is a type-specific way for an operation or function to behave. As we will discuss it in detail later on, most functions and operators in Julia are actually shorthands for a bundle of multiple methods. Julia decides which of these to call given the input, a feature known as multiple dispatch. So, for instance, +
given the input ::Int
means numerical addition, but something rather different for two Boolean values:
julia> true + true
2
In fact, +
is the 'shorthand' for over a hundred methods. You can see all of these by calling methods()
on +
:
julia> methods(+)
# 117 methods for generic function "+":
+(x::Bool) at bool.jl:36
+(x::Bool,y::Bool) at bool.jl:39
+(y::FloatingPoint,x::Bool) at bool.jl:49
+(A::BitArray{N},B::BitArray{N}) at bitarray.jl:848
...and so on. What we need is there to be a method that accommodates the type LSD
. We do that by creating a method of +
for the type LSD
. Again, the function is less important here (it will be trivial after reading the chapter on Functions), what matters is the idea of creating a method to augment an existing function/operator to handle our new type:
julia> import Base.+
julia> function +{LSD}(a::LSD, b::LSD)
newpence = a.pence + b.pence
newshillings = a.shillings + b.shillings
newpounds = a.pounds + b.pounds
subtotal = newpence + newshillings * 12 + newpounds * 240
(pounds, balance) = divrem(subtotal, 240)
(shillings, pence) = divrem(balance, 12)
LSD(pounds, shillings, pence)
end
When entering it in the REPL, Julia tells us that +
now has one more method:
+ (generic function with 118 methods)
Indeed, methods(+)
shows that the new method for two LSD
s is registered:
julia> methods(+)
# 118 methods for generic function "+":
+(x::Bool) at bool.jl:36
+(x::Bool,y::Bool) at bool.jl:39
...
+{LSD}(a::LSD,b::LSD) at none:2
And now we know the price of biscuits and gravy:
julia> biscuits + gravy
LSD(0,1,8)
Representation of types
Every type has a particular 'representation', which is what we encountered every time the REPL showed us the value of an object after entering an expression or a literal. It probably won't surprise you that representations are methods of the Base.show()
function, and a new method to 'pretty-print' our LSD
type (similar to creating a __repr__
or __str__
function in a Python class's declaration) can be created the same way:
function Base.show(io::IO, money::LSD)
print(io, "£$(money.pounds), $(money.shillings)s, $(money.pence)d.")
end
Base.show
has two arguments: the output channel, which we do not need to concern ourselves with, and the second argument, which is the value to be displayed. We declared a function that used the print()
function to use the output channel on which Base.show()
is called, and display the second argument, which is a string formatted version of the LSD
object.
Our pretty-printing worked:
julia> biscuits + gravy
£0, 1s, 8d.
Our new type is looking quite good!
What next for LSD
?
Of course, the LSD
type is far from ready. We need to define a list of other methods, from subtraction to division, but the general concept ought to be clear. A new type is easy to create, but when doing so, you as a developer need to keep in mind what you and your users will do with this new type, and create methods accordingly. Chapter [X] will discuss methods in depth, but this introduction should help you think intelligently about creating new types.
Conclusion
In this chapter, we learned about the way Julia's type system is set up. The issue of types will be at the background of most of what we do in the future, so feel free to refer back to this chapter as frequently as you feel the need to. In the next chapter, we will be exploring collections, a category of types that share one important property – they all act as 'envelopes' for multiple elements, each with their distinct type.
Appendix: Julia types crib sheet
This is a selection of Julia's type tree, omitting quite a few elements. To see the full thing, you can use Tanmay Mohapatra's julia_types.jl
.