Python sorting objects of user defined class

2017-03-11
#python

The most common way of sorting collections of custom objects in Python is to provide a key function that is used to extract a comparison key from each element:

1sorted("Case insensitive Sorting is here".split(), key=str.lower)

But the sorted function compares objects by their nature, and it is possible to define comparison operators for your class to make sorted work automatically.

Documentation guarantees that sorting uses only __lt__() method:

The sort routines are guaranteed to use __lt__() when making comparisons between two objects. So, it is easy to add a standard sort order to a class by defining an __lt__() method.

But it is recommended to define all comparison methods for your class for the sake of safety and code completeness. It can be done with the total_ordering decorator from the functools standard library module. Let’s look at the class:

 1import functools
 2
 3@functools.total_ordering
 4class Point:
 5
 6    def __init__(self, x, y):
 7        self.x, self.y = x, y
 8
 9    def __lt__(self, other):
10        return (self.x, self.y) < (other.x, other.y)
11
12    def __eq__(self, other):
13        return (self.x, self.y) == (other.x, other.y)

Example usage:

1points = [Point(x, y) for x in range(2, 0, -1) for y in range(3, 0, -1)]
2
3print('Not sorted:', ', '.join(map('({0.x}, {0.y})'.format, points)))
4print('Sorted:', ', '.join(map('({0.x}, {0.y})'.format, sorted(points))))

will produce output:

1Not sorted: (2, 3), (2, 2), (2, 1), (1, 3), (1, 2), (1, 1)
2Sorted: (1, 1), (1, 2), (1, 3), (2, 1), (2, 2), (2, 3)

The total_ordering decorator is excessive here, but may be useful in the future. What does it do? It completes the remaining comparison methods from the implemented ones. So if you implement __eq__ and __lt__, it complements __le__, __gt__, and __ge__. Or if you implement __eq__ and __le__, it complements __lt__, __gt__, and __ge__. And so on. It requires __eq__ and one other comparison method to be defined.

Essential part of the generator code is:

 1def total_ordering(cls):
 2    """Class decorator that fills in missing ordering methods"""
 3    # Find user-defined comparisons (not those inherited from object).
 4    roots = [op for op in _convert if getattr(cls, op, None) is not getattr(object, op, None)]
 5    if not roots:
 6        raise ValueError('must define at least one ordering operation: < > <= >=')
 7    root = max(roots)       # prefer __lt__ to __le__ to __gt__ to __ge__
 8    for opname, opfunc in _convert[root]:
 9        if opname not in roots:
10            opfunc.__name__ = opname
11            setattr(cls, opname, opfunc)
12    return cls

where _convert is a dict of predefined implementations of comparison methods via each other. The code gets all the comparison methods defined in the class, prefers one (if several are defined), and defines the other methods.