Parametric inner constructor inherits parameter from global scope

See Parametric composite type inner constructors in Julia: Why is `where {T}` necessary? - Stack Overflow for the original question.

Now parametric inner constructor without where inherits its parameter from global scope if it is defined. I wanted to make sure that it is an intended behavior (the reason I ask is that it can lead to surprising bugs that structure definition depends on global scope).

Here is an example:

julia> T = String
String

julia> struct Point{T}
           x::T
           y::T
           Point{T}(x,y) = new(x,y)
       end

julia> Point{String}("b","a")
Point{String}("b", "a")

julia> Point{String}(SubString("b",1,1),SubString("a",1,1))
Point{String}("b", "a")

julia> Point{Int}(1, 2)
ERROR: MethodError: no method matching Point{Int64}(::Int64, ::Int64)

julia> Point{String}(1, 2)
ERROR: MethodError: Cannot `convert` an object of type Int64 to an object of type String

We have a similar behavior also for “normal” functions:

julia> T
String

julia> f(x::T, y::T) = x*y
f (generic function with 1 method)

julia> methods(f)
# 1 method for generic function "f":
[1] f(x::String, y::String) in Main at REPL[14]:1

I am not sure what you are trying to do, but this will be

  1. very confusing to readers of your code (not sure many people would catch that T is global unless they explicitly look for it),

  2. completely at odds with Julia’s performance model (baking in type unstable code and dynamic lookups from the very design).

Do you want to restrict to constructors other than Point{T}(::T, ::T)? Just defining an inner constructor would do this. Or restrict T further? Again, you can check in the inner constructor. Not sure what the role of the global is here.

This does seem like an antipattern. In general, you should not be relying on globals for local effects – especially when those effects are not necessary.

I don’t think he is asking if doing this is a good idea, but if it is intended for T to bind to global variables when defining structs and methods where you typically put TypeVars. It seems quite brittle.

3 Likes

T is but a name. It’s kind of an unfortunate name for a global, but I certainly wouldn’t consider Julia’s behavior here to be surprising. Consider the case that T in the original example were replaced with ComplexF64, which is just short for Complex{Float64} (as in there’s a statement const ComplexF64 = Complex{Float64} somewhere). Would anyone still be surprised by Julia’s behavior?

1 Like

I would support making type parameters local to their context.

1 Like

A bit. I would have assumed you would write that as struct Foo{T <: ComplexF64}?

1 Like

This is exactly the point. I know that you cannot avoid problems in all cases, as obviously you can write e.g.:

julia> Int64 = String
String

julia> Int64
String

julia> z = Int64[]
0-element Array{String,1}

but the point is that it is natural to assume that type parameters are local to their context as @JeffreySarnoff writes.

And I understand that in the past (pre where) Julia worked this way and this behavior has changed when where was introduced. My question was if this change was intentional or not.

I believe the type parameter T is local inside the struct (e.g. x::T still refers to the local T). It’s just that for constructors, the local names are not looked up, because they are only bound when you have an actual object:

julia> struct Foo{S}
           Foo{S}() = new{S}()
       end
UndefVarError: S not defined

this should be:

struct Foo1{S}
    Foo1{S}() where S = new{S}()
end

I suppose that it could have looked in the struct so it would have been an error when a constructor definition refers a name in that scope, so that your original example would error too. But at this point that would be a breaking change to the language.

1 Like

There are three things going on here:

  • The name T is just like any other name, like TT or even Int. When you define a method, the types need to be identified by something — and that thing can be (and almost always is) a global name. Typically it’s a constant global, but it can be non-constant, too. It’ll just define the method based upon the value when the method was defined.

  • The syntax Point{Int}(...) = ... is defining a constructor for Point{Int}. Before the where revolution, this syntax meant something completely different — it was how we introduced local type variables for a method signature. So yes, the meaning here changed (and did so dramatically) and it was very intentional — the disambiguation between defining a method on Point{Int}() vs. a method on Point() is a huge benefit from the where clause.

  • We introduce type parameters for structs in their definition: the syntax struct Point{T} is how we introduce a local name T that can be used in field definitions and the parameters of supertypes. Now here’s the confusing part — that T isn’t available to inner constructors, even though it’s written within the same indented block. In fact it cannot be because when you call an inner constructor the Point hasn’t be constructed yet so it doesn’t know what that T is! The inner constructor can even change what the parameter is compared to how it was called:

    julia> struct Point{T}
               x::T
               y::T
               Point{Int}(x,y) = new{Float64}(x,y)
           end
    
    julia> Point{Int}(1,2)
    Point{Float64}(1.0, 2.0)
    

    Ok, so that case is crazy and of course you wouldn’t want to do that in real code. But in real code you do often want to compute some additional parameter that cannot be derived automatically. And in such cases, you “get into” the inner constructor without knowing what the type parameters of the struct are.

    So, yeah, it’s confusing that the type parameters of a struct aren’t available to inner constructors. But it is simply because they’re not known yet. I suppose it’d be nice if the way we spelled out struct definitions would make that more obvious, but there’s a value to the simplicity of where we put our inner constructors.

6 Likes