3

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)
bad_coder
  • 11,289
  • 20
  • 44
  • 72
Sebastian Thomas
  • 481
  • 3
  • 14
  • I think this is because with different signatures you can no longer replace, say an `A` instance with a `B` instance, saying in OOP terms that `B` is still an `A`. Note that if the method is a factory to create a subclass from the same `something` type that the base class uses, why would it require more parameters. If `B`is `A` it should be possible to create it in the same ways. If you do not care, maybe that is not a problem. However you can still use `*args, **kwargs` arguments to keep the same apparent signatures. – progmatico Jan 15 '21 at 17:16
  • 1
    Yes, for instance methods this is the reason why they should have the same signature. However, for class methods such as factory methods this is not the case. Using `*args` and `**kwargs` is not an option for me, I would rather ignore the warning. – Sebastian Thomas Jan 15 '21 at 18:16
  • Can you elaborate on why it is not the case for class methods? – progmatico Jan 15 '21 at 18:34
  • 1
    See also the answer [here](https://stackoverflow.com/questions/2294200/issue-with-python-class-hierarchy/2295784#2295784) – progmatico Jan 15 '21 at 18:36
  • @progmatico I would say that class methods are generally not intended to be called from instances. Although Python allows this, I would argue that if you call `b.from_something(something)` for an instance `b` of `B` instead of `B.from_something(something)`, then this is also bad coding style. There might be a situation where calling a class method from an instance may be appropriate, but at least for factory methods I would avoid that. – Sebastian Thomas Jan 15 '21 at 19:06
  • Thank you for your input. I understand your point and agree with you. The linked answer above is probably more about questioning if the same `from_something` name should be kept for the factory in the subclass. And it is likely that the right answer is "it depends on the concrete case". – progmatico Jan 15 '21 at 20:39
  • Sorry @SebastianThomas it took me so long to revise my answer. I answered other questions in the meanwhile because unlike this one they didn't take me any time of research and afterthought. Finding the pinpoint explanations for this case in the Python documentation was rather troublesome. – bad_coder Apr 11 '21 at 02:07

1 Answers1

1

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...
bad_coder
  • 11,289
  • 20
  • 44
  • 72
  • 1
    I was convinced by your proof that the behavior of mypy is different and will turn off the warning. Thank you for your detailed answer! Also, thanks for the inspection name, I thought it was `PyArgumentList` and already wondered why this did not work with the `@classmethod` decorator. – Sebastian Thomas Jan 15 '21 at 19:10
  • 1
    @SebastianThomas hello, my answer is not entirely correct for several reasons. I will try to rewrite it when I get some time to gather my thoughts (this week). With languages like Java this sort of thing has an easy straight answer, but with Python it requires much afterthought. I'm sorry about this, I've run into issues with the PyCharm linter several times [before](https://stackoverflow.com/q/54030579) and that's why I failed to properly consider this issue. – bad_coder Jan 18 '21 at 09:37
  • Why are constructors exempt from the rule? – Andrew Dec 05 '22 at 23:08
  • @Andrew what are you referring to? – bad_coder Dec 05 '22 at 23:09