Signatures of factory methods of subclasses

Viewed 923

It is a good practice that a method of a subclass has the same signature as the corresponding method of the base class. If one violates this principle, PyCharm gives the warning:

Signature of method does not match signature of base method in class

There is (at least) one exception to this principle: the Python initialisation method __init__. It is common that child classes have different initialisation parameters than their parent classes. They may have additional parameters, or they may have less parameters, usually obtained by using a constant value for the parameter of the parent class.

Since Python does not support multiple initialisation methods with different signatures, a Pythonic way of having different constructors are so-called factory methods (see e.g: https://stackoverflow.com/a/682545/10816965).

PyCharm thinks that these factory methods are no exceptions to the principle that methods of subclasses should have the same signature as their corresponding parent classes. Of course, I could ignore these warnings - since these factory methods are similar to __init__ or __new__, I think one could take the position that these warnings are misplaced.

However, I wondered whether I miss something here and my coding style is not best practice.

So my question is: Is this an unintended behavior of PyCharm, or is there indeed a more Pythonic way for this pattern?

class A:
    @classmethod
    def from_something(cls, something):
        self = cls()
        # f(self, something)
        return self

    def __init__(self):
        pass


class B(A):
    @classmethod
    def from_something(cls, something, param):  # PyCharm warning:
        # Signature of method 'B.from_something()' does not match
        # signature of base method in class 'A'

        self = cls(param)
        # g(self, something, param)
        return self

    def __init__(self, param):
        super().__init__()
        self.param = param


class C:
    @classmethod
    def from_something(cls, something, param):
        self = cls(param)
        # f(self, something, param)
        return self

    def __init__(self, param):
        self.param = param


class D(C):
    @classmethod
    def from_something(cls, something):  # PyCharm warning: Signature of
        # method 'D.from_something()' does not match signature of base
        # method in class 'C'

        self = cls()
        # g(self, something)
        return self

    def __init__(self):
        super().__init__(None)
1 Answers

The main issue to consider is:

  • Compatibility of overriding method (not overloaded) signatures between the base and derived classes.

To fully address and understand this, lets first consider:

In Python methods are looked up by name only like attributes. You can check their existance on the instance or the class, depending, by looking at the __dict__ (e.g. Class.__dict__ and instance.__dict__)

Custom classes, 3.2. The standard type hierarchy

A class has a namespace implemented by a dictionary object. Class attribute references are translated to lookups in this dictionary, e.g., C.x is translated to C.__dict__["x"] (although there are a number of hooks which allow for other means of locating attributes). When the attribute name is not found there, the attribute search continues in the base classes.

If we defined Bottom without the method def right(self, one): we would get from the __dict__

>>> Bottom.__dict__

{'__module__': '__main__',
'__init__': <function Bottom.__init__ at 0x0000024BD43058B0>,
'__doc__': None}

If we override in class Bottom the method def right(self, one): the __dict__ will now have

>>> Bottom.__dict__

{'__module__': '__main__',
'__init__': <function Bottom.__init__ at 0x0000024BD43058B0>,
'right': <function Bottom.right at 0x0000024BD43483A0>,
'__doc__': None}

This differs from other OO languages like Java, that have method overloading with resolution/lookup based on number and types of parameters, not only the name. In this aspect Python is overriding the methods since the lookup is made on name/class/instance alone. Python does support type hint "overloading" (in the strict sense of the word), see Function/method overloading, PEP 484 -- Type Hints

9.5. Inheritance, The Python Tutorial.

Derived classes may override methods of their base classes. Because methods have no special privileges when calling other methods of the same object, a method of a base class that calls another method defined in the same base class may end up calling a method of a derived class that overrides it.

Lets verify the above in action, a minimal example:

class Top:
    def left(self, one):
        self.right(one, 2)

    def right(self, one, two):
        pass

    def __init__(self):
        pass

class Bottom(Top):
    def right(self, one):
        pass

    def __init__(self, one):
        super().__init__()

Running the example:

>>> t = Top()
>>> t.left(1)

>>> b = Bottom()
>>> b.left(1)

Traceback (most recent call last):
  File "<input>", line 18, in <module>
  File "<input>", line 3, in left
TypeError: right() takes 2 positional arguments but 3 were given

Here you see that changing the signature of the method in the derived class can break method calls the base class does internally.

This is a serious side-effect because the derived class just constrained the base class. You created an upward dependency that is anti-pattern. Normally you expect constraints/dependencies to move downward in the inheritance, not upward. You just went from a unidirectional dependency to a bidirectional dependency. (In practice this can add more effort for the programmer who now must consider and work around the additional dependency - it goes against the Principle of least astonishment the next programmer looking at your code is likely not going to be happy.)


or is there indeed a more Pythonic way for this pattern?

Pythonic here means you can do it both ways, you have choices:

  1. Overriding the method using different signatures, entails:
  • Being aware of the implications and added bidirectional dependency.
  • If you choose to silence the linter warning it may make the programmer after you even more unhappy (who is now deprived of fair warning).
  1. Using an 4.7.4. Arbitrary Argument Lists
  • This is likely the more Pythonic choice because it's simpler. What you can do is document in the docstring that the factory method returns instances of the class and the arguments passed in the variadic signature should follow the parameters of the constructor, something like this:
class Top:

    def __init__(self):
        pass

    @classmethod
    def from_something(cls, *args, **kwargs) -> "Top":
        """Factory method initializes and returns an instance of the class.
        The arguments should follow the signature of the constructor.
        """

class Bottom(Top):

    def __init__(self, one):
        super().__init__()

    @classmethod
    def from_something(cls, *args, **kwargs) -> "Bottom":
        """Factory method initializes and returns an instance of the class.
        The arguments should follow the signature of the constructor.
        """
  • P.S. For the final "gotcha" of type hinting the return types see this excellent post, in this example "forward declarations" are used for simplicity. It doesn't change the signature, just the __annotations__ attribute of the method. However, "being Pythonic" we could remit to a BDFL post I'm not entirely sure if type hinting the returns violates the Liskov Substitution Principle but Mypy and the PyCharm linter seem to be letting me get away with it...
Related