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 anInt8` – 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 LSDs 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.

results matching ""

    No results matching ""