Home > database >  Correct way to hint that a class is implementing a Protocol?
Correct way to hint that a class is implementing a Protocol?

Time:07-22

On a path of improvement for my Python dev work. I have interest in testing interfaces defined with Protocol at CI/deb building time, so that if a interface isn't actually implemented by a class we will know immediately after the unit tests run.

My approach was typing with Protocol and using implements runtime_checkable to build unit test. That works, but the team got into a little debate about how to indicate a concretion was implementing a Protocol without busting runtime_checkable. In C /Java you need inheritance to indicate implementations of interfaces, but with Python you don't necessarily need inheritance. The conversation centered on whether we should be inheriting from a Protocol interface class.

Consider this code example at the end which provides most of the gist of the question. We were thinking about Shape and indicating how to hint to a future developer that Shape is providing IShape, but doing so with inheritance makes the runtime_checkable version of isinstance unusable for its purpose in unit-testing.

There is a couple of paths to improvement here:

We could find a better way to hint that Shape implements IShape which doesn't involve direct inheritance. We could find a better way to check if an interface is implemented at test deb package build time. Maybe runtime_checkable is the wrong idea.

Anyone got guidance on how to use Python better? Thanks!


from typing import (
    Protocol,
    runtime_checkable
)
import dataclasses

@runtime_checkable
class IShape(Protocol):
    x: float


@dataclasses.dataclass
class Shape(IShape):
    foo:float = 0.

s  = Shape()
# evaluates as True but doesnt provide the interface. Undermines the point of the run-time checkable in unit testing
assert isinstance(s, IShape)
print(s.x)  # Error.  Interface wasnt implemented




#
# Contrast with this assert
#
@dataclasses.dataclass
class Goo():
    x:float = 1

@dataclasses.dataclass
class Hoo():
    foo: float = 1

g = Goo()
h = Hoo()
assert isinstance(g, IShape)  # asserts as true
# but because it has the interface and not because we inherited.
print(g.x)


assert isinstance(h, IShape)  # asserts as False which is what we want

CodePudding user response:

When talking about static type checking, it helps to understand the notion of a subtype as distinct from a subclass. (In Python, type and class are synonymous; not so in the type system implemented by tools like mypy.)

A type T is a nominal subtype of type S if we explicitly say it is. Subclassing is a form of nominal subtyping: T is a subtype of S if (but not only if) T is a subclass of S.

A type T is a structural subtype of type S if it something about T itself is compatible with S. Protocols are Python's implementation of structure subtyping. Shape does not not need to be a nominal subtype of IShape (via subclassing) in order to be a structural subtype of IShape (via having an x attribute).

So the point of defining IShape as a Protocol rather than just a superclass of Shape is to support structural subtyping and avoid the need for nominal subtyping (and all the problems that inheritance can introduce).

class IShape(Protocol):
    x: float


# A structural subtype of IShape
# Not a nominal subtype of IShape
class Shape:
    def __init__(self):
        self.x = 3

# Not a structural subtype of IShape
class Unshapely:
    def __init__(self):
        pass


def foo(v: IShape):
    pass

foo(Shape())  # OK
foo(Unshapely())  # Not OK

So is structural subtyping a replacement for nominal subtyping? Not at all. Inheritance has its uses, but when it's your only method of subtyping, it gets used inappropriately. Once you have a distinction between structural and nominal subtyping in your type system, you can use the one that is appropriate to your actual needs.

  • Related