1. Operator Overloading: comparison#

  • How can we compare two custom objects?

  • Below we have two objects of the Sensor class that have the same data. if we Python ask if these objects are equal , the answer is no.

    class Sensor:
        
        def __init__ (self, name, serial_number):
        
            self.name , self.serial_number = name, serial_number
            
    
    sensor1 = Sensor("Temperature",123)
    sensor2 = Sensor("Temperature",123)
    
    sensor1 == sensor2 
    

    False

    Important

    Python doesn’t consider two objects with the same data equal by default.

  • When we print both sensors, we receive an output with Sensor at and a string (a number in hexadecimal). When an object is created, Python allocates a chunk of memory to that object, and the variable that the object is assigned to actually contains just the reference to the memory chunk.

  • In other words, the code below is saying to Python: Allocate a chunk of memory for a sensor object, and label it sensor1. Then, allocate another chunk of memory, and label it sensor2.

    Note

    When we compare variables sensor1 and sensor2, we are actually comparing references, not the data. Because sensor1 and sensor2 point to different chuncks in memory, they are not considered equal.

    sensor1 = Sensor("Temperature",123)
    sensor2 = Sensor("Temperature",123)
    
    print(sensor1)
    print(sensor2)
    

    <main.Sensor object at 0x7fecb45d2790>
    <main.Sensor object at 0x7fecb45d2a60>

1.1. The __eq__() method#

  • The __eq__() method is implicitly called whenever two objects of the same class are compared to each other.

  • We can re-define this method to execute custom comparison code. The methods should accept two arguments, referring to the objects to be compared. They are usually self and other by convention. It should always return a Boolean value True or False.

  • The following code has a basic Sensor class with id and name attributes, and we redefine the __eq__ method to return True if the values of all attributes are equal.

    class Sensor: 
    
        def __init__(self, id, name):
            self.id, self.name = id, name
            
        # The following is called when `==` is used
        def __eq__(self, other):
            print ("__eq__() is called")
            
            # Return `True` if all attributes match
            return (self.id == other.id) and \
                (self.name == other.name)
    
    sensor3 = Sensor(456, "Conductivity")
    sensor4 = Sensor(456, "Conductivity")
    
    sensor3 == sensor4
    

    __eq__() is called
    True

1.2. Other Comparison Operators#

  • Python allows you to implement all the comparison operators in your custom class using special methods like __eq__.

Operator

Method

Description

Example

==

__eq__(self, other)

returns True if two operands are equal, otherwise False.

a == b

!=

__ne__(self, other)

returns True if two operands are not equal, otherwise False.

a != b

>

__gt__(self, other)

returns True if left operand is greater than the right operand, otherwise False

a > b

<

__lt__(self, other)

returns True if left operand is smaller than the right operand, otherwise False

a < b

>=

__ge__(self, other)

returns True if left operand is greater than or equal to the right operand, otherwise False

a >= b

<=

__le__(self, other)

returns True if left operand is smaller than or equal to the right operand, otherwise False.

a <= b

1.2.1. Example - Comparison Operators 1#

class Data:
    id = 0

    def __init__(self, i):
        self.id = i

    def __eq__(self, other):
        print('== operator overloaded')
        if isinstance(other, Data):
            return True if self.id == other.id else False
        else:
            return False

    def __ne__(self, other):
        print('!= operator overloaded')
        if isinstance(other, Data):
            return True if self.id != other.id else False
        else:
            return False

    def __gt__(self, other):
        print('> operator overloaded')
        if isinstance(other, Data):
            return True if self.id > other.id else False
        else:
            return False

    def __lt__(self, other):
        print('< operator overloaded')
        if isinstance(other, Data):
            return True if self.id < other.id else False
        else:
            return False

    def __le__(self, other):
        print('<= operator overloaded')
        if isinstance(other, Data):
            return True if self.id <= other.id else False
        else:
            return False

    def __ge__(self, other):
        print('>= operator overloaded')
        if isinstance(other, Data):
            return True if self.id >= other.id else False
        else:
            return False


d1 = Data(10)
d2 = Data(7)

print(f'd1 == d2 = {d1 == d2}')
print(f'd1 != d2 = {d1 != d2}')
print(f'd1 > d2 = {d1 > d2}')
print(f'd1 < d2 = {d1 < d2}')
print(f'd1 <= d2 = {d1 <= d2}')
print(f'd1 >= d2 = {d1 >= d2}')

1.2.2. Example - Comparison Operators 2#

  • It is a good practice to check the class of objects passed to __eq__() method to make sure the comparison makes sense.

  • For example, consider the following two classes:

Temperature class

Conductivity Class

class Temperature :
  def __init__(self, number):
     self.number = number

  def __eq__(self, other):
    return self.number == other.number

temp = Temperature(1453)
print(type(temp))
class Conductivity:
  def __init__(self, number):
     self.number = number

  def __eq__(self, other):
    return self.number == other.number

cond = Conductivity(1453) 
print(type(cond))
  • Running temp == cond will return True, even though we are comparing a temperature sensor number with a conductivity sensor number. This is wrong! We need to compare potatoes with potatoes =)

BAD !!!:
temp = Temperature(1453)
cond = Conductivity(1453) 
print(temp == cond)

