More flexible parametric types

I would like to have the functionality displayed in the definition of S₂. This code obviously does not work (and I understand why), but what would be the closest thing to accomplishing such functionality? Thanks!

Background: I (will) have a lot of related types that have a lot in common, each with many fields with varying types. It is much easier (to me!), less maintenance, and less remembering to have a single parametric type than to define many individual ones and use subtyping. I can cut down on the maintenance part by generating various types programmatically, but that is not great for legibility. Alternatively, I can do something like struct S{T1,T2,T3,T4,T5}, but that’s not great either. And yes, I do want parametric types.

(I suspect I’m setting myself up for a hard no, but would appreciate any suggestions and ideas.)

struct S₁{T₁, T₂}
    x   :: T₁
    y   :: T₂
end


struct T
end

struct X
end


const D = Dict( T => ( Int, Float64 ), X => (String, Int ) )


struct S₂{Q}
    x   :: D[Q][1]
    y   :: D[Q][2]
end

x = S₂{T}( 0, 1.0 )


# I want S₂{T} to give me the same as S₁{Int, Float64}

Do you actually need different structs? the easiest thing for me, given your example, would be to overload the constructor:

julia> struct S₁{T1, T2}
           x::T1
           y::T2
       end

julia> struct T end

julia> struct X end

julia> S₁{Q}(x, y) where Q<:T = S₁{Int, Float64}(x, y)

julia> S₁{T}(3, 4)
S₁{Int64, Float64}(3, 4.0)
2 Likes

Thank you.

If the only objective were to create such objects then your method would indeed be great.

But I’d like to use it generically, so to also be able to use

function f( x :: S₁{T} ) where T
....
end

or something like that.

All you can do is to create aliases, like

julia> struct S{T, Q}
           x::T
           y::Q
       end

julia> const SIF = S{Int, Float64}
SIF (alias for S{Int64, Float64})

julia> const SSI = S{String, Int}
SSI (alias for S{String, Int64})

But note that the core of type assertion is to know types at compile time. Something like

struct S₂{Q}
    x   :: D[Q][1]
    y   :: D[Q][2]
end

function f( s :: S₂{T} ) where T
    return s.x + s.y # or anything else
end

would go completely against that. s.x and s.y compile time types would depend on a variable T that is only known at runtime

2 Likes

Taking your question at face value, ComputedFieldTypes.jl kinda does what you want:

julia> using ComputedFieldTypes

julia> struct T end

julia> struct X end

julia> const D = Dict(T => (Int, Float64), X => (String, Int))
Dict{DataType, Tuple{DataType, DataType}} with 2 entries:
  X => (String, Int64)
  T => (Int64, Float64)

julia> @computed struct S₂{Q}
           x::D[Q][1]
           y::D[Q][2]
       end

julia> S₂{T}
S₂{T}

julia> S₂{T}( 0, 1.0 )
S₂{T, Int64, Float64}(0, 1.0)

julia> dump(S₂{T, Int64, Float64})
struct S₂{T, Int64, Float64} <: Any
  x::Int64
  y::Float64

However, this seems like a possible XY problem:

3 Likes

It solves X. I will continue to experiment trying to come up with better solutions for Y.

1 Like

Can you explain what you want a little more concretely? That is, one complete example of what you want to work.

One problem is that complex types (with many parameters) are somewhat sucky to write down. Type aliases can sometimes cut down on this work, but are not flexible enough for your job.

However, nobody prevents you from doing arbitrary computation on types at the time of definition. So you could do

struct S{T1,T2}
x::T1
y::T2
end
d = Dict(:T => S{Int,Float64}, :S => S{Int,Int})

_st = d[:T]
typeof(_st(1,2))
foo(x::_st) = 1

In other words, there is no reason to ever spell out the actual types; you can simply use normal code like your dict to plug together the types you want. It’s just that

foo(x::d[:T]) = ....
struct Bar x::d[:T] end

don’t work. That’s a design decision that forces you to spell out

tmp = d[:T]
foo(x::tmp) = ...

which makes it obvious that the type pattern that foo matches is computed in global/module scope one line before that, when the module is loaded, and that subsequent mutations of tmp or d will not change this definition.

A limitation is that the following won’t work:

julia> function bar()
       t = d[:T]
       function ff(x::t) 1 end
       t = d[:S]
       function ff(x::t) 2 end
       ff
       end
ERROR: syntax: local variable t cannot be used in closure declaration

And it’s clear why this cannot work: The definition of the closure must, after all, be lifted into global scope by lowering, and t is only known once bar() actually runs, not at the time of definition of bar(). (closure definitions are syntactic constructs that do not participate in local control flow)

