Unsoundness of contravariant `Self` type

Consider the following code snippet:

class Base:
    def foo(self, x: "Base") -> None:
         pass

class Derived(Base):
    def foo(self, x: "Derived") -> None:
        x.bar()

    def bar(self) -> None:
        pass

def test(a: Base, b: Base) -> None:
    a.foo(b)
test(Derived(), Base())

It’s easy to see that this program is not type-safe: the foo() method on the Base class is declared to be able to consume any subclasses of Base as argument, but the overriding foo() method on the Derived class can only consume one specific subclass of Base (namely Derived). This breaks Liskov substitution principle, and if we run the program it’ll result in a runtime crash because in the run, Derived.foo() would unexpectedly get a Base object as argument via dynamic dispatching, while it only expected a Derived object. Type checkers today are in universal agreement that this kind of code is problematic and will reject it typically via some kind of “incompatible override” check (mypy, pyright, pyre1).


But what happens if I change 2 type annotations in this example, and keep everything else (including the program logic) the same as follows?

from typing import Self
class Base:
    def foo(self, x: Self) -> None:
         pass

class Derived(Base):
    def foo(self, x: Self) -> None:
        x.bar()

    def bar(self) -> None:
        pass

def test(a: Base, b: Base) -> None:
    a.foo(b)
test(Derived(), Base())

This change should not affect whether the program is type-safe or not – it’s exactly the same code that breaks substitution principle before, and exactly the same crash we’ll run into at runtime as before. So I would expect that this version of the program will be rejected by type checkers as well. However that’s not the case: mypy, pyright and pyre1 (the sandbox version) would accept this program. The only type checker I know of that is willing to reject the program is the internally deployed version (more up-to-date than sandbox version) of Pyre1, albeit with a terrible error message:

8,4: Inconsistent override [14]: `Derived.foo` overrides method defined in `Base` inconsistently. Parameter of type `Variable[_Self_Derived__ (bound to Derived)]` is not a supertype of the overridden parameter `Variable[_Self_Base__ (bound to Base)]`.

If we look at the typing spec, both PEP 673 and the typing spec site dictates that the Self type should be semantically equivalent to a typevar with the declaring class as its bound. One consequence of this is that type checkers are supposed to make the same accept/reject decision on both the previous example that uses Self annotation, and the following example that uses bounded typevar:

class Base:
    def foo[T: Base](self: T, x: T) -> None:
         pass

class Derived(Base):
    def foo[T: Derived](self: T, x: T) -> None:
        x.bar()

    def bar(self) -> None:
        pass

def test(a: Base, b: Base) -> None:
    a.foo(b)
test(Derived(), Base())

Interestingly, mypy still accepts this version of the program, whereas pyright starts to reject it. Pyre1 (the internal version) would reject the program, with similar-looking error message as mentioned above.

Note that from a soundness perspective, none of the examples above is type-safe since they all cause runtime crashes. So it’s interesting to see that 3 different type checkers made 3 different choices on what to do here: mypy chose to be unsound but spec-conforming; pyright choose to be unsound and non-spec-conforming, and pyre1 (internal) is both sound and spec-conforming (but emits incomprehensible error messages). Normally such divergence may not be a big issue if uses of the Self type in similar manners are uncommon in practice. But a quick search in typeshed brings back more hits than I expected, which may indicate that the problem might not be that rare. So I figured it might be useful to at least raise the issue here and get some opinions (hopefully a consensus) about what to do with it.


If we take a step from the examples back and take a more general perspective, my understanding is that if the PEP 673 semantics (i.e. self type = typevar bounded by the containing class) is to be respected, then the use of Self annotation can only be covariant if we still want to maintain the substitution principle. In other words, if the Self annotation appears in a contravariant position (e.g. as type annotation of an argument), the theoretically sound way to handle it should be to either emit a type error, or treat the Self as exactly the same as the containing class (i.e. make it invariant – this is more lenient than erroring immediately on contravariant Self usage, since it could still accepts the safe case where even though contravariant Self appears, the method never gets overridden).

