7  Multiple dispatch

Prerequisites

Before reading this chapter, you are recommended to have read Chapter 6. As many of the examples are numeric in nature (since they provide the richest source of abstract types), Chapter 3 will also be valuable.

7.1 What is multiple dispatch?

7.1.1 Functions and methods

In Chapter 6, we mentioned how functions and methods differ from each other, but glossed over the details. We’ll remind ourselves now, as it will be important to talk about both separately here.

A function is defined by its name, and a list of methods corresponding to it. We can call a function with a Tuple of values, and after some behind-the-scenes work, it returns an answer to us. Much as variables are defined by the first time their name is used, so are functions:

function f
end
f (generic function with 0 methods)

This syntax defines the function f without any methods, as the output tells us. So what are methods? They are algorithms, written out in code, which tell us how to get from a certain set of inputs to a corresponding output. Methods are what we wrote in the last chapter, although we didn’t define the function separately as seen above, instead Julia learnt the function name from the method we wrote for it.

The same function can have multiple methods, which is particularly useful if the function does significantly different things with different types. Instead of a cascade of ifs and elseifs reasoning through all possibilities of typeof(x), we can use type declarations to make it clear which method we want to run in any given case:

f(x::Number) = 9x^2 + 15x - 31
f (generic function with 1 method)
f(x::String) = f(length(x))
f (generic function with 2 methods)

This isn’t the whole story, however. Once a function is called, we still need to work out which method is appropriate for the given inputs. Julia solves this as follows:

  • When a function is called, Julia first looks at the types of the arguments, specifically which ones and in what order

  • It then refers to the method table of the function, looking for the method best fitting that pattern of arguments

  • If a method is found, the code within that method is loaded and run. If no method exists for those arguments, we get a MethodError

The general idea of choosing from a list of methods is called dispatch, and the fact that we use the types of all arguments in this process makes it multiple dispatch. Multiple dispatch makes Julia rather unique, and you would struggle to find another programming language where this idea is as central to the design of the language.

7.1.2 Why multiple dispatch?

There are other ways of overcoming the dispatch hurdle, so why does Julia choose multiple dispatch? The short answer is that the designers of Julia like it, and the long answer explaining why they like it is literally a PhD thesis. Some of the reasoning should be made clearer by the rest of this chapter, demonstrating the power and elegance of multiple dispatch, but first let’s explore the expression problem, which multiple dispatch cleanly solves for Julia.

In Julian terms, the expression problem can be described as requiring two things:

  • A user should be able to define new functions or new methods for existing functions, which can take existing types as inputs

  • A user should be able to define new types, and extend existing functions with new methods to act upon them

or, in other words, teaching various breeds of dog new tricks.

You could say that calling this a ‘problem’ is a bit like calling the fact that humans can’t breathe underwater a problem, in that it would be nice functionality, but hardly essential. Indeed, this is the approach of many programming languages, where the expression problem is just one of the limitations that any language has. Nonetheless, it would be handy to be able to breathe underwater, and equally having the expression problem be solved only increases the boundaries of what a language can do.

While defining new functions and types is simply part of Julia’s functionality, multiple dispatch is critical for allowing more methods to be defined for the same function. In particular, extending existing functions, also known as overloading, is very useful, and we look at that later in the chapter.

7.1.3 Multiple dispatch in practice

How does multiple dispatch actually work in practice? Let’s look at some examples of multiple dispatch working on a function that we write.

First, we need to write a function with multiple methods. This can be done with different types, different numbers of arguments, or a combination of the two:

what(x::String) = println("The word is $x")
what(y::Int64) = println("The number is $y")
what(x::String, y::Int64) = println("The word is $x, the number is $y")
what(y::Int64, x::String) = what(x,y)
what (generic function with 4 methods)

As we’ve used before, the methods function lists out all the methods for a function:

methods(what)
# 4 methods for generic function "what" from Main:
 [1] what(x::String)
 [2] what(y::Int64)
 [3] what(x::String, y::Int64)
 [4] what(y::Int64, x::String)

