Interfaces¶
oop-ext
introduces the concept of interfaces, common in other languages.
An interface is a class which defines methods and attributes, defining a specific behavior, so implementations can declare that they work with an specific interface without worrying about implementations details.
Interfaces are declared by subclassing oop_ext.interface.Interface
:
from oop_ext.interface import Interface
class IDataSaver(Interface):
"""
Interface for classes capable of saving a dict containing
builtin types into persistent storage.
"""
def save(self, data: dict[Any, Any]) -> None:
"""Saves the given list of strings in persistent storage."""
(By convention, interfaces start with the letter I
).
We can write a function which gets some data and saves it to persistent storage, without hard coding it to any specific implementation:
def run_simulation(params: SimulationParameters, saver: IDataSaver) -> None:
data = calculate(params)
saver.save(data)
run_simulation
computes some simulation data, and uses a generic saver
to persist it
somewhere.
We can now have multiple implementations of IDataSaver
, for example:
from oop_ext.interface import ImplementsInterface
@ImplementsInterface(IDataSaver)
class JSONSaver:
def __init__(self, path: Path) -> None:
self.path = path
def save(self, data: dict[Any, Any]) -> None:
with self.path.open("w", encoding="UTF-8") as f:
json.dump(f, data)
And use it like this:
run_simulation(params, JSONSaver(Path("out.json")))
What about duck typing?¶
In Python declaring interfaces is not really necessary due to duck typing, however interfaces bring to the table runtime validation.
If later on we add a new method to our IDataSaver
interface, we will get errors at during
import time about implementations which don’t implement the new method, making it easy to spot
the problems early. Interfaces also verify parameters names and default values, making
it easy to keep implementations and interfaces in sync.
Note
Changed in version 2.0.0.
Interfaces do not check type annotations at all.
It was supported initially, but in practice this feature has shown up to be an impediment to adopting type annotations incrementally, as it discourages adding type annotations to improve existing interfaces, or annotating existing implementations without having to update the interface (and all other implementations by consequence).
It was decided to let the static type checker correctly deal with matching type annotations, as
it can do so more accurately than oop-ext
did before.
Type Checking¶
New in version 1.1.0.
The interfaces implementation has been implemented many years ago, before type checking in Python became a thing.
The static type checking approach is to use Protocols, which has the same benefits and flexibility of interfaces, but without the runtime cost. At ESSS however migrating the entire code base, which makes extensive use of interfaces, is a lengthy process so we need an intermediate solution to fill the gaps.
To bridge the gap between the runtime-based approach of interfaces, and the static
type checking provided by static type checkers, one just needs to subclass from both
Interface and TypeCheckingSupport
:
from oop_ext.interface import Interface, TypeCheckingSupport
class IDataSaver(Interface, TypeCheckingSupport):
"""
Interface for classes capable of saving a dict containing
builtin types into persistent storage.
"""
def save(self, data: dict[Any, Any]) -> None:
"""Saves the given list of strings in persistent storage."""
The TypeCheckingSupport
class hides from the user the details necessary to make type checkers
understand Interface
subclasses.
Note that subclassing from TypeCheckingSupport
has zero runtime cost, existing only
for the benefits of the type checkers.
Note
Due to how Protocol
works in Python, every Interface
subclass also needs to subclass
TypeCheckingSupport
.
Proxies¶
Given an interface and an object that implements an interface, you can call GetProxy
to obtain a proxy object which only contains methods and attributes defined in the interface.
For example, using the JSONSaver
from the previous example:
def run_simulation(params, saver):
data = calculate(params)
proxy = GetProxy(IDataSaver, saver)
proxy.save(data)
The proxy
object contains a stub implementation which contains only methods and attributes in IDataSaver
. This
prevents mistakes like accessing a method that is defined in JSONSaver
, but is not part of IDataSaver
.
Legacy Proxies¶
With type annotations however, this is redundant: the type checker will prevent access to any method not declared in
IDataSaver
:
def run_simulation(params: SimulationParameters, saver: IDataSaver) -> None:
data = calculate(params)
saver.save(data)
However when adding type annotations to legacy code, one will encounter this construct:
def run_simulation(params, saver):
data = calculate(params)
proxy = IDataSaver(saver)
proxy.save(data)
Here “creating an instance” of the interface, passing an implementation of that interface, returns the stub
implementation. This API was implemented like this for historic reasons, mainly because it would trick IDEs into
providing code completion for proxy
as if a IDataSaver
instance.
When adding type annotations, prefer to convert that to GetProxy
,
which is friendlier to type checkers:
def run_simulation(params: SimulationParameters, saver: IDataSaver) -> None:
data = calculate(params)
proxy = GetProxy(IDataSaver, saver)
proxy.save(data)
Or even better, if you don’t require runtime checking, let the type checker do its job:
def run_simulation(params: SimulationParameters, saver: IDataSaver) -> None:
data = calculate(params)
saver.save(data)