1

I have a type and a subtype that define some binary operations in such a way that I'd like the operation to return the most-specific type possible. For example, in the following code example I expect the following behavior.

Logic + Logic => Logic
Logic + Bit   => Logic
Bit + Logic   => Logic
Bit + Bit     => Bit

Example:

class Logic:
    """
    4-value logic type: 0, 1, X, Z
    """

    def __and__(self, other: 'Logic') -> 'Logic':
        if not isinstance(other, Logic):
            return NotImplemented
        # ...

    def __rand__(self, other: 'Logic') -> 'Logic':
        return self & other


class Bit(Logic):
    """
    2-value bit type: 0, 1

    As a subtype of Logic, Logic(0) == Bit(0) and hash(Logic(0)) == hash(Bit(0))
    """

    def __and__(self, other: 'Bit') -> 'Bit':
        if not isinstance(other, Bit):
            return NotImplemented
        # ...

    def __rand__(self, other: 'Bit') -> 'Bit':
        return self & other

While this works at runtime, mypy complains:

example.pyi:19: error: Argument 1 of "__and__" is incompatible with supertype "Logic"; supertype defines the argument type as "Logic"
example.pyi:19: note: This violates the Liskov substitution principle
example.pyi:19: note: See https://mypy.readthedocs.io/en/stable/common_issues.html#incompatible-overrides

How do I express this relationship? I feel like the problem might have something to do with the fact that Python doesn't OOTB support multiple dispatch and that is encoded in mypy's type system in a way that I can't express this. It could be that mypy is being too nitpicky here, this should be fine because the Bit argument mentioned in Bit.__and__ is a subtype of Logic, so "incompatible" is not correct.

ktb
  • 1,498
  • 10
  • 27

2 Answers2

2

You're creating a subclass and overwriting a function from the class you're inheriting from. This is fine, as long as you don't change the signature (which is why mypy is complaining). There are several possible solutions, but you could try this:

from __future__ import annotations


class Base:
    ...


class Logic:
    """
    4-value logic type: 0, 1, X, Z
    """

    def __and__(self, other: Base) -> Logic:
        if not isinstance(other, Logic):
            raise NotImplementedError()
        return self

    def __rand__(self, other: Base) -> Logic:
        return self & other


class Bit(Logic):
    """
    2-value bit type: 0, 1

    As a subtype of Logic, Logic(0) == Bit(0) and hash(Logic(0)) == hash(Bit(0))
    """

    def __and__(self, other: Base) -> Bit:
        if not isinstance(other, Bit):
            raise NotImplementedError()
        return self

    def __rand__(self, other: Base) -> Bit:
        return self & other
Gijs Wobben
  • 1,974
  • 1
  • 10
  • 13
  • After bringing this issue up in the GH repo I got a suggestion to use something similar to this, but instead of creating a `Base` type, it used a `TypeVar` with a bound set to the least derived type, `Logic`. – ktb Jul 05 '21 at 15:37
2

This is a perfect use case for typing.overload. You can specify multiple signatures for the same function and mark them as @overload, and mypy will resolve calls to the appropriate overload.

For your example, you can overload the __add__ and __radd__ methods in the Bit class (see mypy outputs on mypy-play):

from typing import overload

class Logic:
    def __and__(self, other: 'Logic') -> 'Logic':
        pass

class Bit(Logic):
    @overload
    def __and__(self, other: 'Bit') -> 'Bit': ...
    
    @overload
    def __and__(self, other: 'Logic') -> 'Logic': ...
    
    def __and__(self, other):
        pass

reveal_type(Logic() & Logic())  # Logic
reveal_type(Logic() & Bit())    # Logic
reveal_type(Bit() & Logic())    # Logic
reveal_type(Bit() & Bit())      # Bit

Note that overloads are matched in order, so the Bit overload must appear before the Logic overload. You will get a warning from mypy if you wrote it the other way around.

Zecong Hu
  • 2,584
  • 18
  • 33