Seelengrab/RequiredInterfaces.jl

Nested required interfaces?

gdalle opened this issue · 13 comments

gdalle commented

Say I have two abstract types A and B <: A defining interfaces:

  • A requires method f
  • B requires methods f and g

Is there a way to specify that B only requires g and let subtyping take care of f through A?

In theory, that is already required through declaring B <: A - do you have a particular operation in mind that's not exposed with the current interface? For example:

julia> using RequiredInterfaces

julia> abstract type A end

julia> @required A f(::A)
f (generic function with 1 method)

julia> abstract type B end

julia> @required B g(::B)
g (generic function with 1 method)

julia> const RI = RequiredInterfaces
RequiredInterfaces

julia> struct C <: B end

julia> f(::C) = 1
f (generic function with 2 methods)

julia> g(::C) = 1
g (generic function with 2 methods)

julia> RI.check_interface_implemented(A, C)
true

julia> RI.check_interface_implemented(B, C)
true

Ah, do you mean something like:

julia> struct D <: B end

julia> g(::D) = 2
g (generic function with 3 methods)

julia> RI.check_interface_implemented(B, D)
true

Returning the missing methods for A as well?

gdalle commented

No I mean something like this:

julia> using RequiredInterfaces

julia> const RI = RequiredInterfaces
RequiredInterfaces

julia> abstract type A end

julia> @required A f(::A)
f (generic function with 1 method)

julia> abstract type B <: A end

julia> @required B g(::B)
g (generic function with 1 method)

julia> struct C <: B end

julia> g(::C) = 1
g (generic function with 2 methods)

julia> RI.check_interface_implemented(B, C)
true  # I expect false here cause C is missing f to implement B <: A

Yeah, that's what I meant in the second example with D :) You're right, check_interface_implemented should report that as well.

gdalle commented

Yeah, that's what I meant in the second example with D :)

But in yours B didnt subtype A so I wasnt sure

Ah it did, that was in the same session/a continuation of my second comment - sorry, that could have been clearer!

gdalle commented

then yeah we're on the same page :)

Ok, I pushed a fix to feat/report_inherited_interface, which should work - at least it does with your MWE and tests don't complain locally. Please test it with your real code (in which you've presumably found this) and if that fixes your issue, I'll tag a release!

gdalle commented

OK, will do!
I was also wondering: for MyStruct to implement MyInterface, is it required

  • by your package
  • by your philosophy

that MyStruct is actually defined as MyStruct <: MyInterface?

gdalle commented

Basically if you don't require it then we don't even need MyInterface to be a type... and your package is another version of traits

I noticed that the fix wasn't actually proper, so I'll push a fix for the fix to that branch shortly!

As for your questions - it's not required by the package, all the checks work just as well if T doesn't subtype MyInterface. This is also mentioned in the (new) manual page on multiple functions in an interface, which I pushed together with implementing the begin block for @required.

Your second question is actually much more interesting - basically, the issue comes down to what exactly is meant with T <: S. If you view it as "T dispatches just like S", then yes, it means that in some form, T was declared to subtype S. I personally consider this to be preferable, which is why I talk about Meet in the philosophy section of the docs; after all, types exist so we can punt guarantees about objects into some pre-calculatable stage, instead of littering our code with lots and lots of checks everywhere (which we in reality do anyway to serve as some form of "inline documentation"). The issue is, in Julia we can't subtype multiple types, and we can't dispatch like types that we didn't explicitly subtype. So consider these types, where the contract is "has a type parameter":

abstract type Foo{T} end
abstract type Bar{T} end

struct Baz{T} <: Foo{T} end

foo(::Foo{T}) where T = T
bar(::Bar{T}) where T = T

and now we'd like to have Baz dispatch like Bar too, because it has a type parameter. Without explicitly declaring ourselves <: Baz, we currently can't take advantage of the fallback in Bar, and will have to implement our own bar(::Baz).

This unfortunately generalizes to other dispatches we might want to do - I touch on this in the Multiple Abstract Subtyping section of the philosophy chapter.

What I don't touch on is what happens if we think about allowing types to be extended with external interfaces after they have been defined (i.e., don't declare MyStruct <: SomeInterface, but rather "tag the interface on" later on, either by implementing the required methods or somehow declaring MyStruct to be a subtype after the fact) - and that's quite intentional, because it means that all of a sudden you absolutely cannot cache anything about interface compliance anymore, since that may change at any point. It's a similar reasoning to why eval in Julia is only allowed to be noticeable once you hit the global scope, because otherwise you can't really optimize functions anymore.

On top of this, if you think about what it means to declare being a subtype.. If you take <: to communicate API guarantees, that's not something e.g. a third party should be able to do for your package after the fact; they didn't write it after all and don't have the maintenance burden of keeping those additional guarantees. So while it might sometimes be nice, I don't think it's really always a good idea - the one edgecase I can see being desirable is having some type of your package only conditionally subtype some third party interface, if that interface package is actually loaded (though there's also an argument to be made regarding just providing a dedicated type for that interface package, wrapping the internal type.. which seems much easier to do engineering wise than actually modifying the subtyping relationship post-declaration!).

Basically if you don't require it then we don't even need MyInterface to be a type... and your package is another version of traits

Well.. we want to be able to dispatch, and the whole general idea behind having any kind of interface exactly is to have a type :) Traits are types too (just not 100% the types we currently have in Julia)! It's totally fair to say that this package is a version of traits - it's more or less my argument for why Holy Traits are not enough and what we'd have to change in the type system to no longer need that pattern.


Whew, that's quite a wall of text - apologies, if anything is unclear, feel free to ask 😅 If you have more questions, I can also open the "Discussions" feature on the repo?

Alright, I fixed the fix - now, getInterface should report the inherited interface as well. Let me know if it works as expected!

gdalle commented

I'm using this in HiddenMarkovModels.jl v0.3.0 and it seems to work, thanks!