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)
andfoo(x::String)
are not different functions. They aremethods
of the samefunction
.
A function
is really a way of grouping method
s.
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 iffoo
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 method
s 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:
- We can have methods that have different numbers of arguments (here: 1 or 2).
- If we have two methods with the same number of arguments, the more specific one is called.
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?
-
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.
- Suppose you have an object for which you want to define
-
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) forFloat64
vsUInt8
.
- Consider the example of
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
String
s (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)