Interfaces are forever

Fri 12 July 2019 by Moshe Zadka

(The following talks about zope.interface interfaces, but applies equally well to Java interfaces, Go interfaces, and probably other similar constructs.)

When we write a function, we can sometimes change it in backwards-compatible ways. For example, we can loosen the type of a variable. We can restrict the type of the return value. We can add an optional argument.

We can even have a backwards compatible path to make an argument required. We add an optional argument, and encourage people to change it. Then, in the next version, we make the default value be one that causes a warning. In a version after that, we make the value required. At each point, someone could write a library that worked with at least two consecutive versions.

In a similar way, we can have a path to remove an argument. First make it optional. Then warn when it is passed in. Finally, remove it and make it an error to pass it in.

As long as we do not intend to support inheritance, making backwards compatible changes to classes also works. For example, to remove a method we first have a version that warns when you call it, and then remove it in a succeeding version.

However, what changes can we make to an interface?

Assume we have an interface like:

from zope.interface import Interface, implements

class IFancyFormat(Interface):

    def fancify_int(value: int) -> str:
        pass

It is a perfectly reasonable, if thin, interface. Implementing it seems like fun:

@implements(IFancyFormat)
@attr.s(auto_attribs=True)
class FancySuffixer:
    suffix: str

    def fancify_int(self, value: int) -> str:
        return str(value) + self.suffix

Using it also seems like fun:

def dashify_fancy_five(fancifier: IFancyFormat) -> str:
    return f"---{fancifier.fancify_int(5)}---"

These are very different kinds of fun, though! Probably the kind of fun that appeals to different people. The first implementation is in the superfancy open-source library. The second one is in the dash_five open-source library. Such is the beauty of open source: it takes all kinds of people.

We cannot add a method to IFancyFormat: the superfancy library has a unit test that uses verifyImplements, which will fail if we add a method. We cannot remove the method fancify_int, since this will break dash_five: the mypy check will fail, since IFancifySuffixer will not have that method.

Similarly, we cannot make the parameter optional without breaking superfancy, or loosen the return type without breaking dash_five. Once we have published IFancyFormat as an API, it cannot change.

The only way to recover from a bad interface is to create a new interface, IAwesomeFancyFormat. Then write conversion functions from and to IFancyFormat and IAwesomeFancyFormat. Then deprecate using the IFancyFormat interface. Finally, we can remove the interface. Then we can alias IFancyFormat == IAwesomeFancyFormat, and eventually, maybe even deprecate the name IAwesomeFancyFormat.

When publishing interfaces, one must be careful: to a first approximation, they are forever.

(Thanks to Glyph Lefkowitz for his helpful suggestions. Any mistakes or issues that are left are my responsibility.)