Is it possible to express a type relationship between arrays with different numbers of dimensions?

What I really want to be able to do is express a type such as:

struct Foo{T,N}
a::Array{T,N}
b::Array{T,N+1} # doesn't work but I hope it conveys what I want
end

The important part for me is that the b field has one more dimension than the a field regardless of how many dimensions a has. I suppose I could add another type parameter for the number of dimensions of b and enforce the relationship in an inner constructor, but that is unwieldy for me, because then any other container or type that uses Foo also has to know about that type parameter. Plus it just feels unsatisfying to have to add an additional type parameter when it’s derived from another. Is there any way to accomplish something like this? As far as I can tell I don’t think there is, but figured I would see if anyone knows about something clever I’m not aware of.

1 Like

How about b::Vector{Arrray{T,N}} It’s slightly different than what you want, but might get the right result.

I’m not that this will fully solve your problem, but you can always omit the N+1 in the type declaration and enforce it with an inner constructor. As in:

struct Foo{T,N}
  a::Array{T,N}
  b::Array{T}
end

Do you lose anything important that way?

Yeah usually that’s good enough, but in this case I actually do need the memory layout of the higher dimensional array.

That does technically work I guess, but it won’t be type stable which is unfortunate.

The standard solution here is to write

struct Foo{T,N,M}
    a::Array{T,N}
    b::Array{T,M}
end

and then check in the constructor that M == N+1.

7 Likes

Okay, that’s what I thought I had to do. Do you think there is any possibility that there will ever be support for more expressiveness with value types? It seems that at the moment basically every restriction one might want to assert on them has to be handled outside of the type domain: either in inner constructors for structs, or in the body of a function for function arguments. From what I have been able to find it seems that even restricting N to be an Int value has to be done in the constructor. It seems to me that it would be reasonable for values to retain some of their basic relationships to one another even when they are being used as types.

Possibly. It’s a tricky language design problem though. There’s an issue about it somewhere on GitHub. Something about delayed evaluation of types in struct declarations.

Well that led me down a rabbit hole. Things along this line seem to have come up a bunch of times. I think you’re referring to https://github.com/JuliaLang/julia/issues/18466.

Isn’t this type piracy?

5 Likes

Why would you think it is?

Well, the spelling, for one thing!

3 Likes

Yes, because that’s the only place where it can possibly matter. For existing objects which are of a parametrized type, the compiler does not need to care about these restrictions.

The constructor is the right place to verify these restrictions, or compute extra types. This design is elegant because it does not require a special syntax for restrictions on types, but subsumes them under all calculations and restrictions the type needs.

This is such a common question that there should be a FAQ entry about it — I will write one today.

2 Likes

There’s also https://github.com/vtjnash/ComputedFieldTypes.jl . It doesn’t actually change the fact that your type will have a parameter for N and a parameter for N + 1, but it at least lets you avoid having to write out that parameter most of the time.

PR for FAQ:

3 Likes

Aye!

I helped bring this package up to date for Julia v1.0 a while back, I use it in some of my packages for the same purpose as the OP asks about

Thank you for making my day :joy::joy:

I’m not convinced it’s terribly elegant. If I want to restrict T to be some sort of Float I write Foo{T<:AbstractFloat,N}. That sort of restriction is expressed in the type domain via a type hierarchy. If I want to restrict N to be a particular sort of value type I have to check that restraint myself in a constructor.

They are both restrictions on what sorts of types I can use here, but only one of them can be expressed in the type domain. I can see restrictions on what sort of types these parameters can be by looking at the first line of a struct definition unless I have value types. Julia enforces those restrictions on other types, but for value types I have to enforce those restrictions manually in my code. That doesn’t feel elegant to me.

Subtype relation is special because it is fundamental to a lot of things, most importantly dispatch.

All kinds of restrictions can be enforced in Julia, it’s just that subtypes have a special syntax. You could do

struct Foo{T}
    x::T
    function Foo(x::T) where T
        if !(T <: AbstractFloat)
            throw(ArgumentError("look, I really need a float here and $T isn't one"))
        end
        new{T}(x)
    end
end

and get pretty much the same behavior as

struct Foo{T<:AbstractFloat}
    x::T 
end

just without some bells and whistles.

Elegance is of course always a subjective term for programming language design. Personally, I find the current setup elegant because of parsimony: no extra syntax needs to be introduced, and checking these kind of restrictions is pretty much orthogonal to everything else that happens to instances of this type after construction.

Eg consider a hypothetical

struct Foo{T,N}
    A::Array{T,N}
    B::Array{T,N+1}
end

Suppose I want to retrieve the computed type parameter, without hardcoding the actual computation in my code, for which there is a very standard pattern with explicit parameters. Should we introduce special syntax for this too?

Or consider relations between parameters which you can test for, but not compute (because the relation is not a function, or it is expensive/nontrivial to invert). Instead of

struct Foo{T,S}
    a::T
    b::S
    function Foo(a::T, b::S) where {T,S}
        check_Foo_types(T, S) || error("baaad types")
        new{T,S}(a, b)
    end 
end

you would need to introduce a syntax to specify check_Foo_types somewhere, and still use the constructor for other checks.