The output here is not just printed text, it’s a value of type Base.MethodList, a collection of values of type Method, which we can interact with programmatically. In particular, let’s get the sig property of each method, which is the type signature that multiple dispatch uses:

for m  methods(what)
    println(m.sig)
end
Tuple{typeof(what), String}
Tuple{typeof(what), Int64}
Tuple{typeof(what), String, Int64}
Tuple{typeof(what), Int64, String}

You’ll notice that the type of the function is also included for each of these signatures. This is no mistake, as the exact type of the function may be important, particularly for constructors of parametric types (constructors work no different to methods with regards to multiple dispatch). In this case, we don’t need to worry about that, so we can just look at the types that follow.

Suppose we run what("boron", 5). Julia reads this as a function call with parameters (what, "boron", 5), and calculates the type of this input:

typeof((what, "boron", 5))
Tuple{typeof(what), String, Int64}

It then checks this against the possibilities in the method table. In this case, we find a match, and we are directed to method number 3. Julia loads this method and runs it, with the values of the local variables x and y set appropriately at the start. Then, we get our output:

what("boron", 5)
The word is boron, the number is 5

How about if we run what("boron", 10.81)? Going through the same procedure, Julia finds the type signature:

typeof((what, "boron", 10.81))
Tuple{typeof(what), String, Float64}

But this isn’t anywhere to be seen in the method table, so we get a MethodError if we try to run it:

what("boron", 10.81)
ERROR: MethodError: no method matching what(::String, ::Float64)

Luckily, one of the powers of multiple dispatch is that it allows us to add new methods to existing functions at will:

what(x::String, y::Float64) = println("The word is $x, the number is $y")
what (generic function with 5 methods)
what("boron", 10.81)
The word is boron, the number is 10.81

7.2 Multiple dispatch on abstract types

7.2.1 The type graph

When each type is defined in Julia, it is defined with a parent, explicitly or implicitly. This parent type, called its supertype, is an abstract type that encompasses a number of similar types. It can be found for any given type by the function supertype, or the field super:

supertype(Int64)
Signed
Int64.super
Signed

Conversly, the function subtypes applied to an abstract type lists out all of the types that consider it their parent:

subtypes(Signed)
6-element Vector{Any}:
 BigInt
 Int128
 Int16
 Int32
 Int64
 Int8

For custom types, we can define the parent, if we wish, with <: after the type name (the example used here is AbstractString, the supertype of String):

struct SupertypeTest <: AbstractString
end

supertype(SupertypeTest)
AbstractString

The parent type itself has a parent (the grandparent if you like), which also has a parent, and so on. This continues until Any, which is the abstract type representing anything at all. Any is its own supertype, and if a type is defined with no explicit parent, it defaults to Any:

struct NoSupertypeTest
end

supertype(NoSupertypeTest)
Any

Imagine that we wrote down all the type names in Julia, and we joined each type to its parent type in turn, apart from Any as it is its own parent. The network of types that results is called the type graph, or type tree (because it has a tree structure, i.e. it has no loops). We’ve already seen a visualisation like this in Chapter 3 for the subtypes of Number, and we’ll see another for the subtypes of AbstractArray in Chapter 9. Such a diagram would be more impractical for all the (as of Julia 1.9) 143 types that exist in Base, but we can at least list out the immediate subtypes of Any to get an idea of some of this structure:

sts = subtypes(Any)
# `subtypes` lists out all subtypes from any loaded module
# We want to restrict to those defined in `Base`
sts[isdefined.((Base,), Symbol.(sts))]
47-element Vector{Any}:
 AbstractArray
 AbstractChannel
 AbstractChar
 AbstractDict
 AbstractDisplay
 AbstractMatch
 AbstractPattern
 AbstractSet
 AbstractString
 Any
 Cstring
 Cwstring
 Enum
 ⋮
 Symbol
 Task
 Text
 Timer
 Tuple
 Type
 TypeVar
 UndefInitializer
 Val
 VecElement
 VersionNumber
 WeakRef

