-
Notifications
You must be signed in to change notification settings - Fork 31
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
False positive when checking parameter with Callable #113
Comments
In general, when we override a method in a subclass, the principle of substitutability must hold. This principle, also known as Liskov Substitution Principle (LSP), is fundamental in object-oriented programming and type theory. Specifically, in the context of method overriding:
In your case, the types in the parameter are inverted. You're passing Therefore, your example doesn't comply with Liskov's Substitution Principle, which is likely to cause a TypeError when trying to override the function To correct this, you would want to ensure the principle of contravariance for method arguments. So, class Here is the corrected code: from typing import Callable
from overrides import override
class X: pass
class Y(X): pass
class A:
def f(self, o: Callable[[Y], None]) -> None:
pass
class B(A):
@override
def f(self, o: Callable[[X], None]) -> None:
pass Please note that the actual functionality of the classes and methods isn't defined in the code you provided, so my advice is based on type theory and the Liskov Substitution Principle, and not on the specifics of the functionality. |
Yeah GPT-4 wrote that.. I think it is also correct the callables should be the otherway. Wider argument type is accepted but your making it stricter. |
I’ll close this for now. I can open it again if more evidence comes in. |
Thanks for getting back. I agree with the theory you (or rather GPT-4) wrote, but I still think the correction is actually exactly backwards. Let's say we have these toy objects: a = A()
b = B()
takes_x = lambda x: None # Callable[[X], None]
takes_y = lambda y: None # Callable[[Y], None] And if a.f(takes_x) # works as expected, Callable[[X], None] can be invoked to take Y
a.f(takes_y) # works
b.f(takes_x) # works
b.f(takes_y) # should work according to subsitution principle, but argument type is incompatible Because in On the other hand, if a.f(takes_x) # works, argument type is exact match
a.f(takes_y) # does not work AS EXPECTED, A is a super type of B
b.f(takes_x) # works, takes_x can accept instances of Y for its argument
b.f(takes_y) # works, argument type is exact match |
Lets use a bit more specific example. Class X we will call Animal and the subclass Y we will call Dog. Class A we will call AnimalHerd and B we will call DogHerd. The method f we will call feed_animals and the argument o we will call feed function. In your example your stating that the feed_animals of the DogHerd should be able to close the feed function to only take in Dogs. This is not possible. The super class of Animal for example is allowed to use it to feed for example Zepras.. this means that the DogHerd function signature should allow that too. I think what you are looking for might be generics https://mypy.readthedocs.io/en/stable/generics.html |
More specifically feed(animal: Dog) is not a super type of feed(animal:Animal). |
My whole argument is pretty much just the opposite of that: Let's consider the LSP applied on these two objects: feed_any_animal: Callable[[Animal], None]
feed_only_dogs: Callable[[Dog], None]
So something that works on a supertype should also work on the subtype. In our case, we are dealing with functions, and specifically we only care about passing things into them, so that "something that works" or ϕ means "function accepts the argument we passed in". If we suppose On the other hand, if we say As to the issue you pointed out:
I don't think that's an issue at all. It should be possible, and that the parent class can't use the feed function from its children is exactly how it's supposed to be. Suppose our class Fruit: pass
class Apple(Fruit): pass
class AnimalHerd:
def feed_animals(self, fruit: Apple): pass
class DogHerd:
def feed_animals(self, fruit: Fruit): # bark and protest
|
Quoting the all mighty AI: ” The input parameters (arguments) of the overriding method in the subclass can be the same as or wider (more general) than those in the overridden method of the superclass. This is known as contravariance of method arguments.” |
That's... I agree with that... The parameter in the subclass should be a supertype of whatever is in the parent, right? I'm saying that when that parameter itself is a |
Unfortunately it is not. |
It is! I just walked through the substitution principle in my previous comment. Everything you can pass to a Callable dog, you can pass to a Callable Animal. It's the other way around that doesn't work. |
Would this make more sense? class Food: pass
class DogFood(Food): pass
class AnimalHerd:
def feed_animals(self, bucket: list[Food]): pass
class DogHerd:
def feed_animals(self, bucket: list[DogFood]): pass We pass a bucket to It's the same thing with |
Really stupid for me to continue. But for educational purposes please check for example https://docs.oracle.com/javase/tutorial/java/generics/inheritance.html |
You keep giving me references I already agree with... Yes, if my example was talking about But that's not the case. In Those class Food {}
class DogFood extends Food {}
// Java's Callable[..., None]
Consumer<? super Food> feed_food = (food) -> {};
Consumer<? super DogFood> feed_dog_food = (food) -> {};
// feed_food = feed_dog_food; // does not compile: incompatible types
feed_dog_food = feed_food; We have the wild cards because Java uses use-site variance, and we have to explicitly write out the contravariant property of function parameter types. Python uses declare-site variance, and that property is built in to Notice that we can assign |
So your main argument is that a callable c1 taking in argument of type A that is a subclass of B is a super class of callable c2 that is taking in argument of type B? |
You're correct that function parameter types are contravariant. However, it's important to understand what this means. When we say function parameter types are contravariant, it means that if So in the case of To understand why, consider the following scenario: Let's say we have a function that calls def take_dog_to_dinner(feed_func: Callable[[Dog], None], dog: Dog):
feed_func(dog) You can pass take_dog_to_dinner(feed_any_animal, Dog()) However, if we have a similar function that calls def take_animal_to_dinner(feed_func: Callable[[Animal], None], animal: Animal):
feed_func(animal) You can't pass take_animal_to_dinner(feed_only_dogs, Cat()) # This would be a problem! In this sense, To your analogy about |
I'm trying to come up with a counter example. |
@ericv8v9s this is pretty difficult actually. |
@ericv8v9s I think you are correct. This is a bug and I was wrong. |
The list example fails in mypy: class Food: pass
class DogFood(Food): pass
class AnimalHerd:
def feed_animals(self, bucket: list[Food]): pass
class DogHerd(AnimalHerd):
def feed_animals(self, bucket: list[DogFood]): pass With
In this case AnimalHerd could take in something that is not DogFood and the subclass DogHerd should then be able to take that also in. But this is not the same case as the Callable that you mentioned. |
The case of the Callable is pretty interesting and to me very special. Lets say we have a internal field in the AnimalHerd where we store the feed function. class AnimalHerd:
feeder: Optional[Callable[[Animal], None]]
def feed_animals(self, feed: Callable[[Animal], None]) -> None:
...
self.feeder = feed A field is at the same time an input and an output. This makes the type checking on that level different. Then let's go to the DogHerd. class DogHerd(AnimalHerd):
@override
def feed_animals(self, feed: Callable[[Dog], None]) -> None:
...
self.feeder = feed # This should in my opinion fail in type checking because one could feed none Dogs to it.
AnimalHerd.feed_animals(self, feed) # This should in my opinion also fail because the super class might put there none Dogs. But those failures should be catched on another level and are not related to inheritance and method overrides.. Yep. Mypy gives error for the assignment:
And for the super class method call:
|
I'm marking this as a bug. Thanks @ericv8v9s for explaining this pretty peculiar case! |
from typing import List
class Animal: pass
class Dog(Animal): pass
class DogConsumer:
def consume(self, item: Dog) -> None:
# Do something with the dog
pass
class AnimalConsumer(DogConsumer):
def consume(self, item: Animal) -> None:
# Do something with the animal
pass
class AnimalHerd:
animals: List[Animal]
def eat_herd(self, consumer: AnimalConsumer) -> None:
for animal in self.animals:
consumer.consume(animal)
class DogHerd(AnimalHerd):
dogs: List[Dog]
def eat_herd(self, consumer: DogConsumer) -> None:
for dog in self.dogs:
consumer.consume(dog) This is ok for Mypy and for overrides. => Callable is a bug. |
I also just tried with the list example. I can't get it to work with the built-in from typing import TypeVar, Generic
from overrides import override
class Food: pass
class DogFood(Food): pass
T = TypeVar("T", contravariant=True)
class ContraList(Generic[T]):
pass
class AnimalHerd:
def feed_animals(self, bucket: ContraList[Food]): pass
class DogHerd(AnimalHerd):
@override
def feed_animals(self, bucket: ContraList[DogFood]): pass Not that a |
Started working on this #114 |
Here's an example:
Running this, I get:
But as I understand it,
Callable
parameters are contravariant, and thusCallable[[Y], None]
is a super type ofCallable[[X], None]
.mypy seems to agree:
The text was updated successfully, but these errors were encountered: