How to hide a type parameter?

It’s not a big deal, but I’d kinda like to hide a type parameter of a struct. The value of this parameter is determined from the value of the other parameters.

Here’s a small example including working and unexpectedly broken tests. I’m using isbitstype(T) to decide what the second type parameter is and an unsuccessful attempt to hide the type parameter that has behaviour that I find strange (the constructor works for some types and throws an exception for others. I would expect it to work for all reasonable types, or for none, or to throw a parse error).

# No attempt at hiding the type parameter
struct S1{T, U}
    v::Ref{U}

    function S1{T}() where {T}
        U = isbitstype(T) ? T : Union{Some{T}, Nothing}
        new{T, U}(Ref{U}())
    end
end

# Nice, but let's try to hide the type `U` so as not to leak
# implementation details
struct S2{T}
    v::Ref{isbitstype(T) ? T : Union{Some{T}, Nothing}}

    function S2{T}() where {T}
        U = isbitstype(T) ? T : Union{Some{T}, Nothing}
        new{T}(Ref{U}())
    end
end

using Test

@testset begin
    # Works as expected
    @test S1{String}().v isa Ref{Union{Some{String}, Nothing}}
    @test S1{Int}().v isa Ref{Int}

    # Works
    @test S2{String}().v isa Ref{Union{Some{String}, Nothing}}
    # Constructor throws an error when isbitstype(T)
    # I'd expect it to either work, be broken for all types, or be some kind of parse error.
    @test_broken S2{Int}().v isa Ref{Int}
end

# Run this to get the exception below:
S2{Int}()

#=
Exception is from Julia 1.9.2, but an almost identical error also occurs on a
recent build of Julia 1.11 (master)

ERROR: LoadError: MethodError: Cannot `convert` an object of type Base.RefValue{Int64} to an object of type Some{Int64}

Closest candidates are:
  convert(::Type{Some{T}}, !Matched::Some{T}) where T
   @ Base some.jl:37
  convert(::Type{Some{T}}, !Matched::Some) where T
   @ Base some.jl:38
  convert(::Type{T}, !Matched::T) where T
   @ Base Base.jl:64
  ...

Stacktrace:
 [1] convert(#unused#::Type{Union{Nothing, Some{Int64}}}, x::Base.RefValue{Int64})
   @ Base ./some.jl:36
 [2] Base.RefValue{Union{Nothing, Some{Int64}}}(x::Base.RefValue{Int64})
   @ Base ./refvalue.jl:8
 [3] convert(#unused#::Type{Ref{Union{Nothing, Some{Int64}}}}, x::Base.RefValue{Int64})
   @ Base ./refpointer.jl:104
 [4] S2{Int64}()
   @ Main ~/example.jl:24
 [5] top-level scope
   @ ~/example.jl:43
in expression starting at /home/colin/example.jl:43
=#

The issue here is that isbitstype(T) is evaluated only once, when T is not known yet, so the ternary operator is optimzed as Union{Some{T}, Nothing} immediately:

julia> fieldtype(S2{Int}, :v)
Ref{Union{Nothing, Some{Int64}}}

The only way around this I could come up with is to omit the type parameter of the Ref type:

julia> struct S2{T}
           v::Ref

           function S2{T}() where {T}
               U = isbitstype(T) ? T : Union{Some{T}, Nothing}
               new{T}(Ref{U}())
           end
       end

julia> S2{Int}()
S2{Int64}(Base.RefValue{Int64}(139778437527376))

julia> S2{String}()
S2{String}(Base.RefValue{Union{Nothing, Some{String}}}(#undef))
2 Likes

Thanks for the reply! I think leaving the type of v abstract like that will be bad for performance, but it’s a valid workaround for sure.

It looks like T takes the value TypeVar(:T) when passed to functions in expressions after the ::?

f(T) = (@info("f"); dump(T); T)

struct S3{T}
    x::f(T)
    y::Ref{f(T)}
end

S3(1, Ref(2))

#= Prints this 4 times.
[ Info: f
TypeVar
  name: Symbol T
  lb: Union{}
  ub: Any
=#

There’s no user documentation about this (tho there’s some stuff about TypeVars in the devdocs). I guess it makes sense that Julia is replacing the T with a TypeVar value, and you could in theory do stuff with it:

julia> Vector{TypeVar(:T)}
Array{T, 1}

julia> Vector{TypeVar(:T, Union{}, Signed)}
Array{T<:Signed, 1}

julia> t = TypeVar(:T, Union{}, Signed)
T<:Signed

julia> UnionAll(t, Vector{t})
Vector{T} where T<:Signed (alias for Array{T, 1} where T<:Signed)

(Of course anything you do do with it will be pretty sketchy because all of this is undocumented).

It seems kinda useless to expose this behaviour to the user unless it has documented behaviour. Maybe Julia could for now throw an informative parse error if you try to use a type parameter as an argument to a function?

Just for reference (you already noticed that it does not work): https://github.com/JuliaLang/julia/issues/18466, https://github.com/JuliaLang/julia/issues/8472 and GitHub - vtjnash/ComputedFieldTypes.jl: Build types in Julia where some fields have computed types

1 Like

For future readers: the summary answer to “How do you hide a type parameter?” is that you don’t. There’s probably not a good reason for doing so.

But If you really want to you can give a field an abstract type like Any or Real or whatever and assign a value to that, and then the type can be hidden, but it will also be hidden from the compiler in many situations, so performance may suffer.

1 Like