Unsoundness of contravariant `Self` type

Maybe it should be a type checker error?

The section in the PEP on attributes gives some examples that expect Self to specialize to subclasses.

@dataclass
class LinkedList(Generic[T]):
    value: T
    next: Self | None = None

@dataclass
class OrdinalLinkedList(LinkedList[int]):
    def ordinal_value(self) -> str:
        return as_ordinal(self.value)

xs = OrdinalLinkedList(value=1, next=LinkedList[int](value=2))
# Type error: Expected OrdinalLinkedList, got LinkedList[int].

I don’t think it’s particularly useful to always treat Self as syntactic sugar for the enclosing class when used in attributes. I worry that this would be too restrictive in practice, although a better argument would have examples from real world code. Maybe you have a better sense of the real world usage of Self in attributes?

I believe it would be sound to treat Self in attributes as specialized to subclasses, but if we distinguished between the different instantiations of Self that might appear in methods. For example:

class C:
  x: Self

  def __init__(self):
    self.x = self # OK

  def foo(self, other: Self):
    self.x = other # Error

  def bar(self):
    self.x = self # OK

I think it’s confusing that Self is not compatible with Self, but I think it would work.

1 Like

The problem I see with the class type var interpretation is as follows:

a = A()  # a is an A[Self = A[...]] = A* (I just made up notation)
b = B()  # b is a  B[Self = B[...]] = B*
# but Self is an invariant type parameter so although B[T] is a subclass of A[T], B* is not a subclass of A*

Note that this is only an issue when Self appears in a contravariant position. If it is only present in covariant locations then B* is a subclass of A*.

Looking at it, I don’t actually think this is a bad interpretation though. This interpretation allows you to have Protocols for things like strictly typed binary operators :thinking:. It does explain why unsound behavior appears. If you assume A* == A and B* == B then you make the incorrect assumption that B* is a subclass of A* if B is a subclass of A.

This is a very non-intuitive interpretation if you try to use Self in a contravariant location in a base class and your base class is not a protocol or an abstract class, as it means you have to reject o: A = B()

Perhaps not. It is nice to have a spelling that doesn’t require forward-references (quoted annotations etc), though in 3.14+ with PEP 649 this will become less relevant.

I don’t think I do, would need to do a study.

I don’t think this change would address the unsoundness of attribute Self. Consider:

from typing import Self

class A:
    x: Self

class B(A):
    pass

def set_x(a: A):
    a.x = A()

b = B()
set_x(b)
# if we reveal B here, we are unsound; it is A at runtime
reveal_type(b.x)

Attribute Self could soundly specialize in subclasses only if it were used in a ReadOnly attribute (if we have PEP 767).

3 Likes

Do you mean that any use of Self to type an attribute should be a type checker error?

I’m not sure. After reading some of your comments, I looked at uses of Self attributes, and only found what looked like “syntactic sugar for the name of the enclosing class”. Your set_a snippet is another example of that. Therefore, in my opinion, it would be better to just force people to write the class name if that’s what they mean. Writing Self risks readers of code thinking that subclasses see a narrower constraint.

I agree with you 100% about ReadOnly[Self] would ideally be specialized for subclasses.

Historically soundness for attribute has not really been paid much attention to when it comes to typing enforcement. The example Carl gave earlier on unsound Self attribute shares similar underlying issue with examples that any mutable attribute whose type is a covariant type variable is unsound, i.e.

_Self = TypeVar("_Self", covariant=True)
class Placeholder(Generic[_Self]):
  x: _Self

class A: pass
class B(A): pass

def set_x(a: Placeholder[A]) -> None:
  a.x = A()

b = Placeholder[B]()
set_x(b)
# if we reveal B here, we are unsound; it is A at runtime
reveal_type(b.x)

Pre-existing type checkers alway enforce that “covariant type variable cannot appear at contravariant position, and contravariant type variable cannot appear at covariant position” for types that appear on method annotations. But the problem here is that the enforcement is skipped for attributes, where mutable attribute annotation should be treated as invariant position and read-only attribute annotation should be treated as covariant position.

If we grant the intuition that Self type is a special kind of covariant type variables, then I’d prefer a world where type variable variance are treated consistently across the board, e.g.

  • If we want to intentionally keep it such that attribute annotations should not be enforced for variance (for backward-compatible reasons), then I feel it might be easier if we don’t constrain ourselves to look for solutions that has to be sound with respect to Self attributes.
  • If we consider the soundness of Self attribute treatment as important, then I feel we’d want to eventually tighten up the enforcement for mutable attributes with covariant type variable as well.
3 Likes

Of course my earlier points are kind of tangential to the main discussion, which is how to interpreter the Self annotation on methods…

If we don’t want to take the position to ban all contravariant Self occurrences, then intuitively I think that Sam’s interpretation (Self bound to the class not the method) is more useful (something else needs to be done to address the soundness issue though), given that the contravariant Self examples I’ve seen are mostly about strongly-typed binary operators, and my understanding is that Carl’s interpretation would not allow subtype operator implementation to “restrict” the type of the other operand. That said I don’t think I’ve seen enough code to claim that strongly-typed binary operator would be the main use case of contravariant Self.

No, because this is precisely the thing that is unsound to do (it breaks LSP). I don’t think there is any way to “address the soundness issue” while allowing this.

1 Like