Rich comparison reflection for builtin classes

Working example

Consider a class C defining all rich comparison methods based on it’s attribute C.val: int.

Implementation
class C:
    def __new__(cls, val: int):
        inst = object.__new__(cls)
        inst.val = val
        return inst

    def __eq__(self, other: object) -> bool:
        if isinstance(other, C):
            return self.val == other.val
        return False

    def __lt__(self, other: object) -> bool:
        if isinstance(other, C):
            return self.val < other.val
        return NotImplemented

    def __le__(self, other: object) -> bool:
        if isinstance(other, C):
            return self.val <= other.val
        return NotImplemented

    def __gt__(self, other: object) -> bool:
        if isinstance(other, C):
            return self.val > other.val
        return NotImplemented

    def __ge__(self, other: object) -> bool:
        if isinstance(other, C):
            return self.val >= other.val
        return NotImplemented

Consider a class D inheriting C and adding “granularity” to these comparisons through it’s attribute D.subval: int (such that C(3) < D(3, 1) < d(3, 2) < C(4).

Implementation
from functools import _ge_from_lt, _gt_from_lt, _le_from_lt

class D(C):
    def __new__(cls, val: int, subval: int):
        inst = C.__new__(cls, val)
        inst.subval = subval
        return inst

    def __eq__(self, other: object) -> bool:
        if isinstance(other, D):
            return C.__eq__(self, other) and self.subval == other.subval
        return False

    def __lt__(self, other: object) -> bool:
        if isinstance(other, D):
            return C.__lt__(self, other) or (
                C.__eq__(self, other) and self.subval < other.subval
            )
        if isinstance(other, C):
            return C.__lt__(self, other)
        return NotImplemented

    __le__ = _le_from_lt  # Can't use @total_ordering because these methods
    __gt__ = _gt_from_lt  # are defined in class C, so @total_ordering
    __ge__ = _ge_from_lt  # would not override them

Now, as the docs state,

If the operands are of different types, and right operand’s type is a direct or indirect subclass of the left operand’s type, the reflected method of the right operand has priority, otherwise the left operand’s method has priority.

And indeed

>>> c = C(3)
>>> d = D(3, 1)
>>> D.__gt__(d, c)
True
>>> C.__lt__(c, d)
False
>>> c < d
True

Even if C.__lt__ if explicitely defined, D.__gt__ has priority because issubclass(D, C).

The problem

Now, if we try the same thing with a class J inheriting builtin class int:

Implementation
class J(int):
    def __new__(cls, val: int, subval: int):
        inst = int.__new__(cls, val)
        inst.subval = subval
        return inst

    def __eq__(self, other: object) -> bool:
        if isinstance(other, J):
            return int.__eq__(self, other) and self.subval == other.subval
        return False

    def __lt__(self, other: object) -> bool:
        if isinstance(other, J):
            return int.__lt__(self, other) or int.__eq__(self, other) and self.subval < other.subval
        if isinstance(other, int):
            return int.__lt__(self, other)
        return NotImplemented

    __le__ = _le_from_lt
    __gt__ = _gt_from_lt
    __ge__ = _ge_from_lt

We have

>>> i = int(3)
>>> j = J(3, 1)
>>> J.__gt__(j, i)
True
>>> int.__lt__(i, j)
False
>>> i < j
False

It looks like the prementionned mechanism does not work with builtin classes: even if issubclass(J, int), J.__gt__ does not have priority over int.__lt__ and the comparaison results are not those expected.

Is this behavior deliberate? The docs only warn against “virtual subclassing”, but we’re not in that case, are we?

Is there a way to implement this behavior with builtin classes?

Thanks

You didn’t include the implementation of _le_from_lt and friends so I provided my own, and this seems to work:

    def __gt__(self, other):
        return (not self.__lt__(other)) and not self.__eq__(other)

Can you show how _le_from_lt etc are defined? That might be where the issue is.

My results for the final test (I’m doing this as a script so it looks a little different) are:

i = int(3)
j = J(3, 1)
print(J.__gt__(j, i))
print(int.__lt__(i, j))
print("i < j", i < j)
print("j < 4", j < 4)
True
False
i < j True
j < 4 True

They’re the ones used by functools.total_ordering (I forget to put the import statement in J implementation snippet, sorry).

I tried writing all methods explicitely and it did not work, so I think that’s not the problem, but I may have done things wrong!

Ahh, gotcha gotcha.

Interesting. Can you try it with my version, and see if there’s any difference? If there is - that is to say, if you get different results by changing ONLY the definition of __gt__ - then that would be informative.

1 Like

Indeed, it works! It turns out that crucial difference between your implementation and functool’s is that you use not self == other instead of self != other. Adding __ne__ to my J class in the original solves my problem!

I suppose that without it, J.__ne__ relies on int.__ne__ (instead of not J.__eq__, as I would have thought).
And for my “working example” since I did not provide C.__ne__ it did rely on not D.__eq__, so it worked as intended.

Thanks for the investigation!

More specifically, I’m calling self.__eq__ directly. Not sure if that makes any difference, but when I’m writing dunders like that, I’ll often call dunders. Might be an interesting experiment to see which ones work and which don’t.

Yep, that sounds likely. I do like the way that, in Python, even the weirdest corner cases CAN be explained by the normal (if potentially tedious) tracing of method calls.

1 Like