Overloading comparison operators

Overloading the six comparison operators works in a similar way to the overloading of arithmetic operators. The operators and their associated methods are:

Equals

x == y

x.__eq__(y)

Not equals

x != y

x.__ne__(y)

Less than

x < y

x.__lt__(y)

Less than or equal to

x <= y

x.__le__(y)

Greater than

x > y

x.__gt__(y)

Greater than or equal to

x >= y

x.__ge__(y)

Default behaviour

Before we try overloading these operators, it’s worth having a look at their default behaviour. To illustrate, we define a class representing a rectangle in the file Rectangle.py:

class Rectangle:
    """Representation of a rectangle"""

    def __init__(self, length, width):
        self.length = length
        self.width = width
        self.area = length * width

We assume that the dimensions of the rectangle are both ints, so we don’t have to worry about round-off error, as we would with floats.

Now we test this class with the code in main.py:

from Rectangle import *

rec1 = Rectangle(1, 12)
rec2 = Rectangle(3, 4)
rec3 = Rectangle(4, 5)

print(rec1 == rec2)
print(rec1 != rec2)
print(rec1 < rec3)

We might think that all the print statements would raise errors, since we haven’t overloaded any of the operators to handle Rectangle objects. However, the output we get is:

False
True
Traceback (most recent call last):
  File "D:\Documents\Programming\programmingpages\Python\Rectangle comparison 01\Rectangle comparison 01\Rectangle_comparison_01.py", line 9, in <module>
    print(rec1 < rec3)
TypeError: '<' not supported between instances of 'Rectangle' and 'Rectangle'

The == and != operators do not raise errors; they each give a valid boolean result. The < operator, however does raise an error, which is more in line with what we’d expect.

Python implements a default behaviour for both == and !=. Recall that everything in Python is an object. Each object is assigned its own unique ID number, which we can access by using the id() method. Thus id(rec1) returns the ID number assigned to the object rec1. The default behaviour of == and != is to compare the IDs of their operands. In our code above, rec1, rec2 and rec3 are all distinct objects so they have different IDs, thus == will always return False and != will always return True when comparing different objects, regardless of what data they may contain.

The other comparison operators, however, have no default behaviour, so attempting to use them in your own classes will always raise errors unless you overload the operators.

Overloading == and !=

Suppose we wish to define comparison operators for the Rectangle class so that they compare the areas of the rectangles. Thus two Rectangle objects are ‘equal’ if their areas are equal, one Rectangle is ‘less than’ another if its area is smaller, and so on. To this end, we add an overload for ==, as shown:

class Rectangle:
    """Representation of a rectangle"""

    def __init__(self, length, width):
        self.length = length
        self.width = width
        self.area = length * width

    def __eq__(self, value):
        if isinstance(value, Rectangle):
            print('Calling == with Rectangle')
            return self.area == value.area
      

As usual, self refers to the left operand and value to the right operand. We check that value is a Rectangle, and then return a boolean value depending on the equality of their areas. I’ve inserted a print() statement here which will be useful in a minute.

We now test this with the code:

from Rectangle import *

rec1 = Rectangle(1, 12)
rec2 = Rectangle(3, 4)
rec3 = Rectangle(4, 5)

print(rec1 == rec2)
print(rec1 == rec3)
print(rec1 != rec2)
print(rec1 != rec3)

The output is

Calling == with Rectangle
True
Calling == with Rectangle
False
Calling == with Rectangle
False
Calling == with Rectangle
True

This might be a bit of a surprise. As we’ve overloaded only the == operator, we might expect that the default behaviour (comparing IDs) would be used for the != operator, but from the output, we see that all four comparisons called the overloaded == operator. The two comparisons using == work correctly, as rec1 and rec2 have the same area so rec1 == rec2 should be True, while rec1 == rec3 should be False.

However, if we were comparing IDs rather than areas, rec1 != rec2 should be True, but the output shows it as False, which is correct if we’re comparing areas. In the case where an overloaded == operator is provided but an overloaded != operator is not, Python will check to see if an overload for != exists and, if not, it will call the overloaded == and then flip the result from True to False, and vice versa.

Oddly, if we provide an overloaded != operator but not an overloaded == operator, the == operator will then fall back to the default behaviour and compare IDs rather than areas. The moral of the story is that it’s always safest to provide your own overloads for both == and != just to be sure you’re getting what you want.