Thank you very much @foobar_lv2 .

All of this is just needed to avoid carrying around overly complicated type descriptions. I don’t need anything like foo(x::d[:T]).

Let me try to be precise in what the problem is. At the bottom is a type with many fields that vary on a total of (say) five types, e.g.

struct B{T1,T2,T3,T4,T5}
	F1 :: T1
	F2 :: Vector{ T1 }
	F3 :: T2
	....
	F30 :: T5
end

But I don’t need all possible combinations of T1 through T5. What I need B for is to define a collection of types S that have the same set of fields, but differ in T1 through T5. Basically, these contain data for use in different types of estimators, say E1 through E20. Instead of adding a new name for every S and use type aliases, it seems cleaner to have a single type S like

struct S{E}
...
end

which maps to B{T1,T2,T3,T4,T5} for T1 through T5 that depend on E. This is all compile time-resolvable.

In other words, I want to be able to use it as any other type, including allowing for nesting

struct K{E}
	s	:: S{E}
	i	:: Int
	...
end

and using it in function arguments

function foo( x :: S{E} )
	...
end

So if there is a more elegant way of doing this than to use computed field types then I’m all ears.

I think it is linked to one of my question about operation on type parameter. I don’t think it is possible to make complex operations on type parameter to explicit link E with all different type T1, T2,.... However if E appears in one of this type parameter, ( let say T1 == E or T1 = Array{N,E} then you can create an alias as proposed by @VinceNeede and rely on inner constructor to enforce its structure:

struct B{T1,T2,T3,T4}
               F1 :: T1
               F2 :: Vector{ T1 }
               F3 :: T2
               F4 :: Complex{T1}
              #default
              B(f1::T1, f2::T2, f3::T3, f4::T4) where {T1,T2,T3,T4} = error("building B{$T1,$T2,$T3,$T4}")
              # T1 <: Real
              B(f1::T1, f2::Vector{T1}, f3::Int, f4::Complex{T1}) where {T1<:Real} =  new{T1,Vector{T1},Int,Complex{T1}}(f1,f2,f3,f4)
end

S{E} = B{E,T2,T3,T4} where {T2,T3,T4}


Then you can dispatch on S{E} and the following function should work

function foo( x :: S{E} ) where E
	
end

If you don’t want to write all inner constructors for a large number of E then you can rely on generated functions.

2 Likes

Thanks. This seems to be more or less what ComputedFieldTypes does.

You don’t need inner constructors for this.

Inner constructors are really important only for uninitialized fields of object-reference type. Uninitialized object references are encoded as null-pointers, and currently the compiler emits checks on every access to potentially uninitialized reference fields.

So in julia, it is a slightly hidden property of types whether and which fields can be uninitialized; this cannot change later, so all constructors that can leave fields uninitialized must be visible to the compiler at once. This is why inner constructors exist as a language construct. Anything else doesn’t need inner constructors.

I personally would have preferred a @maybe_undef macro, but :person_shrugging:

2 Likes

I don’t think so. I believe that inner constructors are more than that, it is the right tools to prevent the building of object that cannot be described by the alias ‘S{E}’. I can’t see better way to enforce that

1 Like

As FerreolS implied with aliasing S{E} = B{E,T2,T3,T4}, you’re going beyond what type parameters do: types or limited values shared across subtypes, aliases, and fields. If a type parameter shows up in a field, it has to show up in the struct header. ComputedFieldTypes.jl only makes the inner parametric constructor for you to hide those parameters from source code and some printing, not the type system, so if you change how those field types are computed (e.g. mutating D entries in njasko’s earlier demo), the same expressions will start meaning different types that conflict with previously evaluated code in an intentionally obscured way. Parameters just aren’t mere name tags.

Name tags can just be appended to names, specifically that of aliases.

const SE_3 = B{T1_23, T2_57, T3_45, T4_29, T5_31}

And introduce the supertype with

struct B{T1, T2, T3, T4, T5} <: S

though while you can use SE_3 and S in annotations, you can’t use E_3 as a parameter to relate S{E_3} and K{E_3}. That’s a point in favor of putting up with the caveats of @computed struct S{E}, no need for a separate B there. Since you need to use fulltype to compute the full type when you’re not instantiating, wrappers like K{E} also need to be @computed and will hold fulltype(S{E}) as a hidden parameter to match the field’s. Personally I wouldn’t define types for E if they won’t be instantiated, symbols like :E1 are allowed as parameters and they won’t run into type instability stemming from inferring DataType.

2 Likes

Thanks @Benny . I’m aware: good to have it here though.