Julia: Type Tags for Extendable Type Information

Created on 29 Sep 2020  ·  79Comments  ·  Source: JuliaLang/julia

Wrapper types are a mess. The classic example is that TrackedArray{Adjoint{Array}} is no longer seen as an Adjoint and Tracker.jl needed special overloads for handling adjoint arrays. Two deep you can start to special case: three deep is a nightmare. Why?

The issue is that wrapper types aren't exactly new types, they are extended pieces of information. A lot of information stays the same, like length, but only some changes. In the parlance of Julia, this is the kind of behavior that you normally have with type parameters, where Array{T,1} shares dispatches with Array{T,2} but then overrides what changes when you change dimensions. So, we could in theory do Array{T,1,Adjoint}, but now you can see the issue: it's an extendability problem since these tags need to all be pre-specified in the type definition! What if new packages add new tag verbs? SOL, redefine/pirate the type if you want to use it! This is why people use wrapper types: you can always extend, but of course you get the dispatch problem.

What about traits you say? They extend the way things dispatch, but only do so at compile time for a given type. isadjoint(::Array) can't be a thing, unless you have a type parameter for it.

So you need some extra information. That's why I am proposing type tags. A type tag is an extension like Array{Float64,1}{Adjoint}, where the tag adds information to an existing type. It could be like, tag(A,IsAdjoint()). By default, any tags would have the default behavior unless tagged, so Array{Float64,1} would have tag(Adjoint) == DefaultTag(). Then dispatches could be written on the tags, allowing for extension while keeping dispatches.

The one thing to really think about though is ambiguity handling...

Most helpful comment

Good discussion. I see this as basically moving type parameters from being positional to being like keyword arguments, which is something I've thought about. That would make parameters more meaningful as well as more extensible. A lot of things to think about here. For example, Vector{Int} could become an abstract type in some sense --- we know the layout, and we know enough to do compile-time dispatch for most functions, but there could be more parameters. Or we could insist that types written as they are now are exact, and you need something like Vector{Int; ...} to mean the abstract type that includes types with additional parameters. That's very explicit but seems less useful.

All 79 comments

This sounds great!

The one thing to really think about though is ambiguity handling...

Could you elaborate on this a little bit?

Doing some stuff like mul!(::IsAdjoint,..) would cause a lot of dispatch ambiguities if in Array{Float64,1}{Adjoint} the Adjoint has equal precedence to the other types. In this sense, I almost thing that a separate rule needs to be applied, like "tags supersede type parameters" in order to break ambiguities in a nice way. One of the type gods could probably point to some literature on an idea like this with its trade-offs.

Just to copy my thoughts so they don't get lost in the Slack-hole:

I feel like the idea to have "something that extends the idea of type parameters" a-la Array{Float64,1}{Adjoint} is just re-discovering concrete inheritance.

That is, Array{Float64,1}{Adjoint} are types that are distinct from Array{Float64,1}, but have the same memory layout and will be accepted by all the same methods. This is essentially what people are talking about when they discuss inheriting from concrete types.

My understanding is that concrete inheritance is looked at with a lot of skepticism and scorn but it does feel like our abstract array ecosystem is hitting the limits of simple type composition and it may be time to revisit concrete inheritance and see if we can do it sanely.

