Deparametrising types

I’m running into trouble having parametric types that I would sometimes like to refer to by their deparametrised UnionAll versions, while only having instances of the types available.

Here’s a heavily simplified mock-up of what I’m trying to do:

abstract type AbstractContainer end

struct ContainerType1{T} <: AbstractContainer where T <: Any
    loot::T
end

struct ContainerType2{T1, T2} <: AbstractContainer where T <: Any
    loot1::T1
    loot2::T2
end

unpack(c::ContainerType1) = (c.loot,)
unpack(c::ContainerType2) = (c.loot1, c.loot2)

Please don’t take this too literally: There should be more than 2 subtypes of AbstractContainer, and they aren’t all specified by simply how many objects they hold. The point here is the type hierarchy.

There are two, related things I would like to do with these types, that I haven’t figured out a good way to do. First there’s this:

symbolize_contents(x::T) where {T <: AbstractContainer} = T(map(Symbol, unpack(x))...)
symbolize_contents(ContainerType1("1"))

This fails, because there is no constructor ContainerType1{String}(loot::Symbol), whereas what I “mean” is to call ContainerType1(loot::Symbol). The best solution I’ve come up with is to define something like this for each container type,

deparametrize(::Type{<:ContainerType1}) = ContainerType1

after which I can do

symbolize_contents(x::T) where {T <: AbstractContainer} = deparametrize(T)(Symbol(x.loot))

This seems a little silly to me, and more importantly, it doesn’t solve the second thing I’m trying to do, which is to generalise the following to all subtypes of AbstractContainer:

merge_containers(x::ContainerType1, y::ContainerType1) = blahblah
merge_containers(x::ContainerType2, y::ContainerType2) = blahblah

What I’m trying to say here is that I don’t ever want to merge an instance of ContainerType1 with an instance of ContainerType2, but I am fine merging say a ContainerType1{String} with a ContainerType2{Int}. So I would like something like

merge_containers(x::T, y::T) where {T <: AbstractContainer} = blahblah

but with the diagonality happening on the level of deparametrised UnionAll types, rather than the
fully specified concrete types. In other words, ideally, I would want to write

merge_containers(x::T1, y::T2) where {deparametrize(T1) == deparametrize(T2) <: AbstractContainer} = blahblah

or maybe

unionall_containers = (ContainerType1, ContainerType2)
merge_containers(x::T, y::T) where {T in unionall_containers} = blahblah

but neither of these is valid Julia.

I’m interested in both

  1. Learning about any relevant features or design patterns that could help in situations like these.
  2. Understanding why Julia doesn’t allow something like that last bit of code above. Is there a deep reason why what I’m asking for is impossible?

In researching this, I learned about the existence of TypeNames, but I don’t really know if I should try to use them here, considering for instance performance concerns. The manual says very little about them.
Stripping parameter from parametric types is relevant, but doesn’t really solve my case. The second section of this bit of the manual also seems relevant, but I simply don’t understand it.

Adapting this one:

?

1 Like

Thanks, that’s indeed useful. That would give me a more generic way of implementing the deparametrize function, something like

deparametrized_typeof(::T) where T = T.name.wrapper

or

deparametrized_typeof(::T) where T = getfield(parentmodule(T), nameof(T))

Two follow-up questions:

  1. Would something like this come with performance/type stability gotchas?
  2. This doesn’t allow me to do the second thing I wanted to do, the diagonal dispatch type thing that I psedo-coded as merge_containers(x::T1, y::T2) where {deparametrize(T1) == deparametrize(T2) <: AbstractContainer}. If this is impossible to achieve, I would be happy if someone could help me understand why. It seems to me like an entirely natural thing to request from the type system.

Here’s the version of this I have, which also handles arbitraryily deep UnionAlls like e.g. basetype(Array{Complex{T},N} where {T,N}):

basetype(t::DataType) = t.name.wrapper
basetype(t::UnionAll) = basetype(t.body)

The DataType version is inferred and completely optimized away at compile time, you can see e.g. the output is the same for both:

julia> @code_llvm (() -> basetype(Array{Float64,2})([1,2,3]))()
julia> @code_llvm (() -> Array([1,2,3]))()

The UnionAll one also is inferred for some simple cases like basetype(Array{Float32}), but not for anything more complex. I’d be curious if anyone has a way to fix that though.

My intuition would be that the most Julian solution is to drop the requirement that x and y be the same concrete type and instead write your code generically, such that the constraint could just directly be:

merge_containers(x::AbstractContainer, y::AbstractContainer) 

If that doesn’t work, then I think you can do this with some of the trait packages.

1 Like

Thanks for the @code_llvm stuff, that helps.

For merge_containers(x::AbstractContainer, y::AbstractContainer), this is in fact what I’m doing at the moment. The reason I’m not happy with this is that one could accidentally call merge_containers for two different container types, and while this is a nonsense operation, any sort of duck typing might take a while to catch it, or it might even go through but just lead to nonsense behavior. So I would rather catch it early and in an informative way.

I should look into if I can get what I want with traits, it’s time for me to get familiar with them anyway.