Type parameters & constructors

I’ve defined the following type:

struct ComplexNumber{T<:Real} <: Number
    re::T
    im::T
end

I am having trouble understanding why the following two outer constructors behave differently:

ComplexNumber(x::Real, y::Real) = ComplexNumber(promote(x, y)...)
ComplexNumber(x::T, y::T) where {T<:Real} = ComplexNumber(promote(x, y)...)

The second one throws a StackOverflow error, the first one does not. The following modification makes the second constructor behave identically to the first:

ComplexNumber(x::T, y::T) where {T<:Real} = ComplexNumber{T}(promote(x, y)...)

How does the presence or absence of type parameters in the method signature affect access to the default (inner) constructor?

As far as I understand, the one that stack-overflows is just identical to the default constructor (it expect two arguments of the same type). The third one, with the type parameter, is like a different function, which an additional parameter, that here coincides with the type T, but could be something else.

2 Likes

The issue is your second method states that x, y have the same type T, which looks identical to the default constructor for ComplexNumber. When you promote two numbers of type T, you get back two numbers of type T, which dispatches back to the same method. Adding unique type parameters for x and y both makes it work:

julia> ComplexNumber(r::S, i::T) where {S<:Real, T<:Real} = ComplexNumber(promote(r, i)...)

julia> ComplexNumber(1, 2.0)
ComplexNumber{Float64}(1.0, 2.0)

The last one you showed disambiguates because there is a difference between ComplexNumber and ComplexNumber{T}. You haven’t defined a method for ComplexNumber{T} so that dispatches to the default constructor method with the type parameters.

5 Likes

The {T} in ComplexNumber{T}(args...) is not part of the method signature, it’s part of the function name. At the language level, ComplexNumber(...) and ComplexNumber{T}(...) are two independent functions that just so happen to share a part of their names. This little demo might help appreciate this:

julia> struct Foo{T}
           x::T
           
           # Explicit inner constructor to eliminate the default outer constructor
           Foo{T}(x) where {T} = new(x)
       end

julia> methods(Foo{Int})  # This is the inner constructor we defined above 
# 1 method for type constructor:
 [1] Foo{T}(x) where T
     @ REPL[2]:5

julia> methods(Foo)  # Doesn't list the inner constructor because this is a different function
# 0 methods for type constructor

What is happening in your example is that this constructor

ComplexNumber(x::T, y::T) where {T<:Real} = ComplexNumber(promote(x, y)...)

dispatches to ComplexNumber, and the only method of this function is the ComplexNumber(x::T, y::T) where {T<:Real} which we started from; hence you get infinite recursion.

By contrast, this construct

ComplexNumber(x::T, y::T) where {T<:Real} = ComplexNumber{T}(promote(x, y)...)

dispatches to ComplexNumber{T} and therefore ends up calling the implicit inner constructor.

4 Likes