Methods and Multiple Dispatch in Julia

Note

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.

What is Multiple Dispatch?

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.

Why is Julia’s Dispatch System Powerful and Unique?

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.

Benefits of Multiple Dispatch

  1. 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.

  2. 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.

  3. 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.

Specialization and Method Selection

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.

Basic Examples

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:

f(2, 3)         # Calls the method for integers
f(2.5, 3.5)     # Calls the method for floats
f(2, 3.5)       # Calls the mixed-type method
(2, 3) ∈ ℤ × ℤ
(2.5, 3.5) ∈ ℝ × ℝ
(2, 3.5) ∈ ℤ × ℝ

Ambiguous Dispatch

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.

Why Ambiguous Dispatch Can Occur?

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.

Example of Ambiguous Dispatch

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:

g(2, 3)        # Error: Ambiguous dispatch
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.

Parametric Functions

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.

Example: Identity Function

Here’s an example of a simple identity function id, which returns whatever value is passed to it, regardless of its type:

function id(x::T) where T
    return x
end

id(42)       # Integer
id(3.14)     # Float64
id("Hello")  # String
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:

function double(x::T)::T where {T <: Number}
    return 2x
end

double(12) # Integer
24

You can also force to return a specific type:

function triple(x::T)::Float64 where {T <: Real}
    return 3x
end

triple(12) # Float64
36.0

Constraints on Parametric Functions

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.

Example: Adding a Constraint on Numbers

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:

function add_one(x::T) where T <: Number
    return x + 1
end

add_one(3)       # Valid: 3 + 1 = 4
4
add_one(3.14)    # Valid: 3.14 + 1 = 4.14
4.140000000000001
add_one("Hello")  # Error: String is not 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.

Restricting to More Specific Types

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:

function double(x::T) where T <: Int64
    return x * 2
end

double(10)    # Valid: 10 * 2 = 20
20
double(3.14)  # Error: Float64 is not a subtype of Int64
6.28

Comparison with Type Annotations

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:

function display_number(x::Number)
    println("The number is: ", x)
end

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:

function display_number_constrained(x::T) where T <: Number
    println("The number is of type: ", T)
    println("The number is: ", x)
end

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.

Multiple Constrained Arguments

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
add_numbers(2.5, 3.5)   # Valid: 2.5 + 3.5 = 6.0
6.0
add_numbers(3, 3.5)     # Error: Arguments have different types (Int and Float64)
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.

Parametric Arguments and Vectors

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
add_elements([1.5, 2.5, 3.5], 1.0)  # Valid: Adds 1.0 to each element of the vector
3-element Vector{Float64}:
 2.5
 3.5
 4.5
add_elements([1, 2, 3], 3.14)  # Error: Vector contains Int, but value is Float64
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.

Summary

  • Parametric Functions allow you to define functions that can work with multiple types, using type parameters that are inferred when the function is called.
  • Type Annotations are used when you want to specify a fixed type for a function argument, but they don’t offer the same flexibility as parametric functions.
  • Constraints on Parametric Functions let you restrict the type parameter to specific types or subtypes, ensuring that the function only operates on valid types.
  • Restricting to More Specific Types allows you to narrow the scope of types further, offering more control over the types accepted by the function.
  • Multiple Constrained Arguments ensures that two or more arguments in a function have the same type, while still providing flexibility for different types, ensuring consistency in operations with multiple parameters.
  • Using Parametric Arguments with Vectors allows you to define functions that ensure both the vector elements and the second argument match a specific type, ensuring consistency in operations.

Quiz

Question 1. What is a parametric function in Julia?

Select an item

Question 2. What does the following function id do?

function id(x::T) where T
    return x
end

Select an item

Question 3. What happens when you call add_one(3) with the following function?

function add_one(x::T) where T <: Number
    return x + 1
end

Select an item

Question 4. What does the following function do?

function double(x::T) where T <: Int64
    return x * 2
end

Select an item

Question 5. What is the advantage of using parametric functions with constraints?

Select an item

Question 6. What happens when you call add_numbers(3, 3.5) with the following function?

function add_numbers(x::T, y::T) where T <: Number
    return x + y
end

Select an item

Question 7. What does the following code do?

function add_elements(vec::Vector{T}, value::T) where T
    return [x + value for x in vec]
end

Select an item

Question 8. What happens when you call add_elements([1, 2, 3], 3.14)?

add_elements([1, 2, 3], 3.14)

Select an item

Question 9. What happens when you call add_elements([1, 2, 3], 3.14) when the vector contains Int and the value is Float64?

add_elements([1, 2, 3], 3.14)

Select an item
Back to top