diff --git a/dev/.documenter-siteinfo.json b/dev/.documenter-siteinfo.json index 8d87a95..87ea04a 100644 --- a/dev/.documenter-siteinfo.json +++ b/dev/.documenter-siteinfo.json @@ -1 +1 @@ -{"documenter":{"julia_version":"1.10.3","generation_timestamp":"2024-06-04T09:54:37","documenter_version":"1.4.1"}} \ No newline at end of file +{"documenter":{"julia_version":"1.10.3","generation_timestamp":"2024-06-04T10:19:19","documenter_version":"1.4.1"}} \ No newline at end of file diff --git a/dev/api.html b/dev/api.html index 8411b08..df6d24e 100644 --- a/dev/api.html +++ b/dev/api.html @@ -26,4 +26,4 @@ ERROR: MethodError: no method matching bar(::Int64, ::Baz) Stacktrace: [1] top-level scope - @ none:1source + @ none:1source diff --git a/dev/examples/basic.html b/dev/examples/basic.html index 4578175..6aa12fe 100644 --- a/dev/examples/basic.html +++ b/dev/examples/basic.html @@ -41,4 +41,4 @@ @required MyInterface myfunc(::MyInterface) end
Main.A

That is, one additional using, as well as an invocation of @required with the abstract interface type as well as the function and its signature that's part of the interface MyInterface.

With this small change, all issues mentioned above are solvable. First, the ambiguity between "should be implemented" and "not supported" is solved:

julia> using .A
julia> struct Foo <: A.MyInterface end
julia> A.myfunc(Foo())ERROR: NotImplementedError: The called method is part of a fallback definition for the `Main.A.MyInterface` interface. -Please implement `myfunc(::MyInterface)` for your type `T <: Main.A.MyInterface`.

This is because @required defines a fallback method that dispatches to NotImplementedError, allowing users that encounter this error to notify the package maintainter that they have missed to implement a required method. Further, because this is an actually thrown error, it's also discoverable through JET.jl, even without explicit interface testing by implementors.

In the next section, we'll take a look at how package authors that would like to hook into an interface can test that they have successfully done so, at least in terms of defining the correct methods.

+Please implement `myfunc(::MyInterface)` for your type `T <: Main.A.MyInterface`.

This is because @required defines a fallback method that dispatches to NotImplementedError, allowing users that encounter this error to notify the package maintainter that they have missed to implement a required method. Further, because this is an actually thrown error, it's also discoverable through JET.jl, even without explicit interface testing by implementors.

In the next section, we'll take a look at how package authors that would like to hook into an interface can test that they have successfully done so, at least in terms of defining the correct methods.

diff --git a/dev/examples/multifuncs.html b/dev/examples/multifuncs.html index 70ed63e..9bf9625 100644 --- a/dev/examples/multifuncs.html +++ b/dev/examples/multifuncs.html @@ -6,4 +6,4 @@ @required LinearArray begin Base.size(::LinearArray) Base.getindex(::LinearArray, ::Int) -end
getindex (generic function with 193 methods)

Importantly, we don't need to subtype LinearArray in order to check whether a type would implement the interface:

julia> using Test
julia> @test RI.check_interface_implemented(LinearArray, Vector)Test Passed

At the moment, this doesn't handle fallback definitions of abstract types well - for example, if we do the same check with Array, which has a fallback size defined:

julia> using Test
julia> @test RI.check_interface_implemented(LinearArray, Array)Test Passed

While unfortunate, this is only a limitation of the current implementation, and should be remedied in the future.

Another limitation is that we can't use LinearArray to dispatch Array objects, due to Julia not recognizing that the concrete instantiations of Array (Vector etc.) do implement the interface correctly - this is something that could be remedied with the solution presented in About Interfaces, though there are multiple other venues as well.

For now, checks like these can serve as information on whether a type does conform to the interface correctly, even if it doesn't formally subtype the abstract type behind this interface.

+end
getindex (generic function with 193 methods)

Importantly, we don't need to subtype LinearArray in order to check whether a type would implement the interface:

julia> using Test
julia> @test RI.check_interface_implemented(LinearArray, Vector)Test Passed

At the moment, this doesn't handle fallback definitions of abstract types well - for example, if we do the same check with Array, which has a fallback size defined:

julia> using Test
julia> @test RI.check_interface_implemented(LinearArray, Array)Test Passed

While unfortunate, this is only a limitation of the current implementation, and should be remedied in the future.

Another limitation is that we can't use LinearArray to dispatch Array objects, due to Julia not recognizing that the concrete instantiations of Array (Vector etc.) do implement the interface correctly - this is something that could be remedied with the solution presented in About Interfaces, though there are multiple other venues as well.

For now, checks like these can serve as information on whether a type does conform to the interface correctly, even if it doesn't formally subtype the abstract type behind this interface.

diff --git a/dev/examples/testing.html b/dev/examples/testing.html index eb4ccf1..0d0742e 100644 --- a/dev/examples/testing.html +++ b/dev/examples/testing.html @@ -7,7 +7,7 @@ Expression: RI.check_interface_implemented(MyInterface, NonImplementor) Value: Tuple{Any, Tuple}[(Main.A.myfunc, (Main.NonImplementor,))] ERROR: There was an error during testing

