Module that defines an interface for an abstract type without implementing it

Suppose I have a module that defines an abstract type AbstractPerformer (as in, a stage performer) and a stage routine for the performer as follows:

module Performers

abstract type AbstractPerformer end

function rejoice(ap::AbstractPerformer)
    smile(ap)
    say(ap, "Oh joy!")
end

end # module

The important point here is that I don’t define the functions smile() and say() myself. I want users of my module to define their own concrete type under AbstractPerformer and implement these two functions themselves—and thereby get the rejoice() function for their type “for free.”

Here’s what I’ve tried:

  1. Do nothing. This doesn’t work because the user has to be able to extend the smile() and say() symbols referred to in rejoice().

  2. Provide “dummy” implementations within the module such as

    smile(::AbstractPerformer) = throw(MethodError)
    say(::AbstractPerformer, ::Any) = throw(MethodError)
    

    This is annoying because it breaks the debugger. Instead of getting the pretty MethodError output that shows the nearest type matches, the call to these functions just throws a bare MethodError.

  3. Define “empty” symbols such as:

    function smile end
    function say end
    

    I don’t like this approach because it doesn’t encode anything about the expected function signature or types. I noticed that Bonobo.jl (the first package I could think of that is doing something similar) takes this approach and gives the intended function signature in the docstring:

    Bonobo.jl/src/Bonobo.jl at 1f2640f1952df6d484d90e63e07ac027ed3d5de3 · Wikunia/Bonobo.jl · GitHub

    But docstrings are not code, and thus this approach doesn’t provide debugging affordances such as methods(say) which (using approach 2) will give a hint as to the intended signature. It also doesn’t give my IDE (as the module developer) the hints needed to identify inconsistent usage of these symbols within the module.

Is there a better way?

Pardon typos; am on mobile.

1 Like

I would use these dummy functions but instead of throwing a general error, throw a very clear error message instructing the user on what has to be implemented.

I personally prefer method 3 due to the Method Error providing a detailed error (if you just are off “a bit” from the original signature). A generic fallback with an error message would remove that opportunity.

One thing you can do – but I just remembered that was done in one (of my) packages (but of a co-developer of mine) is to register an error hint.

This one adds a hint to the MethodError that the user probably forgot to load a package, since the function we test for here is only available/defined in an Extension. I feel this would work best with 3. Also Method 3 allows the user to import your functions to extend them, which is a nice and clean way to do so.

3 Likes

The problem isn’t (just) that the bare MethodError is uninformative; it’s that the existence of the say(::AbstractPerformer, ::Any) method serves as a catchall that overrides type logic that may be specific to the user’s implementation.

E.g., suppose my module provides

julia> abstract type AbstractPerformer end

julia> foo(::AbstractPerformer, ::Any) = throw("This is a fallback method for AbstractPerformer. You should implement this function for your own concrete type")
foo (generic function with 1 method)

without taking a position on the type of the second argument to foo().

But for a user, they may want to constrain their implementation of foo() further:

julia> struct Performer <: AbstractPerformer end

julia> foo(::Performer, ::AbstractFloat) = "Desired result"
foo (generic function with 2 methods)

But now, if someone accidentally puts in an integer instead of a float, they get the OG throw from foo(::AbstractPerformer, ::Any):

julia> foo(Performer(), 1)
ERROR: "This is a fallback method for AbstractPerformer. You should implement this function for your own concrete type"
Stacktrace:
 [1] foo(::Performer, ::Int64)
   @ Main ./REPL[2]:1
 [2] top-level scope
   @ REPL[5]:1

It would be more helpful if this call behaved the way it does when foo(::AbstractPerformer, ::Any) is undefined (but foo(::Performer, ::AbstractFloat) is):

julia> abstract type AbstractPerformer end

julia> struct Performer <: AbstractPerformer end

julia> foo(::Performer, ::AbstractFloat) = "Desired result"
foo (generic function with 1 method)

julia> foo(Performer(), 1)
ERROR: MethodError: no method matching foo(::Performer, ::Int64)

Closest candidates are:
  foo(::Performer, ::AbstractFloat)
   @ Main REPL[3]:1

Stacktrace:
 [1] top-level scope
   @ REPL[4]:1

(note the “closest candidates” hint)

1 Like

Maybe I am being a perfectionist here, but here’s how approach #3 looks in my IDE:

image

image

I don’t like this, because it makes it hard to distinguish genuine method call errors (e,g, say(a, b, c)) from these false positives.


At the risk of being tedious, the Python code below solves all of my problems (well, most of them—the float vs. int thing would require type annotations):

class PerformerInterface:

    def rejoice(self):
        self.smile()
        self.say("Oh joy!")

    def smile(self):
        raise NotImplementedError

    def say(self, words):
        raise NotImplementedError


class Performer(PerformerInterface):

    def smile(self):
        print(":)")
    
    def say(self, words):
        print(f"I have something to say: {words}")

I agree that an empty function function smile end is the best approach because a MethodError is semantically correct here. If you need additional specification beyond what’s in the docs, take a look at Interfaces.jl or RequiredInterfaces.jl

7 Likes

To add to what @gdalle mentioned, if you use a MethodError you get a semantically correct error AND you can add a lot of extra information and explanations to it. You can register a “hint” that explains that this is an informal abstract method and provide guidance to the user or developer.

help?> Base.Experimental.register_error_hint
  Experimental.register_error_hint(handler, exceptiontype)

  Register a "hinting" function handler(io, exception) that can suggest
  potential ways for users to circumvent errors. handler should examine
  exception to see whether the conditions appropriate for a hint are met, and
  if so generate output to io. Packages should call register_error_hint from
  within their __init__ function.

  For specific exception types, handler is required to accept additional
  arguments:

    •  MethodError: provide handler(io, exc::MethodError, argtypes,
       kwargs), which splits the combined arguments into positional and
       keyword arguments.

