Background information below the question.
In Python 3, I can define a class with an abstract method and implement it in a derived class using a more specialized signature. I know this works, but like many things work in many programming languages, it may not be good practice. So is it?
from abc import ABC, abstractmethod
class Base(ABC):
@abstractmethod
def foo(self, *args, **kwargs):
raise NotImplementedError()
class Derived(Base):
def foo(self, a, b, *args, **kwargs):
print(f"Derived.foo(a={a}, b={b}, args={args}, kwargs={kwargs})")
d = Derived()
d.foo(1, 2, 3, "bar", baz="baz")
# output:
# Derived.foo(a=1, b=2, args=(3, 'bar'), kwargs={'baz': 'baz'})
Is this good or bad practice?
More information as promised.
I have an interface that defines an abstract method. It returns some sort of handle. Specialized implementations must always be able to return a sort of default handle if the method is called without any extra arguments. However, they may define certain flags to tweak the handle to the use case of the caller. In this case, the caller is also the one that instantiated the specialized implementation and knows about these flags. Generic code operating only on the interface or the handles does not know about these flags but does not need to.
from abc import ABC, abstractmethod
class Manager(ABC):
@abstractmethod
def connect(self, *args, **kwargs):
raise NotImplementedError()
class DefaultManager(Manager):
def connect(self, *, thread_safe: bool = False):
if thread_safe:
return ThreadSafeHandle()
else:
return DefaultHandle()
It is specific to my use case that a Manager
implementation may want to issue different implementations of handles specific to the use case of the caller. Managers are defined in one place in my code and callers may or may not have specialized needs, such as thread safety in the example, for the managers they use.
Your first example would be completely fine, as everything the abstract parent accepts is also accepted by the derived class, the argument types are identical (<s>a</s>
, <s>b</s>
untyped). See comment for why the first snippet isn't quite type-correct, only an override with def foo(self, a:Any=default, b:Any=default, *args, **kwargs):
would be correct.
To be type-correct, the type of the accepted arguments has to get broader, not narrower. So I would take issue with the concrete example, since using a DefaultManager
as a Manager
would imply one could pass any amount of positional arguments to it, and any value into thread_safe
.
More concretely, IMO, the best practice here is this:
from abc import ABC, abstractmethod
class Manager(ABC):
@abstractmethod
def connect(self):
raise NotImplementedError()
class DefaultManager(Manager):
def connect(self, *, thread_safe: bool = False):
if thread_safe:
return ThreadSafeHandle()
else:
return DefaultHandle()
because everything Manager.connect
accepts is also accepted by DefaultManager.connect