pythongenericspython-typingpython-3.12

Python 3.13 generic classes with type parameters and inheritance


I'm exploring types in Python 3.13 and can't get the generic typing hints as strict as I would like.

The code below defines a generic Predicate class, two concrete subclasses, and a generic negation predicate.

class Predicate[T](Callable[[T], bool]):
    """
    Base class for predicates: a function that takes a 'T' and evaluates to True or False.
    """
    def __init__(self, _eval: Callable[[T], bool]):
        self.eval = _eval

    def __call__(self, *args, **kwargs) -> bool:
        return self.eval(*args, **kwargs)

class StartsWith(Predicate[str]):
    def __init__(self, prefix: str):
        super().__init__(lambda s: s.startswith(prefix))

class GreaterThan(Predicate[float]):
    def __init__(self, y: float):
        super().__init__(lambda x: x > y)

class Not[T](Predicate[T]):
    def __init__(self, p: Predicate[T]):
        super().__init__(lambda x: not p(x))

if __name__ == '__main__':
    assert StartsWith("F")("Foo")
    assert GreaterThan(10)(42)
    assert Not(StartsWith("A"))("Foo")
    assert Not(GreaterThan(10))(3)

This results in an error:

Traceback (most recent call last):
  File "[...]/generics_demo.py", line 36, in <module>
    class StartsWith(Predicate[str]):
                     ~~~~~~~~~^^^^^
  File "<frozen _collections_abc>", line 475, in __new__
TypeError: Callable must be used as Callable[[arg, ...], result].

When using class StartsWith(Predicate): (i.e. any predicate) it works, but that is too loosely defined to my taste.

Any hints on how to go about this?


Solution

  • If you change your Predicate definition to:

    class Predicate[T]:
    

    it works.

    I think this is because the "new style" Generics are inheriting from the old Generic class, for backward compatibility, this means that internally your class looks something like this:

    class Predicate(Callable[[T], bool], Generic[T]):
    

    And thanks to the multiple inheritance logic, the typing.Callable generic logic overrides your custom Generic logic. This means if you do not change your Predicate class definition, you have to change the inheritance from Predicate[str] to Predicate[[str], bool] and so on. Because Callable estimates a list of types followed by a single type.


    And just as a side note: You do not have to intherit from callable, it is just for ducktyped typehints, this means, you need callable only for defining which function you expect, not for defining which function you are.

    This means, if you want that your Predicate class is of type Callable[[T], bool], you have to typehint your callable in that way, not intheriting from Callable. So you have to change your class to that:

    class Predicate[T]:
        """
        Base class for predicates: a function that takes a 'T' and evaluates to True or False.
        """
        def __init__(self, _eval: Callable[[T], bool]):
            self.eval = _eval
    
        def __call__(self, t: T) -> bool:
            return self.eval(t)
    

    And a nother "improvement" would be a more generic way for the Not class:

    class Not[T](Predicate[T]):
        def __init__(self, p: Callable[[T], bool]):
            super().__init__(lambda x: not p(x))
    
    if __name__ == '__main__':
        assert Not(StartsWith("A"))("Foo")
        assert Not(GreaterThan(10))(3)
        assert Not(lambda x: False)(4)
    

    Because Not expects a callable, that behaves like a predicate, and no predicate the lambda function is also accepted.

    But this is only a side note. Ignore it, if you have a special use case.