Conversion, promotion, user API?

I am struggling to find the right design due to the shear amount of possibilities, so I need help :wink:

Given a type FooID, which is a simple number with a bit of additional convenience attributes (I added that to to show that I cannot do a simple type alias to Int32):

struct FooID
    value::Int32
    q1::Int8
    q2::Int8
    q3::Int8

    function FooID(value)
        d = digits(abs(value), pad=3)
        return new(value, d[1], d[2], d[3])
    end
end

This type is now used as field type in

struct Bar
    fooid::FooID
end

Now I need to define a ton of functions which take either FooID, Bar or even a simple Integer. I tried different ways to provide a general API and this is the best solution I have found so far:

convert(::Type{FooID}, bar::Bar) = bar.fooid
convert(::Type{FooID}, x::Integer) = FooID(x)

function isvalid(x)
    x = convert(FooID, x)
    x.fooid.q1 == 1
end

function hasfuture(x)
    x = convert(FooID, x)
    x.fooid.q1 > 2
end

This works, but I have to repeat that convert line everywhere. I also tried promote, promote_rule and Union things, but I would like to keep the API clear and understandable and could not find a better way yet.
The first idea was to define

hasfuture(x::FooID)

and then implement prmote_rule to do the automatic conversion when a user falls it with Bar, but I don’t like that since it does not clearly communicate that it also accepts a Bar.

Another approach is to define

hasfuture(x::Union{FooID, Bar, Integer})

but that also doesn’t feel right either.

I also thought about using a type hierarchy and introduce an AbstractFooID where everything derives from, but of course Integer will still be a special case…

Is there a more Julian approach to this?

1 Like

I think the Julian approach is something like this:

"""
    hasfuture(x::Union{FooID, Bar, Integer})

docstring here
"""
function hasfuture end

function hasfuture(x::Integer)
    id = FooID(x)
    id.q1 > 2
end

hasfuture(x::Bar) = x.fooid.q1 > 2
hasfuture(x::FooID) = x.q1 > 2

In other words, the docstring defines the API, not the implementation. So in the docstring you can say that the signature is hasfuture(x::Union{FooID, Bar, Integer}), but you actually implement it by implementing three different methods.

1 Like

Ah yeah, you are right.

In fact, I don’t need to duplicate the logic either, it’s just

hasfuture(x::Bar) = hasfuture(FooID(x))

and then provide the conversion functions.

Make sense, thanks! :slight_smile:

1 Like

Oh wait, but then I end up with a lot of

hasfuture(x::Bar) = hasfuture(FooID(x))
haswhatever(x::Bar) = haswhatever(FooID(x))
isvalid(x::Bar) = isvalid(FooID(x))

This is clearly a pattern which should be tackled by Julia easily but I don’t see the forest anymore :see_no_evil:

That looks fine to me. I think that’s just the way it is. It’s better than this:

function hasfuture(x)
    if x isa Integer
        FooID(x).q1 > 2
    elseif x isa FooID
        x.q1 > 2
    elseif x isa Bar
        x.fooid.q1 > 2
    else
        throw(ArgumentError("oops"))
    end
end  

Hm I am sure there is a better design since this is clearly a code (concept) repetition:

hasfuture(x::Bar) = hasfuture(FooID(x))
haswhatever(x::Bar) = haswhatever(FooID(x))

I hope that someone has a better idea, at least I think that’s the right track.

if you can indeed reduce repetition to this, then you can do a loop eval:
https://github.com/JuliaLang/julia/blob/5b7699217267cec91d5b2b3d1ce1d47fd53f6068/base/sort.jl#L323-L325

3 Likes

Yes that does not look so awful, I guess. I also thought about using a macro…

For now I have something similar to:

convert(::Type{FooID}, bar::Bar) = bar.fooid
convert(::Type{FooID}, x::Integer) = FooID(x)

function isvalid(x::FooID)
    x = convert(FooID, x)
    x.fooid.q1 == 1
end
isvalid(x::Union{Bar, Integer}) = isvalid(convert(FooID, x))

function hasfuture(x)
    x = convert(FooID, x)
    x.fooid.q1 > 2
end
hasfuture(x::Union{Bar, Integer}) = hasfuture(convert(FooID, x))

Here’s a generic approach that should work:

struct FooID
    value::Int32
    q1::Int8
    q2::Int8
    q3::Int8

    function FooID(value)
        d = digits(abs(value), pad=3)
        return new(value, d[1], d[2], d[3])
    end
end

fooid(x::FooID) = x
fooid(x::Integer) = FooID(x)

struct Bar
    fooid::FooID
end

fooid(x::Bar) = x.fooid

hasfuture(x) = fooid(x).q1 > 2

Now you only have to define one version of hasfuture, haswhatever, isvalid, etc. :slight_smile:

1 Like

Yes, that was actually one of my first approaches and it is basically the same as using convert. I just miss the API verbosity :confused:

EDIT: a bit more information: the functions are often very large, so writing fooid(x) everywhere is a bit noisy, so one would anyways do something like fooid = fooid(x) at the beginning of the function, which is technically the same as fooid = convert(FooID, x).

I think the generic approach is the most Julian approach here. It’s like starting a function with a = zero(x) instead of a = 0.

Also, defining convert here is a little un-idiomatic. You normally only define convert if you need implicit conversion to happen when, e.g., adding an element to an array. If you need to explicitly construct a new value, you normally call a constructor, not convert.

5 Likes

In fact, defining convert(::Type{A}, b::B) when you really want a constructor can be a little dangerous. Later on you might accidentally push a B into a Vector{A} without meaning to. Implicit conversion also happens inside default constructors, so that’s another area where you could potentially introduce a bug by defining a convert method.

3 Likes

Alright thanks!

1 Like