Skip to content

Methods and Multiple Dispatch

Now we get to one of the key distinguishing features of Julia: multiple dispatch.

Methods

When we create a function, we get a strange status message:

julia> baz(x) = 2 * x
baz (generic function with 1 method)

We just created a "generic function" (we will pretty much never worry about what that means) with 1 method.

But now try

julia> function foo end
foo (generic function with 0 methods)

We just created a function foo with 0 methods. What does this mean?

If we try to call foo, we get a MethodError:

julia> foo(1)
ERROR: MethodError: no method matching foo(::Int64)

Now let's define a method:

julia> foo(x :: Int) = @show x
foo (generic function with 1 method)
julia> foo(10)
x = 10

And a second one:

julia> foo(x :: String) = println("$x is a string")
foo (generic function with 2 methods)
julia> foo("abc")
abc is a string

What methods do we have?

julia> methods(foo)
# 2 methods for generic function "foo":
[1] foo(x::String) in Main at REPL[4]:1
[2] foo(x::Int64) in Main at REPL[5]:1

Calling foo with anything other than an Int or String produces a MethodError.

So what are functions and methods?

A function is just a name. We actually cannot do anything with it.

A method specifies what to do when we call foo with a specific set of arguments.

Key point: foo(x::Int) and foo(x::String) are not different functions. They are methods of the same function.

A function is really a way of grouping methods.

Multiple Dispatch

What do methods do?

When we call foo(1), the compiler looks in the method table for a method that matches the signature foo(::Int64).

  • If this exists, it is compiled and run. And, yes, it is compiled only when called.
  • If this does not exist, we get a MethodError.

Methods allow us to execute different code for the same function call, depending on the type of the input argument.

julia> foo(1);
x = 1

julia> foo("abc");
abc is a string

Why do we want this to happen?

Benefit 1: We don't have to worry about name conflicts

Contrast with Matlab:

  • There can only be one function (and method) named foo.
  • Every time to write a function, you need to create a globally unique name.
  • This makes code reuse very difficult.
  • If two libraries contain a function foo, they cannot both be used at the same time.

In Julia:

  • You can always create a new method foo(::Int64), even if foo is already defined.
  • You only get an error if the exact method foo(::Int64) already exists.

Benefit 2: Generic programming

  • We can write an algorithm that can handle any type of input object.
  • See below.

This is similar to object oriented programming where methods are owned by specific objects.

The key difference is that, in Julia, this also works with multiple arguments:

julia> foo(x,y) = @show x,y
foo (generic function with 3 methods)

julia> foo(x :: String, y) = println("$x is a string, but $y can be anything")
foo (generic function with 4 methods)

julia> foo(1,2)
(x, y) = (1, 2)
(1, 2)

julia> foo("abc", 2);
abc is a string, but 2 can be anything

Notes:

  1. We can have methods that have different numbers of arguments (here: 1 or 2).
  2. If we have two methods with the same number of arguments, the more specific one is called.
  3. foo(x,y) is a fallback method that gets called unless a more specific method exists.

There is a famous talk by Stefan Karpinski called "The unreasonable effectiveness of multiple dispatch" which explains why this is so powerful.

Type Annotations

What happens if we omit type annotations?

julia> foo(x,y) = @show x,y
foo (generic function with 1 method)

Julia tells us that we have one method. But actually, we have infinitely many!

Try foo(1, "a") or foo([1,2], ["a" "b"]). They all work.

foo(x,y) is actually a shorthand for

julia> foo(x :: T1, y :: T2) where {T1 <: Any, T2 <: Any}

which is the same as

julia> foo(x :: Any, y :: Any)

In words, we have implicitly defined a method for any combination of types for (x,y).

Here is a key point for performance: Julia still specializes the code for each combination of types that we actually use.

This means: When we call foo(1,2), Julia compiles foo(::Int64, ::Int64) and calls that method.

It follows that we get the same performance as if we had defined foo(::Int64, ::Int64) by hand.

Type annotations have no effect on performance.

Why then do we need them?

  1. Avoiding name conflicts (again).

    • Suppose you have an object for which you want to define name(x).
    • You can define name(x :: MyType) without worrying about name conflicts.
  2. Different code for different types.

    • Consider the example of show(x).
    • Clearly, each object needs its own show method.
    • There is "generic" useful way of showing an object in the REPL.
    • This is typical for "low level" operations. For example, + works differently (at the hardware level) for Float64 vs UInt8.

Example

Write a function myshow(x) that prints that value of x with a little type annotation:

julia> myshow(1)
"Int64 1"
julia> myshow(1.2345)  # Note the rounding!
"Float64 1.2"
julia> myshow("any other type")
"I do not know this type: String"

Generic Programming

Suppose I want to sort the Vector x = [1, "a", 4, 2].

Calling sort(x) gives an error:

ERROR: MethodError: no method matching isless(::String, ::Int64)

Now let's define that missing method and see what happens:

julia> Base.isless(x :: String, y :: Number) = true

julia> sort([3,1,"a",4,2])
5-element Vector{Any}:
  "a"
 1
 2
 3
 4

The sorting algorithm is generic. It works on any Vector as long as isless(x,y) is defined.

This makes the algorithm highly reusable.

  • We can define a new DataType and apply the existing sorting algorithms.
  • Conversely, we can write algorithms without type annotations. They will just work on any type (even if defined "after the fact") that supports a few methods.

This is not only possible, it is very common in Julia.

Extending existing functions

For this to work, we need to be able to extend functions that were defined by others (in modules, including Base) to new data types.

This is exactly what we did in our sorting example:

For example, suppose we want to be able to sum Strings (this is a bad idea, but we can do it):

julia> Base.isless(x :: String, y :: Number) = true

Note that we wrote Base.isless, not just isless. Had we written

julia> isless(x :: String, y :: Number) = true

we would have defined a new function Main.isless, not a new method.

Then calling sort[1, "abc"] would have failed with the same MethodError that we originally encountered.

The reason is that sort lives in module Base (we talk soon about what modules are). Therefore, any unqualified call to isless translates into Base.isless, which is a different function (not method) from Main.isless.

Another, less clear, way of extending an existing function:

julia> import Base: isless
julia> isless(x :: String, y :: Number) = true

More on Type Annotations

We can annotate types in various ways.

# No annotation
foo(x, y) == foo(x :: Any, y :: Any)
# Give an abstract type
# Defines one method for each concrete T <: Real
foo(x :: Real) == foo(x :: T) where T <: Real
# Concrete types
foo(x :: Float64)

Again: none of this has any impact on performance.

We can impose type restrictions:

# Force (x,y) to be the same type
# where T is the same as `where T <: Any`
foo(x :: T, y :: T) where T
# Now this works
foo(1, 2)
# and this errors
foo(1, 2.0)