Here’s a version of Rectangle with full overloads for both == and !=, expanded to cover comparison with ints. We take a Rectangle to be equal to an int if its area has that value.

class Rectangle:
    """Representation of a rectangle"""

    def __init__(self, length, width):
        self.length = length
        self.width = width
        self.area = length * width

    def __eq__(self, value):
        if isinstance(value, Rectangle):
            print('Calling == with Rectangle')
            return self.area == value.area
        elif isinstance(value, int):
            print('Calling == with int')
            return self.area == value    
        
    def __ne__(self, value):
        print('Calling !=')
        return not self == value
            

Since != should return the opposite of ==, we’ve just called __eq__ from __ne__ and applied the not operator to the result. I’ve left in some print() statements so we can trace the code; obviously you’d remove these when the code is used for real.

We can test this class with the code:

from Rectangle import *

rec1 = Rectangle(1, 12)
rec2 = Rectangle(3, 4)
rec3 = Rectangle(4, 5)

print(rec1 != rec2)
print(rec1 != rec3)
print(rec3 == 20)
print(rec3 != 20)
print(20 == rec3)

We get the output:

Calling !=
Calling == with Rectangle
False
Calling !=
Calling == with Rectangle
True
Calling == with int
True
Calling !=
Calling == with int
False
Calling == with int
True

We see that we get the correct results.

The last statement print(20 == rec3) shows that we can reverse the order of the arguments so that the calling object is on the right. For arithmetic operators, we needed a special overload to handle this case, but for comparison operators, Python automatically switches the operands to do the comparison. Thus print(20 == rec3) is converted to print(rec3 == 20).

Overloading <, <=, >, >=

The other four comparison operators can be overloaded in the same way. Here’s a complete Rectangle class with all 6 operators overloaded:

class Rectangle:
    """Representation of a rectangle"""

    def __init__(self, length, width):
        self.length = length
        self.width = width
        self.area = length * width

    def __eq__(self, value):
        if isinstance(value, Rectangle):
            print('Calling == with Rectangle')
            return self.area == value.area
        elif isinstance(value, int):
            print('Calling == with int')
            return self.area == value    
        
    def __ne__(self, value):
        print('Calling !=')
        return not self == value

    def __lt__(self, value):
        if isinstance(value, Rectangle):
            print('Calling < with Rectangle')
            return self.area < value.area
        elif isinstance(value, int):
            print('Calling < with int')
            return self.area < value    

    def __le__(self, value):
        print('Calling <=')
        return self < value or self == value

    def __gt__(self, value):
        print('Calling >')
        return not self <= value

    def __ge__(self, value):
        print('Calling >=')
        return not self < value 

As many of the operators are logical combinations or opposites of other operators, we can condense the code a bit by delegating some comparisons to others. Here, we provide basic definitions for == and <, and then all other comparisons can be expressed in terms of these two.

Here are a few tests of this code:

from Rectangle import *

rec1 = Rectangle(1, 12)
rec2 = Rectangle(3, 4)
rec3 = Rectangle(4, 5)

print(rec1 < rec2)
print(rec3 >= 12)
print(12 < rec3)

This generates the output:

Calling < with Rectangle
False
Calling >=
Calling < with int
True
Calling >
Calling <=
Calling < with int
Calling == with int
True

The statement print(rec1 < rec2) calls the __lt__ and, since the areas of rec1 and rec2 are equal, returns False.

The statement print(rec3 >= 12) calls __ge__ which in turn calls __lt__ with an int right operand, and returns True since the area of rec3 is 12.

The final statement print(12 < rec3) illustrates how Python handles the calling object being the right operand. As with ==, the operands are swapped, but this time the < operator must be changed to >. This results in __gt__ being called with self being rec3 and value being 12. As the area of rec3 is 20, this returns True.

Exercise

Write a Vector class with one data field storing a list of n components v_{1},v_{2},\ldots,v_{n} for the vector and another data field for the magnitude. [You can extend the Vector class from the post on arithmetic operators if you like, although this exercise doesn’t use any of the methods from that class.] The magnitude m of a vector is its length, calculated as

    \[ m=\sqrt{v_{1}^{2}+v_{2}^{2}+\ldots+v_{n}^{2}} \]

Overload all 6 comparison operators so that the comparison is done by comparing the magnitudes of the vectors. The magnitudes should be floats, so you will need to deal with round-off error when deciding whether two vectors have equal magnitudes. Review the isclose() method from the post on floats to see how to do this. You should use a fairly large relative tolerance (say, 10^{-3} or so) to allow vectors with slightly different magnitudes to be judged as ‘equal’.