Another approach we could take here is to modify the spec to give up PEP 673 semantics, and just declare that Self always behaves covariantly even if it’s put at contravariant positions (i.e. aligning the spec with what mypy’s implementation today). Soundness will be unavoidably broken if we take this option so I hope we would take extra care of documenting this decision well. But it might be the path of least resistance.

15 Likes

On a separate note: if we look at those contravariant usages of Self in typeshed, a large number of use cases seem to aim at providing some kind of strongly-typed binary method. As an example, here’s an attempt to define a strongly-typed “equal” method:

class Interface:
    def typed_equal(self, other: Self) -> bool:
        raise NotImplementedError()

class Derived0(Interface):
    def typed_equal(self, other: Self) -> bool: ...
class Derived1(Interface):
    def typed_equal(self, other: Self) -> bool: ...

def test(d0: Derived0, d1: Derived1) -> None:
    # User expects type checkers to accept these
    d0.typed_equal(d0)
    d1.typed_equal(d1)
    # User expects type checkers to reject these
    d0.typed_equal(d1)
    d1.typed_equal(d0)

The goal here is to have the typed_equal() method would enforce that the two arguments are of the same subclass of Interface. But as mentioned before, code like this does break substitution principle and does open up the possibility of runtime crashes regardless of how we define Self semantics or whether we count contravariant Self arguments as errors or not. In fact, this well-cited research paper suggests that for binary methods, basic inheritance rule and basic subtyping rule are inherently in conflict with each other. The typed_equal() function may not be possible to write in a standard OOP system in a type-safe way.

If code change is an option, then one pattern found in other OOP languages for implementing type-safe binary operator is this:

class Interface[T: Interface[T]]:
    def typed_equal(self: T, other: T) -> bool:
        raise NotImplementedError()

class Derived0(Interface[Derived0]):
    def typed_equal(self: Derived0, other: Derived0) -> bool: ...
class Derived1(Interface[Derived1]):
    def typed_equal(self: Derived1, other: Derived1) -> bool: ...

def test(d0: Derived0, d1: Derived1) -> None:
    # These should type check
    d0.typed_equal(d0)
    d1.typed_equal(d1)
    # These shouldn't type check
    d0.typed_equal(d1)
    d1.typed_equal(d0)

This pattern has different names in different languages (it’s called CRTP in C++ and F-Bounded type in Scala. Java’s Comparable and C#'s IComparable interface are also similar though they choose to bound their generic parameters differently), but they share the basic idea of making the base class generic, and then having subclasses “inject” themselves into the type argument of the base class. The weird-looking bound T: Interface[T] (often referred to as an “F-bound”) is essential to make the base class type check, but no type checkers today have support for it so the same kind of trick can’t be easily applied in Python.

5 Likes

My analysis of this is that the issue is (and where an error message should be emitted) is at overriding the method foo in the subclass, and I agree that what Self is does mean that where a Self annotation is contravariant, overriding it with a new Self annotation shouldn’t be allowed, the way to write that which works for comparison (both runtime and presumably all type checkers agree on this one)

Code sample in pyright playground

class Base:
    def foo[T: Base](self: T, x: T) -> None:
         pass

class Derived(Base):
    def foo[T: Base](self: T, x: T) -> None:
        Derived.bar(x)
    
    @staticmethod
    def bar(o: Base) -> None:
        pass

def test(a: Base, b: Base) -> None:
    a.foo(b)

test(Derived(), Base())

or go even further and make bar a function rather than a method of a subclass.

Jia, thanks for the detailed post! I agree that ideally, type checkers should reject all three examples you showed (with concrete parameter types, with Self, and with a bounded TypeVar), as they are all unsafe in the same way.

The fact that typeshed relies so much on the Self pattern makes this trickier. IMO we’d need to come up with an alternative that we can move typeshed to, and suggest for third-party libraries/stubs, before we can consider rejecting this.

6 Likes

Thanks for the post Jia. I agree that all three code samples above should be flagged as an error by a type checker. Pyright failed to detect the error in the second code sample because of a bug in my override consistency check. The fix is simple, and it will be included in the next release of pyright.

This bug fix results in about two dozen new errors within the typed code bases covered by our regression tests. I examined all of these errors to convince myself that I wasn’t introducing any new type checker bugs. All of these new errors appear to be legitimate and highlight bugs either in the implementation or in the type annotations. Most of them will be trivial to fix.

All of these new errors fell into one of three categories where Self was used: 1) in a method parameter annotation (hence contravariant), 2) in a return type annotation as a type argument to an invariant class, and 3) in the annotation for a mutable attribute (hence invariant).

