Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1324,5 +1324,58 @@ def f(c: C):
reveal_type(c.x) # revealed: ~AlwaysFalsy
```

## Methods on intersections

### The same method from a common base

```py
from ty_extensions import Intersection

class Base:
def f(self) -> int:
return 0

class X(Base):
pass

class Y(Base):
pass

def test(x: Intersection[X, Y]) -> None:
reveal_type(x.f) # revealed: (bound method X.f() -> int) & (bound method Y.f() -> int)
reveal_type(x.f()) # revealed: int

# Example subclass that inhabits that intersection:
class Z(X, Y):
pass

test(Z())
```

### A method that could be compatible with a protocol (without violating Liskov)

```py
from typing import Protocol
from ty_extensions import Intersection

class Proto(Protocol):
def method(self) -> int: ...

class X:
# This `method` isn't compatible with `Proto`, but a subclass could refine it...
def method(self) -> int | str:
return "hello"

def test(x: Intersection[X, Proto]) -> None:
reveal_type(x.method()) # revealed: int

# ...like this. This subclass inhabits the intersection above.
class Y(X):
def method(self) -> int:
return 42

test(Y())
```

[complement laws]: https://en.wikipedia.org/wiki/Complement_(set_theory)
[de morgan's laws]: https://en.wikipedia.org/wiki/De_Morgan%27s_laws
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,88 @@ static_assert(not is_disjoint_from(TypeOf[f], FunctionType))
static_assert(not is_disjoint_from(TypeOf[f], object))
```

### Bound methods

```py
from typing import final
from ty_extensions import TypeOf, is_disjoint_from, static_assert

class A:
def foo(self) -> None: ...
def bar(self) -> None: ...

class B:
def foo(self) -> None: ...

# Bound methods with different names are disjoint.
static_assert(is_disjoint_from(TypeOf[A().foo], TypeOf[A().bar]))

# Bound methods with the same name but different self types are not disjoint, because a subclass
# could inherit from both...
static_assert(not is_disjoint_from(TypeOf[A().foo], TypeOf[B().foo]))

@final
class F1:
def foo(self) -> None: ...

@final
class F2:
def foo(self) -> None: ...

# ...unless one or both of those classes are `@final`.
static_assert(is_disjoint_from(TypeOf[A().foo], TypeOf[F2().foo]))
static_assert(is_disjoint_from(TypeOf[B().foo], TypeOf[F2().foo]))
static_assert(is_disjoint_from(TypeOf[F1().foo], TypeOf[F2().foo]))
```

### Bound `@final` methods

Two different `@final` methods are disjoint, even if they share the same name and neither class is
`@final`, because no subclass could satisfy both without overriding one:

```py
from typing import final
from ty_extensions import TypeOf, is_disjoint_from, static_assert

class C:
@final
def foo(self) -> None: ...

class D:
@final
def foo(self) -> None: ...

static_assert(is_disjoint_from(TypeOf[C().foo], TypeOf[D().foo]))
```

We do have to be careful not to get confused when the same `@final` method has different (but
compatible) bound self types:

```py
class E(C): ...

# `E.foo` and `C.foo` are the same method, so `E().foo` satisfies both `BoundMethod` types.
static_assert(not is_disjoint_from(TypeOf[E().foo], TypeOf[C().foo]))
static_assert(is_disjoint_from(TypeOf[E().foo], TypeOf[D().foo]))
```

Also, we can't establish disjointness when only one of the methods is `@final`. Consider this tricky
multiple inheritance case:

```py
class F:
def foo(self) -> None: ...

static_assert(not is_disjoint_from(TypeOf[C().foo], TypeOf[F().foo]))
static_assert(not is_disjoint_from(TypeOf[D().foo], TypeOf[F().foo]))

class G(C, F): ...

static_assert(not is_disjoint_from(TypeOf[C().foo], TypeOf[G().foo]))
static_assert(is_disjoint_from(TypeOf[D().foo], TypeOf[G().foo]))
static_assert(not is_disjoint_from(TypeOf[F().foo], TypeOf[G().foo]))
```

### `AlwaysTruthy` and `AlwaysFalsy`

```py
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ static_assert(not is_single_valued(Callable[[int, str], None]))
class A:
def method(self): ...

static_assert(is_single_valued(TypeOf[A().method]))
# Binding the same method to different instances yields different objects: `[].sort != [].sort`
static_assert(not is_single_valued(TypeOf[A().method]))
static_assert(is_single_valued(TypeOf[types.FunctionType.__get__]))
static_assert(is_single_valued(TypeOf[A.method.__get__]))
```
Expand Down
6 changes: 5 additions & 1 deletion crates/ty_python_semantic/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2076,7 +2076,6 @@ impl<'db> Type<'db> {
pub(crate) fn is_single_valued(self, db: &'db dyn Db) -> bool {
match self {
Type::FunctionLiteral(..)
| Type::BoundMethod(_)
| Type::WrapperDescriptor(_)
| Type::KnownBoundMethod(_)
| Type::ModuleLiteral(..)
Expand Down Expand Up @@ -2131,6 +2130,11 @@ impl<'db> Type<'db> {
false
}

Type::BoundMethod(_) => {
// Binding the same method to different instances yields different objects: `[].sort != [].sort`
false
}

Type::TypeIs(type_is) => type_is.is_bound(db),
Type::TypeGuard(type_guard) => type_guard.is_bound(db),

Expand Down
55 changes: 53 additions & 2 deletions crates/ty_python_semantic/src/types/relation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use crate::types::constraints::{
};
use crate::types::cyclic::PairVisitor;
use crate::types::enums::is_single_member_enum;
use crate::types::function::FunctionDecorators;
use crate::types::set_theoretic::RecursivelyDefined;
use crate::types::{
CallableType, ClassBase, ClassType, CycleDetector, DynamicType, KnownBoundMethodType,
Expand Down Expand Up @@ -1877,15 +1878,13 @@ impl<'a, 'c, 'db> DisjointnessChecker<'a, 'c, 'db> {
(
// note `LiteralString` is not single-valued, but we handle the special case above
left @ (Type::FunctionLiteral(..)
| Type::BoundMethod(..)
| Type::KnownBoundMethod(..)
| Type::WrapperDescriptor(..)
| Type::ModuleLiteral(..)
| Type::ClassLiteral(..)
| Type::SpecialForm(..)
| Type::KnownInstance(..)),
right @ (Type::FunctionLiteral(..)
| Type::BoundMethod(..)
| Type::KnownBoundMethod(..)
| Type::WrapperDescriptor(..)
| Type::ModuleLiteral(..)
Expand Down Expand Up @@ -2197,6 +2196,58 @@ impl<'a, 'c, 'db> DisjointnessChecker<'a, 'c, 'db> {
.negate(db, self.constraints)
}

// A `BoundMethod` type includes instances of the same method bound to a
// subtype/subclass of the self type.
(Type::BoundMethod(a), Type::BoundMethod(b)) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to also consider method finality here (not just class finality, which is implicitly considered by the self-type disjointness check)? A @final method cannot be overridden in a subclass.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Playing with this a bit, there are some corner cases. Here's an example where a non-final method and a @final method are not disjoint, because the latter inherits from the former:

class A:
    def f(self): ...
class B(A):
    @final
    def f(self): ...

Multiple inheritance can also come into play here:

class A:
    def f(self): ...
class B:
    @final
    def f(self): ...
class C(B, A): ...

The @final method wins in the MRO in that case, so it presumably doesn't violate the rules for @final, but the opposite inheritance order presumably would violate those rules? On the other hand only Mypy currently gives a diagnostic for that.

That said, if both sides are @final (and they're not literally the same method), it does seem fair to conclude that they should be disjoint?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@carljm could you take a look at 327fdcb and tell me if it makes sense to you?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good!

if a.function(db).name(db) != b.function(db).name(db) {
// We typically ask about `BoundMethod` disjointness when we're looking at a
// method call on an intersection type like `A & B`. In that case, the same
// method name would show up on both sides of this check. However for
// completeness, if we're ever comparing `BoundMethod` types with different
// method names, then they're clearly disjoint.
self.always()
} else if a.function(db) != b.function(db)
&& a.function(db)
.has_known_decorator(db, FunctionDecorators::FINAL)
&& b.function(db)
.has_known_decorator(db, FunctionDecorators::FINAL)
{
// If *both* methods are `@final` (and they're not literally the same
// definition), they must be disjoint.
//
// Note that we can't establish disjointness when only one side is `@final`,
// because we have to worry about cases like this:
//
// ```
// class A:
// def f(self): ...
// class B:
// @final
// def f(self): ...
// # Valid in this order, though `C(A, B)` would be invalid.
// class C(B, A): ...
// ```
self.always()
} else {
// The names match, so `BoundMethod` disjointness depends on whether the bound
// self types are disjoint. Note that this can produce confusing results in the
// face of Liskov violations. For example:
// ```
// class A:
// def f(self) -> int: ...
// class B:
// def f(self) -> str: ...
// def _(x: Intersection[A, B]):
// x.f()
// ```
// `class C(A, B)` could inhabit that intersection, but `int` and `str` are
// disjoint, so the type of `x.f()` there is going to be inferred as `Never`.
// That's probably not correct in practice, but the right way to address it is
// to emit a diagnostic on the definition of `C.f`.
self.check_type_pair(db, a.self_instance(db), b.self_instance(db))
Comment on lines +2208 to +2247
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Don't treat every same-named bound method pair as overlapping

When two overlapping classes define methods with the same name but different implementations, this branch now keeps their bound-method types non-disjoint purely because the names match and the instance types overlap. That is too permissive: class A: foo(self, x: int) -> int, class B: foo(self, x: str) -> str, class D(A, B): pass, and class E(B, A): pass all inhabit Intersection[A, B], but D().foo(1) and E().foo(1) do not both succeed because MRO exposes only one implementation. Since BindingsElement::retain_successful in crates/ty_python_semantic/src/types/call/bind.rs drops failing intersection branches once any branch matches, x: Intersection[A, B]; x.foo(1) becomes accepted after this change even though it is invalid for some inhabitants.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should (though currently don't) reject both class D(A, B): pass and class E(B, A): pass as Liskov-violating, so I'm not sure this is an issue.

I think enforcing Liskov is the thing that makes it OK for this PR to not consider the actual signatures of the methods. That may be worth a comment here.

}
}

(Type::BoundMethod(_), other) | (other, Type::BoundMethod(_)) => {
self.check_type_pair(db, KnownClass::MethodType.to_instance(db), other)
}
Expand Down
Loading