Multiple Dispatch in Julia: Extensible Scientific Code Without Inheritance
Julia's multiple dispatch lets you write generic scientific code that plugs into existing machinery without inheritance. Learn the pattern, worked example, and trade-offs around ambiguity, inference, and invalidation.
30 Jun 2026, 15:10 UTC

You need a numerical driver that runs the same simulation with either a dense matrix or a sparse matrix backend. The familiar options are unsatisfying: duplicate the driver for each backend, or build a class hierarchy with virtual methods and hope the abstraction holds. Julia offers a third path. You write the algorithm once against abstract types, then add narrow methods for concrete types where representation matters. This is multiple dispatch, and it is best understood not as a language quirk but as a design tool.
Dispatch belongs to the operation, not the class
In many object-oriented languages, a method belongs to a class, and the runtime looks up the implementation based primarily on the receiver. Julia selects a method by matching the types of all arguments. Behavior belongs to the operation rather than to a class hierarchy.
This has a practical consequence. Because generic algorithms in Base and in packages call ordinary functions, adding a method for your own type plugs it into existing machinery. Sorting, broadcasting, and linear algebra become extensible without inheritance or wrapper types. You do not need to subclass AbstractVector to make your container work with sort; you define a method for sort that accepts your type.
A worked example: one driver, two backends
Consider a small ODE-style right-hand side and a time stepper. The goal is a single driver that works whether the system matrix is dense or sparse.
# Generic fallback: any matrix-like p, any array-like u
function rhs!(du, u, p, t)
# generic implementation, e.g. du .= p * u
return du
end
# Sparse-specific method
function rhs!(du, u, p::SparseMatrixCSC, t)
# sparse-aware implementation, e.g. mul!(du, p, u)
return du
end
# step! dispatches on the solver type
abstract type AbstractSolver end
struct DenseEuler <: AbstractSolver end
struct SparseEuler <: AbstractSolver end
function step!(solver::DenseEuler, sys, dt)
rhs!(sys.du, sys.u, sys.p, sys.t)
# dense update
end
function step!(solver::SparseEuler, sys, dt)
rhs!(sys.du, sys.u, sys.p, sys.t)
# sparse update
end
# One driver
function run!(solver::AbstractSolver, sys, dt, steps)
for _ in 1:steps
step!(solver, sys, dt)
end
end
The driver run! never mentions dense or sparse. It calls step!, and Julia selects the method based on the solver type. Inside step!, the call to rhs! selects a method based on the type of p. If you later add a BigFloat backend or a dual-number type for automatic differentiation, you add methods rather than editing the driver.
To confirm the expected methods exist in a fresh environment, run julia --project=. -e 'methods(rhs!)' and inspect the output. The generic fallback and the sparse method should both appear.
Trade-offs you should plan for
Ambiguity
The sharp edge is method ambiguity. When two applicable methods are equally specific, Julia raises a MethodError or requires an explicit disambiguating method. Overlapping abstract signatures—for example f(x::AbstractVector) and f(x::AbstractVector{<:Real})—need deliberate design. Write tests that call the ambiguous signature and confirm the error, so the disambiguating method is not accidentally removed later.
Inference and the function barrier
Dynamic dispatch is cheap per call, but untyped or unstable hot loops defeat inference. If a field is typed as Any or a variable is a global, the compiler cannot generate specialized code. The standard fix is a function barrier: keep the outer API generic, but pass arguments into a small inner function where types are concrete. Use @code_warntype on the hot loop and confirm no Any appears in the inferred types.
Invalidation and precompilation
Changing a method signature or struct layout can invalidate dependent code and force recompilation. This is a real engineering cost in large projects. A committed Manifest.toml plus CI on a pinned Julia version keeps latency and behavior predictable. When you upgrade Julia, expect some packages to recompile; pin the minor version if you need reproducible build times.
An actionable starting point
Start with one generic method over abstract types. Add narrow methods only when precision or semantics differ. Test for ambiguity by deliberately defining overlapping methods and confirming the resulting error message. Guard hot loops with a function barrier and inspect them with @code_warntype. Finally, commit Manifest.toml and run CI on a pinned Julia version.
Multiple dispatch does not eliminate design work. It moves the design from class hierarchies to method tables. The payoff is code that is generic where it should be generic and specific where it must be specific, without a layer of wrapper objects to maintain.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.