True

GOOD !:
class Conductivity:
  def __init__(self, number):
    self.number = number

  def __eq__(self, other):
    return True if (self.number == other.number) and \
                   isinstance(other, Conductivity) else False

class Temperature:
  def __init__(self, number):
    self.number = number

  def __eq__(self, other):
    return True if (self.number == other.number) and \
                   isinstance(other, Temperature) else False

# ----------------------------------- %
temp1 = Temperature(1453)
cond1 = Conductivity(1453) 
print (temp1.number, cond1.number)
print(f'temp1 == cond1 = {temp1 == cond1}')
# ------------------------------------ %
temp2 = Temperature(1453)
print (temp1.number, temp1.number)
print(f'temp1 == temp2 = {temp1 == temp2}')
# ------------------------------------ %
temp3 = Temperature(3985)
print (temp1.number, temp3.number)
print(f'temp1 == temp3 = {temp1 == temp3}')
# ------------------------------------ %
cond2 = Conductivity(5050)
print (cond1.number, cond2.number)
print(f'cond1 == cond2 = {cond1 == cond2}')

2. Operator Overloading: string representation#

  • There are two special methods that we can define in a class that will return a printable representation of an object.

2.1. The __str__() method#

  • This method is executed when we call print or str on an object

  • This method must return the String object. If we don’t implement __str__() function for a class, then built-in object implementation is used that actually calls __repr__(): function.

2.2. The __repr__() method#

  • This method is executed when we cal repr on the object, or when we print it in the console without calling print explicitly.

Note

If both the functions return strings, which is supposed to be the object representation, what’s the difference?

The __str__ function is supposed to return a human-readable format, which is good for logging or to display some information about the object. Whereas, the __repr__ function is supposed to return an “official” string representation of the object, which can be used to construct the object again.

Reference

import datetime
now = datetime.datetime.now()
print(now.__str__())  
print(now.__repr__())  
  • Let’s check what happens if we don’t define the functions for a custom object

2.3. Example - String Representation 1#

class Sensor:

    def __init__(self, sensor_name, serial_number ):
        self.sensor_name = sensor_name
        self.serial_number = serial_number

p = Sensor('Temperature', 1416)

print(p.__str__())
print(p.__repr__())

Note

As you can see, the default implementation is useless.

2.4. Example - String Representation 2#

class Sensor:

    def __init__(self, sensor_name, serial_number ):
        self.sensor_name = sensor_name
        self.serial_number = serial_number

    # INFORMAL
    def __str__(self):
        sensor_str = """ Sensor:
        sensor_name: {sensor_name} 
        serial_number: {serial_number}
        """.format(sensor_name = self.sensor_name, \
                   serial_number = self.serial_number)
        
        return sensor_str
    
    # FORMAL
    def __repr__(self):
        return "Sensor('{sensor_name}', {serial_number})"\
        .format(sensor_name = self.sensor_name, serial_number = self.serial_number)

p = Sensor('Temperature', 1416)

print(p)
print(p.__str__())
print(str(p))
print(p.__repr__())
print(repr(p))

3. Exceptions#

  • Exceptions are used to prevent the program from terminating.

3.1. Raising an Exception#

Reference

  • We can use raise to throw an exception if a condition occurs.

x = 10
if x > 5 :
    raise Exception('x should not exceed . The value of x was {}'.format(x))

Error

Exception Traceback (most recent call last)
/tmp/ipykernel_48/555160632.py in
1 x = 10
2 if x > 5 :
—-> 3 raise Exception(‘x should not exceed . The value of x was {}’.format(x))

Exception: x should not exceed . The value of x was 10

  • The program comes to a halt and displays our exception to screen, offering clues about what went wrong.

3.2. Custom Exceptions#

class SalaryError(ValueError): pass
class BonusError(SalaryError): pass

class Employee:
  MIN_SALARY = 30000
  MAX_BONUS = 5000

  def __init__(self, name, salary = 30000):
    self.name = name    
    if salary < Employee.MIN_SALARY:
      raise SalaryError("Salary is too low!")      
    self.salary = salary
    
  # Rewrite using exceptions  
  def give_bonus(self, amount):
    if amount > Employee.MAX_BONUS:
      raise BonusError("The bonus amount is too high!")  
        
    elif self.salary + amount <  Employee.MIN_SALARY:
      raise SalaryError("The salary after bonus is too low!")
      
    else:  
      self.salary += amount

emp1 = Employee("Fernando", 30000) 
emp1.give_bonus(1000)
print(emp1.salary)
emp1.give_bonus(6000)
print(emp1.salary)

Error

31000
BonusError Traceback (most recent call last)
/tmp/ipykernel_67/3863838099.py in
26 emp1.give_bonus(1000)
27 print(emp1.salary)
—> 28 emp1.give_bonus(6000)
29 print(emp1.salary)

/tmp/ipykernel_67/3863838099.py in give_bonus(self, amount)
15 def give_bonus(self, amount):
16 if amount > Employee.MAX_BONUS:
—> 17 raise BonusError(“The bonus amount is too high!”)
18
19 elif self.salary + amount < Employee.MIN_SALARY:

BonusError: The bonus amount is too high!