f(x::Int, y::Int) = println("($x, $y) ∈ ℤ × ℤ")
f(x::Float64, y::Float64) = println("($x, $y) ∈ ℝ × ℝ")
f(x::Int, y::Float64) = println("($x, $y) ∈ ℤ × ℝ")
f (generic function with 3 methods)
This page is still under construction. For more details about methods and multiple dispatch in Julia, please refer to the official Julia Methods Documentation.
Julia’s multiple dispatch system is a defining feature and core paradigm of the language. Multiple dispatch allows Julia to select which method to execute based on the types of all arguments provided to a function, rather than just the first one. This approach enables Julia to adaptively execute optimized methods for each specific combination of argument types, resulting in highly flexible and efficient code.
In languages with single dispatch, such as Python, Java, or C++, method selection is determined solely by the type of one object, often the first argument or the calling object (e.g., object.method()
). In contrast, multiple dispatch in Julia means that methods are chosen based on all arguments, making functions truly polymorphic in response to different type combinations.
This behavior can be seen in Julia with the syntax:
function my_function(x::Int, y::Float64)
println("Called with Int and Float64")
end
function my_function(x::String, y::Int)
println("Called with String and Int")
end
In this example, Julia will dynamically determine the appropriate method based on the types of both arguments passed to my_function
. This flexibility is central to Julia’s design and unlocks substantial benefits for performance and usability.
Julia’s dispatch system is distinctive because it combines the flexibility of dynamic typing with the performance of compiled languages. With multiple dispatch, Julia compiles specialized versions of functions for specific type combinations, enabling it to achieve high performance close to that of statically compiled languages like C and Fortran. This capability solves the “two-language problem,” where developers often prototype in high-level languages (e.g., Python, R) but rewrite performance-critical parts in low-level languages for speed.
Performance: Julia’s compiler generates efficient machine code for specific type combinations, allowing function calls to avoid the overhead of type checks and branching, which are often required in other dynamically typed languages.
Code Flexibility and Reusability: Multiple dispatch allows developers to write more modular and reusable code. Functions can be extended to handle new types by simply defining additional methods, without modifying existing code.
Cleaner, More Intuitive Code: With multiple dispatch, function definitions naturally describe the intended behavior for specific types, making code easier to read and understand. There’s no need for verbose type checking inside functions, which keeps code concise.
In Julia, you can define multiple methods for the same function, each specialized for different combinations of argument types. This is done by specifying the types of the function’s arguments using type annotations. Julia will then choose the appropriate method based on the types of the arguments passed at runtime.
Let’s define a function f
that handles different types of input.
f(x::Int, y::Int) = println("($x, $y) ∈ ℤ × ℤ")
f(x::Float64, y::Float64) = println("($x, $y) ∈ ℝ × ℝ")
f(x::Int, y::Float64) = println("($x, $y) ∈ ℤ × ℝ")
f (generic function with 3 methods)
Now, depending on the types of the arguments, Julia will dispatch the appropriate method:
Ambiguous dispatch occurs when Julia cannot determine which method to call because multiple methods are applicable for the given arguments. This happens when there is overlap in the argument types of different methods, making it unclear which method should be selected.
When you define multiple methods for the same function, each method is associated with specific types of arguments. Ambiguous dispatch happens when there are two or more methods that could potentially match the types of the arguments passed to the function. Julia relies on the order of method definitions and their specificity to resolve which method to dispatch, but sometimes it’s unable to make a clear decision, resulting in ambiguity.
Let’s define methods for the function g
where the ambiguity arises because of overlapping types:
g(x::Real, y::Real) = println("($x, $y) ∈ ℝ × ℝ")
g(x::Integer, y::Real) = println("($x, $y) ∈ ℤ × ℝ")
g(x::Real, y::Integer) = println("($x, $y) ∈ ℝ × ℤ")
g (generic function with 3 methods)
Let first call g
with arguments that match only one method:
g(1.0, 2.0) # Calls the method for two floats
g(1, 2.0) # Calls the method for integer and float
g(1.0, 2) # Calls the method for float and integer
(1.0, 2.0) ∈ ℝ × ℝ
(1, 2.0) ∈ ℤ × ℝ
(1.0, 2) ∈ ℝ × ℤ
Now, let’s try to call g
with arguments that could match both methods, like this:
MethodError: g(::Int64, ::Int64) is ambiguous. Candidates: g(x::Real, y::Integer) @ Main In[48]:3 g(x::Integer, y::Real) @ Main In[48]:2 Possible fix, define g(::Integer, ::Integer) Stacktrace: [1] top-level scope @ In[50]:1
In this case, the methods g(x::Integer, y::Real)
and g(x::Real, y::Integer)
both match, and Julia cannot decide which one to dispatch to, that is why the error occurs. To resolve this ambiguity, you can follow the advice in the error message and define another specialized method that covers the ambiguous case: g(x::Integer, y::Integer)
. You can also refactor the existing methods to avoid ambiguity: replace for instance Real
with Float64
or Integer
with Int
.
You can define parametric functions in Julia that work with different types, which are specified using type parameters. These functions are flexible and can operate on any type that is passed to them when called.
Here’s an example of a simple identity function id
, which returns whatever value is passed to it, regardless of its type:
julia> id(42) = 42
julia> id(3.14) = 3.14
julia> id("Hello") = "Hello"
In this case, the function id
works for any type T
, and you can pass an Int
, Float64
, String
, or any other type. Julia automatically infers the type of T
based on the argument passed to the function. This makes id
a highly flexible function.
You can specify the return type of a parametric function by adding a return type annotation:
You can also force to return a specific type:
You can also add constraints to parametric functions, ensuring that the parametric type parameter must be a subtype of a specific type. This is useful when you want the function to operate only on certain types, such as numeric types or specific structures.
Here’s an example where we define a function add_one
that only works with numeric types. The type parameter T
is constrained to be a subtype of Number
:
MethodError: no method matching add_one(::String) The function `add_one` exists, but no method is defined for this combination of argument types. Closest candidates are: add_one(::T) where T<:Number @ Main In[54]:1 Stacktrace: [1] top-level scope @ In[56]:1
In this case, the function add_one
will only accept types that are subtypes of Number
(such as Int
, Float64
, etc.). If you try to pass a non-numeric type like String
, Julia will throw an error.
You can further restrict the parametric type to more specific types. For example, you could specify that a function should only accept Int64
or a specific subtype of Number
, excluding other subtypes like Float64
or Complex
:
When you use type annotations, you specify a fixed type for a function argument. For example, if you want to ensure that an argument is a subtype of Number
, you can use a type annotation like this:
In this case, x
can be of any type that is a subtype of Number
(such as Int
, Float64
, etc.). However, the type is not explicitly accessible in the function body.
On the other hand, parametric functions with constraints allow you to achieve the same flexibility but also give you direct access to the type parameter. For example, you can write a function with a parametric type T
constrained to Number
, and you will have access to the type T
directly:
In this parametric version, T
is directly accessible inside the function body, allowing you to print the type along with the value. This provides more flexibility if you need to work with the type itself.
Both functions will accept any subtype of Number
, but the parametric version also allows you to access and use the type parameter explicitly, while the annotated version does not.
You can also add constraints on multiple arguments to ensure that they all have the same type. This is useful when you want to perform operations on multiple variables that should all belong to the same type, but still want the flexibility of working with different types.
Here’s an example where we define a function that accepts two arguments, both constrained to be of the same type:
function add_numbers(x::T, y::T) where T <: Number
return x + y
end
add_numbers(3, 4) # Valid: 3 + 4 = 7
7
MethodError: no method matching add_numbers(::Int64, ::Float64) The function `add_numbers` exists, but no method is defined for this combination of argument types. Closest candidates are: add_numbers(::T, ::T) where T<:Number @ Main In[61]:1 Stacktrace: [1] top-level scope @ In[63]:1
In this case, the function add_numbers
will only accept two arguments that have the same type T
. If you try to pass arguments of different types, such as an Int
and a Float64
, Julia will throw an error. This ensures that the function works with consistent types for both arguments while maintaining flexibility for different numeric types.
You can also use parametric types with multiple arguments to ensure that both the elements of a vector and the vector itself conform to a specific type. Here’s an example where we define a function that accepts a vector of a parametric type and a second parametric argument:
function add_elements(vec::Vector{T}, value::T) where T
return [x + value for x in vec]
end
add_elements([1, 2, 3], 2) # Valid: Adds 2 to each element of the vector
3-element Vector{Int64}:
3
4
5
3-element Vector{Float64}:
2.5
3.5
4.5
MethodError: no method matching add_elements(::Vector{Int64}, ::Float64) The function `add_elements` exists, but no method is defined for this combination of argument types. Closest candidates are: add_elements(::Vector{T}, ::T) where T @ Main In[64]:1 Stacktrace: [1] top-level scope @ In[66]:1
In this example, both the vector and the value
passed to the function must have the same type T
. If you try to pass a vector of Int
with a Float64
value, Julia will throw an error, ensuring type consistency between the vector elements and the value being added.
Question 1. What is a parametric function in Julia?
Question 2. What does the following function id
do?
Question 3. What happens when you call add_one(3)
with the following function?
Question 4. What does the following function do?
Question 5. What is the advantage of using parametric functions with constraints?
Question 6. What happens when you call add_numbers(3, 3.5)
with the following function?
Question 7. What does the following code do?
Question 8. What happens when you call add_elements([1, 2, 3], 3.14)
?
Question 9. What happens when you call add_elements([1, 2, 3], 3.14)
when the vector contains Int
and the value is Float64
?