When not to use Multiple Dispatch?

Hi folks!

On my endless quest to make AstroTime.jl the best date and time handling package in the universe, I am pondering another redesign :see_no_evil:

When modelling my problem domains, I always gravitate to pushing a lot of information into the type system. Let’s have a look at an example:

# We want to model `DateTime`-like objects in different time scales
abstract type AbstractTimeScale end

struct TAI <: AbstractTimeScale end
struct UTC <: AbstractTimeScale end

# Now our epochs can have a `scale` field but need to be parametric
struct Epoch{T<:AbstractTimeScale}
    scale::T
    # ...
end

In principle, this seems elegant because you can easily dispatch on different AbstractTimeScale subtypes. There are some less than ideal side effects though.
First of all there is a proliferation of parametric types, e.g., a state vector type which uses Epoch now needs to have a type parameter for the AbstractTimeScale to remain concrete. The dynamic dispatch also seems to have a performance impact (?). Another issue is that handling collections of different subtypes of AbstractTimeScale is painful and requires use of @generated functions and lots of type annotations in my real world code to remain performant, which I consider a code smell.
Finally, a silly thing but something that bothers me is the fact that this coding style requires lots of empty parens, e.g., Epoch(..., UTC()). Feels unintuitive…

TL;DR: Elegant in theory but complex in practice.

A simpler design could just use Symbol for the time scale, e.g.:

type Epoch
    scale::Symbol
end

You lose the benefits of multiple dispatch and need to write “branchy” code. It also makes it harder to make the system user-extensible.

function convert_epoch(ep, scale)
    if ep.scale === :TAI
        if scale === :UTC
            tai_to_utc(ep)
       elseif ...
       ...
       end
    end
end

On the other hand, you do not need to deal with type parameters and generated functions and everything remains concretely typed.

I am not quite sure where I am going with this TBH. I feel like maybe we miss something like Rust’s enum in Julia, i.e., some form of sum types.
Anyhow, how do you approach these things? Am I overthinking this? Are there situations where you avoid multiple dispatch?

3 Likes

What you’re saying makes sense to me. If a type or type parameter isn’t known at compile-time or there are a ton of them to compile for, then there probably isn’t much benefit compared to refactoring runtime-checked types as instances of types known at compile-time.

I agree that long if-else statements aren’t feasibly extensible, my go-to is a const global dictionary mapping symbols to functions (the const makes the variable “constant”, the dictionary itself is still mutable), but there might be better methods. If possible, I would try function barriers (check Performance Tips for an example) to minimize the impact of the type instability from indexing such a dictionary. However, that doesn’t always help e.g. a for-loop that needs a different function each iteration.

Take some time to consider the expected use cases of your package before planning the refactoring, I wouldn’t want to throw away performant code over collections of 1 subtype of AbstractTimeScale if I would need to work with a lot of them.

3 Likes

Another way to deal with the extensibility problem is to offer a Val-based escape hatch.

function convert_epoch(ep, scale)
    if ep.scale === :TAI
         if scale === :UTC
            tai_to_utc(ep)
        elseif ...
            ...
        else
            convert_epoch(ep, Val(ep.scale), Val(scale))
        end
    end
end

Then the user can provide a method for convert_epoch(ep, ::Val{:TAI}, ::Val{:Foo}).

I’m not sure if I understand correctly, but maybe the issue is pretending that Epoch objects built using UTC and TAI are different types, while they actually are exactly the same entity (i.e. a well defined instant in time). Hence, an alternative approach may be to chose a reference frame (e.g. UTC with, say, milliecond precision) and use it as an internal representation for all Epoch objects to be used for calculations:

struct Epoch
    UTC_ms::Int64  # ms since, e.g., 00:00:00 UTC on 1 January 1970
end

Then you may have EpochRepresentation{T<:AbstractTimeScale} objects which are just representations of an Epoch object on a specific time scale, possibly rendered as a string:

abstract type AbstractTimeScale end
struct TAI <: AbstractTimeScale end
struct UTC <: AbstractTimeScale end
struct EpochRepresentation{T<:AbstractTimeScale}
    string::String
end

By doing so Epoch would already be concrete by itself, and you should only implement the conversion routines for I/O against files or terminals, possibly extending the convert function as discussed here.

That is an approximation which only holds if you are close to the geoid where TAI and UTC are defined. If you also need to consider relativistic time scales, you cannot use an absolute reference and two points in time in different time scales are not comparable. I go into the details in my JuliaCon talk from last year. Have a look if you are interested :grin:

1 Like

Definitely, correct! But in this case it is no longer sufficient to specify UTC, TAI or whatever… you’ll also need information concerning an intertial frame.

The topic is definitely interesting and I’ll have a look to your talk. Thanks!

Add some ideas, perhaps, some of them would be helpful.

#==== Initial version =====#

abstract type AbstractTimeScale end

struct TAI <: AbstractTimeScale end
struct UTC <: AbstractTimeScale end

struct Epoch{T<:AbstractTimeScale}
    scale::T
    # ...
end

Epoch(x..., ::Type{T}) where {T<:AbstractTimeScale} = Epoch(x..., T())

# Conversion - OK
convert(::Type{Epoch{TS1}}, y::Epoch{TS2}) where {TS1,TS2} = error("Not implemented")

# Array of Epochs
# Nope, need to promote to smth common first, e.g. UTC, but conversion may do it automatically

# Init - OK
# See constructor above


#==== Version with constants =====#

struct TimeScale
    x::Symbol
    # meta information etc
end

const TAI = TimeScale(:tai)
const UTC = TimeScale(:utc)

struct Epoch
    scale::TimeScale
    # ...
end

# Conversion
# "Branchy"?

# Array of Epochs - OK
# Epoch now is concrete

# Init - OK
Epoch(..., UTC)

#==== Topsy-turvy =====#

abstract type Epoch end

# Common interface
value1(ep::Epoch) = ep.val1
value2(ep::Epoch) = ep.val2
# ...

struct UTC <: Epoch
    # ...
end

struct TAI <: Epoch
    # ...
end

# Conversion - OK
convert(x::E1, y::E2) where {E1,E2} = error("Not implemented")

# Array of Epochs
# Nope, need to promote to smth common first, e.g. UTC, but conversion may do it automatically

# Init - OK
UTC(...)
TAI(...)
1 Like