RequiredInterfaces.jl
RequiredInterfaces.jl copied to clipboard
Interfaces with unspecified, non-Any argument types
Consider the following interface where I have a method whose other input types are known in context, but those types are different in general and do not share a common supertype outside of Any:
using RequiredInterfaces, DataFrames
abstract type AbstractFoo end
@required AbstractFoo begin
get_length(::AbstractFoo, _)
end
# Returns rows of DF
struct DataFrameFoo <: AbstractFoo
switch::Bool
end
function get_length(f::DataFrameFoo, x::AbstractDataFrame)
return f.switch ? size(x)[1] : nothing
end
# Returns length of vector
struct VectorFoo <: AbstractFoo
switch::Bool
end
function get_length(f::VectorFoo, x::AbstractVector)
return f.switch ? length(x) : nothing
end
RequiredInterfaces.check_implementations(AbstractFoo, [VectorFoo, DataFrameFoo])
# Fails
There's no way to represent this right now other than requiring an implementation for Any for both
Hmmm, this is an interesting one! Yes, since the other argument doesn't share a non-Any supertype, this can't currently be specified. In theory, if they would share one you'd have to specify that shared supertype there.
Really though, what this seems to require is that there's an additional interface that types of AbstractFoo must implement to be able to check what kinds of objects are allowed as a second argument. Kind of like this pseudo-syntax:
@required AbstractFoo begin
ObjType(::AbstractFoo)
get_length(::AbstractFoo, ::ObjType(::AbstractFoo))
end
where ObjType would return AbstractDataFrame for DataFrameFoo and AbstractVector for VectorFoo. I'm a bit hesitant of adding something like this, because it makes interface checking recursive; in order to check one part of the interface (get_length in this case) we not only need to check other parts of the interface (ObjType), they also need to be available for execution during checking time. Since in General there isn't an instance of AbstractFoo available, this means that ObjType needs to be at least type stable for e.g. DataFrameFoo.
One workaround here is to define your own get_length(::AbstractFoo, ::Any) = ... do-nothing method. check_implementations is pretty stringent when it comes to checking what constitutes a non-implementation, but by providing one catch-all you can just override the catch-all that RequiredInterfaces.jl itself gives you. Bear in mind that this has the consequence of making the interface valid for all new types if you only have that overridden get_length as part of your interface.
It's a really interesting example of the limitations of the current system, thank you for that!
I think this is likely an instance where you end up wanting to work with traits, as your solution attempt shows, where ObjType seems like a trait indicating the compatible second argument.
I guess what we really want here is an associated type (in the Rust jargon), which we might be able to work in with type parameters on AbstractFoo, but still seems like it might not admit a nice solution.
FWIW, this is something I came across with BinaryTraits.jl and broke on 1.10. The solution BT.jl uses is to differentiate foo(x::Any) and foo(x) as interface signatures.
The first one gets checked with
hasmethod(foo, Tuple{Any})
which matches only if you can handle anything passed in. The second gets checked with
hasmethod(foo, Tuple{Union{}})
which matches if there is any single instance that you could handle something being passed in since Union{} is the bottom type. This breaks on 1.10 (Tuple{Union{}} is not a valid type anymore) so the approach can't be copied over, but it can be convenient.
In general, I think a trait approach to this is probably preferable since it's more explicit, but I wanted to raise the issue as food for thought
I think this is likely an instance where you end up wanting to work with traits, as your solution attempt shows, where ObjType seems like a trait indicating the compatible second argument.
Yes, I immediately thought of traits too! Since traits are just another form of interface specification, it's equivalent to adding something that associates a new type per-interface to the second argument.
which we might be able to work in with type parameters on AbstractFoo, but still seems like it might not admit a nice solution.
It's the only (IMO) nice solution achievable in the current type system; the syntax would be
@required AbstractFoo{T} begin
get_length(::AbstractFoo{T}, ::T)
end
but that's currently not supported by @required. If you think about the dispatch constraints though, this is exactly what you'd want! You'd then have
# Returns rows of DF
struct DataFrameFoo <: AbstractFoo{AbstractDataFrame}
switch::Bool
end
function get_length(f::DataFrameFoo, x::AbstractDataFrame)
return f.switch ? size(x)[1] : nothing
end
# Returns length of vector
struct VectorFoo <: AbstractFoo{AbstractVector}
switch::Bool
end
function get_length(f::VectorFoo, x::AbstractVector)
return f.switch ? length(x) : nothing
end
and I think this should work out?
FWIW, this is something I came across with https://github.com/tk3369/BinaryTraits.jl/issues/62 and broke on 1.10. The solution BT.jl uses is to differentiate foo(x::Any) and foo(x) as interface signatures.
hasmethod(foo, Tuple{Any})
hasmethod(foo, Tuple{Union{}})
Those are okay solutions - you could replace Union{} with a custom concrete type internally and hide that implementation detail that way. Of course, that's more manual handling that needs to be done correctly (and is again something that wouldn't occur if this were part of the core type lattice).
Admittedly, I dislike using ::Any and untyped arguments in interface definitions. Nothing (except for the identity function) can truly handle Anything. There's always some hidden requirement/constraint.
In general, I think a trait approach to this is probably preferable since it's more explicit, but I wanted to raise the issue as food for thought
It's certainly a preference :shrug: If we had multiple abstract subtyping, there wouldn't be a need for using e.g. Holy Trait-style dispatches, since then traits are just different abstract types to implement.
Either way, thank you for bringing it up! Great argument for supporting parametric interfaces :)