Parameterize by Value with Union

I’d like to use a struct to represent a spatial mesh with an arbitrary number of dimensions D, something like

struct Mesh{D}
...
end

Of course, D can only be 1, 2, or 3. Is there a way to enforce this using a union, e.g.,

struct Mesh{D<:Union{1,2,3}}
...
end

which would work if the Union involved types, but not values.

A simple way would be to define an inner constructor which checks the value of D, but I’m also curious if there is a better approach

3 Likes

You could wrap the values in Val to lift them to the type domain. Then you could do:

struct Mesh{D<:Union{Val{1},Val{2},Val{3}}}
...
end

Base does something similar with AbstractVecOrMat which is just an alias for Union{AbstractArray{T, 1}, AbstractArray{T, 2}}.

So perhaps just define an alias like

struct ArbitraryMesh{D}
...
end

const Mesh = Union{ArbitraryMesh{1}, ArbitraryMesh{2}, ArbitraryMesh{3}}

dims(::ArbitraryMesh{D}) where D = D

Then dims(m::Mesh) will give the number of dimensions statically.

1 Like

Splatting an iterable can reduce retyping when there are more cases or the name needs to change: Union{(ArbitraryMesh{i} for i in 1:3)...}. I would prefer something like struct Mesh{D} end; const MeshTrio = Union{(Mesh{i} for i in 1:3)...} because writing Mesh{2} is easier than ArbitraryMesh{2}, and you can’t tack a parameter onto a Union to retrieve one of its members.

AFAIK, what densmojd is thinking of seems impossible now. Inner constructors generally check constraints because redefinable methods and mutable instances can change what constraint checks do, yet type annotations and previous instances of the type can’t feasibly change with them and must use the “obsolete” types. Subtype checking is a built-in function <:, and types can’t be changed, so those are fine to incorporate into the type. isa is also a built-in function, so that seems feasible as a future parameter constraint. On the other hand, membership checking is a generic function in. in(::Any, ::Tuple) falls back to an iteration-based in(x, itr) method, so there’s no underlying built-in function to use instead. We’d also need something semantically different for parameter membership because missing in (1, missing) is missing, not true. If parameter constraints are ever expanded beyond subtyping checks, such a built-in function will probably show up.

2 Likes

A few alternatives:

Parameterize a singleton type with an Int value

struct Mesh{D<:Union{Val{1},Val{2},Val{3}}} end

If code duplication is an issue, you could do this:

struct Mesh{D<:Union{map(i -> Val{i}, 1:3)...,}} end

Hardcoded singleton types, each representing an integer

struct N1 end
struct N2 end
struct N3 end
struct Mesh{D<:Union{N1,N2,N3}} end

Set-theoretic construction of the natural numbers

This is like the above, but avoids hardcoding the values of the natural numbers. Inspired by a construction of the natural numbers, Zermelo ordinals.

module Naturals

# Arbitrarily choose to start counting from zero
struct Zero end

struct Natural{Previous} end

successor(::M) where {M<:Union{Zero,Natural}} = Natural{M}()

natural(::Val{0}) = Zero()

function natural(::Val{N}) where {N}
  N::Int
  signbit(N) && throw(ArgumentError("negative"))
  successor(natural(Val(N - 1)))::Natural
end

natural(n::Int) = natural(Val(n))::Union{Zero,Natural}

natural_type(n::Union{Int,Val}) = typeof(natural(n))

end

struct Mesh{D<:Union{map(i -> Naturals.natural_type(i), 1:3)...,}} end

For more fun, check out GitHub - Seelengrab/TypingTheJuliaInterview.jl: A humorous port of "Typing the technical interview" to the Julia type system

2 Likes

Thanks for all the ideas! The suggestions of @abraemer and @nsajko are what I had in mind.

Of course, the follow up question is can I somehow extract the dimensions from the parameter, e.g.,

function numdim(mesh::Mesh{D}) where D
# return 1, 2, or 3 based on whether D is a Val{1}, Val{2}, or Val{3}
end

I’m also beginning to think gdalle’s* suggestion to throw an assertion from an inner constructor is the better approach.

*Apparently new users can only mention 2 other users in a post.

julia> struct Mesh{D<:Union{Val{1},Val{2},Val{3}}} end

julia> numdim(mesh::Mesh{Val{D}}) where {D} = D
numdim (generic function with 1 method)

julia> numdim(Mesh{Val{2}}())
2

Why?