JuliaLang / julia

The Julia Programming Language
https://julialang.org/
MIT License
45.4k stars 5.46k forks source link

Type Tags for Extendable Type Information #37790

Open ChrisRackauckas opened 3 years ago

ChrisRackauckas commented 3 years ago

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...

DilumAluthge commented 3 years ago

This sounds great!

DilumAluthge commented 3 years ago

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

Could you elaborate on this a little bit?

ChrisRackauckas commented 3 years ago

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.

MasonProtter commented 3 years ago

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.

DilumAluthge commented 3 years ago

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}
ChrisRackauckas commented 3 years ago

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.

DilumAluthge commented 3 years ago

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.

DilumAluthge commented 3 years ago

I agree that the tag approach is probably more sustainable.

yuyichao commented 3 years ago

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.

DilumAluthge commented 3 years ago

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
DilumAluthge commented 3 years ago

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!

DilumAluthge commented 3 years ago

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?

yuyichao commented 3 years ago

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>)
yuyichao commented 3 years ago

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.

MasonProtter commented 3 years ago

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)

DilumAluthge commented 3 years ago

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.

ChrisRackauckas commented 3 years ago

@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}}.

MasonProtter commented 3 years ago

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}
DilumAluthge commented 3 years ago

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

ChrisRackauckas commented 3 years ago

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).

MasonProtter commented 3 years ago

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.

DilumAluthge commented 3 years ago

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?

DilumAluthge commented 3 years ago

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])) 
DilumAluthge commented 3 years ago

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.

MasonProtter commented 3 years ago

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

DilumAluthge commented 3 years ago

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

Good point.

DilumAluthge commented 3 years ago

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 commented 3 years ago

@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.

DilumAluthge commented 3 years ago

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
MasonProtter commented 3 years ago

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}.

DilumAluthge commented 3 years ago

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.

MasonProtter commented 3 years ago

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.

DilumAluthge commented 3 years ago

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.

DilumAluthge commented 3 years ago

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}})
MasonProtter commented 3 years ago

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

DilumAluthge commented 3 years ago

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.

longemen3000 commented 3 years ago

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

DilumAluthge commented 3 years ago

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}.

MasonProtter commented 3 years ago

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
DilumAluthge commented 3 years ago

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

DilumAluthge commented 3 years ago
# 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.

DilumAluthge commented 3 years ago

@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)
MasonProtter commented 3 years ago

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.

MasonProtter commented 3 years ago

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

πŸŽ‰

DilumAluthge commented 3 years ago

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?

MasonProtter commented 3 years ago

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

DilumAluthge commented 3 years ago

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
MasonProtter commented 3 years ago

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

DilumAluthge commented 3 years ago

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
MasonProtter commented 3 years ago

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.