The Unreasonable Efficiency and Effectiveness of Multiple Dispatch: Your Favourite Examples

Yesn’t. Short version: Both are kinds of polymorphism where a function has multiple methods, though multimethods is only synonymous with multiple dispatch. Function overloading is compile-time polymorphism over compile-time information like types or arity. Multiple dispatch works on runtime information instead and is typically thought to be done at runtime, but Julia’s type inference can resolve dispatch at compile-time, which confuses people used to statically typed languages’ starker divide between static and dynamic.

This is a downside of polymorphism in general, which is why static analysis is such a big want. It’s easier to do by eye in statically typed languages with type declarations, but some rely on type inference, too.

2 Likes

I’m surprised by that. As I see it, it’s totally possible to program in Julia with concret types only, which would then be a defacto static-like subset of the language. This would still be multiple dispatch. So from a theoretical point of view, this would mean multiple dispatch concept is orthogonal to runtime’s. Maybe I’m missing something?

This is why I think it would be great to have systematically the discipline to “stay true” to the original intent of a function (not methods, which are “just” implementations of the aforementioned function).
Most of the confusions arise when function naming is too vague, or generic.
In such case, inferring what the pair (function name + arguments) is expected to do becomes quite subjective. Which results in surprises…
To be more specific with an example, getperimeter(::AbstractPolygon) is quite clear, and so is what’s expected from any method that would implements the function. Conversely, conjugation(::AbstractFoo) isn’t.

My point is, multiple dispatch can be so powerful that it must be accompanied with some care and good practices. And these are quite specific to this language (or other niche languages), which means it’s not widespread programming knowledge.

… this is unfortunately the flip side of the unreasonable efficiency of multiple dispatch.

3 Likes

Correct. That would mean the compiler can and has figured out what the runtime information will be. You don’t need to program “with concrete types only” to accomplish this, you just need the compiler to always infer concrete types. If the compiler infers abstract types, then it will make extra code to work on runtime information anyway.

This stands in contrast to statically typed languages where the compile-time information and the code compiled for it can deviate from runtime information. Say we instantiate Super x = new Sub();, x.staticfoo() will work on the static type Super while x.dynamicfoo() will work on the runtime type made by Sub. The types can even deviate when there is type inference instead of explicit declarations. This doesn’t happen in Julia because 1) it is a dynamically typed language, and 2) it doesn’t have concrete supertypes to even instantiate and compile code for. Some newer statically typed languages eschew inheritance in part due to the simplicity of (2).

“Runtime information sometimes known at compile-time” may seem like a terminological copout, especially since we freely differentiate “static dispatch” from “dynamic dispatch” and loosely say “static Julia” when avoiding the latter. But bear in mind most of the terminology and lines were drawn by older popular statically typed languages, so it’s understandably awkward for describing a newer unusual dynamically typed language.

3 Likes

Thanks for the explanation.

After digging a bit, I found out why I was bothered by the fact that “multiple” implies “dynamic”: it’s just a matter of convention :sweat_smile:
(looking only at the definition of “multiple” and “dispatch” was not enough)

For exemple, c++ templates are considered to be a kind of dispatch, but “static”.
So it appears that “multiple dispatch” requires, by definition, to also involve runtime.

Anyway, so the answer to @xiaodai is … “yes”, it’s a superset.

1 Like
1 Like

In chemistry we have the ideal gas Law PV=NRT

So do anyone program like this?

function CalcVIR(v::Union{Number,Missing},
                 i::Union{Number,Missing},
                 r::Union{Number,Missing})
    ''' If voltage is missing then calc v = i * r
        If current is missing then calc i = v / r
        If resistance is missing then calc r = v / i
    '''
    if ismissing(v)
        return i * r
    elseif ismissing(i)
        return v / r
    elseif ismissing(r)
        return v / i
    end
end

And using it like