To test the Vector class, write a program which generates a number of 3-dimensional Vectors (that is, with 3 components each) with each component being a random float between 0 and 1. The program should then count up the number of pairs of vectors that satisfy each of the 6 comparisons. The left operand in each comparison should precede the right operand in the list of Vectors. For example, if we had 4 Vectors in the order [W, X, Y, Z], compare W with each of X, Y and Z, then X with Y and Z, and finally Y with Z. When you’ve done all the comparisons, print out the totals for each of the 6 operators.

Note that for N Vectors, you will need to do \frac{1}{2}N\left(N-1\right) comparisons, so the number of comparisons increases proportionally to the square of the number of Vectors. Thus you’ll find that the program will probably run quite slowly if you input a large value of N.

See answer

One possible definition for the Vector class is:

from math import *

class Vector(object):
    """vector with magnitude comparisons"""
    relTol = 1e-3

    def __init__(self, components):
        self.components = components
        self.mag = sqrt(sum(map(lambda a: a ** 2, components)))

    def __eq__(self, value):
        if isinstance(value, Vector):
            return isclose(self.mag, value.mag, rel_tol = Vector.relTol)

    def __ne__(self, value):
        if isinstance(value, Vector):
            return not self == value

    def __lt__(self, value):
        if isinstance(value, Vector):
            return not self == value and self.mag < value.mag

    def __gt__(self, value):
        if isinstance(value, Vector):
            return not self == value and self.mag > value.mag

    def __le__(self, value):
        if isinstance(value, Vector):
            return self == value or self.mag < value.mag

    def __ge__(self, value):
        if isinstance(value, Vector):
            return self == value or self.mag > value.mag
    
        


On line 9, we use the map() function together with a lambda function (see this post) to produce a list of the squared components of the Vector. We then use Python’s built-in sum() function to add up the squares, and then the sqrt() function from the math module to get the square root. You can do the same thing using loops if you like.

The overloaded == operator on line 11 returns True if the magnitudes of the two Vectors satisfy the isclose() criterion with a relative tolerance of 10^{-3}. You can adjust this value to see what effect it has on the results.

All the other overloaded operators can be expressed using the overloaded == together with the built-in operators for comparing floats. If you’re certain that comparisons will always be done between two Vectors (and not between a Vector and a float, say), you can omit all the if isinstance(value, Vector): lines.

A suitable program to compare pairs from a list of Vectors is:

from Vector import *
from random import *

while True:
    numVec = input('How many vectors? ')
    if numVec == 'quit': break

    numVec = int(numVec)
    vecList = []
    for i in range(numVec):
        vecList += [Vector([random(), random(), random()])]

    compareDict = {'eq':0, 'ne':0, 'lt':0, 'gt':0, 'le':0, 'ge':0}

    for i in range(numVec):
        for j in range(i + 1, numVec):
            if vecList[i] == vecList[j]: 
                compareDict['eq'] += 1
            else:
                compareDict['ne'] += 1

            if vecList[i] <= vecList[j]:
                compareDict['le'] += 1
            else:
                compareDict['gt'] += 1

            if vecList[i] < vecList[j]:
                compareDict['lt'] += 1
            else:
                compareDict['ge'] += 1

    print(compareDict)

Lines 9 to 11 create the list of Vectors with random components. One way of storing the results is in a dictionary, so we define this on line 13. The comparisons are done with two nested for loops. I’ve grouped the comparisons in pairs, so, for example, two vectors are either equal or not equal, or one vector is either greater than or equal to another, or it is less than, and so on.

The sum of the number of pairs in each complementary pair of operations should add up to the total number \frac{1}{2}N\left(N-1\right) of comparisons.

A typical run of the program produces the output:

How many vectors? 1000
{'eq': 1036, 'ne': 498464, 'lt': 253446, 'gt': 245018, 'le': 254482, 'ge': 246054}
How many vectors? quit

For randomly generated vectors, we’d expect the number of ‘lt’ pairs to be roughly equal to the number of ‘ge’ pairs, and the number of ‘le’ pairs to be about the same as the number of ‘gt’ pairs. Since it’s much more likely that two vectors will have different lengths, we’d expect the number for ‘eq’ to be significantly less than for ‘ne’. These results seem to verify these expectations.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.