Say I have a parametric type Foo{B} and I want to restrict B to two possible values. I can think of a couple of ways to do this:
Have B take on a Boolean value and use an assertion in the inner constructor to verify this. This is what I usually see recommended when invariants have to be enforced on type parameters which are values.
struct Foo{B}
function Foo{B}() where {B}
@assert B isa Bool "B must be Boolean (got $B)"
return new()
end
end
However, it has one major disadvantage: no error is produced if an invalid type is constructed (as opposed to an instance of the type).
julia> Foo{Int}()
ERROR: AssertionError: B must be Boolean (got Int64)
Stacktrace:
[1] Foo{Int64}()
@ Main ./REPL[2]:3
[2] top-level scope
@ REPL[5]:1
julia> Foo{Int}
Foo{Int64}
Restrict B being either Val{true} or Val{false}. This does cause an error if an invalid type is constructed.
However, it feels clunky (and possibly an abuse) to introduce Val here, and I’ve never seen a design pattern like this recommended anywhere.
Create types that only act as type parameters for Foo{B}, and restrict B to a supertype containing them. I’m not sure if Bar and Baz could be considered traits, but that’s what they feel like. This is the most explicit approach, and the one I’d personally favor (certainly if the parameter may take on more values). One could argue that something like this would clutter the namespace.
abstract type BarOrBaz
end
struct Bar <: BarOrBaz
end
struct Baz <: BarOrBaz
end
struct Foo{B<:BarOrBaz}
end
Are there reasons I should favor one approach over the other?
Unlike your Case 1., my code disallows anything but Bool as the type parameter:
julia> Foo{true}()
Foo{true}()
julia> Foo{false}()
Foo{false}()
julia> Foo{-1}()
ERROR: Type parameter -1 is not a Bool
Stacktrace:
[1] error(s::String)
@ Base .\error.jl:35
[2] Foo{-1}()
@ Main .\REPL[1]:6
[3] top-level scope
@ REPL[4]:1
julia> Foo{1.0}()
ERROR: Type parameter 1.0 is not a Bool
Stacktrace:
[1] error(s::String)
@ Base .\error.jl:35
[2] Foo{1.0}()
@ Main .\REPL[1]:6
[3] top-level scope
@ REPL[5]:1
Well, I just realized I’ve been using @assert incorrectly in a lot of my code. Oops. Aside from @assert being disabled, our code appears to work identically, though?
julia> struct Foo{B}
function Foo{B}() where {B}
@assert B isa Bool "B must be Boolean (got $B)"
return new()
end
end
julia> Foo{1}()
ERROR: AssertionError: B must be Boolean (got 1)
Stacktrace:
[1] Foo{1}()
@ Main ./REPL[1]:3
[2] top-level scope
@ REPL[2]:1
julia> Foo{Int}()
ERROR: AssertionError: B must be Boolean (got Int64)
Stacktrace:
[1] Foo{Int64}()
@ Main ./REPL[1]:3
[2] top-level scope
@ REPL[3]:1
julia> Foo{1.0}()
ERROR: AssertionError: B must be Boolean (got 1.0)
Stacktrace:
[1] Foo{1.0}()
@ Main ./REPL[1]:3
[2] top-level scope
@ REPL[4]:1
This one is maybe the best. Except I’d make the supertype a Union instead of an abstract type (that way both you and the compiler’s subtyping know there will not be more subtypes added later):
struct Bar end
struct Baz end
const BarOrBaz = Union{Bar, Baz}
struct Foo{B <: BarOrBaz} end
However keep in mind this design still allows invalid types such as Foo{BarOrBaz} or Foo{Union{}}.
One thing you could perhaps do is restrict the type parameter to be a subtype of a concrete type, so the only valid types would be Foo{Union{}} and Foo{Concrete}:
This is a really interesting option I hadn’t considered. It would certainly lock the possible types down to two.
One reason I think it’s useful to include true and false specifically as type parameters is because these values might be useful in methods using these types. The concrete example I’ll give is CliffordNumbers.Z2CliffordNumber{P,Q,T,L}, which has two aliases:
Usually, we deal with even or odd grade elements of Clifford algebras (and not mixed grade elements). The type parameter P encodes the parity of the grade, which allows a decent amount of simplification when writing algorithms.