Starting Julia…

   _       _ _(_)_     |  Documentation: https://docs.julialang.org
  (_)     | (_) (_)    |
   _ _   _| |_  __ _   |  Type "?" for help, "]?" for Pkg help.
  | | | | | | |/ _  |  |
  | | |_| | | | (_| |  |  Version 1.2.0 (2019-08-20)
 _/ |\__ _|_|_|\__ _|  |  Official https://julialang.org/ release
|__/                   |

julia> v = CalcVIR(missing,3,5)
15

julia> v = CalcVIR(missing,3.4,5.6)
19.04

julia> i = CalcVIR(7,missing,3)
2.3333333333333335
1 Like

I like the following multiple dispatch way:

calcvir(::Missing, i::Number, r::Number) = i * r
calcvir(v::Number, ::Missing, r::Number) = v / r
calcvir(v::Number, i::Number, ::Missing) = v / i
15 Likes

CalcVIR and calcvir seem unnecessarily vague, take the varying output meanings and types given Int64 inputs for example. If the missing position is fixed, I’d rather they be clearer, separate functions that don’t need me to write missing to begin with. If the missing position varies for some reason (maybe read from a file), I’d rather calcvir output a consistently typed, v,i,r-named 3-tuple, and I’d wrap it in another function that takes keyword arguments defaulting to missing so I don’t have to write missing manually.

2 Likes

You are right. But I intend to replicate what CalcVIR does, which is not necessary reflecting my preference.

2 Likes

I just want to show the bizarre way, you can use julia. I don’t think you can do this in C++

Yes, you can do it via multiple dispatch as well.

1 Like

I guess an alternative would be to just have a 2-arg version and put the information about whether a number is representing current, voltage or resistance into the type domain like:

struct Voltage; v; end
struct Current; i; end
struct Resistance; r; end

calcivr(x::Current, y::Resistance) = x.i * y.r;
calcivr(x::Voltage, y::Resistance) = x.v / x.r;
calcivr(x::Current, y::Voltage) = y.v / x.i;
5 Likes

Perhaps, a good case for Unitful.jl:

using Unitful

calcvir(I::Unitful.Current, R::Unitful.ElectricalResistance) = I * R
calcvir(V::Unitful.Voltage, R::Unitful.ElectricalResistance) = V / R
calcvir(V::Unitful.Voltage, I::Unitful.Current) = V / I

v = 10u"V"; i = 5u"A"; r = 2u"Ω"

calcvir(v,i)
calcvir(v,r)
calcvir(i,r)

PS:
Would need to define more methods with arguments’ order swapped.

5 Likes

Personally, I like naming my functions after the thing it calculates, e.g.

voltage(i::Number, r::Number) = i * r
current(v::Number, , r::Number) = v / r
resistance(v::Number, i::Number) = v / i

The next layer of challenge I experience in general is the fact that there’s many ways to compute these terms. At first I tried dispatching off Vals, e.g. (with power)

voltage(::Val{:IR}, i::Number, r::Number) = i * r
voltage(::Val{:PI}, p::Number, i::Number) = p / i
voltage(::Val{:PR}, p::Number, r::Number) = sqrt(p * r)

I thereafter realised that users can’t extend my interfaces/models without piracy. And in my field, there are A LOT of metrics and A LOT of models for computing those metrics, and those models can use the same parameters which have the same units, which throws the dispatching by Unitful.jl quantities out the window :sob:. (See Speed of sound in sea water for an example of what I’m talking about, and that’s just for ocean sound speed; it’s only a small subset of equations for sound speed; there’s many metrics in ocean acoustics that also have a variety of models). So the number of models and functions I need to define starts to proliferate in my field of ocean sonar. (For those interested in why, it’s because the theoretical models have proven insufficient in modelling reality — we need real-world-data-interpolated models, and there’ve been so many papers on developing equations based on recorded data for ocean sound speed alone. The same is true for bottom reflection, acoustical scattering, ambient noise, … this list of things is very long.)

Defining a new type ala Voltage, Resistance, Current above will very quickly start overpopulating and overpolluting the namespace. Additionally, while “Sound Speed” would be a desired output metric — so there’d be a function named sound_speed, there’ll also be other metrics that will be best labelled by a model named “Sound Speed” so I’d have SoundSpeed as a type. Again, this leads to namespace overpopulation and overpollution. Plus the inconsistency of having SoundSpeed as a type for numeric dispatch, and SoundSpeed as a type for model dispatch, just clashes.

I’ve started exploring having one abstract type, abstract type ModelName end which my users and I can MyModelName <: ModelName and then extend sound_speed and other functions by the new model.

Another layer of complexity is that in my field of ocean sonar, sound_speed also needs to be calculated for the seabed (and atmosphere), so I’ve tried out having sound_speed(::Seabed, ::Clay, z::Real) with sound_speed(::Ocean, ::Mackenzie, z::Real)

OR

ocean_sound_speed(::Mackenzie, z::Real) with seabed_sound_speed(::Clay, z::Real).

OR (and I gave up on this one very early because nested module navigation and namespace management between the layers was a bit of a nightmare in Julia, which I assume/hope will get easier in the future)

module OceanSonar
  abstract type ModelName end

  struct Mackenzie <: ModelName end
  struct Clay <: ModelName end

  module Ocean
    using ..OceanSonar: Mackenzie

    sound_speed(::Mackenzie, z::Real) = ...
  end

  module Seabed
    using ..OceanSonar: Clay

    sound_speed(::Clay, z::Real) = ...
  end
end

which will require e.g. import OceanSonar.Ocean: sound_speed for extension.
I think the latter needs to be my solution. The top level OceanSonar will contain all the <:ModelName subtypes for dispatching, and then the inner modules will define methods on them.

Finally, my users (and I) will DEFINITELY want to query what models are available for what function, and what inputs are needed for each model. Maybe traits? Either traits or introspection with things like InteractiveUtils.jl and MethodInspector.jl. I think I’ll have to go with traits, but this is the the layer of exploration I’m up to right now, and I’ve explored the latter plenty and the former a little but currently. On top of that, as per Julia’s progress in the generation of binaries, I may have troubles with introspection moving ahead.

I can’t find much in the Julia ecosystem that explores this. As far as I’ve seen, they resort to defining new types for each model. As argued above, I arguably can’t do that.

So to bring this thread full circle, the above complexity is most easily simplified and implemented in Julia with its multiple dispatch paradigm. I’ve been trying it out in various languages (i.e. MATLAB and C++) and OH BOY I definitely, definitely want to use Julia for this. You should see my boss’ code for trying to do this model and input variable dispatching in MATLAB. So. Much. Boilerplate. Even more boilerplate in C++ where it’s a whole lot of nested conditionals and doesn’t have introspection. I still have plans on exploring how other languages would handle this, but the fact that Julia is the only language that has multiple dispatch as the main paradigm makes me somewhat content for the time being.

Bringing this comment full circle, I also have plans on exploring acausal modelling both in differential equations (which applies to the field of ocean acoustics as a subfield of ocean sonar) and in just the equation above. The implementations of the VIR relationship are just one equation, so in the spirit of acausal modelling it should be theoretically possible to define the equation V = IR and then somehow specify the inputs and desired output, obviating the boilerplate of writing a function for every manipulated version of the equation. This is possible with the recent advances of Symbolics.jl in symbolic equation solving.

Additional Thoughts: I’ve also been thinking a lot (long before this thread), how best to do something like how packages can write code for something that is Tables.jl compliant, i.e. it will accept types defined off Tables.jl but it will be completely unaware of what that type is, and it will “just work.” For example, the ocean acoustics submodule of OceanSonar will have a variety of ways to solve the wave equation (Helmholtz equation, Eikonal equation, etc.) and various solvers from DifferentialEquations.jl should be accessible by my package, without my package knowing what that solver is. There’s a few other contexts where such a composable design pattern is needed. Julia enables such composable design to increase linearly with the number of types, with a multiplicatively exploding number of functionalities. In half-theory at least (half because we’ve already demonstrated this in so many places, a recent magical moment I had was seeing how DimensionalData.jl works so well with AlgebraOfGraphics.jl, and they don’t even know each other, but Tables.jl facilitated their composability — the other half is, I’m still writing my package so we’ll see how I go).

3 Likes

FWIW, we have a (non-registered, in-progress) package handling thermodynamic functions for a few idealized models. The variety of possible inputs is handled by letting the input be a named tuple. Each thermodynamic function (e.g., pressure, temperature) dispatches on both the model and the input. Plus some syntactic sugar to make broadcasting easier, and unit tests with autodiff to check as many relationships between functions as we can. This works for us so far. Extensibility relies heavily on multiple dispatch and other core Julia features, and seems hard to obtain without them.

3 Likes