4

For my code I have an aggregate class that needs a validation method defined for each of the subclasses of base class BaseC, in this case InheritC inherits from BaseC.

The validation method is then passed into the aggregate class through a register method.

See the following simple example

from typing import Callable


class BaseC:
    def __init__(self) -> None:
        pass
    
class InheritC(BaseC):
    def __init__(self) -> None:
        super().__init__()

    @classmethod
    def validate(cls, c:'InheritC') ->bool:
        return False

class AggrC:
    def register_validate_fn(self, fn: Callable[[BaseC], bool])-> None:
        self.validate_fn = fn

ac = AggrC()
ic = InheritC()
ac.validate_fn(ic.fn)

I added type hints on the parameter for registering a function, which is a Callable object Callable[[BaseC], bool] since potentially there will be several other validation methods which is defined for each class inherited from BaseC.

However, pylance doesn't seem to recognize this polymorphism in a Callable type hint and throws a warning (I set up my VScode to type check it) that said

Argument of type "(c: InheritC) -> bool" cannot be assigned to parameter "fn" of type "(BaseC) -> bool" in function "register_fn"
  Type "(c: InheritC) -> bool" cannot be assigned to type "(BaseC) -> bool"
    Parameter 1: type "BaseC" cannot be assigned to type "InheritC"
      "BaseC" is incompatible with "InheritC" Pylance(reportGeneralTypeIssues)

I don't see where I made an mistake in design, and I don't want to simply ignore the warning.

Can any one explain why this is invaid? Or is it just simply a bug from pylance

I'm using python version 3.8.13 for development.

ted
  • 93
  • 1
  • 5
  • Without checking myself, I suspect the issue is that `InheritC::validate` is incompatible because it doesn't just take one `BaseC`-compatible parameter, it also takes the class `cls` parameter. I _believe_ that a free-standing function, called `validate`, which _only_ takes the `BaseC`-compatible object as a parameter, would work. – Marcus Harrison Jan 26 '23 at 17:44
  • I haven't checked, but you might also want to try removing the `@classmethod` decorator, and just declaring the method as `def validate(self: 'InheritC'): return False`, then passing _that_ to the `register_validate_fn`. I assume the examples are stripped down from a real application; you're not calling `register_validate_fn` before calling `validate_fn`, but I assume you meant to. – Marcus Harrison Jan 26 '23 at 17:46
  • Sorry to keep responding, but... there's another bug in the sample code: `ic.fn` is not defined. I'll start working on an answer with what I _think_ the sample is supposed to read like. – Marcus Harrison Jan 26 '23 at 17:53
  • I was wrong about the method parameters - the problem is more subtle, I'll write a full answer because it's a fairly abstract problem. – Marcus Harrison Jan 26 '23 at 17:57
  • Sorry, the `ic.fn` is my initial naming of the function. I renamed it for better readabilaty on stack overflow but I forgot to change all of them – ted Jan 27 '23 at 03:17

1 Answers1

3

I'll be using the below sample code, where I've fixed a couple of bugs:

from typing import Callable


class BaseC:
    def __init__(self) -> None:
        pass

class InheritC(BaseC):
    def __init__(self) -> None:
        super().__init__()

    @classmethod
    def validate(cls, c:'InheritC') ->bool:
        return False

class AggrC:
    def register_validate_fn(self, fn: Callable[[BaseC], bool])-> None:
        self.validate_fn = fn

ac = AggrC()
ic = InheritC()
ac.register_validate_fn(ic.validate)

This Python runs without errors, but still produces the same error you're seeing when run through a type checker (in my case, MyPY):

$ mypy stackoverflow_test.py 
stackoverflow_test.py:21: error: Argument 1 to "register_validate_fn" of "AggrC" has incompatible type "Callable[[], bool]"; expected "Callable[[BaseC], bool]"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

This is a subtle issue, which is easy to overlook: it's an issue with contravariance of parameter types.

The reason this is easy to overlook is because most object-oriented tutelage focuses on classes and objects, and doesn't really discuss functions as being types with inheritance. Indeed, most languages with object-oriented language features don't support declaring a function as inheriting from another function!

Lets reduce this as much as possible:

from typing import Callable

class Parent:
    def foo(self):
        pass

class Child(Parent):
    def bar(self):
        pass
    
def takesParent(parameter: Parent):
    parameter.foo()
    
def takesChild(parameter: Child):
    parameter.bar()
    
def takesFunction(function: Callable):
    # What should the signature of `function` be to support both functions above?
    pass

How should you define function: Callable to make it compatible with both functions?

Lets take a look at what takesFunction could do, which would be valid for both functions:

def takesFunction(function: Callable):
    child = Child()
    function(child)

This function should work if you pass either function, because takesParent will call child.foo(), which is valid; and takesChild will call child.bar(), which is also valid.

OK, how about this function?

def takesFunction(function: Callable):
    parent = Parent()
    function(parent)

In this case, function(parent) can only work with takesParent, because if you pass takesChild, takesChild will call parent.bar() - which doesn't exist!

So, the signature that supports passing both functions looks like this:

def takesFunction(function: Callable[[Child], None]):

Which is counter-intuitive to many people.

The parameter function must be type-hinted as taking the most specific parameter type. Passing a function with a less specific parameter - a superclass - is compatible, but passing one with a more specific parameter isn't.

This can be a difficult topic to understand, so I'm sorry if I didn't make it very clear, but I hope this answer helped.

Marcus Harrison
  • 819
  • 6
  • 19
  • 2
    It seems that this is rather a design problem, I looked again on my code and realized it is better to just simply do the validation during the construction of the base class `BaseC`, no need to double check on the aggregate class, according to the information expert principle. – ted Jan 27 '23 at 03:39