Limitations of `Base.convert` for the construction of parametric types

I have a couple of user-defined types in which one contains the other, as in the following minimal example:

struct MyType
    x::Float64
end

struct Container
    c::MyType
end

In this example, MyType is some kind of Float64 with additional custom properties (methods that apply to it but not to regular numbers), and it would make sense to create Containers directly from the underlying number. In other words, I would like to be able to create a Container(MyType(x)) by just writing Container(x).

Of course, this (and other conversions) are conveniently handled if I also define:

Base.convert(::Type{MyType}, x::Real) = MyType(x)

However, the solution is not as straightforward if the types are parametric. Namely, if I define:

struct ParType{T}
    x::T
end

struct ParContainer{T}
    c::ParType{T}
end

Base.convert(::Type{ParType{T}}, x::T) where T = ParType(x)

Then ParContainer(1) throws ERROR: MethodError: no method matching ParContainer(::Int64).

It does work if I give the parameter of ParContainer, i.e. ParContainer{Int}(1). Another workaround is to define a parameter-less constructor explicitly:

ParContainer(x::T) where T = ParContainer{T}(x)

However, I would prefer not having to define new constructors for ParContainer, and rely only on methods dispatching on ParType, like happens with Base.convert. Is there a way of doing this?

Does Base.convert(::Type{<:ParType}, x) = ParType(x) do what you want? Or maybe Base.convert(T::Type{<:ParType}, x) = T(x)?

This issue doesn’t really involve convert or nested types, that part is actually fine here. Parametric type definitions just don’t automatically make the parameter-less constructor methods that many people want. You have to define them yourself, which you did.

julia> struct MyType
           x::Float64
       end

julia> methods(MyType) # one for known field, one for conversions
# 2 methods for type constructor:
 [1] MyType(x::Float64)
     @ REPL[13]:2
 [2] MyType(x)
     @ REPL[13]:2

julia> struct MyType2{T} # useless parameter
           x::Float64
       end

julia> methods(MyType2) # all gone!
# 0 methods for type constructor

julia> methods(MyType2.body) # parametric constructor still here
# 1 method for type constructor:
 [1] (var"#ctor-self#"::Type{MyType2{T}} where T)(x)
     @ REPL[16]:2

The equivalent approach to what you demonstrated would be:

MyType2(x::T) where T = MyType{T}(x)

You might spot the problem here: T is a useless parameter and x would be converted to Float64 prior to successful instantiation, so there’s no reason for T to match typeof(x). We don’t need MyType2{Int64}(1.0), in fact we’d have more type stability if we just instantiated MyType{Float64}(x). There isn’t a good automatic way to compute type parameters entirely from inputs. A more practical but complex example would be inputs with different types for fields that share only 1 parameter; it’s not clear if promotion is desired, and you’d still have to implement promote yourself. I think there is an issue about adding these default constructors anyway, letting us opt out manually like usual, that ended up saying it’d be breaking.

1 Like

@mikmoore : no, the result is the same with the alternatives that you suggest.

@Benny : you explanation clarifies some things indeed (and now I understand better how the constructors of parametric types work, thank you!). I would have expected my example to work as desired, since the parameter of my container type is not “useless”, so ParContainer does have a parameter-less constructor:

julia> struct ParContainer{T}
           c::ParType{T}
       end

julia> methods(ParContainer)
# 1 method for type constructor:
 [1] ParContainer(c::ParType{T}) where T
     @ REPL[5]:2

Now, I understand that the problem is that I made an excessively broad interpretation of this statement about convert:

I thought that “assigning to a field of an object” would also apply to the action of default constructors, but I see that this is not the case. The default constructor is like a regular function with respect to this, and convert is not called to match the types of function arguments. It only seems to apply to nonparametric types because they also have default constructors with generic arguments. Right?

And I guess that this won’t change, if as you say:

That occurs within the (possibly automatic) constructor method; it cannot occur during the prior method dispatch. In your case, the ParContainer call won’t do any automatic conversion, and the only available method it can dispatch to, ParContainer(c::ParType{T}), only works on a ParType input.

I suppose a reason against automatically making the ParContainer(x::T) where T = ParContainer{T}(x) method is that there’s no language-level guarantee that the convert would make a ParType{S} from x::T where T==S, so ParContainer{T} containing a ParType{T} would fail to hold ParType{S}. There isn’t even a guarantee that convert would make a ParType at all; you implement the method so you can do anything. I can still see a world where it is automatically made, but with all the nuances of instantiation, it really seems simpler to generally make our constructors ourselves while enjoying the more obvious freebies sometimes.

1 Like