Default v.s. inner constructor changes dispatch?

I’m running into an inconsistency between default and inner constructors and have tried to construct an MWE below. Consider code version a)

struct MyType{V}
      x::V
       y::Int64
end
MyType{V}(x::V, y::Real) where {V} = MyType{V}(x, Int(floor(y)))
MyType{Float64}(3.5, 4.5) # infinite loop

and code version B)

struct MyType{V}
       x::V
       y::Int64
       MyType{V}(x::V, y::Int) where {V} = new{V}(x,y)
end
MyType{V}(x::V, y::Real) where {V} = MyType{V}(x, Int(floor(y)))
MyType{Float64}(3.5, 4.5) # gives MyType{Float64}(3.5, 4)

My understanding of the default constructors suggests that the inner constructor in code version B) should be automatically generated in code version A). But these codes are not equivalent! The first one infinite loops. Is this a bug or am I misunderstanding something?

1 Like

NOTE:

Read @sukera’s answer below, as it is the correct one. :slight_smile:


Without knowing the real reasons behind this behavior, we can try something

julia> struct MyType{V}
           x::V
           y::Int64
       end

julia> MyType{Float64}()
ERROR: MethodError: no method matching MyType{Float64}()
Closest candidates are:
  MyType{V}(::Any, ::Any) where V at REPL[1]:2
Stacktrace:
 [1] top-level scope
   @ REPL[2]:1

julia> struct MyType2{V}
           x::V
           y::Int64
           MyType2{V}(x::V, y::Int) where {V} = new{V}(x,y)
       end

julia> MyType2{Float64}()
ERROR: MethodError: no method matching MyType2{Float64}()
Closest candidates are:
  MyType2{V}(::V, ::Int64) where V at REPL[3]:4
Stacktrace:
 [1] top-level scope
   @ REPL[4]:1

Here we see that the default constructor is not MyType{V}(::V, ::Int), but MyType{V}(::Any, ::Any) where V. So when you declare the first variant, the constructor MyType{V}(x::V, y::Real) where V is actually more specific than the original constructor, and so it is what gets called, leading you to a stack overflow.

On the second hand, the inner constructor MyType2{V}(::V, ::Int) is the only defined. When you declare the outer one, it will be less specific and will call the first without having stack overflow.

The reason of why the default constructor is not more specific is unknown to me, but probably someone else can answer that design choice.

1 Like

Right answer was already said, and here’s just another way to see it:

julia> struct MyType{V}
             x::V
              y::Int64
       end

julia> methods(MyType)
# 1 method for type constructor:
[1] MyType(x::V, y::Int64) where V in Main at REPL[18]:2

julia> methods(MyType{Int}) # Int as filler
# 1 method for type constructor:
[1] MyType{V}(x, y) where V in Main at REPL[18]:2

You can see the default outer constructor MyType annotates y::Int64, but the default inner constructor MyType{V} was not (the only hint it’s the inner one is the fact the name was written with the exact parameters of the struct, as the example showed you could also write an outer constructor name with the same parameter signature).

I think inner constructors tend to not annotate arguments because outer constructors must go through them to the root constructor new(), so the default inner is designed to take in explicit type parameters and any arguments an outer constructor can give. As your example showed, you can write your own type-annotated inner constructor to further restrict what outer constructors can give, but the downside is you need to do all the necessary type conversions in each of your outer constructors. Since inner constructors are contained to the struct and often much fewer than the outer ones, it is easier to do type conversions and enforce other constraints in them.

The default outer one is intended to only take in valid field values and infer all the type parameters from those, so it can afford more specific annotations.

Thanks! Is this explicitly documented anywhere? I suppose it’s semi-mentioned in the tutorial, but it would be nice to where to get an authoritative reference for these kind of things

This is not correct - there are two functions defined by default:

If any inner constructor method is defined, no default constructor method is provided: it is presumed that you have supplied yourself with all the inner constructors you need. The default constructor is equivalent to writing your own inner constructor method that takes all of the object’s fields as parameters (constrained to be of the correct type, if the corresponding field has a type), and passes them to new , returning the resulting object:

https://docs.julialang.org/en/v1/manual/constructors/#man-inner-constructor-methods

julia> methods(MyType)
# 1 method for type constructor:
 [1] MyType(x::V, y::Int64) where V
     @ REPL[1]:2

julia> methods(MyType{Float64})
# 2 methods for type constructor:
 [1] MyType{V}(x::V, y::Real) where V
     @ REPL[2]:1
 [2] (var"#ctor-self#"::Type{MyType{V}} where V)(x, y)
     @ REPL[1]:2

Notice that both MyType(::v, ::Int64) and (var"#ctor-self#"::Type{MyType{V}} where V) (which is the default-catch all that calls convert on all its arguments) are both defined in the same “line” - REPL[1]:2, which was the place I defined my struct.

1 Like

What did you run exactly, because I’m not seeing the (var"#ctor-self#"::Type{MyType{V}} where V) method in either version, with or without the outer constructors. Version A below is almost the same but it certainly didn’t print that method.

version A, no outer methods (don't define inner -> default outer + default inner)
julia> struct MyType{V}
             x::V
              y::Int64
       end

julia> methods(MyType)
# 1 method for type constructor:
[1] MyType(x::V, y::Int64) where V in Main at REPL[1]:2

julia> methods(MyType{Float64})
# 1 method for type constructor:
[1] MyType{V}(x, y) where V in Main at REPL[1]:2
version A
julia> struct MyType{V}
             x::V
              y::Int64
       end

julia> MyType{V}(x::V, y::Real) where {V} = MyType{V}(x, Int(floor(y)))

julia> methods(MyType)
# 1 method for type constructor:
[1] MyType(x::V, y::Int64) where V in Main at REPL[1]:2

julia> methods(MyType{Float64})
# 2 methods for type constructor:
[1] MyType{V}(x::V, y::Real) where V in Main at REPL[2]:1
[2] MyType{V}(x, y) where V in Main at REPL[1]:2
version B, no outer methods (define inner -> no defaults)
julia> struct MyType{V}
              x::V
              y::Int64
              MyType{V}(x::V, y::Int) where {V} = new{V}(x,y)
       end

julia> methods(MyType)
# 0 methods for type constructor:

julia> methods(MyType{Float64})
# 1 method for type constructor:
[1] MyType{V}(x::V, y::Int64) where V in Main at REPL[1]:4
version B
julia> struct MyType{V}
              x::V
              y::Int64
              MyType{V}(x::V, y::Int) where {V} = new{V}(x,y)
       end

julia> MyType{V}(x::V, y::Real) where {V} = MyType{V}(x, Int(floor(y)))

julia> methods(MyType)
# 0 methods for type constructor:

julia> methods(MyType{Float64})
# 2 methods for type constructor:
[1] MyType{V}(x::V, y::Int64) where V in Main at REPL[1]:4
[2] MyType{V}(x::V, y::Real) where V in Main at REPL[2]:1

Ah, the printing may be a little different for me - I was on a PR version with improved printing for method lists. Seems like I have to fix a bug, thank you for making me aware!

I ran the first example from the initial post, including the extra outer method. Nevertheless, the important part is that there are two default constructors. The first one for the bare type and the second one for the parametrized type.

1 Like