class Parent:
    # Category 1
    def method1(self, x: Self) -> None: ...

    # Category 2
    def method2(self) -> list[Self]: ...

    # Category 3
    attr: Self

I’m not concerned about reporting new diagnostics in typeshed stubs. The maintainers of typeshed are typing experts, and they understand the tradeoffs that are required for stdlib. If you look in the stdlib stubs, you’ll see that it’s not uncommon to see # type: ignore and # pyright: ignore comments.

My bigger concern here would be effects on code that overrides methods defined in typeshed stdlib stubs. Based on the regression test results from my change, this doesn’t appear to be a widespread problem. I didn’t see any examples of this.

12 Likes

Glad to hear that for pyright, this was a simple oversight with an easy fix.

My concern re: typeshed was more about whether type checkers would start erroring on a common, useful pattern without a clear alternative. Since you didn’t find this to be a widespread problem (and thank you for looking into this, Eric!), I’m now much less concerned :slight_smile:

I started looking into updating this Protocol conformance test, as I believe C2 can’t be allowed to match P2Parent in that test for the same reason that the method overrides above should be rejected (here’s a modification of one of Jia’s examples showing that we can get into the same problematic situation using protocols).

But I realized that the spec actually says:

If a protocol uses Self in methods or attribute annotations, then a class Foo is assignable to the protocol if its corresponding methods and attribute annotations use either Self or Foo or any of Foo ’s subclasses.

which matches the current conformance test behavior. This seems to me like a mistake/oversight that should be fixed.

Before I continue down this route, is there any reason I’m missing for why Self should be treated differently in protocols?

4 Likes

For mypy it was a very conscious decision (made long time ago), this is why it consistently accepts both Self-type based example, and the equivalent one with regular type variables. The reason is that handling self-types safely would make them a huge pain to use (in particular in protocols, but also in regular classes).

Consistent with what Ivan said, I think this comes down to a tradeoff between safety and practicality. Consider the following examples where Self appears in a contravariant or invariant position within a protocol definition.

from __future__ import annotations
from typing import Protocol, Self

class Contra:
    def method(self, a: Self) -> None: ...

class ContraProto(Protocol):
    def method(self, a: Self) -> None: ...

a: ContraProto = Contra()  # Allowed?

class Inv:
    def method(self) -> list[Self]: ...

class InvProto(Protocol):
    def method(self) -> list[Self]: ...

b: InvProto = Inv()  # Allowed?

@ilevkivskyi, is this what you had in mind when you said that it would be a huge pain to use? Can you think of other usage patterns that would suffer?

I did a quick experiment with pyright where I changed it to enforce type safety for protocols that use Self. I expected mypy_primer to reveal a bunch of new errors across many typed code bases, but the fallout was surprisingly small. Based on this experiment, I’d be in favor of changing the spec to enforce safety when Self is used in protocols.

In any event, the current wording in the spec seems confusing and ambiguous. It makes sense in a covariant context, but it doesn’t make sense in a contravariant or invariant context. If we decide not to amend the spec in favor of type safety, we should at least improve the wording so it’s less ambiguous and explain why the spec chooses to allow unsafe behavior in this case.

