Types and Multiple Dispatch

Author

Darren Irwin

If you’ve worked through the material in the previous pages, you now have the tools to write all sorts of reasonably complex programs to do interesting things. You might take a pause here and build up your skills in combining those tools in ways you find interesting. After spending some time doing that, come back to this page and continue below.

Here, we’ll build up to an understanding of one of the most powerful features of Julia: multiple dispatch. Rather than define that at the start, we’ll build up an understanding. We’ll start with better understanding of type specifications of objects, then learn how to define our own types of data structures, then learn about single dispatch, and finally explore multiple dispatch.

Specifying types of objects

Several pages back we learned about how Julia has dynamic typing meaning you often don’t need to think about the types of objects. But as your programming skills increase, it becomes increasingly beneficial to think about types. This facilitates organized code, interpretation of error messages, reading of documentation, and defining and using functions effectively.

We can specify the type of an object using the :: operator, which can be thought of as meaning “is an instance of”:

x::Int64 = 6
6

The above declares an object x as being of type Int64, and then assigns the value 6 to it. If you try to assign something like 6.3 to it, you will get an error (try it!). However, Julia tries to be friendly so if you assign something that can be converted to Int64 it works:

y::Int64 = 6.0
y
6
println(typeof(6.0))
println(typeof(y))
Float64
Int64

Why would you want to force a variable to have a type, when Julia can usually type things correctly on the fly? Maybe you want to make sure that the code is working as you want it to, or you want to prevent code designed for one type of data to be accidentally used for another.

Create your own types: struct

You can create your own data structures, which have their own types, using the struct keyword. For instance, let’s say we are working with shapes:

struct Circle
    radius
end

So now we have defined a new type (Circle) that has one field (radius). We can now create (“instantiate” or “construct”) an actual object that is of this type by calling Circle like a function:

circA = Circle(1)
Circle(1)
typeof(circA)
Circle

So now, circA is a specific object in memory of type Circle. To get its radius (its only field), we type the name of the object then a . and then the field name:

circA.radius
1

Let’s define and instantiate another kind of shape:

struct Square
    side
end
sq1 = Square(5)
Square(5)
More shapes!

Can you define a new Rectangle type? It needs to have two fields: length and width. Then, create a specific rectangle.

Mutable vs. immutable types

The types defined above by the struct keyword are used to instatiate immutable objects. This means we get an error if we try to alter a field within an object:

sq1.side = 7

The REPL responds with an error saying “immutable struct of type Square cannot be changed”. To set up the ability to change our objects after they are created, we need to use the mutable struct phrase to define the type:

mutable struct SquareMutable
  side
end
sqA = SquareMutable(3.5)
SquareMutable(3.5)
sqA.side = 9.7
9.7

Defining functions for specific types

Now that we have some shape objects, let’s do something with them. Functions are great at doing things with objects, so let’s define a function:

getArea(x::Circle) = π * (x.radius)^2    # write π with: \pi tab
getArea (generic function with 1 method)

We have now defined a function that requires a Circle object as input, and returns the area of the circle based on the radius field. We can call it on our actual objects in memory:

getArea(circA)
3.141592653589793

We also want to get areas of squares. So, we can define a function with the same name but applies only to Squares:

getArea(x::Square) = x.side^2
getArea (generic function with 2 methods)

Now here is where it gets really neat. Let’s call the getArea() function on our our actual square object in memory:

getArea(sq1)
25

And now let’s call the same named function on our circle:

getArea(circA)
3.141592653589793

In each case, Julia applies the correct formula (for a square vs. a circle) because we’ve set up our function to have two actual methods, and it chooses the method to apply based on the type of the argument.

Add a method

Can you define another method for the getArea() function, this time applying to the Rectangle type (which you defined above)? Then call the function using the specific Rectangle you instantiated above.

Multiple Dispatch: When a method depends on multiple arguments to a function

In the above example using shapes, we had an example of single dispatch, when the method of a function depends on the type of a single argument. This is common in a variety of computer languages. Julia is unusual in having the concept of multiple dispatch at its core. This is when the method of a function depends on the types of multiple arguments. We’ll develop this idea using an example:

Interacting cats and dogs

struct Dog
  name::String
  age::Int  # Int is an general type for all integer types (e.g. Int64)
  sound::String 
end

The above creates a Dog type. Now let’s make two actual Dogs:

rosie = Dog("Rosie", 3, "woof")
leah = Dog("Leah", 4, "howl")
Dog("Leah", 4, "howl")

We can learn about what is within a certain object in memory by using the ‘dump()’ function (which can be thought of as “dumping” or showing everything in the object):

dump(rosie)
Dog
  name: String "Rosie"
  age: Int64 3
  sound: String "woof"

Now let’s make a Cat type and two instantiated Cats:

struct Cat
  name::String
  age::Int  # Int is an general type for all integer types (e.g. Int64)
  sound::String 
end

fluffy = Cat("Fluffy", 6, "meow")
milo = Cat("Milo", 11, "rarr")
Cat("Milo", 11, "rarr")

Now that we’ve made some dogs and cats, let’s define the rules by which they interact:

function interact(x::Dog, y::Dog)
  println("$(x.name) wags tail and makes a $(x.sound) toward $(y.name).")
end
interact (generic function with 1 method)
function interact(x::Dog, y::Cat)
  println("$(x.name) chases $(y.name).")
end
interact (generic function with 2 methods)
function interact(x::Cat, y::Dog)
  println("$(x.name) runs from $(y.name).")
end
interact (generic function with 3 methods)
function interact(x::Cat, y::Cat)
  println("$(x.name) stretches and says $(x.sound).")
end
interact (generic function with 4 methods)

Now, let’s call our function:

interact(rosie, leah)
interact(rosie, fluffy)
interact(fluffy, rosie)
interact(fluffy, milo)
Rosie wags tail and makes a woof toward Leah.
Rosie chases Fluffy.
Fluffy runs from Rosie.
Fluffy stretches and says meow.

The above display that the interact() function has four different methods depending on the types of the two input arguments. This is multiple dispatch.

Importance of multiple dispatch

The examples above are just an initial look at the power of multiple dispatch. If Julia did not have multiple dispatch, then the method of the function call would depend only the first argument (i.e., single dispatch). You could still write a general interact() function code that checks whether the second argument is a Cat, a Dog, etc., and have if statements to govern what to do in the different cases. But multiple dispatch provides an efficient, concise way to ensure just the appropriate code is evaluated, without explicit if statements.

One reason it is important to understand multiple dispatch is that it helps you understand how to use built-in Julia functions, and helps you understand errors when you get them. For example, enter this incorrect expression:

sqrt([9, 4])

The REPL responds by saying: ERROR: MethodError: no method matching sqrt(::Vector{Int64})

It is telling you that there is no method for the ‘sqrt’ function that uses an input an object of type Vector{Int64} (meaning a vector of integers). If we can understand this message, we might then realize that we have to either enter a single number as input (sqrt(9)) or broadcast over all elements of the vector using the dot operator (sqrt.([9, 4])).

If we want to see what methods a function can use, we can use the methods() function:

methods(interact)
# 4 methods for generic function interact from Main:
  • interact(x::Cat, y::Cat) in Main at In[25]:1
  • interact(x::Cat, y::Dog) in Main at In[24]:1
  • interact(x::Dog, y::Cat) in Main at In[23]:1
  • interact(x::Dog, y::Dog) in Main at In[22]:1

The REPL then tells us that there are four methods and summarizes what types go into them.

If curious, you can try this on any function–for instance, try methods(+) to see that there are at least 189 methods for the +() function depending on the arguments passed into it.

Next steps

This and the previous pages have brought you through what I consider to be an introductory tour of the central concepts needed to program in Julia. You can use these building blocks to compose programs that do all sorts of complex things–virtually anything you can imagine, as long as you can think through the logic of what you want your program to do.

Two things though that we have not said much about yet are 1) how to make nice plots, and 2) how to import data in order to analyze and graph it. We will now explore those two topics on the next pages.