Methods
So far, we defined all functions (with some exceptions) without annotating the types of input arguments. When the type annotation is omitted, the default behaviour in Julia is to allow values to be of any type. One can write many useful functions without stating the types. When additional expressiveness is needed, it is easy to introduce type annotations into previously untyped code.
In Julia, one function consists of multiple methods. A prime example is the convert
function. When a user calls a function, the process of choosing which method to execute is called dispatch. The dispatch system in Julia decides which method to execute based on:
- the number of function arguments;
- the types of function arguments.
Using all function arguments to choose which method should be invoked is known as multiple dispatch.
As an example of multiple dispatch, we define the product
function that computes the product of two numbers.
julia> product(x, y) = x * y
product (generic function with 1 method)
In the REPL, we can see that the product
function has only one method. In this case, we defined the method for any two input arguments without type specification.
julia> product(1, 4.5)
4.5
julia> product(2.4, 3.1)
7.4399999999999995
The methods
function lists all methods for a function.
julia> methods(product)
# 1 method for generic function "product" from Main:
[1] product(x, y)
@ none:1
Because we did not specify types of input arguments, the product
function accepts arguments of all types. For some inputs, such as symbols, the *
operator will not work.
julia> product(:a, :b)
ERROR: MethodError: no method matching *(::Symbol, ::Symbol)
[...]
We can avoid such errors by specifying types of input arguments. Since we want to create a function that computes the product of two numbers, it makes sense to allow input arguments to be only numbers.
product(x::Number, y::Number) = x * y
product(x, y) = throw(ArgumentError("product is defined for numbers only."))
The second line redefined the original definition of the product
function. It now throws an error if product
is called with non-numeric inputs.
julia> methods(product)
# 2 methods for generic function "product" from Main:
[1] product(x::Number, y::Number)
@ none:1
[2] product(x, y)
@ none:1
Now, we have a function with two methods, that returns a product if the input arguments are numbers, and throws an error otherwise.
julia> product(1, 4.5)
4.5
julia> product(:a, :b)
ERROR: ArgumentError: product is defined for numbers only.
[...]
julia> product("a", "b")
ERROR: ArgumentError: product is defined for numbers only.
[...]
Type hierarchy
It is always better to use abstract types like Number
or Real
instead of concrete types like Float64
, Float32
, or Int64
. The reason is that if we use an abstract type, the function will work for all its subtypes. To find a supertype for a specific type, we can use the supertype
function from the InteractiveUtils
package.
julia> using InteractiveUtils: supertype
julia> supertype(Float64)
AbstractFloat
The problem with the supertype
function is that it does not return the whole supertype hierarchy, but only the closest larger supertype. For Float64
the closest larger supertype is AbstractFloat
. However, as in the example above, we do not want to use this supertype, since then the function will only work for floating point numbers.
Create a function supertypes_tree
which prints the whole tree of all supertypes. If the input type T
satisfies the following condition T === Any
, then the function should do nothing. Use the following function declaration:
function supertypes_tree(T::Type, level::Int = 0)
# code
end
The optional argument level
sets the printing indentation level.
Hints:
- Use the
supertype
function in combination with recursion. - Use the
repeat
function and string with white space" "
to create a proper indentation.
Solution:
The supertypes_tree
function can be defined by:
function supertypes_tree(T::Type, level::Int = 0)
isequal(T, Any) && return
println(repeat(" ", level), T)
supertypes_tree(supertype(T), level + 1)
return
end
The first line checks if the given input type is Any
. If yes, then the function returns nothing. Otherwise, the function prints the type with a proper indentation provided by repeat(" ", level)
, i.e., four white-spaces repeated level
-times. The third line calls the supertypes_tree
function recursively for the supertype of the input type T
and the level of indentation level + 1
.
Now we can use the supertypes_tree
function to get the whole supertype hierarchy for Float64
.
julia> supertypes_tree(Float64)
Float64
AbstractFloat
Real
Number
We can check the type hierarchy by the <:
operator for comparing types: If T1 <: T2
is true, then T1
is a subtype (or the same type) of T2
.
julia> Float64 <: AbstractFloat <: Real <: Number
true
Similarly to the supertype
function, there is the subtypes
function that returns all subtypes for the given type.
julia> using InteractiveUtils: subtypes
julia> subtypes(Number)
2-element Vector{Any}:
Complex
Real
This function suffers from a similar disadvantage as the supertype
function: It is impossible to get the whole hierarchy of all subtypes using only this function.
Create a function subtypes_tree
which prints the whole tree of all subtypes for the given type. Use the following function declaration:
DocTestSetup = quote
using InteractiveUtils: subtypes
end
function subtypes_tree(T::Type, level::Int = 0)
# code
end
The optional argument level
sets the printing indentation level.
Hints:
- Use the
subtypes
function in combination with recursion. - Use the
repeat
function and string with white space" "
to create a proper indentation.
Solution:
The subtypes_tree
function is similar to supertypes_tree
. The only differences are that we do not need to check for the top level of Any
, and that we need to call the vectorized version subtypes_tree.
because subtypes(T)
returns an array.
function subtypes_tree(T::Type, level::Int = 0)
println(repeat(" ", level), T)
subtypes_tree.(subtypes(T), level + 1)
return
end
Now we can use the subtypes_tree
function to get the whole subtypes hierarchy for the Number
type.
julia> subtypes_tree(Number)
Number
Complex
Real
AbstractFloat
BigFloat
Float16
Float32
Float64
AbstractIrrational
Irrational
Integer
Bool
Signed
BigInt
Int128
Int16
Int32
Int64
Int8
Unsigned
UInt128
UInt16
UInt32
UInt64
UInt8
Rational
This tree shows the whole structure of Julia numerical types. If we want to define a function that accepts all numeric types, we should use inputs of type Number
. However, many operations are restricted to only real numbers. In such a case, we want to use the Real
type instead of Number
.
Multiple dispatch
Now we can go back to our example with the product
function. The problem with this function is that it is too restrictive because the product of two strings is a legitimate operation that should return their concatenation. We should define a method for strings. To use the proper type, we can use the supertypes_tree
function for the String
type.
julia> supertypes_tree(String)
String
AbstractString
We see that the largest supertype for String
is AbstractString
. This leads to
product(x::AbstractString, y::AbstractString) = x * y
product(x, y) = throw(ArgumentError("product is defined for numbers and strings only."))
We also redefined the original definition of the product
function to throw an appropriate error.
julia> product(1, 4.5)
4.5
julia> product("a", "b")
"ab"
julia> product(:a, :b)
ERROR: ArgumentError: product is defined for numbers and strings only.
[...]
Sometimes, it may be complicated to guess which method is used for concrete inputs. In such a case, there is a useful macro @which
that returns the method that is called for given arguments.
julia> using InteractiveUtils: @which
julia> @which product(1, 4.5)
product(x::Number, y::Number)
@ Main none:1
julia> @which product("a", :a)
product(x, y)
@ Main none:1
julia> @which product("a", "b")
product(x::AbstractString, y::AbstractString)
@ Main none:1
The previous example with the product
function shows how methods in Julia works. However, it is a good practice to use type annotation only if we want to have a specialized function or if we want to define a function, which does different things for different types of input arguments.
g(x::Real) = x + 1
g(x::String) = repeat(x, 4)
For example, the g
function returns x + 1
if the input x
is a real number or repeats four times the input argument if it is a string. Otherwise, it will throw a method error.
julia> g(1.2)
2.2
julia> g("a")
"aaaa"
julia> g(:a)
ERROR: MethodError: no method matching g(::Symbol)
Closest candidates are:
g(!Matched::String)
@ Main none:1
g(!Matched::Real)
@ Main none:1
[...]
The product
function should be defined without the type annotation. It is a good practice not to restrict input argument types unless necessary. The reason is that, in this case, there is no benefit of using the type annotation. It is better to define the function product_new
by:
product_new(x, y) = x * y
Then we can apply this function to the same inputs as the original product
function, and we will get the same results
julia> product(1, 4.5)
4.5
julia> product_new(1, 4.5)
4.5
julia> product("a", "b")
"ab"
julia> product_new("a", "b")
"ab"
with only one exception
julia> product("a", :a)
ERROR: ArgumentError: product is defined for numbers and strings only.
[...]
julia> product_new("a", :a)
ERROR: MethodError: no method matching *(::String, ::Symbol)
[...]
Here we get a different error. However, the error returned by the product_new
function is more useful because it tells us what the real problem is. We can see that it is impossible to use the *
operator to multiply a String
and a Symbol
. We can decide if this is the desired behaviour, and if not, we can define a method for the *
operator that will fix it.
We show a simple example when the multiple dispatch is useful.
We define the abstract type Student
and specific types Master
and Doctoral
. The latter two are defined as structures containing one and three fields, respectively.
abstract type Student end
struct Master <: Student
salary
end
struct Doctoral <: Student
salary
exam_mid::Bool
exam_english::Bool
end
We can check that the subtypes_tree
works correctly on any type, including the type Student
which we defined.
julia> subtypes_tree(Student)
Student
Doctoral
Master
We create instances of two students by providing values for the struct fields.
s1 = Master(5000)
s2 = Doctoral(30000, 1, 0)
Write the salary_yearly
function which computes the yearly salary for both student types. The monthly salary is computed from the base salary (which can be accessed via s1.salary
). Monthly bonus for doctoral students is 2000 for the mid exam and 1000 for the English exam.
Solution:
Julia prefers to write many simple functions. We write salary_yearly
based on the not-yet-defined salary_monthly
function.
salary_yearly(s::Student) = 12*salary_monthly(s)
We specified that the input to salary_yearly
is any Student
. Since Student
is an abstract type, we can call salary_yearly
with both Master
and Doctoral
student. Now we need to define the salary_monthly
function. Since the salary is computed in different ways for both students, we write two methods.
salary_monthly(s::Master) = s.salary
salary_monthly(s::Doctoral) = s.salary + s.exam_mid*2000 + s.exam_english*1000
Both methods have the same name (they are the same function) but have different inputs. While the first one is used for Master
students, the second one for Doctoral
students. Now we print the salary.
println("The yearly salary is $(salary_yearly(s1)).")
println("The yearly salary is $(salary_yearly(s2)).")
The yearly salary is 60000.
The yearly salary is 384000.
Method ambiguities
It is possible to define a set of function methods with no most specific method applicable to some combinations of arguments.
f(x::Float64, y) = x * y
f(x, y::Float64) = x + y
Here, f
has two methods. The first method applies if the first argument is of type Float64
, and the second method applies if the second argument is of type Float64
.
julia> f(2.0, 3)
6.0
julia> f(2, 3.0)
5.0
Both methods can be used if both arguments are of type Float64
. The problem is that neither method is more specific than the other. This results in MethodError
.
julia> f(2.0, 3.0)
ERROR: MethodError: f(::Float64, ::Float64) is ambiguous.
Candidates:
f(x, y::Float64)
@ Main none:1
f(x::Float64, y)
@ Main none:1
Possible fix, define
f(::Float64, ::Float64)
[...]
We can avoid method ambiguities by specifying an appropriate method for the intersection case.
julia> f(x::Float64, y::Float64) = x - y
f (generic function with 3 methods)
Now f
has three methods.
julia> f(2.0, 3.0)
-1.0