Type instability in parametric struct

I have a struct where one field is some collection, and another is just a single element of the collection’s eltype. (The real application is more complicated, but I describe the MWE.) I need to use the collection’s type frequently, and I really need to ensure that functions involving this struct specialize, so I parametrize the struct with that type, and then try to specify the types of the fields:

struct X{CT}
    a::CT
    b::eltype(CT)
end

The problem is that the fieldtypes are not properly inferred:

julia> fieldtypes(X{Vector{Float64}})
(Vector{Float64}, Any)

This has huge negative effects when I try to actually use that b field. I’ve tried to annotate the following function in every way I can think of, but there’s always an Any in there.

julia> b(x::CT) where CT = x.b

julia> @code_warntype b(X([1.0, 2.0], 3.0))
MethodInstance for b(::X{Vector{Float64}})
  from b(x::CT) where CT @ Main REPL[6]:1
Static Parameters
  CT = X{Vector{Float64}}
Arguments
  #self#::Core.Const(b)
  x::X{Vector{Float64}}
Body::Any
1 ─ %1 = Base.getproperty(x, :b)::Any
└──      return %1

Even if I specify the return type, there’s still an Any that gets converted, but it still appears to be slow and leads to allocations. Is there any way to get around this that doesn’t involve changing the parameters of the original struct?

versioninfo
julia> versioninfo()
Julia Version 1.10.4
Commit 48d4fd48430 (2024-06-04 10:41 UTC)
Build Info:
  Official https://julialang.org/ release
Platform Info:
  OS: macOS (arm64-apple-darwin22.4.0)
  CPU: 12 × Apple M2 Max
  WORD_SIZE: 64
  LIBM: libopenlibm
  LLVM: libLLVM-15.0.7 (ORCJIT, apple-m1)
Threads: 1 default, 0 interactive, 1 GC (on 8 virtual cores)
1 Like

I don’t think there is a way around that. I believe you must change the Struct definition to

struct X{CT,ET}
    a::CT
    b::ET

    # This inner constructor definition prevents us from constructing the struct if eltype(CT) != ET
    function X(a::CT,b) where CT
        cb = convert(eltype(CT),b)
        return new{CT,eltype(CT)}(a,cb)
    end
end
3 Likes

Since eltype is a function, it cannot be used directly as part of a type parameter in type definitions. This is because type parameters are a compile-time concept, while function calls are executed at runtime.

I don’t know if this is what you need.

struct MyStruct{C, T}
    a::C
    b::T
    function MyStruct(a::C, b::T) where {C, T}
        if eltype(C) != T
            throw(TypeError(:MyStruct, "element type mismatch", T, eltype(C)))
        end
        new{C, T}(a, b)
    end
end
b(x::MyStruct) = x.b
@code_warntype b(MyStruct([1, 2, 3], 4))
MethodInstance for b(::MyStruct{Vector{Int64}, Int64})
  from b(x::MyStruct) @ Main In[6]:1
Arguments
  #self#::Core.Const(b)
  x::MyStruct{Vector{Int64}, Int64}
Body::Int64
1 ─ %1 = Base.getproperty(x, :b)::Int64
└──      return %1
2 Likes

I hate that this is silent. This is what often happens when you try to put function calls in annotations:

julia> struct Y{N}
           a::N
           b::(N+1)
       end
ERROR: MethodError: no method matching +(::TypeVar, ::Int64)

The definition actually immediately tries to execute the call, but most methods reasonably don’t work on TypeVar type parameters. No error was thrown by X and the annotation became ::Any because eltype(::TypeVar) actually reaches the fallback eltype(x) = Any method. I however have no good ideas for an error a struct definition could throw in general to catch these things, and function calls not involving TypeVar could be useful in annotations.

Your intent seemed to be enforcing a constraint at instantiation and trimming a “redundant” type parameter, but annotations can’t do that because methods are redefineable (and very little of Julia are the fixed intrinsics and builtins). Let’s say I instantiate a X{MyOnes}(MyOnes(), 1im), but then I redefine eltype(::Type{MyOnes}) = Bool and instantiate a X{MyOnes}(MyOnes(), true). Now X{MyOnes} has 2 instances with different structures, this can’t have a good implementation anymore. That second parameter is not actually redundant after all.

3 Likes

Thanks for the comments everyone. I had seen this kind of annotation on a struct before, and assumed it worked properly by analogy with what happens in default arguments to a function. Maybe the place I had seen this annotation was also using @computed from ComputedFieldTypes.jl.

I would have thought something like

struct X{T, S<:AbstractArray{T}}
    a::T
    b::S
end

would be a possible solution. Or must it be more general than an array?

1 Like

Yeah, it has to be more general than AbstractArray. And, ideally, I was hoping to do it without having to change the type parameters, as there are downstream issues to deal with. Oh well.

There are a few macros that support more annotation features, but yes they all get transformed elsewhere in the definition.