Parametric type conversion without eval/repetition?

Suppose I have several parametric wrapper types, like so (toy example):

abstract type AbsWrapper end
struct WrapperA{T <: Real} <: AbsWrapper
    x::T
end
struct WrapperB{T <: Real} <: AbsWrapper
    x::T
end

I’d like to write converter functions that convert instances of an AbsWrapper to a “similarly typed” value wrapping a Float64. E.g.
to_float64_form(w::WrapperA) = WrapperA(Float64(w.x))

Great, this works! And if I have just two wrapper types and one conversion, two one-line functions are all I need. But suppose I have types WrapperA, WrapperB, WrapperC, etc., and I want to write converters to_float64_form, to_float32_form, to_rational_form,… Then there are (number of types) * (number of conversion) functions to write, and the source code gets repetitive. This can be solved (or at least mitigated?) with @eval, e.g.

for T in [WrapperA, WrapperB]
    @eval to_float64_form(val::$T) = $T(Float64(val.x))
end

But I’m not sure if @eval is necessary here. This feels like a use case that should be solvable with a single parametric method, but I’m struggling. Is @eval the preferred way to handle this, and if not what is best practice?

Your @eval based approach looks good assuming the best design is creating many variants of basically the same type. However, I’m not sure this is the case. Instead you might an enum that stores a tag to associate with the Wrapper. Which approach is better depends a bit on what your eventual use-case is.

1 Like

Depending what other type-parameters / constructors your real case has, you could use Base.typename, which strips out all type parameters like Base.typename(WrapperA{Float32}).wrapper will return just WrapperA. Then you can just re-call the constructor with the appropriate conversion:

to_float64_form(val::A) where {A<:AbsWrapper} = Base.typename(A).wrapper{Float64}(val.x)

# returns WrapperA{Float64}(1.0), is type-stable:
to_float64_form(WrapperA(1f0)) 
# same for:
to_float64_form(WrapperB(1f0)) 

@Oscar_Smith and @marius311, thank you both for the helpful responses.

@marius311 I can’t find where I saw this, so I’m starting to doubt myself, but I thought I recalled that using .wrapper isn’t good practice, since the structure of Base.typename(A) is an implementation detail subject to change. Not so? I guess I’m even worried about using typename, since it’s not documented. If these are both okay, then yeah this seems like a nice concise solution!

@Oscar_Smith A little detail on my use case for context. I’m working on a simple hexagon library. There are several kinds of hexagon coordinates. I have (for now) hexagon types HexFlatTop and HexPointyTop, both <: AbstractHexagon. Both hex types are wrappers around a coordinate object (and possibly other information like size). So the conversions I’m interested in look like HexFlatTop{CoordCubic}HexFlatTop{CoordAxial}, etc. With @eval I have

for hextype in (HexPointyTop, HexFlatTop), coordtype in (CoordAxial, CoordCubic)
    @eval to_coord_form(::Type{$coordtype}, h::$hextype) = ($hextype)(convert($coordtype, h.coords))
end

Yea I think that’s right, if you have low tolerance for possibly needing to update this code with future Julia versions, I think @Oscar_Smith’s suggestion is right, make the A or B be a type parameter rather than part of the name, then you can use normal dispatch. You always type alias it back to exactly what you have,

struct A end
struct B end
struct GenericWrapper{AorB, T} <: AbsWrapper
    x :: T
end
const WrapperA{T} = GenericWrapper{A,T}
const WrapperB{T} = GenericWrapper{B,T}
2 Likes

In this case, one option to consider would be to have 1 struct with multiple constuctors. That way your program will be working on data of all the same type, but users can input how they want.

1 Like

Sorry to necro a week-old thread, but: I keep thinking about this problem, and realize I don’t understand something about the design of Julia’s type system. Why is there no function to get the “outer” type of a parametric type, ie the non-parametric part of a parametric type. For example, Set{Int} -> Set.

As @marius311 shows above, this is easy enough to write: outer_type(t) = Base.typename(t).wrapper. But, that implementation requires undocumented internals and an undocumented and un-exported Base function. So it doesn’t feel like the intended way to do this, and best I can tell by reading the docs and liberal apropos(), there isn’t a documented built-in for this functionality.

Why not? Is getting the outer type of a type not a useful thing to do? It would certainly help in the case presented in this thread, though I think Oscar and Marius are correct that my initial post is mostly just bad design. But in general having a function that maps like Set{Int} -> Set or Vector{String} -> Array seems…pretty handy? It’d be a way to learn about the flavor of a collection, or the structure of an object without regard to what kind of data that object holds.

I’ve been using Julia and slowly learning for…5 years?..now, and this is the first time I’ve felt like the type system and associated Base functions prevent me from doing something natural. So I must be confused about why this would be useful, right?

1 Like

I am not sure about this (apart from interactive exploration). It is still not clear to me what the use case is.

Generally, it is not good design to decompose generic types to name + parameters, as it does not always make sense (eg BitVector <: AbstractVector{Bool}, but has no such type parameter). It is better to design a lightweight API for these things. See similar.

1 Like

Not sure this is the best example—maybe the answer is there is no good example, so this missing feature isn’t important, but—

Suppose I wanted to write a function larger_collection that took two inputs collections c1 and c2. Both inputs have to have the same “structural type”, e.g. Set, Vector, etc. larger_collection simply returns whichever collection is larger. But, in this case, I want it to be an error to compare collections of different “outer types”; eg. you can’t compare a Vector to a Set. So I could write definitions for each collection type I know about:

larger_collection(c1::AbstractVector, c2::AbstractVector) = ifelse(length(c1) > length(c2), c1, c2)
larger_collection(c1::AbstractSet c2::AbstractSet = ifelse(length(c1) > length(c2), c1, c2)
larger_collection(c1::AbstractDict, c2::AbstractDict) = ifelse(length(c1) > length(c2), c1, c2)
# etc

But this seems pretty repetitive, and won’t work for collection types I haven’t seen before. It’d be nice to write

function larger_collection(c1, c2)
    if outer_type(c1) != outer_type(c2)
        error(...)
    end
    length(c1) >= length(c2) && return c1
    return c2
end

or even, less ideally because of the BitVector point you correctly make, something like (this won’t parse, as far as I can tell)

function larger_collection(c1::S{T}, c2::S{R}) where {S, T, R}
    length(c1) >= length(c2) && return c1
    return c2
end

It seems like Julia doesn’t support the second or third options, leaving users with the more repetitive and less generic first option.

1 Like

I think one difficulty in this example is guessing at which level the type should be the same. That is, you write larger_collection(c1::AbstractVector, c2::AbstractVector) = ... which works for e.g. c1::SArray and c2::SparseVector. It’s not clear whether larger_collection(c1::S{T}, c2::S{R}) (if implemented) should work for such types, or be constrained to the same concrete type (c1::SArray and c2::SArray), or some other level of hierarchy (StaticArray? AbstractSparseVector? Any?).

1 Like

I am not sure I understand the example fully (since you are using abstract types, this includes subtypes you don’t necessarily know about), but you probably want traits. Eg

struct VectorLike end
struct DictLike end

# users can extend these beyond subtype relations
structural_type(::Type{T}) where {T <: AbstractVector} = VectorLike()
structural_type(::Type{T}) where {T <: AbstractDict} = DictLike()

function larger_collection(a, b)
    _larger_collection(structural_type(a), structural_type(b), a, b)
end
_larger_collection(A::T, B::T, a, b) where T =
    length(a) > length(b) ? a : b
_larger_collection(A, B, _, _) =
    error("every time you try to compare $A to $B a kitten dies")
2 Likes