36

What is the standard way of making a class comparable in Python 3? (For example, by id.)

Neil G
  • 32,138
  • 39
  • 156
  • 257

5 Answers5

42

To make classes comparable, you only need to implement __lt__ and decorate the class with functools.total_ordering. You should also provide an __eq__ method if possible. This provides the rest of the comparison operators so you don't have to write any of them yourself.

agf
  • 171,228
  • 44
  • 289
  • 238
  • 3
    +1 Just want to mention for anyone else searching this decorator is available as `functools.total_ordering`. – Neil G Aug 02 '11 at 05:09
  • @Neil G : + everyone else: Also be aware that that decorator has bugs, and shouldn't be used if you ever expect anyone to compare your class with any other class http://bugs.python.org/issue10042 My preferred method is a mixin: http://python3porting.com/preparing.html#comparatively-tricky – Lennart Regebro Aug 02 '11 at 07:37
  • @Lennart: Please add an answer. I did notice that mixin. Why wasn't it added to the Python standard library like the decorator? – Neil G Aug 02 '11 at 13:12
  • @Neil G: Well, it's not old and widely used enough. – Lennart Regebro Aug 02 '11 at 14:03
  • I'll try to remember to note that in the errata. – Lennart Regebro Aug 02 '11 at 23:00
  • 4
    This has been fixed in 3.4, and it is probably the best answer. – Neil G Mar 19 '20 at 06:40
  • The [docs](https://docs.python.org/3/library/functools.html) for total_ordering say you should also implement `__eq__`. – Entropic Thunder May 08 '21 at 22:17
  • 1
    @EntropicThunder Added to answer. – agf May 10 '21 at 03:24
  • I used ChatGPT to generate the rest of the methods, because there's a note in the documentation saying that `total_ordering` has a cost of slower execution, and performance is relevant for what I'm doing specifically. If that's not the case then I'd say `total_ordering `should be fine – netotz Aug 23 '23 at 16:51
15

For a full set of comparison functions I have used the following mixin, which you could put in say for example a mixin.py in your module.

class ComparableMixin(object):
    def _compare(self, other, method):
        try:
            return method(self._cmpkey(), other._cmpkey())
        except (AttributeError, TypeError):
            # _cmpkey not implemented, or return different type,
            # so I can't compare with "other".
            return NotImplemented

    def __lt__(self, other):
        return self._compare(other, lambda s, o: s < o)

    def __le__(self, other):
        return self._compare(other, lambda s, o: s <= o)

    def __eq__(self, other):
        return self._compare(other, lambda s, o: s == o)

    def __ge__(self, other):
        return self._compare(other, lambda s, o: s >= o)

    def __gt__(self, other):
        return self._compare(other, lambda s, o: s > o)

    def __ne__(self, other):
        return self._compare(other, lambda s, o: s != o)

To use the mixin above you need to implement a _cmpkey() method that returns a key of objects that can be compared, similar to the key() function used when sorting. The implementation could look like this:

>>> from .mixin import ComparableMixin

>>> class Orderable(ComparableMixin):
...
...     def __init__(self, firstname, lastname):
...         self.first = firstname
...         self.last = lastname
...
...     def _cmpkey(self):
...         return (self.last, self.first)
...
...     def __repr__(self):
...         return "%s %s" % (self.first, self.last)
...
>>> sorted([Orderable('Donald', 'Duck'), 
...         Orderable('Paul', 'Anka')])
[Paul Anka, Donald Duck]

The reason I use this instead of the total_ordering recipe is this bug. It's fixed in Python 3.4, but often you need to support older Python versions as well.

Lennart Regebro
  • 167,292
  • 41
  • 224
  • 251
  • It's worth pointing out to anyone finding this answer that the `mixin` module is a hypothetical module one could define — it's not in the Python standard library. – Neil G Aug 02 '11 at 14:39
  • The bug has now been fixed. Would you mind updating your answer or adding a new one? – Neil G May 21 '15 at 03:03
  • Thank you! So, in Python >3.4, you would recommend applying the decorator `functools.total_ordering`? – Neil G May 21 '15 at 08:55
  • I haven't looked at the final solution they put into 3.4, so I can't tell for sure, but it's probably fine. – Lennart Regebro May 21 '15 at 09:52
0

Not sure if this is complete, but you'd want to define:

__eq__, __gt__, __ge__, __lt__, __le__

As agf said, I'm missing:

__ne__
Community
  • 1
  • 1
jcomeau_ictx
  • 37,688
  • 6
  • 92
  • 107
0

You said you are trying to do this:

max((f(obj), obj) for obj in obj_list)[1]

You should simply do this:

max(f(obj) for obj in obj_list)

EDIT: Or as gnibbler said: max(obj_list, key=f)

But you told gnibbler you need an object reference to the max object. I think this is simplest:

def max_obj(obj_list, max_fn):
    if not obj_list:
        return None

    obj_max = obj_list[0]
    f_max = max_fn(obj)

    for obj in obj_list[1:]:
        if max_fn(obj) > f_max:
            obj_max = obj
    return obj_max

obj = max_obj(obj_list)

Of course you might want to let it raise an exception rather than return none if you try to find the max_obj() of an empty list.

steveha
  • 74,789
  • 21
  • 92
  • 117
  • The first version breaks ties with the object itself. – agf Aug 02 '11 at 05:15
  • Yes, I didn't quite grok what you were doing when I wrote the first version. I think I have it now. – steveha Aug 02 '11 at 05:42
  • Did you really need to post two separate answers? They're both doing the same thing, and both are way less clear than the original `max((f(obj), obj) for obj in obj_list)` – agf Aug 02 '11 at 05:47
  • Do you consider it some sort of breach of etiquette to post two completely different answers as two separate answers? And do you really think they are doing the same thing? One calls max() with a key, the other is a for loop; how are they doing the same thing? And I am not sure I agree my solutions are less clear. By the way, you left off the `[1]` from his answer, which was tricky enough it fooled me the first time I read it. – steveha Aug 02 '11 at 05:58
0

I just thought of a really hackish way to do it. This is in the same spirit as what you were originally trying to do. It does not require adding any functions to the class object; it works for any class.

max(((f(obj), obj) for obj in obj_list), key=lambda x: x[0])[1]

I really don't like that, so here's something less terse that does the same thing:

def make_pair(f, obj):
    return (f(obj), obj)

def gen_pairs(f, obj_list):
    return (make_pair(f, obj) for obj in obj_list)

def item0(tup):
    return tup[0]

def max_obj(f, obj_list):
    pair = max(gen_pairs(f, obj_list), key=item0)
    return pair[1]

Or, you could use this one-liner if obj_list is always an indexable object like a list:

obj_list[max((f(obj), i) for i, obj in enumerate(obj_list))[1]]

This has the advantage that if there are multiple objects such that f(obj) returns an identical value, you know which one you will get: the one with the highest index, i.e. the latest one in the list. If you wanted the earliest one in the list, you could do that with a key function.

steveha
  • 74,789
  • 21
  • 92
  • 117