Mypy accepts an incompatible type in __init__ override

Viewed 511

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?

2 Answers

Note I'm really not sure about this. I'll let the voting judge.

tl;dr LSP doesn't apply to __init__ in that way because it's not meant for use as a method on instances.

LSP states

Subtype Requirement: Let Q(t) be a property provable about objects t of type T. Then Q(s) should be true for objects s of type S where S is a subtype of T.

For do, we have

Q(x) = signature of x.do
t = Foo(...)
T = Foo
s = Bar(...)
S = Bar

and since do has the same signature for all instances of Foo (i.e. it's a provable property of objects t of T), this must also be true for objects of type Bar. However, for __init__, which is effectively a method on the class, we have

t = Foo
T = type[Foo]
s = Bar
S = type[Bar]

In this case, there's no provable property regarding __init__ about objects t (i.e. classes) of type type[Foo], as this includes both Foo and Bar, so LSP says nothing about the signature of Bar.

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.

Related