Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Python method overriding - more specific arguments in derived than base class

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?

like image 843
malyvsen Avatar asked Aug 12 '26 06:08

malyvsen


2 Answers

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 :)

like image 83
malyvsen Avatar answered Aug 13 '26 19:08

malyvsen


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

  • I will have a simple set of classes, not messing around with ABC
  • No class methods, the __ini__() will be enough
  • I have a single factory function create_document, which will create a document where the type depends on the input
  • A different approach is to tweak Document.__new__() method, but that requires some knowledge of how __new__() works and not everybody knows that.
like image 29
Hai Vu Avatar answered Aug 13 '26 18:08

Hai Vu



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!