check_interface_implemented not only detects that the interface wasn't fully implemented, it can also report which signature was missed, and for which function.

If there are a lot of types implementing a specific interface, it's also possible to test all types who claim to implement the interface, or only a subset of them, instead of doing that on per-type basis:

Julia Bug

The first testset below should in reality produce an error, due to not all subtypes of MyInterface actually implementing the interface. However, due to a bug in Julia (see this issue), MyInterface claims to not have any subtypes, in spite of the fact that the subtypes have MyInterface as their supertype, leading to an empty testset. As a workaround, there is a second testset using the explicit collection version to check the subtypes manually, to show the expected failure. This bug in Julia should not impact the functionality of this package.

julia> struct AnotherImplementor <: MyInterface end
julia> A.myfunc(::AnotherImplementor) = "I'm different!"
julia> @testset "Test all subtypes" RI.check_implementations(MyInterface);Test Summary: |Time -Test all subtypes | None 0.4s
julia> @testset "Test all subtypes" RI.check_implementations(MyInterface, [AnotherImplementor, MyImplementor, NonImplementor]);Main.NonImplementor: Error During Test at /home/runner/work/RequiredInterfaces.jl/RequiredInterfaces.jl/src/RequiredInterfaces.jl:314 +Test all subtypes | None 0.5s
julia> @testset "Test all subtypes" RI.check_implementations(MyInterface, [AnotherImplementor, MyImplementor, NonImplementor]);Main.NonImplementor: Error During Test at /home/runner/work/RequiredInterfaces.jl/RequiredInterfaces.jl/src/RequiredInterfaces.jl:314 Expression evaluated to non-Boolean Expression: check_interface_implemented(interface, implementor) Value: Tuple{Any, Tuple}[(Main.A.myfunc, (Main.NonImplementor,))] @@ -18,4 +18,4 @@ Main.MyImplementor | 1 1 0.0s Main.NonImplementor | 1 1 0.0s ERROR: Some tests did not pass: 2 passed, 0 failed, 1 errored, 0 broken.
julia> @testset "Test subset" RI.check_implementations(MyInterface, [AnotherImplementor, MyImplementor]);Test Summary: | Pass Total Time -Test subset | 2 2 0.0s
+Test subset | 2 2 0.0s diff --git a/dev/index.html b/dev/index.html index d2226c3..3fe299d 100644 --- a/dev/index.html +++ b/dev/index.html @@ -1,2 +1,2 @@ -Main Page · RequiredInterfaces.jl

RequiredInterfaces.jl Documentation

This is the documentation for RequiredInterfaces.jl, a small package intended to mark the parts of an interface that implementors of that interface MUST implement in order to conform to it.

Check out the examples to get an introduction to interface specifications! If you're interested in the larger philosophy behind this package, check out the section About Interfaces.

Goals

  • Giving interface-writers the ability to declare "these are the required functions developers who would like to hook into this interface need to implement".
  • Treat abstract types like implicit interfaces.
  • Be precompilation friendly & zero-overhead.
  • Allow built-in testing for whether at least the required methods to conform to an interface are defined.
+Main Page · RequiredInterfaces.jl

RequiredInterfaces.jl Documentation

This is the documentation for RequiredInterfaces.jl, a small package intended to mark the parts of an interface that implementors of that interface MUST implement in order to conform to it.

Check out the examples to get an introduction to interface specifications! If you're interested in the larger philosophy behind this package, check out the section About Interfaces.

Goals

  • Giving interface-writers the ability to declare "these are the required functions developers who would like to hook into this interface need to implement".
  • Treat abstract types like implicit interfaces.
  • Be precompilation friendly & zero-overhead.
  • Allow built-in testing for whether at least the required methods to conform to an interface are defined.
diff --git a/dev/interfaces.html b/dev/interfaces.html index 9807609..1fc00ef 100644 --- a/dev/interfaces.html +++ b/dev/interfaces.html @@ -68,4 +68,4 @@ isMyTrait(::Foo) = IsMyTrait() isAnother(::Foo) = IsAnother() -myInterfaceFunc(x) = _myInterfaceFunc(isMyTrait(x), x)

Now, without modifying myInterfaceFunc, we can't define isMyTrait(::Foo) = IsAnotherTrait() to also support that kind of trait, because that would require giving up on MyTrait. We could introduce a second layer of indirection, to perhaps create a Meet-like of the supported traits, but that then exposes the true problem of the ambiguity between which implementation of _myInterfaceFunc we'd like to use, if both exist. The only way out is yet again defining _myInterfaceFunc(::Foo), breaking the ambiguity.

In contrast, since both MyTrait and AnotherTrait share this trait function as part of their interface, Foo ought to have already been aware of the ambiguity, and implemented to specialized version myInterfaceFunc(::Foo) itself directly (or fallen back to myInterfaceFunc(::Meet{MyTrait, AnotherTrait}), should that be available). The aambiguity needs to be broken some way or another, either by the Foo type for itself (it can't define the Meet version without piracy) or by either MyTrait or AnotherTrait via a package extension (ideally in coordinate, as otherwise you may get conflicting definitions overwriting each other).

All of this will need to be discussed & thought through thoroughly though - there is no silver bullet.

Relation to API stability

A related topic to "Abstract types are (implicit) interfaces" is how this interpretation relates to version changes under Semver. If an abstract type declares an (implicit) interface, it follows that changing that interface in a version bump requires considerations regarding stability, in order to not create a breaking change where none was intended. Specifically, if a non-breaking change is desired, at least the following must hold true:

There are certainly more details regarding interface stability between versions - this list is not exhaustive. An attempt at that exhaustiveness specific to Julia was recorded here, though there certainly is room for improvement and a more thorough calculus on what is permitted in terms of a change in API. There is also the possibility of incorporating existing literature into this (most I could find was in regards to empirical studies of API stability in Java, but even those results are bound to be useful). There is also some existing work from the rust community aabout this - see the references list down below.

Finally, the large body of work on preconditions, postconditions & invariants is also related to this topic.

Appendix

A large part of this discussion is inspired by looking at how other languages design their type system, but especially poignant (and what ultimately sparked my attempt here to equate abstract types with interfaces) was Abstract types have existential type, by Mitchell & Plotkin, 1988.

It's a lucky coincidence that their work, combined with classic Liskov substitution and applied to the Julia type system has this somewhat clean interpretation of abstract types as interfaces just dropping out - in spite of multiple dispatch making things sometimes substantially harder. I attribute most of that lucky coincidence to the design choice not to have abstract types have structure, i.e. the lack of structural inheritance in Julia. Having abstract types only be relevant for dispatch means they map very cleanly to behavioral subtyping, as that is all they can express - they have no structure other than the implicit requirements placed on them by the functions they are passed to.

Admittedly, there are edge cases with Meet, in particular with how Meet and Union interact in dispatch, that I have not yet had the time to fully describe here. I do hope that their interactions fall cleanly out of the lattice geometry, though I haven't checked yet.

References

+myInterfaceFunc(x) = _myInterfaceFunc(isMyTrait(x), x)

Now, without modifying myInterfaceFunc, we can't define isMyTrait(::Foo) = IsAnotherTrait() to also support that kind of trait, because that would require giving up on MyTrait. We could introduce a second layer of indirection, to perhaps create a Meet-like of the supported traits, but that then exposes the true problem of the ambiguity between which implementation of _myInterfaceFunc we'd like to use, if both exist. The only way out is yet again defining _myInterfaceFunc(::Foo), breaking the ambiguity.

In contrast, since both MyTrait and AnotherTrait share this trait function as part of their interface, Foo ought to have already been aware of the ambiguity, and implemented to specialized version myInterfaceFunc(::Foo) itself directly (or fallen back to myInterfaceFunc(::Meet{MyTrait, AnotherTrait}), should that be available). The aambiguity needs to be broken some way or another, either by the Foo type for itself (it can't define the Meet version without piracy) or by either MyTrait or AnotherTrait via a package extension (ideally in coordinate, as otherwise you may get conflicting definitions overwriting each other).

All of this will need to be discussed & thought through thoroughly though - there is no silver bullet.

Relation to API stability

A related topic to "Abstract types are (implicit) interfaces" is how this interpretation relates to version changes under Semver. If an abstract type declares an (implicit) interface, it follows that changing that interface in a version bump requires considerations regarding stability, in order to not create a breaking change where none was intended. Specifically, if a non-breaking change is desired, at least the following must hold true:

There are certainly more details regarding interface stability between versions - this list is not exhaustive. An attempt at that exhaustiveness specific to Julia was recorded here, though there certainly is room for improvement and a more thorough calculus on what is permitted in terms of a change in API. There is also the possibility of incorporating existing literature into this (most I could find was in regards to empirical studies of API stability in Java, but even those results are bound to be useful). There is also some existing work from the rust community aabout this - see the references list down below.

Finally, the large body of work on preconditions, postconditions & invariants is also related to this topic.

Appendix

A large part of this discussion is inspired by looking at how other languages design their type system, but especially poignant (and what ultimately sparked my attempt here to equate abstract types with interfaces) was Abstract types have existential type, by Mitchell & Plotkin, 1988.

It's a lucky coincidence that their work, combined with classic Liskov substitution and applied to the Julia type system has this somewhat clean interpretation of abstract types as interfaces just dropping out - in spite of multiple dispatch making things sometimes substantially harder. I attribute most of that lucky coincidence to the design choice not to have abstract types have structure, i.e. the lack of structural inheritance in Julia. Having abstract types only be relevant for dispatch means they map very cleanly to behavioral subtyping, as that is all they can express - they have no structure other than the implicit requirements placed on them by the functions they are passed to.

Admittedly, there are edge cases with Meet, in particular with how Meet and Union interact in dispatch, that I have not yet had the time to fully describe here. I do hope that their interactions fall cleanly out of the lattice geometry, though I haven't checked yet.

References