Search code examples
pythonmypypython-typingliskov-substitution-principle

Mypy accepts an incompatible type in __init__ override


I have the following Foo base class, and Bar that inherits from it:

class Foo:
    def __init__(self, x: int) -> None:
        self._x = x

    def do(self, x: int) -> None:
        pass


class Bar(Foo):
    pass

If I override Foo.do in Bar, and change the type of the x parameter for something incompatible (that is, not more generic than int), then Mypy returns an error - which is of course what I expect.

class Bar(Foo):
    def do(self, x: str) -> None:
        pass

Error:

test.py:10: error: Argument 1 of "do" is incompatible with supertype "Foo"; supertype defines the argument type as "int"
test.py:10: note: This violates the Liskov substitution principle
test.py:10: note: See https://mypy.readthedocs.io/en/stable/common_issues.html#incompatible-overrides
Found 1 error in 1 file (checked 1 source file)

However, if I override __init__ with an incompatible type for the argument, then Mypy accepts it:

class Bar(Foo):
    def __init__(self, x: str) -> None:
        self._x = 12

Mypy output:

Success: no issues found in 1 source file

It looks to me that overriding __init__ with incompatible types violates the LSP as well, since code like foo = Foo(12) does not type-check if we replace Foo with Bar.

Why does Mypy accept that I override __init__ with an incompatible type? Is __init__ treated differently from other methods? Also, is Mypy right in doing so? Am I correct that this last Bar class violates the LSP?


Solution

  • The Liskov Substitution Principle is not generally considered to apply to constructor methods. If we were to treat constructor methods as part of an object's interface, it would make inheritance systems extremely difficult to manage in many situations, and lead to a whole host of other complications. See this question I posed on Software Engineering a while back.

    The situation is complicated somewhat, however, by the fact that __init__ isn't really a constructor method (that would be __new__) — it's an initialiser method, and it can be called multiple times on the same instance. It just "happens" to be the case that the initialiser method nearly always has the same signature as the constructor method.

    Because of the way __init__ can be called multiple times on the same instance, just like a "normal" method that would be considered part of an object's interface, there's currently active discussion among the core devs regarding whether __init__ methods should be considered part of an object's interface in some respects.

    To conclude: ¯\_(ツ)_/¯

    Python is an extremely dynamic language, and that means reasoning about its type system can often be kind of strange.