If a function returns a subclass of a Protocol
, what is the recommended type-hint for the return type of that function?
The following is a simplified piece of code for representation
from typing import Protocol, Type
from abc import abstractmethod
class Template(Protocol):
@abstractmethod
def display(self) -> None:
...
class Concrete1(Template):
def __init__(self, grade: int) -> None:
self._grade = grade
def display(self) -> None:
print(f"Displaying {self._grade}")
class Concrete2(Template):
def __init__(self, name: str) -> None:
self._name = name
def display(self) -> None:
print(f"Printing {self._name}")
def give_class(type: int) -> Type[Template]:
if type == 1:
return Concrete1
else:
return Concrete2
concrete_class = give_class(1)
concrete_class(5)
In the line concrete_class(5)
, Pylance informs Expected no arguments to "Template" constructor
.
Let me start of by emphasizing that protocols were introduced specifically so you do not have to define a nominal subclass to create a subtype relation. That is why it is called structural subtyping. To quote PEP 544, the goal was
allowing users to write [...] code without explicit base classes in the class definition.
While you can subclass a protocol explicitly when defining a concrete class, that is not what they were designed for.
Protocols are not abstract base classes. By using your protocol like an ABC, you are basically discarding everything that makes a protocol useful in the first place.
As to why you are getting that error, that is easily explained. Your Template
protocol does not define its own __init__
method. When a variable is declared to be of type type[Template]
(i.e. a class implementing the Template
protocol) and you want to instantiate it, the type checker will see that Template
does not define an __init__
and fall back to the object.__init__
, which takes no arguments. Thus, providing an argument to the constructor is correctly marked as an error.
Since you want to use your protocol not only to annotate pure instances that follow it, but also classes that you want to instantiate (i.e. type[Template]
), you need to think about the __init__
method. If you want to express that for a class to implement your Template
protocol it can have any constructor whatsoever, you should include such a permissive __init__
signature in the protocol, for example:
class Template(Protocol):
def __init__(self, *args: Any, **kwargs: Any) -> None: ...
If you want to be more specific/restrictive, that is possible of course. You could for example declare that Template
-compliant classes must take exactly one argument in their __init__
, but it can be of any type:
class Template(Protocol):
def __init__(self, _arg: Any) -> None: ...
Both of these solutions would work in your example. The latter however would restrict you from passing a keyword-argument to the constructor with any name other than _arg
obviously.
To conclude, I would suggest you actually utilize the power of protocols properly to allow for structural subtyping and get rid of the explicit subclassing and abstractmethod
decorators. If all you care about is a fairly general constructor and your display
method, you can achieve that like this:
from typing import Any, Protocol
class Template(Protocol):
def __init__(self, _arg: Any) -> None: ...
def display(self) -> None: ...
class Concrete1:
def __init__(self, grade: int) -> None:
self._grade = grade
def display(self) -> None:
print(f"Displaying {self._grade}")
class Concrete2:
def __init__(self, name: str) -> None:
self._name = name
def display(self) -> None:
print(f"Printing {self._name}")
def give_class(type_: int) -> type[Template]:
if type_ == 1:
return Concrete1
else:
return Concrete2
concrete_class = give_class(1)
concrete_class(5)
This passes mypy --strict
without errors (and should satisfy Pyright too). As you can see, both Concrete1
and Concrete2
are accepted as return values for give_class
because they both follow the Template
protocol.
There are of course still valid applications for abstract base classes. For example, if you wanted to define an actual implementation of a method in your base class that itself calls an abstract method, subclassing that explicitly (nominal subtyping) can make perfect sense.
Example:
from abc import ABC, abstractmethod
from typing import Any
class Template(ABC):
@abstractmethod
def __init__(self, _arg: Any) -> None: ...
@abstractmethod
def display(self) -> None: ...
def call_display(self) -> None:
self.display()
class Concrete1(Template):
def __init__(self, grade: int) -> None:
self._grade = grade
def display(self) -> None:
print(f"Displaying {self._grade}")
class Concrete2(Template):
def __init__(self, name: str) -> None:
self._name = name
def display(self) -> None:
print(f"Printing {self._name}")
def give_class(type_: int) -> type[Template]:
if type_ == 1:
return Concrete1
else:
return Concrete2
concrete_class = give_class(1)
obj = concrete_class(5)
obj.call_display() # Displaying 5
But that is a totally different use case. Here we have the benefit that Concrete1
and Concrete2
are nominal subclasses of Template
, thus inherit call_display
from it. Since they are nominal subclasses anyway, there is no need for Template
to be a protocol.
And all this is not to say that it is impossible to find applications, where it is useful for something to be both a protocol and an abstract base class. But such a use case should be properly justified and from the context of your question I really do not see any justification for it.