Constructors for parametric types

Hi all, I am studying how Julia handles constructors in parametric types and have found a puzzling behavior.

Consider this trivial type:

struct Test1{R <: Real}
    x::R
end

I can instantiate Test1 either with or without stating R explicitly:

# Works
println(Test1(1.0))
# Works
println(Test1{Float64}(1.0))

If I provide an explicit inner constructor, the first case no longer works but the second one does:

struct Test2{R <: Real}
    x::R

    Test2{R}(r::R) where {R <: Real} = new{R}(r)
end

# Does not work, of course!
# println(Test2(1.0))
println(Test2{Float64}(1.0))

Here comes the puzzling part: suppose that I want my type to handle the case when the caller passes a complex number by just taking its real part:

struct Test3{R <: Real}
    x::R

    function Test3{C}(z::C) where {C <: Complex}
        r = real(z)
        R = typeof(r)

        return new{R}(r)
    end
end

# Why does this not work?
println(Test3{ComplexF64}(1.0 + 2.0im))

Although it seems to me that Test3 closely matches the way the inner constructor for Test2 was defined, this does not work and produces this error:

ERROR: LoadError: TypeError: in Test3, in R, expected R<:Real, got Type{ComplexF64}

The way to solve this problem is to drop {C} from the inner constructor:

struct Test4{R <: Real}
    x::R

    # No `Test4{C}` here, just `Test4`
    function Test4(z::C) where {C <: Complex}
        r = real(z)
        R = typeof(r)

        return new{R}(r)
    end
end

# Works!
println(Test4(1.0 + 2.0im))

I am curious about what’s happening in Test3: I do not understand why Julia still requires that the input type be <: Real when the inner constructor clearly says that it can be <: Complex. May somebody help me understand Julia’s behavior here?

Hi,

This issue is that a constructor function Test3{C}(...) should create an instance of type Test3{C}, regardless of the input arguments. The error then expresses that C (ComplexF64) does not satisfy the <: Real you have in struct Test3{R <: Real}.

Something like

struct Test5{R <: Real}
    x::R

    function Test5{R}(z::Complex{R}) where R
        r = real(z)
        return new{R}(r)
    end
end

println(Test5{Float64}(1.0 + 2.0im))
# Test5{Float64}(1.0)

will work just fine, even though the input type Complex{Float64} does not satisfy <: Real.

1 Like

Thank you @eldee, for your answer. So, if I understand correctly, it is not enough that the new{R}(r) statement within the Test3{C} inner constructor satisfies the requirement R <: Real, because the very definition of the constructor (Test3{C}(z::C) where {C <: Complex}) should have satisfied the same requirement too?

Julia doesn’t enforce this. As you say, the error comes from the type itself, before the constructor call even occurs. The constructor’s signature cannot widen the type’s parameters.

julia> struct X{T<:Real}
         function X{T}() where T<:Number
           return "hello"
         end
       end

julia> X{Int}()
"hello"

julia> X{Int}
X{Int64}

julia> X{Complex}
ERROR: TypeError: in X, in T, expected T<:Real, got Type{Complex}
2 Likes

Thanks a lot, @Benny , your example really nails it!

I must confess that it would have been more useful to prevent the compilation of the inner constructor for struct X{T<:Real} instead of signaling the problem only once you try to refer to X{Complex}.

Compilation does not occur when definitions are evaluated; method compilation is triggered by precompile or calls. But yes it could be nice if the type bounds related to types are checked at definitions; this isn’t done for subtypes either, so manually match or narrow the type bounds on subtypes to stay sane. But on the other hand you would no longer be able to write where T as a shorthand because that’s an implicit T<:Any.

1 Like