Default parametric constructors of composite types

proposal

#1

Observe the following code under Julia 1.1

julia> struct A{T}
       a::T
       b::Vector{Int}
       end

julia> A(1, [1])
A{Int64}(1, [1])

julia> A(1, [1,2])
A{Int64}(1, [1, 2])

julia> A(1, 1:2)
ERROR: MethodError: no method matching A(::Int64, ::UnitRange{Int64})
Closest candidates are:
  A(::T, ::Array{Int64,1}) where T at REPL[1]:2
Stacktrace:
 [1] top-level scope at none:0

julia> A{Int}(1, 1:2)
A{Int64}(1, [1, 2])

julia> A(a::T, b) where {T} = A{T}(a, b)
A

julia> A(1, 1:2)
A{Int64}(1, [1, 2])

The problem is that the default constructor A(a,b) does not perform any automatic conversions although the constructor A{Int}(a::Int,b) does perform an automatic conversion of b to an appropriate type. Also if the struct is not parametric then also automatic conversion is performed.

I find it a bit inconsistent. Was it considered to allow for automatic conversion of non-parametric fields in parametric type? Or maybe there are some problems if this were allowed?


#2

I think the question is: how does Julia know how to convert from UnitRange{Int} to Vector{Int}?


#3

Though I see that in this case there is

julia> Vector{Int}(1:3)
3-element Array{Int64,1}:
 1
 2
 3

#4

Julia knows this conversion. The problem is that the method signature generated for a default constructor is too specific, see here:

julia> struct A{T}
              a::T
              b::Vector{Int}
              end

julia> methods(A)
# 1 method for generic function "(::Type)":
[1] (::Type{A})(a::T, b::Array{Int64,1}) where T in Main at REPL[1]:2

julia> methods(A{Int})
# 1 method for generic function "(::Type)":
[1] (::Type{A{T}})(a, b) where T in Main at REPL[1]:2

and you see that the signature for b in the first case (constructor without a parameter) is restricted, but it is not restricted for a constructor with a parameter.


#5

I think it is actually consistent with method dispatch. The default constructor defines a specific method, but automatic conversion would mess up with type dispatch. The default constructor is just defining a method just like any other function.


#6

Where it would mess up?

My question is why we have

(::Type{A})(a::T, b::Array{Int64,1}) where T

and not

(::Type{A})(a::T, b) where T

automatically defined method signature.


#7

Yeah, I agree with you here. The key thing is that without any type parameters, we get two methods:

julia> struct B
           a::Int
           b::Float64
       end

julia> methods(B)
# 2 methods for generic function "(::Type)":
[1] B(a::Int64, b::Float64) in Main at REPL[4]:2
[2] B(a, b) in Main at REPL[4]:2

The first is the actual constructor, the second is converts arguments appropriately.

With a type parameter, we don’t get the converting method defined on A — it’s only defined on A{T}.

julia> struct A{T}
           a::T
           b::Int
           c::Float64
       end

julia> methods(A)
# 1 method for generic function "(::Type)":
[1] (::Type{A})(a::T, b::Int64, c::Float64) where T in Main at REPL[1]:2

#8

This is my point. In general it is possible that in complex signatures it might be non-trivial/impossible to derive type parameters in a “converting” constructor unambiguously, but already the rule:

  • if a field type is parameterized in any way (i.e. depends on a type parameter) then it is not converted but its type passed exactly as-is to the constructor method signature;
  • if a field type does not depend on any parameter of the type then it is converted;

would improve things and probably would cover most use-cases in practice.


#9

x-ref: https://github.com/JuliaLang/julia/issues/17186#issuecomment-286300768