7.2.2 Methods with abstract type declarations

As we saw in Chapter 6, one of the primary uses for abstract types is as type declarations to allow functions to work on any of a selection of types at once.

imagine(x::Real) = x*im
imagine (generic function with 1 method)

If we give this an Int64 argument, for example imagine(4), the type signature will be:

typeof((imagine, 4))
Tuple{typeof(imagine), Int64}

Meanwhile, the list of type signatures known to Julia is:

for m  methods(imagine)
    println(m.sig)
end
Tuple{typeof(imagine), Real}

So can’t we do imagine(4), as it has the wrong type signature? Thankfully, we can:

imagine(4)
0 + 4im

This is because when multiple dispatch sees abstract types, it understands them as such. So when Tuple{typeof(imagine), Int64} is checked against Tuple{typeof(imagine), Real}, Julia checks whether Int64 is below Real in the type tree, in other words it checks:

typeof(4) <: Real
true

Since this is true, our type signature matches the method, and the function can run.

This also applies to methods where some or all arguments don’t have type declarations, since this is equivalent to ::Any, and Any is an abstract type. Whatever the type of the argument that we get, running <: Any on it will return true, so the signature will match in that argument.

With abstract types introduced, we also find another problem, which is that there can be multiple methods with matching type signatures.

delib_ambig(x::Int64, y::Integer) = println("One Int64, one Integer")
delib_ambig(x::Integer, y::Int64) = println("One Integer, one Int64")
delib_ambig(x::Integer, y::Integer) = println("Two Integers")
delib_ambig (generic function with 3 methods)
for m  methods(delib_ambig)
    println(m.sig)
end
Tuple{typeof(delib_ambig), Int64, Integer}
Tuple{typeof(delib_ambig), Integer, Int64}
Tuple{typeof(delib_ambig), Integer, Integer}

There are two cases to cover here. The first occurs with inputs such as (1, 0x1). This has type signature:

typeof((delib_ambig, 1, 0x1))
Tuple{typeof(delib_ambig), Int64, UInt8}

Looking down the method table, and considering abstract types, we see that this fits both methods 1 and 3. However, Julia can pick between these two, as anything that works with method 1 would also work with method 3. In such a case, we always pick the most specialised method that we can, which is method 1 here, so:

delib_ambig(1, 0x1)
One Int64, one Integer

However, something different happens with the input (1, 1). This has type signature:

typeof((delib_ambig, 1, 1))
Tuple{typeof(delib_ambig), Int64, Int64}

That’s a match with the type signatures of all three methods! So we need to pick the most specific of these. The problem is, neither of method 1 or method 2 are more specialised than the other (we can find inputs that matches one but not the other each way around), and we can’t pick between them. This ambiguity gives a MethodError, although of a different form:

delib_ambig(1, 1)
ERROR: MethodError: delib_ambig(::Int64, ::Int64) is ambiguous.

[...]

Possible fix, define
  delib_ambig(::Int64, ::Int64)

It’s as if delib_ambig was written to be deliberately ambiguo… oh.

As the error message suggests, we can remedy the issue in this example by simply defining a fourth method to be more specific:

delib_ambig(x::Int64, y::Int64) = println("Two Int64s")
delib_ambig (generic function with 4 methods)
delib_ambig(1, 1)
Two Int64s

Nonetheless, this is something to be aware of when writing many methods for the same function, the more methods you write, the more chance of ambiguity slipping in.

7.3 Using where for type declarations

Being able to use abstract types when declaring functions certainly saves us time and effort in combining what would be several methods into one. However, there are instances in which abstract type declarations won’t give us the desired effects. We’ll examine three cases, and see how the keyword where comes into play in each.

7.3.1 Using the type as an variable

The most basic use of where is when we want to give a variable name to the type of one of the inputs, which we can then use later on (within the method body, since T will be local to that scope). Instead of specifying a type, we can give whatever the type is a name. Then, after the Tuple part of the method definition, we use where, followed by whatever conditions on those types that we need:

integercheck(x::T) where T <: Integer = println("$x is an Integer, specifically of type $T")
integercheck (generic function with 1 method)
integercheck(1)
1 is an Integer, specifically of type Int64
integercheck(0x1)
1 is an Integer, specifically of type UInt8
integercheck(1.0)
ERROR: MethodError: no method matching integercheck(::Float64)

Even if we don’t have any conditions to impose on T, we still need to write where T to tell Julia that T is a local variable with value the type of that input.

typecheck(x::T) where T = println("$x is of type $T")
typecheck (generic function with 1 method)
typecheck(1)
1 is of type Int64
typecheck('1')
1 is of type Char

We can do the same with multiple inputs, by giving each of them different variable names and enclosing any conditions we have on them in curly braces { } after where, comma-separated for each variable:

# T can be anything, S need to be an Integer type
doubletypecheck(x::T, y::S) where {T, S <: Integer} =
    println("$x is of type $T, while $y is an Integer, specifically of type $S")
doubletypecheck (generic function with 1 method)
doubletypecheck('1', 1)
1 is of type Char, while 1 is an Integer, specifically of type Int64
doubletypecheck(1, '1')
ERROR: MethodError: no method matching doubletypecheck(::Int64, ::Char)

In these examples, using where is pretty redundant, as typeof within the method code would work just fine (although 'where can be preferable in some such cases, for cleaner code or avoiding calling typeof multiple times). However, the syntax introduced by where here provides the means of solving problems through multiple dispatch that typeof cannot.

7.3.2 Two arguments of the same type

We just saw how to use two variables to represent the types of two inputs. But what if we only use one?

sametypecheck(x::T, y::T) where T = ("$x and $y are both of type $T")
sametypecheck (generic function with 1 method)

where T defines T to be a local variable, and its value will be set by the types of the inputs. If x and y have the same type, then it’s clear what value T should take:

sametypecheck(1, 2)
"1 and 2 are both of type Int64"

If not, Julia won’t resolve the ambiguity, and we get a MethodError.

sametypecheck(1, '2')
ERROR: MethodError: no method matching sametypecheck(::Int64, ::Char)

Closest candidates are:
  sametypecheck(::T, ::T) where T
Note

This is a slight oversimplification. As we saw before, what x::T and y::T actually check is typeof(x) <: T and typeof(y) <: T respectively. Therefore, Julia needs to have some idea of what T is first to be able to check these.

When T is defined as a local variable by where T, Julia has no idea what its value will be. From context, it guesses T = typeof(x), and then checks typeof(x) <: typeof(x) and typeof(y) <: typeof(x). The first of these is obviously true, while the second is only true when typeof(y) == typeof(x), since both are concrete types. Therefore, the type signature is matched if and only if x and y have the same type, as expected, with local variable T = typeof(x) set and carried through to the method.

A common use of this is to define a operation between two values when their types are the same, and then write a second method when values don’t have the same type, which alters inputs to a common type. We’ll see more of this idea when discussing promotion later in the chapter.

7.3.3 Parametric type declarations

In Chapter 6, we defined a very simple parametric type called Doublet as follows:

struct Doublet{T}
    first::T
    second::T
end

It contains a pair of fields, seemingly of the same type T. Indeed, when we use Doublet as a constructor, we observe this behaviour:

Doublet(1, 2)
Doublet{Int64}(1, 2)
Doublet(1, 2.0)
ERROR: MethodError: no method matching Doublet(::Int64, ::Float64)

Closest candidates are:
  Doublet(::T, ::T) where T

However, when we specify T in curly braces as part of the constructor, the behaviour changes:

Doublet{Int64}(1, 2.0)
Doublet{Int64}(1, 2)

Because we’ve specified T, Julia doesn’t need to guess what we mean, and it will instead try to convert (the function used to convert automatically between different types) our arguments to the type T. If this conversion can’t happen, we still get an error, but a different one:

Doublet{Int64}(1, "two")
ERROR: MethodError: Cannot `convert` an object of type String to an object of type Int64

This allows us to exploit that fact that ::T doesn’t require a value of type T, only a value whose type is <: T, if we give the parameter as an abstract type, such as Number:

d = Doublet{Number}(1, 2.0)
Doublet{Number}(1, 2.0)

The two fields here don’t need any conversion, because:

typeof(1) <: Number
true
typeof(2.0) <: Number
true

so Julia sees nothing wrong, and returns the object. Indeed, if we query the types, we find nothing has been changed:

typeof(d)
Doublet{Number}
typeof(d.first)
Int64
typeof(d.second)
Float64

What this means is that, despite Number being an abstract type, Doublet{Number} is concrete.

We’re talking about this as if it’s a problem, and for most purposes, it isn’t at all. In fact, it’s very useful to have types like Vector{Any} for a collection of values of unconstrained types. However, it causes us a little trouble with type declarations, because:

Doublet{Int64} <: Doublet{Number}
false

Let’s suppose that we want to define a doubletsum function for Doublets, but only if the type parameter is numeric. To be able to condition on the type parameter, we need to give it a name (unimaginatively, let’s say T), and then we can use where as before:

doubletsum(d::Doublet{T}) where T <: Number = d.first + d.second
doubletsum (generic function with 1 method)
doubletsum(d)
3.0
doubletsum(Doublet("1", "2"))
ERROR: MethodError: no method matching doubletsum(::Doublet{String})

Closest candidates are:
  doubletsum(::Doublet{T}) where T<:Number

This works identically for other parameters. You’ll note we used similar syntax for the parameter N when defining Password in Chapter 6, although in that instance we couldn’t condition on N in the first line of the method definition, since multiple dispatch deals in types not values. Instead, we had to use conditionals as normal in the body of the method.

7.4 Overloading inbuilt functions

When defining Password in the last chapter, we also saw how we could use import to define a new method for show to give our custom types a nicer look. This is an example of overloading a function, which means writing new methods for an existing function.

Julia doesn’t really know about function names from Base until you use them, so we can actually use function names from Base ourselves if we want to:

sin = 1
1
typeof(sin)
Int64

The problem then comes if you actually want to use the function name for its intended purpose:

sin(1)
ERROR: MethodError: objects of type Int64 are not callable
Maybe you forgot to use an operator such as *, ^, %, / etc. ?

Instead, we now need to specify that we want the one from Base, not the one we’ve just defined locally (this can be thought of as a scope issue):

Base.sin(sin)
0.8414709848078965

This is actually more of a problem than it looks though, as other code that you run that assumed sin was going to be the trigonometric function might now break completely. So we don’t want to redefine sin as anything.

Unfortunately, we can’t just add a new method like this either:

cos(x::String) = println("You can't cosine a String!")
cos (generic function with 1 method)

Julia understands this as a function definition, and the name cos now refers to this function in the current scope, not the original one from Base with this as a new method.

If we have mentioned a function name before, then Julia behaves differently.

tan(1)
tan(x::String) = println("You can't tangent a String either!")
ERROR: error in method definition: function Base.tan must be explicitly imported to be extended

Now, Julia recognises tan as a function already, so tries to add this as a new method. However, the tan function isn’t defined in the current module, it’s defined in Base. What’s more, for all we know tan could be defined separately somewhere else, or added onto, so to protect against modules accidentally overwriting each other, we need to tell Julia specifically that we want to overload a function from a particular module. This is the purpose of using import:

import Base.tan

We’re now allowed to overload the function tan, since we’ve told Julia exactly what we meant:

tan(x::String) = println("You can't tangent a String either!")
tan (generic function with 19 methods)
tan("x")
You can't tangent a String either!

7.5 Example: Promotion

7.5.1 The many ways to add

There are 66 different types that a number can take in Base Julia, specifically:

  • 12 Integer types

  • 12 Rational types, one with each of the Integer types as a parameter

  • 4 floating point types

  • 5 Irrational types, namely Irrational{:π}, Irrational{:ℯ}, Irrational{:γ}, Irrational{:φ}, and Irrational{:catalan}

  • 33 Complex types, one with each of the other types as a parameter

We can add values of any two of these types with +. However, the way to add numbers is very different depending on what type they are (e.g. UInt8s can be added column by column with carrying, Rationals need to be added by a//b + c//d = (a*d + b*c)//(b*d), Float64s need to be rescaled so that their exponents match), so multiple dispatch is required to work out how to add numbers of the given types.

The problem we run into here is that there’s an awful lot of options. If we were to write a method for every possible pair of numeric types, that would need 4356 methods! With some careful use of where to eliminate obvious repeated cases (for example, adding any two Complex numbers boils down to adding the real and imaginary parts separately, regardless of exact parameter of the type), we could probably get this down into the several hundreds. This is still an awful lot (and has to be repeated for the likes of -, *, / etc.), but in fact Julia uses far fewer methods. The number of methods for + with two numeric inputs is given by:

length(methods(+, (Number, Number)))
38

Only 37 methods are needed to be able to add any two Numbers together! The key ingredient here is promotion, which we’ll meet soon.

It’s worth pointing out that adding is far too fundamental operation to be wholly implemented within Julia, indeed the basics of adding unsigned integers can be found in the hardware of your computer. As a result, this number is slightly deflated, but we’ll demonstrate promotion by writing a function called plus that very closely mirrors + with only 20 methods.

function plus end
plus (generic function with 0 methods)

First, we want to be able to add any two Numbers of the same type. For the more basic types, we’ll simply appeal to +, since Julia doesn’t implement these itself. We could write this all in one method, but to simulate the fact that these would be implemented:

for T  (Int8, Int16, Int32, Int64, Int128, BigInt, UInt8, UInt16, UInt32, UInt64, UInt128, Float16, Float32, Float64, BigFloat)
    plus(x::T, y::T) = x + y
end

That’s 15 methods already written. We could do the same for Bool, but since that’s such a simple type, we can implement that ourselves:

plus(x::Bool, y::Bool) = x ? (y ? 2 : 1) : (y ? 1 : 0)
plus (generic function with 16 methods)
Note

Note that here the output isn’t a Bool, it’s an Int64. This is the same behaviour as +, since there’s no Bool value that true + true could be, and it’s preferable to have the output type be the same whenever the input types are (this is called type stability, and it’s important for better performance).

For Rational types, we simply use the existing methods for plus with Integers, combined with the formula for adding fractions. For now, we’ll only write a method for when the types are the same, as we don’t have a way to use plus on differing types yet:

plus(x::Rational{T}, y::Rational{T}) where T <: Integer = plus(x.num * y.den, x.den * y.num)//(x.den * y.den)
plus (generic function with 17 methods)

The Irrational types are singleton types, so the answer of a calculation is rarely going to be expressible as that Irrational type again. Instead, they are almost always converted to Float64s before the calculation is made, and we’ll do the same here for adding two of them:

plus(x::Irrational{sym}, y::Irrational{sym}) where sym = plus(convert(Float64, x), convert(Float64, y))
plus (generic function with 18 methods)

Complex types are added by using the existing methods to add real and imaginary parts, then combining them back together:

plus(x::Complex{T}, y::Complex{T}) where T = complex(plus(x.re, y.re), plus(x.im, y.im))
plus (generic function with 19 methods)

That covers the 66 cases where the two inputs have the same type, using 19 methods in total. Amazingly, we can do the rest with just one more, using the function promote, and the splat operator ... which we’ll look at further in Chapter 9:

plus(x::Number, y::Number) = plus(promote(x,y)...)
plus (generic function with 20 methods)

And indeed, the plus function can add any two numbers:

plus(2, 3.0)
5.0
plus(π, -22//7)
-0.0012644892673496777
plus(plus(1.0, (3//5)*im), true)
2.0 + 0.6im

…nearly!

plus(π, complex(π, π))
ERROR: StackOverflowError:
Note

There’s other ways in which plus doesn’t quite behave like +, mainly involving strange numbers like NaN or 1//0, or edge cases of the Integer types near the maximum/minimum representable values. This accounts for some of the discrepency in the number of methods we wrote for plus compared to +, the rest come mainly from skipping promote in certain cases for better efficiency.

Note

We’re focusing on how multiple dispatch is used for promotion, but did you notice where else we used it in defining plus?

Implicitly, we used it when referring to the functions +, *, //, and complex, all of which dispatch on their inputs. So it’s doing a lot of work here in saving us time writing methods!

7.5.2 The magic of promote

Since we’ve managed to replace four thousand methods with one, then you may think that the promote function we used to do so must be doing something pretty special. In fact, it isn’t really, and the premise behind it is very simple: promote(x,y) should return the values x and y in a common format (i.e. the same type) which can represent both. It returns them as a Tuple, so we need to splat with ... to get them as individual arguments to the function plus. Then, since we’ve already written methods for plus whenever the types of the inputs are the same, these take precedence over the more general x::Number, y::Number method, and we get our answer.

Slightly more complicated is the means by which promote determines the right common format. For this, we actually want to dig a level or two deeper, and look at the function promote_type, which takes in two types as arguments, and returns the type that values of these types should be promoted to. For instance:

promote_type(Float64, Int64)
Float64

The output of promote_type is determined by promote_rule, which has similar looking outputs, but is written such that it can be more easily overloaded than promote_type. Methods for promote_rule have specific types as inputs (the type signature ::Type{T} only accepts the type T as an input), defining how a given pair of types should be promoted. Doesn’t this just shift the problem to writing four thousand methods for promote_rule though? No, for two main reasons.

Firstly, the fact that we’re defining the promotion for a general operation rather than anything specific allows us to be very liberal in our use of where in type signatures, to massively cut down on the number of methods we need to write. If an operation does something unexpected for certain inputs, then a specific case for that method can be written to overrule to promote behaviour. Indeed, for pairs of numeric types, Julia implements only 60 methods for promote_rule in Base:

prmethods = methods(promote_rule, Base)
generalsig = Tuple{typeof(promote_rule), Type{S}, Type{T}} where {S<:Number, T<:Number}
numericmethods = prmethods[[prmethods[i].sig <: generalsig for i  eachindex(prmethods)]]
length(numericmethods)
60

Secondly, this is work that only needs to be done once. The same function promote can now be used for subtracting, multiplying, dividing, etc., with no additional effort. Indeed, we’ve piggybacked off of promote to define our own plus function, and promote is written such that this can be done trivially.

Among numeric types, there’s a hierarchy of sorts which determines how any given pair of types should promote. The diagram below shows this for Integer, Rational, and AbstractFloat types, with the result of promote_type being determined by the ‘most recent common ancestor’, i.e. the lowest point which both types are connected to on a path of downward lines:

Figure 7.1: Promotion hierarchy of Integer, Rational, and AbstractFloat types

We can demonstrate this in a few examples:

promote_type(Int16, UInt16)
UInt16
promote_type(Rational{Int16}, UInt64)
Rational{UInt64}
promote_type(UInt128, Float16)
Float16
promote_type(Float16, BigInt)
BigFloat

With the addition of Complex, we simply promote the underlying Real types, and then take the Complex version of that type, for instance:

promote_type(Complex{Int16}, UInt16)
Complex{UInt16}
promote_type(Rational{Int16}, Complex{UInt64})
Complex{Rational{UInt64}}

Irrational is a slightly unusual type anyway, and doesn’t behave particularly well with respect to promotion as we’ve already seen from plus above, so we’ll leave it at that.

Warning

As similar as it may sound, the type hierarchy of promote is entirely separate from the hierarchy given by <: and the type graph, not least because it only deals in concrete types. Don’t confuse the two, they have very different purposes!

Promotion is mostly limited to numeric types, but there’s nothing stopping it from being used in other scenarios with a complicated type structure. For instance, there are some promotion rules defined for the subtypes of AbstractArray that we’ll meet in Chapter 9.