Let's say I want to create an abstract base class called Document. I want the type checker to guarantee that all its subclasses implement a class method called from_paragraphs, which constructs a document from a sequence of Paragraph objects. However, a LegalDocument should only be constructable from LegalParagraph objects, and an AcademicDocument - only from AcademicParagraph objects.
My instinct is to do it like so:
from abc import ABC, abstractmethod
from typing import Sequence
class Document(ABC):
@classmethod
@abstractmethod
def from_paragraphs(cls, paragraphs: Sequence["Paragraph"]):
pass
class LegalDocument(Document):
@classmethod
def from_paragraphs(cls, paragraphs: Sequence["LegalParagraph"]):
return # some logic here...
class AcademicDocument(Document):
@classmethod
def from_paragraphs(cls, paragraphs: Sequence["AcademicParagraph"]):
return # some logic here...
class Paragraph:
text: str
class LegalParagraph(Paragraph):
pass
class AcademicParagraph(Paragraph):
pass
However, Pyright complains about this because from_paragraphs on the derived classes violates the Liskov substitution principle. How do I make sure that each derived class implements from_paragraphs for some kind of Paragraph?
Turns out this can be solved using generics:
from abc import ABC, abstractmethod
from typing import Generic, Sequence, TypeVar
ParagraphType = TypeVar("ParagraphType", bound="Paragraph")
class Document(ABC, Generic[ParagraphType]):
@classmethod
@abstractmethod
def from_paragraphs(cls, paragraphs: Sequence[ParagraphType]):
pass
class LegalDocument(Document["LegalParagraph"]):
@classmethod
def from_paragraphs(cls, paragraphs):
return # some logic here...
class AcademicDocument(Document["AcademicParagraph"]):
@classmethod
def from_paragraphs(cls, paragraphs):
return # some logic here...
class Paragraph:
text: str
class LegalParagraph(Paragraph):
pass
class AcademicParagraph(Paragraph):
pass
Saying bound="Paragraph" guarantees that the ParagraphType represents a (subclass of) Paragraph, but the derived classes are not expected to implement from_paragraphs for all paragraph types, just for the one they choose.
The type checker also automatically figures out the type of the argument paragraphs for LegalDocument.from_paragraphs, saving me some work :)
This pattern is called factory pattern: depends of the input, you get different types of object. What you have there will not work because:
# Because Document should not have knowledge of derived class:
doc = Document.from_paragraphs(...)
# Because type mismatch
doc = LegalDocument.from_paragraphs([AcademicParagraphs()])
Here is how I approach this problem:
class Document:
def __init__(self, paragraphs):
self.paragraphs = paragraphs
class LegalDocument(Document):
pass
class AcademicDocument(Document):
pass
class Paragraph:
def __init__(self, text):
self.text = text
class LegalParagraph(Paragraph):
pass
class AcademicParagraph(Paragraph):
pass
def create_document(*paragraphs):
# assume that all paragraphs are of the same type
if isinstance(paragraphs[0], LegalParagraph):
klass = LegalDocument
elif isinstance(paragraphs[0], AcademicParagraph):
klass = AcademicDocument
else:
raise TypeError()
return klass(paragraphs)
d1 = create_document(LegalParagraph("foo"), LegalParagraph("bar"))
assert isinstance(d1, LegalDocument)
d2 = create_document(AcademicParagraph("moo"))
assert isinstance(d2, AcademicDocument)
Notes
__ini__() will be enoughcreate_document, which will create a document where the type depends on the inputDocument.__new__() method, but that requires some knowledge of how __new__() works and not everybody knows that.If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With