3 Likes

All examples ultimately boil down to something like that, but it has many consequences (again, not just for protocols). For example:

  • Some generic classes that may have been covariant, will be forced to be invariant
  • Two classes with “problematic” methods can’t be used in multiple inheritance
  • Self-types in variable annotations (one of the selling point of PEP 673) become (almost) moot, since this is unsafe:
    class B:
        x: Self
    class C(B):
        other = 1
    

Another problem (IIRC this is where discussion got stuck last time) is if we prohibit this, where to give the error? For protocols it should be obviously at the definition site, but for regular classes it is not so obvious. Technically, having Self in a contravariant position is safe unless you actually override a method, but then some library authors may want to be warned that their API is “problematic” (i.e. impossible to override).

Anyway, I am not really interested in discussing this all again, just adding my 5 cents.

If the decision were to reject contravariant Selfs, then I would propose we error at definition site, unless the method is decorated with @final (note that this is not an argument on whether or not we should reject contravariant Selfs – I’m just trying to address Ivan’s concern of “where to error” if we were to take the rejection route). E.g.

class Foo:
  def foo(self, x: Self) -> None: ...  # E: It's not safe to use `Self` at this position. Use `Foo` instead or mark the method as final.

class Bar(Protocol):
  def bar(self, x: Self) -> None: ...  # E: It's not safe to use `Self` at this position. Use `Bar` instead or mark the method as final.

class Baz:
  @final
  def baz(self, x: Self) -> None: ... # OK

This may appear overly restrictive at first glance. But I think it can be argued that such restriction is a good thing from tool usability perspective as it would timely warn the users that the type annotation may not mean what they thought it would mean. When writing down this kind of code, my guess is that the authors of the code almost always expect that the Self type could still behave covariantly in subclasses. Def-site errors has the capability to warn the users immediately (while they are writing down the code) that such assumption is generally unsafe to make, preventing the users from going down the path further.

Actionable suggestions can also be easily offered in the error message, nudging the users to either replace Self with the containing class or marking the method with @final. If neither suggestions apply, i.e. the user really wants to implement a strongly-typed binary operator, then def-site error can still serve as a natural place to put in links to documentations explaining how strongly-typed binary operator is not implementable in the current type system (until a future type system extension could offer a solution there).


Regarding Self annotations in class attributes, anecdotally I remember it was a pretty late revision when PEP 673 were drafted, and I do regret today for not flagging it for further discussions at that time. One additional piece of context though was that back when the PEP was drafted, there was no typing spec or conformance tests, and it was up to each type checker to decide whether they want to reject covariant mutable attribute definitions. e.g.

class A: pass
class B(A): pass

class C:
  x: A
class D(C):
  x: B  # Should this be an error?

Back then most type checkers did not (and still do not) choose to emit an error here. So it can be argued that allowing covariant self attribute definition at least did not make the unsoundness issue worse, and it’s fine to include that late revision back then.

If we were to be 100% consistent and sound, then yes I do think that we need to both reject Self annotation on mutable attributes (i.e. removing that part of PEP 673) and reject covariant mutable attributes. If we want to keep PEP 673 intact, then we’d have to make a choice on whether to give up consistency (i.e. allow covariant Self attributes but not covariant mutable attributes, or the other way around), or to give up soundness (i.e. allow both covariant Self attribute and covariant mutable attributes).


There were 2 other concerns mentioned (regarding generic classes and multiple inheritance) but I don’t think I understand the underlying issues well enough to comment. Happy to follow up if I get access to some concrete examples demonstrating the problems!

3 Likes

I was happy to see it when pyright started emitting an error for this.
It helped me see things that were unsafe, that I didn’t see before.
And PEP 767 – Annotating Read-Only Attributes | peps.python.org is looking to make it so we can use this pattern more safely.

2 Likes