Instead of concrete inheritance, is it possible to accomplish this with only abstract multiple inheritance (a la #5)?

E.g. for the tracker example, something like this:

abstract type AbstractArray{T, N}
end

abstract type AbstractAdjoint{A, T, N} <: AbstractArray{T, N}
end

struct Adjoint{A, T, N} <: AbstractAdjoint{A, T, N}
    data::A
end 

abstract type AbstractTrackedArray{B, T, N} <: AbstractArray{T, N}
end

struct TrackedArray{B, T, N} <: AbstractTrackedArray{B, T, N}
    data::B
    sensitivities
end

struct TrackedAdjointArray{B, T, N} <: AbstractTrackedArray{B, T, N}, AbstractAdjoint{B, T, N} # requires abstract multiple inheritance
    data::B
    sensitivities
end

We have a method adjoint with the following signature:

adjoint(x::A) where A <: AbstractArray{T, N} -> AbstractAdjoint{A, T, N}

And we have two methods for track with the following signatures:

track(x::B) where B <: AbstractArray{T, N} -> TrackedArray{B, T, N}

track(x::B) where B <: AbstractAdjoint{A, T, N} -> TrackedArray{B, T, N}

That will hit this issue that we want something to be Tracked, Adjoint, and Symmetric. I think the tagging needs to be "from the outside" instead of being defined at type definition time.

Yeah I see what you mean. It's not sustainable to have different structs struct TrackedAdjointSymmetricArray for each possible combination.

Also we don't currently have abstract multiple inheritance, so that would be another barrier.

I agree that the tag approach is probably more sustainable.

They extend the way things dispatch, but only do so at compile time for a given type. isadjoint(::Array) can't be a thing, unless you have a type parameter for it.

Why is this? Trait doesn't have to happe all at compile time, much like dispatch doesn't have to happen at compile time. It's generally even just a way to compute a property from an object for dispatch. Why can't isadjoint(::Array) be a thing?

Is this tag supposed to be per object or per type. When and how is it attached? If it's attached to the type then it seems to be exactly what traits are fore. If it's attached to the object, then this is essentially something that is used for dispatch that can be changed at runtime so it cannot be statically inferred. I don't think that's desireable effect.

If I understand correctly, the information is attached to instances, not types.

julia> f(x::IsSymmetric) = 1

julia> f(x::IsNotSymmetric) = 2

julia> x = [3.0 4.0; 4.0 3.0]

julia> f(x)
2

julia> tag!(x, IsSymmetric)

julia> f(x)
1

Why can't isadjoint(::Array) be a thing?

To do this at compile time, you need a type parameter. E.g. a Boolean type parameter Adjointness:

struct Array{T, N, Adjointness}
    data
end

Then we define:

isadjoint(x::Array{T, N, Adjointness} = Adjointness

To do it at run time, we need a field in the Array struct:

struct Array{T, N} <: AbstractArray{T, N}
    data
adjointness::Bool

Then we define:

isadjoint(x::Array{T, N} = x.adjointness

In either case, we need to decide ahead of time which properties (adjointness, symmetricness, etc) we will support, and we have to hardcode those as either type parameters or fields.

As Chris says:

So, we could in theory do Array{T,1,Adjoint}, but now you can see the issue: it's an extendability problem since these tags need to all be pre-specified in the type definition!

That will hit this issue that we want something to be Tracked, Adjoint, and Symmetric. I think the tagging needs to be "from the outside" instead of being defined at type definition time.

@ChrisRackauckas This would also mean that concrete inheritance would also not solve this issue, right?

If I understand correctly, the information is attached to instances, not types.

If that is the case, then essentially f(x) will almost never be statically dispatched. It'll be really hard for type inference to know if the type tag is changed. Is this the desired effect? This is roughly equivalent to putting values into type domain, which is a standard form of dispatch abuse for exactly this reason.

Still, it's unclear why this should be per-object.

To do this at compile time, you need a type parameter. E.g. a Boolean type parameter Adjointness:

Hmmm, no? What you need, if this property is not defined/determined by yourself, is a way to delegate this check to the part that should determine this, i.e. TrackedArray{A} should implement an isadjoint to call that on A instead. If there's nothing else to delegate to, a explicit implementation should be provided or the fallback is used and there's no way around this, no matter what you do.

If the complaint is that TrackedArray has to implement isadjoint and unknown number of other properties and you would rather like a way to transfer all properties to the wrapped object without enumerating over all of them, well,

  1. I think passing all properties may not be the desired behavior since if you can't enumerate over all the possible properties, you very much also can't enumerate over all the ones you do/don't want to inherit in order to white/black list them.
  2. You can do that very easily with a standard trait interface, i.e. by spelling isadjoint as sth like trait(..., ::IsAdjoint) and simply implement trait(::TrackedArray, ::Any) = trait(<delegate to the wrapped array>)

Or if you replace the trait function I have above with tag, you already get an explicit version of what this is doing without the tag mutation part of the API that kills inference. In another word, instead of doing f(a) you do f(a, tag(a, IsAdjoint())) and that's exactly how all traits works with various different spelling. The tag function can rely on the type or rely on the object which may affect how inference friendly it is but at least it is not guaranteed to kill inference.... And note that even though IsAdjoint() is explicitly used here, this is on the consumer of the information which has to know about it and will not be dealing with unknown number of traits.

And such a system is already there. Improving it/them and adopting a standard one are certainly things to be worked on. @vtjnash had even made a comment of replacing most complex dispatch with it. Changing the syntax so that this is done automatically without additional argument necessary can potentially be on the table once there's a clear winner/one that's widely adopted.


Another way to look at this is that methods are already a way to attach compiler friendly info to arbitrary object/types (i.e. methods are mappings from object/type to an output). They also already allow very flexible manipulate/inheritance which seems to cover the need here. Attaching other generic metadata to object could have other use and it is basically what WeakKeyDict are for, but I highly doubt it's the right solution here.

If I understand correctly, the information is attached to instances, not types.

No, I don't think that's correct @DilumAluthge. We want the information to be a part of the type. Basically, Chris is asking for an un-ordered set of extra parameters we can stick on the end of types to attach additional meaning.


So, we could in theory do Array{T,1,Adjoint}, but now you can see the issue: it's an extendability problem since these tags need to all be pre-specified in the type definition! What if new packages add new tag verbs? SOL, redefine/pirate the type if you want to use it! This is why people use wrapper types: you can always extend, but of course you get the dispatch problem.

@ChrisRackauckas Can't we do something like

using LinearAlgebra: Adjoint
struct MyArray{T, N, Tag <: Tuple}
    data::Array{T, N}
end
MyArray(x::Array{T, N}) where {T,N} = MyArray{T, N, Tuple{}}(x)

function Base.adjoint(x::MyArray{T, N, Tag}) where {T, N, Tag}
    AdjointTag =  Adjoint in Tag.parameters ? Tuple{(T for T in Tag.parameters if T != Adjoint)...} : Tuple{Adjoint, Tag.parameters...}
    MyArray{T, N, AdjointTag}(x.data)
end

now we have

julia> MyArray([1,2,3])
MyArray{Int64,1,Tuple{}}([1, 2, 3])

julia> MyArray([1,2,3])'
MyArray{Int64,1,Tuple{Adjoint}}([1, 2, 3])

julia> (MyArray([1,2,3])')'
MyArray{Int64,1,Tuple{}}([1, 2, 3])

In principal, other packages should be able to add their own tags as they please without any baking in ahead of time. Of course, the biggest downside to this approach that I see would be that basically all Tag functions would need to be @generated for performance which is a total nightmare.

However, if we had something that was analogous to Tuple which could have a variable number of parameters and was un-ordered, then perhaps we could make it work.

That is, you would need a type Tag such that

julia> Tag{Int, Float64} === Tag{Float64, Int}
true

and you would need to be able to write methods like

f(x::Array{T, N, Tag{Adjoint}}) = ...

and have it apply to a x::Array{T, N, Tag{Sorted, Adjoint}} (and thus causing a new host of ambiguities)

If I understand correctly, the information is attached to instances, not types.

No, I don't think that's correct @DilumAluthge. We want the information to be a part of the type. Basically, Chris is asking for an un-ordered set of extra parameters we can stick on the end of types to attach additional meaning.

Ah I see!

Could we make it so that you can stick Tag on the end of any type, without the definition of the type needing to include tags?

E.g. suppose I have some structs

struct Foo
end

struct Bar{A, B}
end

Can we make it so I can stick tags on these types, even though they weren't written with tags in mind?

E.g. I'd want to be able to write methods like this:

f(x::Foo{Tag{Adjoint}}) = ...

g(x::Bar{A, B, Tag{Adjoint}}) = ...

Even though the definitions of Foo and Bar don't mention tags.

@MasonProtter that would be an interesting implementation for this, yes. And indeed the big deal here would be some kind of rule or machinery to reduce the ambiguities. I think you do have to give the tags precedence, which would make it be like how the wrapper type dispatches always take control, unless there's no definition and then it would get the dispatch of the non-tagged version.

@ChrisRackauckas This would also mean that concrete inheritance would also not solve this issue, right?

Yes, it's the expression problem on inheritance in some sense.

Could we make it so that you can stick Tag on the end of any type, without the definition of the type needing to include tags?

Yup, that's precisely what I am proposing with the Array{T,N}{Adjoint}.

Still, it's unclear why this should be per-object.

It's the same thing as Adjoint{Array{T,N}}: not every Array is the Adjoint of an Array. The type has to change when you do such an operation, since otherwise you can't change the dispatches. Traits are functions on the type information, but if you can't distinguish the types, you can't distinguish the trait outcome. Tagging can be inferred in any case you can infer the wrapper type: it's basically just a way to get Array{T,N,Adjoint} instead of Adjoint{Array{T,N}}, or as @MasonProtter showcases, Array{T,N,Tag{Adjoint}}.

Just as a side-note on syntax, Array{T, N}{Adjoint} won't work at least in 1.0 because we curry type parameters. T{U}{V} is the same as T{U, V}:

julia> Array{Int}{2}
Array{Int64,2}

@MasonProtter in your working example above, can you post what the @generated version of your Base.adjoint function would look like?

Yeah, I'm not wedded to the syntax at all: I just needed a syntax to express the idea that it's not really a type parameter because I want to be able to do this even if the user didn't give me a type parameter for this piece of information (though you show a way such that opting in could be one parameter for all tags at least).

And indeed the big deal here would be some kind of rule or machinery to reduce the ambiguities. I think you do have to give the tags precedence, which would make it be like how the wrapper type dispatches always take control, unless there's no definition and then it would get the dispatch of the non-tagged version.

Hm, actually thinking about this again, I assumed you wanted the tags to be unordered as that would be more expressive, but now I realize that unordered tags would also cause far more dispatch ambiguity problems. Consider

f(::Vector{Int, Tag{Sorted}})  = 1
f(::Vector{Int, Tag{Adjoint}}) = 2

If we make it so that Tag{Sorted, Adjoint} === Tag{Adjoint, Sorted}, what does this return?

f(Vector{Int, Tag{Sorted,Adjoint}}([1, 2, 3])) 

This has all the ambiguities of extra type params PLUS all the extra ambiguities of multiple inheritance (because this would basically be a route to multiple inheritance).

Meanwhile, ordered tags (at least with them having higher precedence than other type params) wouldn't cause a new class of ambiguities.

If tags are ordered, then are you envisioning that

f(Vector{Int, Tag{Sorted, Adjoint}}([1, 2, 3])) 

Would dispatch to the

f(::Vector{Int, Tag{Sorted}}) = 1

method, because Sorted appears earlier in the tag list and thus has higher precedence?

Now, if we have

f(::Vector{Int, Tag{Sorted}})  = 1
f(::Vector{Int, Tag{Adjoint}}) = 2
f(::Vector{Int, Tag{Sorted, Adjoint}}) = 3

Then

f(Vector{Int, Tag{Sorted, Adjoint}}([1, 2, 3])) 

Would instead dispatch to

f(::Vector{Int, Tag{Sorted, Adjoint}}) = 3

Right?

But then what does this call dispatch to?

f(Vector{Int, Tag{Adjoint, Sorted}}([1, 2, 3])) 

Consider

f(::Vector{Int, Tag{Sorted}})  = 1
f(::Vector{Int, Tag{Adjoint}}) = 2

If we make it so that Tag{Sorted, Adjoint} === Tag{Adjoint, Sorted}, what does this return?

What if we throw an error when you try to define this second method f(::Vector{Int, Tag{Adjoint}}) = 2?

Something like this:

julia> f(::Vector{Int, Tag{Sorted}})  = 1
Generic function `f` with 1 method(s)

julia> f(::Vector{Int, Tag{Adjoint}}) = 2
ERROR: Because the method `f(::Vector{Int, Tag{Sorted}})` has already been defined, you are not allowed to define the method `f(::Vector{Int, Tag{Adjoint}})` unless you first define the method `f(::Vector{Int, Tag{Sorted, Adjoint}})`. Please note that `Tag{Sorted, Adjoint} === Tag{Adjoint, Sorted}`.

julia> f(::Vector{Int, Tag{Sorted, Adjoint}}) = 3
Generic function `f` with 2 method(s)

julia> f(::Vector{Int, Tag{Adjoint}}) = 2
Generic function `f` with 3 method(s)

To me, this seems better than making tags ordered.

That can't work because then if two different packages separately defined tags, they could break eachother.

That can't work because then if two different packages separately defined tags, they could break eachother.

Good point.

Wait.... only one package can own the generic function f, right? So the other package is committing type piracy.

(I don't think that adding a tag to a type makes it "your type", does it?)

@MasonProtter in your working example above, can you post what the @generated version of your Base.adjoint function would look like?

Basically the same:

@generated function Base.adjoint(x::MyArray{T, N, Tag}) where {T, N, Tag}
    AdjointTag =  Adjoint in Tag.parameters ? Tuple{(T for T in Tag.parameters if T != Adjoint)...} : Tuple{Adjoint, Tag.parameters...}
    :(MyArray{T, N, $AdjointTag}(x.data))
end

it's just bad to have a proliferation of generated functions like this.

E.g. if my package does not own the type Foo{A, B}, then it also doesn't own the type Foo{A, B, Tag{MyTag}}, even if the tag MyTag is owned by my package.

So in this case, at least one of the two packages in question is committing type piracy.

Unless we are saying that Foo{A, B, Tag{MyTag}} is now "my type". E.g. is it type piracy if I define this method in my package:

julia> Base.length(x::Array{T, N, Tag{MyTag}}) = 1

Wait.... only one package can own the generic function f, right? So the other package is committing type piracy.

(I don't think that adding a tag to a type makes it "your type", does it?)

If that's piracy, then there's no point in doing this. It would make it impossible for a package to safely define their own tags and overload existing functions, in which case why care about any of this?

Think of the tag as like a type parameter. If I own MyType, then I also own Array{MyType}.

Wait.... only one package can own the generic function f, right? So the other package is committing type piracy.
(I don't think that adding a tag to a type makes it "your type", does it?)

If that's piracy, then there's no point in doing this. It would make it impossible for a package to safely define their own tags and overload existing functions. Think of the tag as like a type parameter. If I own MyType, then I also own Array{MyType}.

I see. That makes things harder... we can't throw an error because that would allow packages to break each other.

But having sorted tags seems weird... you could have f(::Foo{Tag{Sorted, Adjoint}}) and f(::Foo{Tag{Adjoint, Sorted}}) be completely different methods.

But having sorted tags seems weird... you could have f(::Foo{Tag{Sorted, Adjoint}}) and f(::Foo{Tag{Adjoint, Sorted}}) be completely different methods.

it is weird and unfortunate, but it's also at least not worse than the current situation with wrappers where Sorted{Adjoint{Vector}} and Adjoint{Sorted{Vector}} are also completely different types.

But having sorted tags seems weird... you could have f(::Foo{Tag{Sorted, Adjoint}}) and f(::Foo{Tag{Adjoint, Sorted}}) be completely different methods.

Although, maybe that's not a big deal.

What if we have a convenience macro:

@tag function f(x::Vector{Int, Tag{A, B, C}})
    ...
end

And in this case, that macro would autogenerate all of the following methods, with the same body:

f(x::Vector{Int, Tag{A, B, C}})

f(x::Vector{Int, Tag{A, C, B}})

f(x::Vector{Int, Tag{B, A, C}})

f(x::Vector{Int, Tag{B, C, A}})

f(x::Vector{Int, Tag{C, A, B}})

f(x::Vector{Int, Tag{C, B, A}})

It's sounding like this is all doable in a third party package at least as a proof-of-concept. TaggedArrays.jl or something.

The main thing missing from the prototype that you've put together in this thread is what the dispatch function actually looks like. E.g. what is the code for

f(::Vector{Int, Tag{Sorted}})  = 1

IIUC, you can't just do this

f(::Vector{Int, Tuple{Sorted}})  = 1

Because this will accept Vector{Int, Tuple{Sorted}} but not Vector{Int, Tuple{Sorted, Adjoint}}.


If you can figure that out, I definitely think you have everything you need for a TypeTaggedArrays.jl package.

i really need the approach of a sorted type parameter, that works like a set on the type system T[a,b,c} -> T{Set((a,b,c)} i basically had a crude implementation of this on a package using:

f(::Vector{Int, Tag{Sorted}})  = 1
f(::Vector{Int, Tag{Adjoint}}) = 2`

and a generated function:
https://github.com/longemen3000/ThermoState.jl/blob/master/src/state_type.jl

where T{a,b,c} = T{c,b,a}

If I am understanding correctly, what Mason is suggesting is that Tag{a,b,c} would NOT be equal to Tag{c,b,a}.

Now I feel silly. "If only there was an unordered type with a variable number of parameters that supports things like subtyping!"

It's a union. We want a union. Unions even have the added benefit of when they get bigger, they get less specific.

using LinearAlgebra: LinearAlgebra, Adjoint, Symmetric
struct TaggedArray{T, N, Tag}
    data::Array{T, N}
end
TaggedArray(x::Array{T, N}) where {T,N} = TaggedArray{T, N, Union{}}(x)

Base.adjoint(x:: TaggedArray{T, N, Tag}) where {T, N, Tag} = TaggedArray{T, N, Union{Adjoint, Tag}}(x.data)
# Base.adjoint(x:: TaggedArray{T, N, Tag}) where {T, N, Tag, Adjoint <: Tag} =  # get rid of the adjoint
LinearAlgebra.Symmetric(x::TaggedArray{T, 2, Tag}) where {T, Tag} = TaggedArray{T, 2, Union{Tag, Symmetric}}(x.data)

f(x::TaggedArray{T, 2, Tag}) where {T, Tag, Adjoint <: Tag} = 1
f(x::TaggedArray{T, 2, Tag}) where {T, Tag, Symmetric <: Tag} = 2
f(x::TaggedArray{T, 2, Tag}) where {T, Tag, Union{Adjoint, Symmetric} <: Tag} = 3

Unfortunately, the final line there does not work currently, giving

julia> f(x::TaggedArray{T, 2, Tag}) where {T, Tag, Union{Adjoint, Symmetric} <: Tag} = 2
ERROR: syntax: invalid type parameter name "Union{Adjoint, Symmetric}" around REPL[8]:1
Stacktrace:
 [1] top-level scope at REPL[8]:1

but we can work around it:

const AdjointSymmetric = Union{Adjoint, Symmetric}
f(x::TaggedArray{T, 2, Tag}) where {T, Tag, AdjointSymmetric <: Tag} = 3

For completeness, you need an additional using LinearAlgebra at the top of your code.

# Base.adjoint(x:: TaggedArray{T, N, Tag}) where {T, N, Tag, Adjoint <: Tag} =  # get rid of the adjoint

I think there's a "union subtraction" function somewhere in Base.

@MasonProtter Instead of this:

Base.adjoint(x:: TaggedArray{T, N, Tag}) where {T, N, Tag} = TaggedArray{T, N, Union{Adjoint, Tag}}(x)

Did you mean this?

Base.adjoint(x:: TaggedArray{T, N, Tag}) where {T, N, Tag} = TaggedArray{T, N, Union{Adjoint, Tag}}(x.data)

Turns out my idea doesn't work:

julia> f(TaggedArray(rand(2,2))')
1

julia> f(TaggedArray(rand(2,2)) |> Symmetric)
1

😞

It's because I need to do the where Tag part first and that kills it I guess.

This works:

using LinearAlgebra: LinearAlgebra, Adjoint, Symmetric
struct TaggedArray{T, N, Tag}
    data::Array{T, N}
end
TaggedArray(x::Array{T, N}) where {T,N} = TaggedArray{T, N, Union{}}(x)

Base.adjoint(x:: TaggedArray{T, N, Tag}) where {T, N, Tag} = TaggedArray{T, N, Union{Adjoint, Tag}}(x.data)
# Base.adjoint(x:: TaggedArray{T, N, Tag}) where {T, N, Tag, Adjoint <: Tag} =  # get rid of the adjoint
LinearAlgebra.Symmetric(x::TaggedArray{T, 2, Tag}) where {T, Tag} = TaggedArray{T, 2, Union{Tag, Symmetric}}(x.data)


f(x::TaggedArray{T, 2, Adjoint}) where {T} = 1
f(x::TaggedArray{T, 2, Union{Adjoint, Other}}) where {T, Other} = 1

f(x::TaggedArray{T, 2, Symmetric}) where {T} = 2
f(x::TaggedArray{T, 2, Union{Symmetric, Other}}) where {T, Other} = 2

f(x::TaggedArray{T, 2, Union{Symmetric, Adjoint}}) where {T} = 3
f(x::TaggedArray{T, 2, Union{Symmetric, Adjoint, Other}}) where {T, Other} = 3

At the repl:

julia> f(TaggedArray(rand(2,2))')
1

julia> f(TaggedArray(rand(2,2)) |> Symmetric)
2

julia> f(TaggedArray(rand(2,2))' |> Symmetric)
3

julia> f(TaggedArray(rand(2,2)) |> Symmetric |> adjoint)
3

🎉

If you only define these methods for f:

f(x::TaggedArray{T, 2, Adjoint}) where {T} = 1
f(x::TaggedArray{T, 2, Union{Adjoint, Other}}) where {T, Other} = 1

f(x::TaggedArray{T, 2, Symmetric}) where {T} = 2
f(x::TaggedArray{T, 2, Union{Symmetric, Other}}) where {T, Other} = 2

Then you get this:

julia> f(TaggedArray(rand(2,2))')
1

julia> f(TaggedArray(rand(2,2)) |> Symmetric)
2

julia> f(TaggedArray(rand(2,2))' |> Symmetric)
ERROR: MethodError: no method matching f(::TaggedArray{Float64, 2, Union{Adjoint, Symmetric}})
Closest candidates are:
  f(::TaggedArray{T, 2, Union{Symmetric, Other}}) where {T, Other} at REPL[13]:1
  f(::TaggedArray{T, 2, Union{Adjoint, Other}}) where {T, Other} at REPL[11]:1
  f(::TaggedArray{T, 2, Adjoint}) where T at REPL[10]:1
  ...
Stacktrace:
 [1] top-level scope
   @ REPL[16]:1

julia> f(TaggedArray(rand(2,2)) |> Symmetric |> adjoint)
ERROR: MethodError: no method matching f(::TaggedArray{Float64, 2, Union{Adjoint, Symmetric}})
Closest candidates are:
  f(::TaggedArray{T, 2, Union{Symmetric, Other}}) where {T, Other} at REPL[13]:1
  f(::TaggedArray{T, 2, Union{Adjoint, Other}}) where {T, Other} at REPL[11]:1
  f(::TaggedArray{T, 2, Adjoint}) where T at REPL[10]:1
  ...
Stacktrace:
 [1] top-level scope
   @ REPL[17]:1

Is this the correct behavior?

Nope, that's unintentional. I wonder why the Other trick isn't working 😞

This is with defining only the first four methods for f. The Other trick works for Foo but not for Symmetric:

julia> struct Foo end

julia> a = TaggedArray{Float64, 2, Union{Adjoint, Foo}}([1.0 2.0; 3.0 4.0])
TaggedArray{Float64, 2, Union{Foo, Adjoint}}([1.0 2.0; 3.0 4.0])

julia> f(a)
1

julia> b = TaggedArray{Float64, 2, Union{Adjoint, Symmetric}}([1.0 2.0; 3.0 4.0])
TaggedArray{Float64, 2, Union{Adjoint, Symmetric}}([1.0 2.0; 3.0 4.0])

julia> f(b)
ERROR: MethodError: no method matching f(::TaggedArray{Float64, 2, Union{Adjoint, Symmetric}})
Closest candidates are:
  f(::TaggedArray{T, 2, Union{Symmetric, Other}}) where {T, Other} at REPL[9]:1
  f(::TaggedArray{T, 2, Union{Adjoint, Other}}) where {T, Other} at REPL[7]:1
  f(::TaggedArray{T, 2, Adjoint}) where T at REPL[6]:1
  ...
Stacktrace:
 [1] top-level scope
   @ REPL[28]:1

Maybe it's a bug due to Symmetric being a UnionAll?

If I only define the first two methods of f:

julia> struct Foo end

julia> a = TaggedArray{Float64, 2, Union{Adjoint, Foo}}([1.0 2.0; 3.0 4.0])
TaggedArray{Float64, 2, Union{Foo, Adjoint}}([1.0 2.0; 3.0 4.0])

julia> f(a)
1

julia> b = TaggedArray{Float64, 2, Union{Adjoint, Symmetric}}([1.0 2.0; 3.0 4.0])
TaggedArray{Float64, 2, Union{Adjoint, Symmetric}}([1.0 2.0; 3.0 4.0])

julia> f(b)
ERROR: MethodError: no method matching f(::TaggedArray{Float64, 2, Union{Adjoint, Symmetric}})
Closest candidates are:
  f(::TaggedArray{T, 2, Union{Adjoint, Other}}) where {T, Other} at REPL[7]:1
  f(::TaggedArray{T, 2, Adjoint}) where T at REPL[6]:1
Stacktrace:
 [1] top-level scope
   @ REPL[12]:1

MWE:

julia> g(::Ref{Union{Int, Other}}) where {Other} = 1
f (generic function with 1 method)

julia> g(Ref{Union{Int, Float64}}(1))
1

julia> g(Ref{Union{Int, Array}}(1))
ERROR: MethodError: no method matching f(::Base.RefValue{Union{Int64, Array}})
Closest candidates are:
  f(::Ref{Union{Int64, Other}}) where Other at REPL[1]:1
Stacktrace:
 [1] top-level scope at REPL[3]:1

Seems like a bug with UnionAll to me for sure. I gotta go to bed, but I can open an issue in the morning if nobody beats me to it.

That would work if TaggedArray was a Tuple and instead of an isa, <::

julia> Tuple{Float64, 2, Union{Adjoint, Symmetric}} <: Tuple{T, 2, Union{Adjoint, Other}} where {T, Other}  
true
julia> typeof(rand(5)) isa Array #basically the same
false

covariant vs invariant, i guess

I'm not sure if invariance is the issue here. It specifically seems to be the UnionAll that is the issue. Other abstract types work fine without needing any <: annotation.

julia> g(::Ref{Union{Int, Other}}) where {Other} = 1
g (generic function with 1 method)

julia> g(Ref{Union{Int, Float64}}(1))
1

julia> g(Ref{Union{Integer, AbstractFloat}}(1))
1

julia> g(Ref{Union{Int, Array}}(1))
ERROR: MethodError: no method matching g(::Base.RefValue{Union{Int64, Array}})
Closest candidates are:
  g(::Ref{Union{Int64, Other}}) where Other at REPL[1]:1
Stacktrace:
 [1] top-level scope at REPL[4]:1

julia> g(Ref{Union{Int, Array{Float64, 100}}}(1))
1

julia> g(Ref{Union{Int, Array{AbstractFloat, 100}}}(1))
1

julia> g(Ref{Union{Integer, Array{AbstractFloat, 100}}}(1))
1

julia> g(Ref{Union{Number, Array{Real, 100}}}(1))
1

@MasonProtter for un-adjointing, we could try this:

@generated function unionsubtract(::Type{U}, ::Type{T}) where {U, T}
    return Union{setdiff(Core.Compiler.uniontypes(U), [T])...}
end

In the REPL:

julia> using LinearAlgebra

julia> struct Foo end

julia> struct Bar end

julia> unionsubtract(Union{Foo, Adjoint, Bar}, Adjoint)
Union{Bar, Foo}

Or this might be better:

@generated function unionsubtract(::Type{U}, ::Type{T}) where {U, T}
    return Core.Compiler.typesubtract(U, T, typemax(Int))
end

In the REPL:

julia> using LinearAlgebra

julia> struct Foo end

julia> struct Bar end

julia> unionsubtract(Union{Foo, Adjoint, Bar}, Adjoint)
Union{Bar, Foo}

I think we don't need to use type subtract if we can figure out the UnionAll bug:

struct Foo end
struct Bar end

Base.adjoint(x::TaggedArray{T, N, Adjoint}) where {T, N} = TaggedArray{T, N, Union{}}(x.data)
Base.adjoint(x::TaggedArray{T, N, Union{Adjoint, Other}}) where {T, N, Other} = TaggedArray{T, N, Other}(x.data)

julia> TaggedArray(rand(2,2))'
TaggedArray{Float64,2,Adjoint}([0.7337422713379738 0.6323261034343546; 0.6549766230552179 0.0531575103233175])

julia> (TaggedArray(rand(2,2))')'
TaggedArray{Float64,2,Union{}}([0.5639123718675811 0.9175558237352524; 0.01610087829178175 0.632625236637625])

julia> (TaggedArray{Float64, 2, Union{Foo, Bar}}(rand(2,2))')
TaggedArray{Float64,2,Union{Bar, Foo, Adjoint}}([0.699743822169709 0.9959177311945779; 0.5597322929561694 0.8551353920860059])

julia> (TaggedArray{Float64, 2, Union{Foo, Bar}}(rand(2,2))')'
TaggedArray{Float64,2,Union{Bar, Foo}}([0.44928212809483625 0.6453411569592404; 0.5148462355738783 0.9647290574345497])

~The type subtract is for converting an adjoint back to an non-adjoint. I.e. if you have something with Union{Adjoint}, and then you call ', you need to unadjoint it, right? Because presumably (x')' is always exactly equal to x.~

Hmmm wait it seems you already got it working.

Ahh I see what you mean. If we can get the Other trick to work, then no need for subtraction.

Will that work for multiple things? E.g. suppose I have Union{Foo, Adjoint, Bar} and I want to unadjoint it. Does the Other trick still work?

Will that work for multiple things? E.g. suppose I have Union{Foo, Adjoint, Bar} and I want to unadjoint it. Does the Other trick still work?

It's a bit on the illegible side, but see the final two lines of my example above. Covers exactly that case with even those type names :smile:

That's what I get for being on my phone 😂

Hard to scroll horizontally

So really #37793 is the only blocker, right?

Want to create a package with this prototype? Then @ChrisRackauckas can stress-test it and see how it does in his various use cases.

FWIW, #37793 seems likely to eventually be fixed by ensuring that Other is left undefined in that function

Hmmm. That is unfortunate news for us, because IIUC Mason is using Other in the body of that function above.

So @MasonProtter we may need to have the restriction that tags cannot be UnionAlls? That seems fine, right? We can just do struct SymmetricTag end and use that in the tag.

I mean that all types should behave like UnionAll currently does. Currently, it gets the answer wrong when presented with a concrete type.

Hmmm. In that case, we can't use the Other trick at all.

Late to the party. I've certainly seen the problem reported in the OP with wrapper types, but I've sometimes wondered if we could get a lot of mileage by defining iswrapper(::Type{<:MyNewArray}) = true and then having the fallback for most traits be

sometrait(::MyNewArray) = Awesome()
sometrait(A::AbstractAray) = iswrapper(A) ? sometrait(parent(A)) : Mundane()

If you take a look at ArrayInterface this is done a lot with parent_type. If a trait isn't defined on an array then we see if there is a parent and try digging until we find something.

Fore example, known_length uses this strategy.

Re: Tim’s idea, does it happen in practice that a wrapper type is a wrapper with respect to some traits but not others?

f(x::TaggedArray{T, 2, Tag}) where {T, Tag, Union{Adjoint, Symmetric} <: Tag} = 3

Do you mean

f(x::TaggedArray{T, 2, Tag}) where {T, Tag >: Union{Adjoint, Symmetric}} = 3

?

Good discussion. I see this as basically moving type parameters from being positional to being like keyword arguments, which is something I've thought about. That would make parameters more meaningful as well as more extensible. A lot of things to think about here. For example, Vector{Int} could become an abstract type in some sense --- we know the layout, and we know enough to do compile-time dispatch for most functions, but there could be more parameters. Or we could insist that types written as they are now are exact, and you need something like Vector{Int; ...} to mean the abstract type that includes types with additional parameters. That's very explicit but seems less useful.

```julia
f(x::TaggedArray{T, 2, Tag}) where {T, Tag, Union{Adjoint, Symmetric} <: Tag} = 3

Do you mean

f(x::TaggedArray{T, 2, Tag}) where {T, Tag >: Union{Adjoint, Symmetric}} = 3

?
```

Huh, interesting I had definitely tried the angry-face operator there and failed to make it work for me, but I probably just did something silly. Thanks for pointing that out. As I said earlier today on Zulip,

Sometimes you just gotta do something badly and then hope someone smart comes and tells you why it's bad.

I think that this is a step in the right direction for traits in general. For example, an issue brought up on Zulip was that someone wanted to add the table interface (i.e. istable) to a bunch of types that did not have a common supertype but had another related trait. A simple solution would be doing istable(x) = istable(tags(x)) instead.

This is an interesting discussion and being able to attach extra tags to types seems like it might be quite useful, but I'm not convinced it solves the problem it's intended to here. What made me skeptical is the appearance of Adjoint and Sorted in the same example somewhere above. That makes sense for vectors, but if you think about higher-dimensional arrays, you see the problem coming. Imagine (and assume for the sake of discussion) the Sorted tag is also defined for higher-dimensional arrays, meaning the array is sorted in iteration order. Now one could add the Sorted tag when verifying the contents are sorted, and adjoint would attach the Adjoint tag. But wait! the adjoint of a sorted matrix is in general no longer sorted, so adjoint would need to be aware of the Sorted tag and remove it. Would it also need to remove other tags from other packages? Also the order of operations matters: Taking the adjoint first, and then verifying the result is sorted, one could safely attach the Sorted tag. But that's more or less where we're at now: Sorted{<:Adjoint} is sorted, Adjoint{<:Sorted} is not (necessarily) sorted, for SomeOtherWrapper{<:Sorted} we don't know.

So I'm afraid for the problem of array wrappers, we replace the combinatorial explosion of wrapper combinations with the same explosion of tag combinations. Or can someone give a brief example, how, say, Sorted, Adjoint, TrackedArray, and SizedArray, all worked together more easily using tags? Ideally with no code using definition from TrackedArray and SizedArrayat once.

@martinholters: good points. Let me rephrase your comments to digest their consequences. You are essentially saying that if tags are commutative (i.e. their order doesn't matter, so we encode them with a Union instead of a Tuple) they need to encode orthogonal information. At the very least they should never conflict with each other. One should be able to add any tag in any order without contradicting (i.e. having to modify) any other existing tag.

If we instead want to keep tags general (so we can have both Sorted and Adjoint tags, which are not orthogonal), then tags should necessarily be non-commutative. Wrapper types are already explicitly non-commutative. What would we gain with tags over wrapper types then? As presented in the OP, the advantage would be to be able to dispatch on a given tag regardless of the order it occupies in the tag list. But that has little value, I think, as the order is crucial. Matrix{Int}{Sorted, Adjoint} does not allow the same algorithms as Matrix{Int}{Adjoint, Sorted}, as @martinholters notes. Or another example: it doesn't make sense to dispatch on an Matrix with the UpperTriangular tag if Adjoint was added after it, because then it is actually LowerTriangular. So, it would seem that if we are adamant about allowing general, non-orthogonal tags, we might just as well stick to wrappers.

What we could perhaps attempt in relation to the problem in the OP is to allow defining equivalence classes of types in terms of commutation rules of wrappers (or tags), such as

Adjoint{UpperTriangular{T}} == LowerTriangular{Adjoint{T}} where {T<:AbstractMatrix}
Adjoint{Diagonal{T}} == Diagonal{Adjoint{T}} where {T<:AbstractMatrix}
Adjoint{Adjoint{T}} == T where {T<:AbstractMatrix}
TrackedArray{Adjoint{T}} == Adjoint{TrackedArray{T}} where {T<:AbstractMatrix}

Then, if we write a dispatch like f(::LowerTriangular{T}) where {T<:AbstractArray}, the type machinery could work out, based on repeated application of the commutation rules, that it also applies to f(::TrackedArray{Adjoint{UpperTriangular{Matrix{Int}}}}). But I wonder whether that is a hopelessly complex task in practice.

Uhm, I guess there is another aspect in which tags as proposed in the OP are prefereable to wrappers: they don't require unwrapping to access the fields of a type. The interface of a tagged type would not change as a result of the added types. So the above stuff on commutation rules should actually be reformulated in terms of non-commutative tags instead of wrappers so that the same code of the f method could work on types with equivalent sets of tags.

But that only holds if the tag (née wrapper) does not interfere with how the internals are to be interpreted. So most algorithms would have to differentiate whether Adjoint is present or not, which would also mean that those that don't would have to opt-in to ignoring it. (Otherwise, functions unprepared for the Adjoint tag could silently give wrong results.) That doesn't sound that much better than having to peel off a wrapper. So maybe the tag idea shines for additional information, as in a Sorted tag? Not really, the problem persists for mutating functions. And as we don't distinguish mutating/non-mutating functions on the language level, that doesn't really gain us anything.

Yes, I agree this is a bad fit for Adjoint. I think the most attractive use for this is things like Sorted and that it gives people a way to 'alias' existing structs and have a little copy that they can put their own methods on and treat differently without having to do all the extra stuff involved with making wrappers.

Good discussion. I see this as basically moving type parameters from being positional to being like keyword arguments, which is something I've thought about. That would make parameters more meaningful as well as more extensible. A lot of things to think about here. For example, Vector{Int} could become an abstract type in some sense --- we know the layout, and we know enough to do compile-time dispatch for most functions, but there could be more parameters. Or we could insist that types written as they are now are exact, and you need something like Vector{Int; ...} to mean the abstract type that includes types with additional parameters. That's very explicit but seems less useful.

@JeffBezanson I think at least for the purposes of labelling the fields of structs, we'd be forced for sanity reasons to say that e.g.

struct Foo
    a::Vector{Int}
end

can only store the un-tagged version. However, I wonder if in all other circumstances, we could get away with having it be 'abstract'? i.e. people can write

struct Foo{V <: Vector{Int}}
    a::V
end

and then that would be compatible with Vector{Int; MyTag}? This has the downside of being an extra rule that people have to remember about spelling types though.

Was this page helpful?
0 / 5 - 0 ratings