Using abc.ABC vs Protocols
There are many opportunities within programming in general to solve problems by creating a standard/framework/mechanism that generalises those problems so they can be solved consistently.
This collection of static typing advice originated in a codebase that used
pkgutil.resolve_name in many places as a form of dependency injection for
“manager” objects that abstract away implementation details depending on how
the software was configured at runtime. This created a need to think about how
we can use this mechanism in a type safe way. The advice here remains specific
to that use case, but can be useful more generally.
Prelude: Safety at Runtime vs Statictime
An important concept to consider when designing an interface is what and when the interface is providing safety.
Safety in this context refers to the idea that when we write code our linters and tests will accurately and completely tell us when we are doing something that isn’t guaranteed to work at runtime. And when at runtime, our code will stop and rollback before data is corrupted.
At runtime we have ABC classes that provide a level of runtime protection, which is explained below.
Within the context of static type checking this concept of safety boils down to “does this variable name a value having this attribute and does the value I get from that attribute in turn have other attributes I depend on and so on”.
The type checker runs at static time, which means we are talking about what is and isn’t guaranteed, as opposed to what is or isn’t true at runtime.
To illustrate this point, take this example:
class A:
def __init__(self) -> None:
self.some_information = 1
def modifies_a(a: A, *, attr: str) -> None:
setattr(a, attr, 2)
my_variable = A()
modifies_a(my_variable, attr="sneaky_other_information")
print(my_variable.sneaky_other_information)
In this example, we can easily read it and see this will work at runtime. But the static type checker doesn’t read code the way a human typically does, and at any kind of scale it’s essentially an NP complete problem for it to understand whether this kind of code would work at runtime (it’s also quite smelly code!)
There are definitely limitations to the current state of Python type checking, but it does offer a lot of protection and often times, when it complains, it’s mypy’s love language for saying the flow of information needs to be adjusted.
Prelude: How do abc.ABC classes work?
Python offers “Abstract Base Classes” under the abc standard library that offer a way to formalise what methods/attributes an object should have, such that we have runtime protection from making objects that don’t have these methods/attributes.
The way it works is you subclass abc.ABC and mark required
attributes with the @abc.abstractmethod decorator. For example:
import abc
class BaseProcessor(abc.ABC):
@property
@abc.abstractmethod
def some_information(self) -> SomeInformation: ...
@abc.abstractmethod
def process(self) -> None: ...
A Protocol version of this would look like
from typing import Protocol
class BaseProcessor(Protocol):
@property
def some_information(self) -> SomeInformation: ...
def process(self) -> None: ...
There are a few key differences between the two however:
An ABC class can also contain an implementation, whereas a protocol can not
An ABC class represents a runtime concept whereas the protocol represents a static time only concept.
An ABC class can inherit from other concrete objects, whereas it often doesn’t make sense for a protocol to do that
You can use
isinstancechecks with an ABC class, whereas doing that with a Protocol doesn’t make sense and the@typing.runtime_checkabledecorator doesn’t do what it looks like and is best avoided.
The nice thing about the abc class is that we can only instantiate instances
of the abstract class that have an implementation for these attributes marked
with abc.abstractmethod.
For example
import attrs
@attrs.frozen
class ConcreteProcessor(BaseProcessor):
some_information: SomeInformation
def process(self) -> None:
print(self.some_information)
When we instantiate this ConcreteProcessor, the metaclass that this class is
created with (given we inherit from abc.ABC) will make sure that none of the
attributes on the instance are marked as abstract.
If our subclass overrides all the abstract attributes on the base class, no exception will be raised.
Whereas:
import attrs
@attrs.frozen
class ConcreteProcessor(BaseProcessor):
some_information: SomeInformation
When this implementation is instantiated, the .process will be the abstract
method from the parent class and an exception will be raised complaining that
we have not implemented all abstract instances.
This is how we get runtime safety, because we are guaranteed that if we have an instance of this class, then all required attributes have some implementation. And we combined this with mypy enforcing Liskov substitution principle to ensure that the types of these attributes are compatible with what we expect.
Prelude: Breaking safety
It’s very easy to break the safety these constructs give us! In both cases it’s via code that should be better anyways!
To break the safety for an abc class is to do something like this:
import attrs
@attrs.frozen
class ConcreteProcessor(BaseProcessor):
some_information: SomeInformation
def process(self) -> None:
raise NotImplementedError
In this case we can instantiate the object because it satisfies the checks that ABC makes, but we don’t actually have an implementation we can rely on!
In this case we don’t actually have an implementation and this can happen when a base class has too many responsibilities and the codebase ends up with with subclasses that only need to implement some of the methods.
The better approach is to break up the functionality provided into multiple base classes rather than having one object with too many responsibilities.
To break a Protocol means ignoring the type checker, for example:
import pkgutil
class Manager(protocol):
def do_something(self) -> None: ...
def get_manager() -> Manager:
return pkgutil.resolve_name(settings.MY_MANAGER)
The return of resolve_name is an Any and nothing in this example guarantees
that it matches the protocol that is in the return annotation of the function.
When we use the function however we don’t know that the function is lying!
get_manager().do_something()
# mypy would think this is fine, but at runtime nothing guarantees that!
And the goal at the end of the day with all our linters is to find and fix bugs before users that depend on our deployed code finds them!
Patterns: It is useful to provide a common ABC with no restrictions
Specifically when it has been decided to make a standard interface using an ABC class, start with a class that makes no assumption about how it’s implemented!
This could be called the “Common” interface. Even if there is only one default implementation, it makes it far easier to work with the interface when requirements change enough in the future that a second default implementation becomes useful. It’s much easier to add behaviour to a class than it is to remove behaviour from a class!
# The interface that is being provided
class CommonManager(abc.ABC):
@abc.abstractmethod
def functionality_one(self) -> str: ...
@abc.abstractmethod
def functionality_two(self) -> str: ...
@abc.abstractmethod
def functionality_three(self) -> str: ...
# A default implementation in terms of `SomeObj` that is created using attrs
@attrs.frozen
class BaseManager[T_Obj: SomeObj](CommonManager, abc.ABC):
@property
@abc.abstractmethod
def obj(self) -> T_Obj: ...
@abc.abstractmethod
def functionality_one(self) -> str: ...
@abc.abstractmethod
def functionality_two(self) -> str: ...
def functionality_three(self) -> str:
if self._some_choice_for_functionality_three():
self.obj.do_something()
@abc.abstractmethod
def _some_choice_for_functionality_three(self) -> bool: ...
# A concrete implementation using our default BaseManager for `ElecObj`
@attrs.frozen
class ElecManager(BaseManager[ElecObj]):
obj: ElecObj
def functionality_one(self) -> str:
return obj.one
def functionality_two(self) -> str:
return "stuff"
def _some_choice_for_functionality_three(self) -> bool:
return True
# A concrete implementation using our default BaseManager for `GasObj`
@attrs.frozen
class GasManager(BaseManager[GasObj]):
obj: GasObj
def functionality_one(self) -> str:
return obj.make_one()
def functionality_two(self) -> str:
return "tree"
def _some_choice_for_functionality_three(self) -> bool:
return False
This example [attempts to] demonstrate the idea that what the object provides is not always a 1:1 match with how it provides it.
In this case the implementation for ElecManager still provides
.functionality_three but implements it in terms of
_some_choice_for_functionality_three
By having the CommonManager ancestor only be concerned with the functionality
a consumer should know about we don’t limit other implementations of this object
with concerns related to this family of SomeObj objects that may not be
relevant in all circumstances.
The lesson here is separation of concern: Specifically separating what is being provided from how it is provided.
Patterns: Prefer to not use runtime_checkable
The python typing module provides a decorator by the name runtime_checkable
that makes it so that you can make a Protocol class work with the isinstance
builtin.
The functionality provided by this decorator however is not how it seems:
from typing import runtime_checkable, Protocol
# Don't do this
@runtime_checkable
class MyProtocol(Protocol):
def do_something(self, one: int, *, two: str) -> bool: ...
class MyImplementation:
def do_something(self) -> dict[str, int]:
return {}
def takes_something(instance: object) -> None:
assert isinstance(instance, MyProtocol)
# mypy believes that if that doesn't raise an exception then instance
# must match MyProtocol and the following will work
instance.do_something(1, two="two")
# The assertion type narrowing in the function won't fail
# Because runtime_checkable doesn't check types
# It only checks existance.
takes_something(MyImplementation())
The problem with the runtime_checkable is that it doesn’t check that the types
of those attributes are correct. And especially for generic protocols, it’s not
really possible for it to do that.
The alternative is to check against known classes that implement the desired protocol.
This is because type narrowing is fundamentally a runtime check because it’s at runtime that we know what the object actually is. At static time each choice is only a possibility. And so we must check the object against runtime concepts rather than static time concepts to decide what the object actually is.
Patterns: type narrowing dynamically retrieved logic
A place where Protocols begin to lose their type safety is when we retrieve logic
that is typed as typing.Any. For example the standard library
pkgutils.resolve_name
which takes a string and returns the result of doing a python import with that
value:
from django.conf import settings
import pkgutil
from typing import Protocol
class Bike(Protocol):
def steer(self, direction: str) -> ...
def get_bicycle() -> Bike:
return pkgutil.resolve_name(settings.BIKE_CLASS)()
In this case resolve_name can return anything, and some unsuspecting code may
try to do a get_bicycle() and it may take a few layers before the code fails
because that object wasn’t actually a bike!
It is much safer to instead do something like:
from django.conf import settings
import pkgutil
import abc
class CommonBike(abc.ABC):
@abc.abstractmethod
def steer(self, direction: str) -> ...
def get_bicycle() -> CommonBike:
constructor = pkgutil.resolve_name(settings.BIKE_CLASS)
instance = constructor()
assert isinstance(instance, CommonBike)
return instance
Note
❗ Prefer assert isinstance over assert issubclass so any callable object
can be found instead of only class objects.
In this pattern we assume that whatever we get from resolve_name is a callable
object and returns an instance of the object we want.
Patterns: Using resolve_name in a type safe way for objects that do take extra information
Let’s increase the difficulty a little from the previous section!
Sometimes it’s useful to provide information when we get our object:
def get_bicycle(color: str) -> CommonBike:
constructor = pkgutil.resolve_name(settings.BIKE_CLASS)
# But constructor is Any, how do we know it takes color!?!?!
instance = constructor(color=color)
assert isinstance(instance, CommonBike)
return instance
In this case we want an object that we know takes in color as a keyword
argument but we have an Any and we don’t want to limit ourselves to what kind
of factory we get.
Note
😅 fun fact, a class constructor is a factory that returns an instance of that class.
One way to make this type safe is to expect an intermediate object that is used to create what we want. This involves a little more boilerplate, but it’s worth it for code that ages so much better as requirements change over time.
import abc
import pkgutil
class BikeMaker(abc.ABC):
def make(self, *, color: str) -> CommonBike: ...
def get_bicycle(color: str) -> CommonBike:
constructor = pkgutil.resolve_name(settings.BIKE_CLASS_MAKER)
maker = constructor()
assert isinstance(maker, BikeMaker)
return maker.make(color=color)
Note that with this pattern it becomes ok for the make method to have a Protocol
as a return annotation because once we do our type narrowing against an abc class
we know we have an object that is returning something matching that protocol
(assuming that implementation isn’t doing something smelly like a type ignore comment!).
The key takeaway is a great pattern that universally applies is to assume the
return of resolve_name is a callable object that returns what we want.
The variation here is that what we want from resolve_name is an object that
can then in turn make the final object we are returning.
Patterns: Using resolve_name in a type safe way for generic objects
We can take the previous section and add one more thing onto it to enable us to return generic objects:
import abc
import pkgutil
class CommonLaptop[T_CPU: CommonCPU](abc.ABC):
@property
@abstractmethod
def cpu(self) -> T_CPU: ...
@abc.abstractmethod
def run_code(self, code: str) -> None: ...
class LaptopMaker(abc.ABC):
def make[T_CPU: CommonCPU](
self, *, cpu_type: type[T_CPU]
) -> CommonLaptop[T_CPU]: ...
def get_laptop(cpu_type: type[T_CPU]) -> CommonLaptop[T_CPU]:
constructor = pkgutil.resolve_name(settings.LAPTOP_MAKER)
maker = constructor()
assert isinstance(maker, LaptopMaker)
return maker.make(cpu_type=cpu_type)
def get_arm_laptop() -> CommonLaptop[ARMCPU]:
return get_laptop(ARMCPU)
def get_x86_laptop() -> CommonLaptop[X86]:
return get_laptop(X86)
The trick to understanding this is that a TypeVar is the same as any other
type annotation except that it’s the caller that decides what the type annotation
is rather than the definition.
And so to get a generic object with a specific type, we need to provide information about what that inner type is, which in turn means we need a factory that takes in information and is able to generate our final object.
Note
😁 Note a really nice feature from this pattern of assuming resolve_name
returns a callable object is that it gives us a really easy built in way of
avoiding import time side effects, because being a callable means we can
provide code that is only run when we execute the object inside our get
function!
Patterns: Prefer managers that have objects rather than take objects
Let’s say we have this manager class:
class BaseManager:
def get_vendor_name_from_dispenser(self, dispenser: CommonDispenser) -> str:
return dispenser.vendor_name
This is very restrictive because it means when we try to specialise such a manager managers we need to worry about all the different dispenser types rather than only the specialised one.
There are two solutions to this
Make CommonManager generic to the type of the dispenser
Make the CommonManager not care about the dispenser
Let’s talk about option (2)
import pkgutil
class CommonDispenser
"""
A placeholder for this example that represents a generalized dispensing
mechanism that specialized dispensers can subclass.
"""
class CommonManager(abc.ABC):
@abc.abstractmethod
def do_something_with_dispenser(self) -> str: ...
@attrs.frozen
class CandyManager(CommonManager):
dispenser: SpecialisedCandyDispenser
def do_something_with_dispenser(self) -> str:
self.dispenser.some_candy_feature()
return "treats"
@attrs.frozen
class SnackManager(CommonManager):
dispenser: SpecialisedSnackDispenser
def do_something_with_dispenser(self) -> str:
self.dispenser.some_snack_feature()
return "bag of snacks"
class MakeManagerFromDispenser(abc.ABC):
def make(self, *, dispenser: CommonDispenser) -> CommonManager: ...
def get_manager_from_dispenser(self, *, dispenser: CommonDispenser) -> CommonManager:
constructor = pkgutil.resolve_name(settings.MANAGER_FROM_DISPENSER_MAKER)
maker = constructor()
assert isinstance(maker, MakeManagerFromDispenser)
return maker.make(dispenser=dispenser)
class SpecialisedMakeManagerFromDispenser(abc.ABC):
def make(self, *, dispenser: CommonDispenser) -> CommonManager:
match dispenser:
case SpecialisedCandyDispenser():
return CandyManager(dispenser=dispenser)
case SpecialisedSnackDispenser():
return SnackManager(dispenser=dispenser)
case _:
raise UnknownDispenserType
The difference is that we make a manager instance per instance of the dispense. Rather than one manager instance that may take multiple dispensers.
It also means that the interface we work against is no longer generic and doesn’t leak where the information is coming from (because the interface does not expose the dispenser itself).
It also means we can do things like this:
class CandyManager(CommonManager):
dispenser: SpecialisedCandyDispenser
def get_candy_manager(self, dispenser: SpecialisedCandyDispenser) -> CandyManager:
return CandyManager(dispenser=dispenser)
And use the manager we’ve already created to create other managers that get the objects we are working with. This makes it much easier to cater interesting edge cases where we need to pass around and build up context to get the right functionality for the situation.
Patterns: AdHoc Protocols are best defined where they are used
Whilst there can be a case for defining protocols in a common place, it’s likely to follow the same logic in Go and define protocols (which are like Go interfaces) where they are used. And use a protocol as an expectation of what is required rather than a contract of what is provided.
For example:
from typing import Protocol
class HasProcessMethod(Protocol):
def process(self) -> bool: ...
def do_processing(self, processor: HasProcessMethod) -> bool:
return processor.process()
Doing this means any object that has a process method that doesn’t require
any arguments and returns a boolean can be used by this function.
This function is the perfect lego piece, it makes no assumption about what is passed in or its purpose other than what it wants to do with the object. It depends on the caller to know what purpose is being satisfied by using its functionality.
It can be a smell for this kind of function to have opinions about what higher purpose it has! It is but a soldier doing its one assigned task!
The main exception to leaning towards abc classes for protocols is for representing callable objects.
In most cases this replaces using collections.abc.Callable
For example, instead of:
from collections.abc import Callable
def make_processor() -> Callable[[], None]:
return lambda: None
Instead have:
from typing import Protocol
class Processor(Protocol):
def __call__(self) -> None: ...
def make_processor() -> Processor:
return lambda: None
There are two reasons for this:
Means the Callable has a name
Allows us to represent complex signatures that have positional and keyword only arguments and star args.
It also makes a lot of sense to represent a constructor as a separate protocol.
For example:
class MyObj(Protocol):
@property
def one(self) -> str: ...
@property
def two(self) -> int: ...
class MyObjConstructor(Protocol):
def __call__(self, *, one: str, two: int) -> MyObj: ...
This is because there are many ways to create an object and the __init__
method is only one of those ways. And often times it is useful to split what we
construct from where we get the data to construct it from.
For example, this statically works:
@attrs.frozen
class MyImplementation:
one: str
two: int
three: bool
def takes_constructor(
constructor: MyObjConstructor, *, one: str, two: int
) -> MyObj:
return constructor(one=one, two=two)
takes_constructor(functools.partial(MyImplementation, three=True), one="one", two=1)
But this does not
@attrs.frozen
class MyImplementation:
one: str
two: int
three: bool
def takes_constructor(
constructor: type[MyObj], *, one: str, two: int
) -> MyObj:
return constructor(one=one, two=two)
takes_constructor(functools.partial(MyImplementation, three=True), one="one", two=1)