Multiple Dispatch#
If you remember one chapter from this book, make it this one.
Multiple dispatch is Julia’s answer to the question every language has to answer: when I write f(x, y), which code actually runs? Most languages answer with “look at the function name” (procedural) or “look at the object on the left of the dot” (object-oriented). Julia answers: look at the types of all the arguments, and pick the most specific match.
That sounds like a modest technical variation. It isn’t. It changes how you design programs, and it’s the reason Julia’s ecosystem composes in ways that genuinely surprise people coming from other languages.
The mechanics#
A function in Julia is a name. A method is one specific implementation of it for a particular combination of argument types. One function, many methods.
struct Bond
face_value::Float64
coupon::Float64
end
struct Equity
shares::Int
price::Float64
end
value(b::Bond) = b.face_value
value(e::Equity) = e.shares * e.price
We just defined one function, value, with two methods. Julia picks between them by looking at the argument type:
julia> value(Bond(1000.0, 0.05))
1000.0
julia> value(Equity(100, 45.50))
4550.0
julia> methods(value)
# 2 methods for generic function "value":
[1] value(b::Bond)
[2] value(e::Equity)
So far this is just single dispatch — the same thing you’d get from bond.value() in Python. Now the interesting part.
Dispatching on all the arguments#
abstract type Currency end
struct USD <: Currency; amount::Float64; end
struct EUR <: Currency; amount::Float64; end
convert_amount(from::USD, to::Type{EUR}) = EUR(from.amount * 0.92)
convert_amount(from::EUR, to::Type{USD}) = USD(from.amount * 1.09)
Which method runs depends on both arguments together. There’s no privileged first argument, no “this” object that owns the method.
Consider what that means for a genuinely two-sided operation like multiplication. A * B where A is a sparse matrix and B is a dense vector should run a completely different algorithm from A * B where both are dense. In an object-oriented language, where does that code live? On the sparse matrix class? On the vector class? Whoever owns it has to know about the other, and if a third party defines a new matrix type, they can’t add a method to your class.
In Julia, the method just says what it’s for:
*(A::SparseMatrix, b::DenseVector) = ...
and it lives wherever it makes sense. The dispatch system handles the rest.
Specificity: how Julia chooses#
When several methods could apply, Julia picks the most specific one:
describe(x) = "something"
describe(x::Number) = "a number"
describe(x::Integer) = "an integer"
describe(x::Int64) = "specifically an Int64"
julia> describe("hello")
"something"
julia> describe(3.14)
"a number"
julia> describe(big(3)) # BigInt
"an integer"
julia> describe(3)
"specifically an Int64"
The rule is intuitive once stated: the method whose signature is a subtype of all the others wins. This lets you write one general fallback plus specialized fast paths for the cases you care about — and crucially, adding a specialization later doesn’t require touching the general version.
If two methods are equally specific and neither dominates, Julia raises an ambiguity error rather than guessing. That’s occasionally annoying and always correct.
Extending functions you didn’t write#
This is where the practical magic is. You can add methods to functions defined in Base or in someone else’s package.
struct Money
cents::Int
end
# Teach Julia how to add two Money values
Base.:+(a::Money, b::Money) = Money(a.cents + b.cents)
# And how to print one
Base.show(io::IO, m::Money) = print(io, "\$", m.cents ÷ 100, ".", lpad(m.cents % 100, 2, '0'))
julia> Money(1250) + Money(399)
$16.49
We didn’t subclass anything. We didn’t wrap anything. We added two methods to two existing functions, and now our type participates in the language’s normal syntax.
Keep going, and it starts to compound:
Base.zero(::Type{Money}) = Money(0)
Base.:*(n::Integer, m::Money) = Money(n * m.cents)
Base.isless(a::Money, b::Money) = a.cents < b.cents
julia> wallet = [Money(1250), Money(399), Money(2000)]
julia> sum(wallet)
$36.49
julia> sort(wallet)
3-element Vector{Money}:
$3.99
$12.50
$20.00
julia> maximum(wallet)
$20.00
Look carefully at what just happened. I never wrote a sum for Money. I never wrote a sort. Those functions in Base were written generically — sum needs + and zero, sort needs isless — and by supplying those three small methods, my type inherited every algorithm in the standard library that’s built on them.
Note
This is the composability everyone talks about Here’s the story Julia programmers tell, and it’s true. Someone writes a differential equation solver. Someone else, who has never met them, writes a package for numbers with physical units. A third person writes a package for numbers that carry uncertainty. Nobody coordinates.
Then you solve a differential equation with uncertain, unit-carrying numbers and it just works — because the solver was written against +, *, and <, and the number packages supplied methods for those. In a language built on classes and inheritance, this requires each library to explicitly anticipate the others. In Julia it falls out of the dispatch model.
It doesn’t work 100% of the time. But it works often enough to be a real, load-bearing advantage of the language.
Dispatch and speed#
There’s a natural worry here: if Julia has to decide which method to run, isn’t that a runtime cost on every single call?
Almost never. When the compiler specializes a function for a set of concrete argument types, it knows exactly which method will be selected, and it wires in a direct call — often inlining it entirely. The dispatch happened at compile time.
The exception is when the compiler can’t know the types — because a value came from an untyped global, or a Vector{Any}, or a function whose return type it couldn’t infer. Then Julia falls back to dynamic dispatch, looking up the method at runtime. That’s the thing that makes Julia code slow, and it’s what we’ll learn to detect in the Performance chapter.
So: dispatch is free when types are inferable, expensive when they aren’t. This is the same lesson as everywhere else in this language, arriving from a new direction.
Designing with dispatch#
Coming from OOP, the instinct is to reach for a class hierarchy with inherited behaviour. Julia doesn’t offer that, and the replacement is a different way of thinking:
Define the interface as a set of functions. Rather than a base class with methods, decide which functions a type must support. Base does this all the time — to make your type iterable, define iterate. To make it indexable, define getindex, setindex!, and size. There’s no formal interface keyword; the contract is documented and enforced by whether things work.
Use abstract types for grouping, generic functions for behaviour.
abstract type Instrument end
# The general contract: every Instrument must implement value()
value(x::Instrument) = error("value not implemented for $(typeof(x))")
# Generic code written once, against the abstraction
portfolio_value(xs) = sum(value, xs)
riskiest(xs) = argmax(value, xs)
New instrument types slot in by defining value, and every generic function above works on them immediately.
Prefer small, focused functions. Dispatch works on functions, so the more your logic is expressed as functions rather than branching inside one big function, the more extensible it is. An if typeof(x) == ... chain is dispatch you’re doing by hand — and worse, it’s a closed set that nobody else can extend.
A worked example#
Let’s build a tiny expense-classification system and watch dispatch do the work of a class hierarchy.
abstract type Expense end
struct Travel <: Expense
amount::Float64
miles::Float64
end
struct Meal <: Expense
amount::Float64
attendees::Int
end
struct Software <: Expense
amount::Float64
annual::Bool
end
# Each type answers the same questions differently
deductible(e::Travel) = e.amount
deductible(e::Meal) = e.amount * 0.5 # 50% rule
deductible(e::Software) = e.annual ? e.amount / 12 : e.amount
# Generic reporting, written once
total_deductible(xs) = sum(deductible, xs)
# A fallback for anything we haven't specialized
deductible(e::Expense) = e.amount
julia> expenses = Expense[Travel(450.0, 300.0), Meal(120.0, 3), Software(1200.0, true)]
julia> total_deductible(expenses)
610.0
Adding a new expense category tomorrow means writing one struct and one deductible method. Nothing else changes. No base class to modify, no if chain to find and update, no risk of forgetting a branch.
Try it yourself#
Define a
Temperaturestruct holding aFloat64in Celsius. Add methods so+works between twoTemperatures andshowprints something like21.5°C.Define
describe(x)with methods forString,Number, and a fallback for anything else. Test all three.Add
Base.islessforTemperatureand confirm thatsortandmaximumnow work on a vector of them without you writing either.Look at
methods(+)and count how many methods Julia ships for addition. Then pick one and read it with@which 1 + 2.
Solutions#
# 1.
struct Temperature
celsius::Float64
end
Base.:+(a::Temperature, b::Temperature) = Temperature(a.celsius + b.celsius)
Base.show(io::IO, t::Temperature) = print(io, t.celsius, "°C")
# 2.
describe(x) = "some other thing: $(typeof(x))"
describe(x::String) = "a string of length $(length(x))"
describe(x::Number) = "a number, specifically $(typeof(x))"
# 3.
Base.isless(a::Temperature, b::Temperature) = a.celsius < b.celsius
sort([Temperature(21.5), Temperature(18.0), Temperature(30.2)])
maximum([Temperature(21.5), Temperature(18.0)])
# 4.
length(methods(+)) # a few hundred, and every one is a specialization
@which 1 + 2 # +(x::T, y::T) where T<:BitInteger — in Base
That last exercise is worth actually running. Seeing that + has hundreds of methods, and that @which will tell you exactly which one your call landed on, makes the whole model concrete in a way that no amount of explanation does.
Next we’ll look at how to organize your code into modules and packages — and how Julia’s environment system keeps your projects from stepping on each other.