Chaining classmethod constructors

71 Views Asked by At

There is an idiom in Python to use classmethods to provide additional ways to construct an object, where the conversion/transformation logic stays in the classmethod and the __init__() exists solely to initialize the fields. For example:

class Foo:
  field1: bytes

  def __init__(self, field1: bytes):
    self.field1 = field1

  @classmethod
  def from_hex(cls, hex: str) -> Foo:
    '''
    construct a Foo from a hex string like "12:34:56:78"
    '''
    return cls(field1=bytes.fromhex(hex.replace(':', ' ')))

Now, let's say I define a class derived from Foo:

class Bar(Foo):
  field2: str

  def __init__(self, field1: bytes, field2: str):
    Foo.__init__(self, field1)
    self.field2 = field2

With this hierarchy in mind, I want to define a constructor Bar.from_hex_with_tag() that would serve as an extension of Foo.from_hex():

class Bar(Foo):
  <...>

  @classmethod
  def from_hex_with_tag(cls, hex: str, tag: str) -> Bar:
    return cls(
      field1=bytes.fromhex(hex.replace(':', ' ')),  # duplicated code with Foo.from_hex()
      field2=tag
    )

How do I reuse Foo.from_hex() in Bar.from_hex_with_tag()?

1

There are 1 best solutions below

0
ShadowRanger On

Reusing classmethods like this relies on everything adhering to the Liskov Substitution Principle. Your subclass doesn't adhere to it (its initializer requires an extra argument), so the best you can do is factor out the type-conversion code in the parent (likely as an underscore-prefixed @staticmethod utility) so both the parent and the child can use it.

For example, you might do:

class Foo:
  field1: bytes

  def __init__(self, field1: bytes):
    self.field1 = field1

  @staticmethod
  def _field_from_hex(hex: str) -> bytes:
      return bytes.fromhex(hex.replace(':', ' '))

  @classmethod
  def from_hex(cls, hex: str) -> Foo:
    '''
    construct a Foo from a hex string like "12:34:56:78"
    '''
    return cls(field1=cls._field_from_hex(hex))

so Bar can be:

class Bar(Foo):
  <...>

  @classmethod
  def from_hex_with_tag(cls, hex: str, tag: str) -> Bar:
    return cls(
      field1=cls._field_from_hex(hex),
      field2=tag
    )

If the __init__ for Bar can make the field2 argument optional (so the parent classmethod is valid), you could make this work like so:

class Bar(Foo):
  def __init__(self, field1: bytes, field2: str = ''):
    super().__init__(field1)
    self.field2 = field2

  @classmethod
  def from_hex_with_tag(cls, hex: str, tag: str) -> Bar:
    self = cls.from_hex(hex)
    self.field2 = tag
    return self

where you just have a placeholder at construction time which you then manually replace after, but that would also allow users of your class to construct without the second argument, which may be undesirable.