ANN: Convertible.jl - Instant multi-step convert

Hi, everyone,

I proudly present Convertible.jl. The package provides the isconvertible trait that can be applied to type definitions via the @convertible macro. Types that share this trait can easily be converted into one another with a single call to Base.convert even though multiple intermediate conversions might be required.

For those of you who have seen my JuliaCon talk last year, this is a less dirty and generalised version of the type-system-exploiting conversion trick I presented there.

Example

Define convertible types:

@convertible struct A
    val::Int
end

@convertible struct B
    val::Int
end

@convertible struct C
    val::Int
end

Define convert methods:

Base.convert(::Type{B}, a::A) = B(a.val+1)
Base.convert(::Type{C}, b::B) = C(b.val+1)

Convert… (you need to opt-in to this behaviour by wrapping the expression with @convert )
Internally Convertible.jl will compute the shortest conversion path and emit a specialized method based on a generated function, e.g. convert(C, convert(B, a)) in this case.

julia> a = A(1)
julia> @convert convert(C, a)
C(3)

Profit!

2 Likes

This is really cool!

I wonder if it would be possible for Convertible.jl to automatically define the missing convert methods instead of requiring the user to opt-in to the behavior.

I’m imagining a use case where I have ~10 different types that should all be convertible between each other and I don’t want to burden the user with using a macro to do the conversions (I’d like convert to just work). It would be neat if I could simply define a minimal number of convert methods and use Convertible.jl to define the missing ones.

The package originally worked this way (requiring just @convertible) but @tkelman asked me to change it. His objection was that changing the behaviour of a Base method upon import of a package isn’t very good style and might lead to subtle breakage in other people’s code. A valid point in my opinion.

In my packages I tend to hide the call to convert from the users anyways. The user is supposed to use type constructors instead. Like this:

abstract type Blob end

@convertible struct Foo <: Blob end
@convertible struct Bar <: Blob end
@convertible struct FooBar <: Blob end

convert(::Type{Bar}, ::Foo) = Bar()
convert(::Type{FooBar}, ::Bar) = FooBar()

(::Type{T}){T<:Blob, S<:Blob}(s::S) = @convert convert(T, s)

and then the user calls

f = Foo()
fb = FooBar(f)

and never sees the @convert macro.

I just realised that my example above does not work :see_no_evil:

But I fixed the bug and a new release is on its way to METADATA.

Hmm. That’s not quite what I had in mind.

@convertible struct A end
@convertible struct B end
@convertible struct C end

convert(::Type{B}, ::A) = B()
convert(::Type{C}, ::B) = C()

@define_convert_method A C
# expands to: convert(::Type{C}, x::A) = convert(C, convert(B, x))

I think if you do it this way you’re not changing the behavior of convert when Convertible.jl is imported. Rather it’s just helping you define a bunch of additional methods for convert.

Interesting.

I think I would also have something like @generate_convert without arguments that generates the missing methods for all permutations if you have many convertible types.

Your approach shifts the costs from run-time to compile-time which might be good for many applications.

Of course after writing that I realized I can get what I want with

convert(::Type{C}, x::A) = @convert convert(C, A)

which isn’t a whole lot more typing than @define_convert_method A C.

The macro that generates all the missing permutations is probably more useful than my idea.