Search code examples
pythonpython-typingpython-dataclasses

Python type hints for a hashable, callable dataclass


I want to write a Python function that takes Callable objects and corresponding arguments as input, and returns a mapping from the Callable objects to the values of these objects on the arguments. More concretely, the code might look something like the following.

>>> import collections
>>> import dataclasses
>>> from typing import Iterable, List, Mapping
>>> @dataclasses.dataclass(frozen=True)
... class Adder:
...     x: int = 0
...     def __call__(self, y: int) -> int:
...             return self.x + y
... 
>>> def fn_vals(fns: Iterable[Adder], vals: Iterable[int]) -> Mapping[Adder, List[int]]:
...     values_from_function = collections.defaultdict(list)
...     for fn in fns:
...             for val in vals:
...                     values_from_function[fn].append(fn(val))
...     return values_from_function
... 
>>> fn_vals((Adder(), Adder(2)), (1, 2, 3))
defaultdict(<class 'list'>, {Adder(x=0): [1, 2, 3], Adder(x=2): [3, 4, 5]})

However, I'm struggling to get this to work with a broader class of Callable objects. In particular, the following fails with an error saying that __hash__ has not been implemented.

>>> import dataclasses
>>> from typing import Callable, Hashable
>>> class MyFunctionInterface(Callable, Hashable): pass
... 
>>> @dataclasses.dataclass(frozen=True)
... class Adder(MyFunctionInterface):
...     x: int = 0
...     def __call__(self, y: int) -> int:
...             return self.x + y
... 
>>> Adder()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/alex/anaconda3/lib/python3.7/typing.py", line 814, in __new__
    obj = super().__new__(cls)
TypeError: Can't instantiate abstract class Adder with abstract methods __hash__

I'd like to modify my fn_vals function so that fns has type Iterable[MyFunctionInterface] because the only properties that I need the elements of fns to have is that they be Callable and Hashable. Is there a way to indicate that a dataclass satisfies MyFunctionInterface, and have the __hash__ function still be generated by the dataclass decorator?


Solution

  • The problem here is a bad interaction between abc and class decorators.

    Quoting the abc docs,

    Dynamically adding abstract methods to a class, or attempting to modify the abstraction status of a method or class once it is created, are not supported.

    Once a class is created, you're not allowed to change its abstractness (or at least, it's not allowed on the Python version you're on). Unfortunately, class decorators like dataclasses.dataclass kick in after the class is already created.

    When Adder is initially created, it doesn't have a __hash__ implementation. abc inspects the class at this point and determines that the class is abstract. Then, the decorator adds __hash__ and all the other dataclass stuff to the class, but it's already too late.

    Your class does have a __hash__ method, but the abc mechanism doesn't know that.


    As of Python 3.10, changing the abstractness status of a class now is allowed, if you call abc.update_abstractmethods on the class once you're done. The Python 3.10 version of dataclasses uses this to fix the problem you ran into. You're on 3.7, though. (3.10 wasn't out when this question was asked.)


    As for how to proceed, there are three primary options. The first, now that 3.10 is out, is to update Python, but that doesn't work if you have to support versions below 3.9. The second option is to eliminate MyFunctionInterface entirely and just annotate your callable-hashable objects as Any. The third is, assuming you want your objects to specifically be callable with a single int argument and return an int, you can define a protocol

    class MyProto(typing.Protocol):
        def __call__(self, y: int) -> int: ...
        def __hash__(self) -> int: ...
    

    and then annotate your objects as MyProto.