3 Likes

I don’t really know how to write macros, but something like this could work.

macro todo(expr)
    @assert expr.head in (:call, :function)

    return quote
        $(esc(expr)) = error($(esc("implement this: " * string(expr))))
    end
end

@todo f(a::Int)

julia> f(1)
ERROR: implement this: f(a::Int)

But Interface.jl and RequiredInterfaces.jl look cool.

Yes, the idea of relying on the MethodError is exactly to get the hint that the integer might have been wrong and the float one is the one closest, that the MethodError hints to.

Similarly if the user confuses two.
Initiale we had quite a few throwing fallbacks. By now we removed all of them.

Concerning the IDE, for our cases, adding doc strings to the specific methods (but not implementing them) helped for most cases, see for example something like

but sure the Linter is not yet perfect there within VS Code.

As the discussion above shows, we’re still figuring this out. Many would prefer to have some kind of interface check through some kind of static analysis. Honestly, we have made the interface checks too complicated thus far, while I also think the MethodError approach is a bit too passive.

Rather than passively wait for MethodErrors to arise, we should preemptively and simply test the interface. I’m starting to think we can use the dynamic features of Julia to test.

My proposal for interface testing is that one should provide a function to test the interface when declaring such an interface. Focus on testAbstractPerformerInterface below.

module Performers
    using Test

    export AbstractPerformer, rejoice

    abstract type AbstractPerformer end

    function smile end
    function say end

    function rejoice(ap::AbstractPerformer)
        smile(ap)
        say(ap, "Oh joy!")
    end

    function testAbstractPerformerInterface(ap::AP) where AP <: AbstractPerformer
            @testset "Testing if $AP implements AbstractPerformerInterface" begin
                @test hasmethod(smile, Tuple{AP})
                @test hasmethod(say, Tuple{AP, String})
                @test isnothing(smile(ap))
                @test isnothing(say(ap, "Oh joy!"))
            end
        end
    end

    # ... insert macro here ...

end # module Performers

An implementing type could do the following.

using Performers
struct MyPerformer <: AbstractPerformer end
Performers.testAbstractPerformerInterface(MyPerformer())
...
Test Summary:                                                | Fail  Error  Total  Time
Testing if MyPerformer implements AbstractPerformerInterface |    2      2      4  2.7s
ERROR: Some tests did not pass: 0 passed, 2 failed, 2 errored, 0 broken.

We could even provide some macro and preferences support so the tests do not have to run every time.

module Performers
    # ... see above

    # macro support
    using Preferences
    const abstract_performer_tests = @load_preference("abstract_performer_tests", false)

    macro implementsAbstractPerformer(ex, T)
        T = esc(T)
        quote
            $(esc(ex))
            if abstract_performer_tests
                testAbstractPerformerInterface($T)
            end
        end
    end

end # module Performers

An implementing type could the be declared as follows.

using Performers
Performers.@implementsAbstractPerformer begin
    struct MyPerformer <: AbstractPerformer end
    Performers.smile(::MyPerformer) = println("*smiles*")
    Performers.say(::MyPerformer, s::String) = println(s)
end MyPerformer()

The above will do nothing extra by default. However, during testing or development we could turn the tests on.

julia> using Preferences

julia> Preferences.set_preferences!("Performers", "run_abstract_performer_tests" => true, force=true)

julia> using Performers

julia> Performers.@implementsAbstractPerformer begin
           struct MyPerformer <: AbstractPerformer end
           Performers.smile(::MyPerformer) = println("*smiles*")
           Performers.say(::MyPerformer, s::String) = println(s)
       end MyPerformer();
*smiles*
Oh joy!
Test Summary:                                                | Pass  Total  Time
Testing if MyPerformer implements AbstractPerformerInterface |    4      4  0.0s

For an incorrect implementation, we would get the following.

julia> Performers.@implementsAbstractPerformer begin
           struct BadPerformer <: AbstractPerformer end
           Performers.smile(::BadPerformer) = println("*smiles*")
           Performers.say(::BadPerformer) = "Hello World!"
       end BadPerformer();
Testing if BadPerformer implements AbstractPerformerInterface: Test Failed at /home/mkitti/blah/Performers.jl/src/Performers.jl:20
  Expression: hasmethod(say, Tuple{AP, String})

Stacktrace:
...
*smiles*
Testing if BadPerformer implements AbstractPerformerInterface: Error During Test at /home/mkitti/blah/Performers.jl/src/Performers.jl:22
  Test threw exception
  Expression: isnothing(say(ap, "Oh joy!"))
  MethodError: no method matching say(::BadPerformer, ::String)
  
  Closest candidates are:
    say(::BadPerformer)
     @ Main REPL[4]:4
  
  Stacktrace:
  ...
Test Summary:                                                 | Pass  Fail  Error  Total  Time
Testing if BadPerformer implements AbstractPerformerInterface |    2     1      1      4  0.2s
ERROR: Some tests did not pass: 2 passed, 1 failed, 1 errored, 0 broken.

One aspect that I like about the above is that we’re actively declaring what we think implements the interface.

In summary, if you want someone to implement a particular interface, provide a test function to check if they actually did implement that interface.

That is exactly what Interfaces.jl does, take a look at the documentation for more details.

I have succesfully used it in GraphsInterfaceChecker.jl for the JuliaGraphs ecosystem

ping @Raf

4 Likes

Relevant Julia issue: