Classes and Instances¶

Some Terminology to know:

Attributes are defined as the data of a class

Methods are defined as the functions of a Specific class

A Class is a the map of the structure that we design and later on the objects we create would follow this design

Here Instance is called as an object created from a class

In [1]:
class Employee:
  pass #This is an empty class so far

Here we created a Employee Class (over the course of the blog we will develop this)

In [2]:
emp_1 = Employee()
emp_2 = Employee()

emp_1 and emp_2 are Instances of the Employee Class

In [3]:
print(emp_1)
print(emp_2)
<__main__.Employee object at 0x7e0df18818e0>
<__main__.Employee object at 0x7e0df1880a10>

You can see in the above output both of instances have their own memory locations.

This reinforces that they are two different instances of the Employee Class.

Here we populated both the objects dynamically with certain attributes such as name, mail and pay

In [4]:
emp_1.first = 'John'
emp_1.last = 'Paul'
emp_1.mail = 'John.Paul@gmail.com'
emp_1.pay  = 120000
In [5]:
emp_2.first = 'Rahul'
emp_2.last = 'Jain'
emp_2.mail = 'Rahul.Jain@gmail.com'
emp_2.pay  = 180000
In [6]:
print(emp_1.mail)
print(emp_2.mail)
John.Paul@gmail.com
Rahul.Jain@gmail.com

If we are sure that every employee is going to have these characteristics (i.e. name, mail and pay) then we can initialise a Constructor for the class.

A Constructor helps us initialise objects, this is done with the help of the keyword self. It runs automatically right after the object is made, letting you set up its initial state.

This helps reuse the same piece of code for multiple instances and also makes the codebase more readable.

In [7]:
class Employee:

  def __init__(self,first,last,pay):
    self.first = first
    self.last = last
    self.mail = first + '.' + last + '@gmail.com'
    self.pay = pay

As seen above a Constructor is defined by the __init__ method. Here now when we initialse an Object (Object and Instance are mostly interchangable) we also pass in the values of its attributes

In [8]:
#Now instead of having to specifically adress it for each emp we can just pass them as arguments

emp_3 = Employee('Zhang','Wei',190000)
emp_4 = Employee('Wang','L',150000)

print(emp_3.mail)
print(emp_4.mail)
Zhang.Wei@gmail.com
Wang.L@gmail.com
In [9]:
print('{} {}'.format(emp_3.first,emp_3.last))
Zhang Wei

Similar the __init__ method we can also define other methods that we deem neccessary for our class.

Methods that have double underscores __ before and after their name are known as Dunder/Magic methods. You can learn more about them here Guide to Magic Methods

For example below we define a new method fullname that prints out the full name of an Employee.

These methods help us in reusing the functions for various objects. Therefore if there are certain general functionalities of a certain kind of objects, check if they can be adding in as methods inside the class itself.

In [10]:
class Employee:

  def __init__(self,first,last,pay):
    self.first = first
    self.last = last
    self.mail = first + '.' + last + '@gmail.com'
    self.pay = pay

#This become a function that all the objects can now use
  def fullname(self):
    print('{} {}'.format(self.first,self.last))
In [11]:
emp_3 = Employee('Zhang','Wei',190000)
emp_4 = Employee('Wang','L',150000)

#When calling a method of a class, include parentheses () this help us invoke the class method
print(emp_3.mail)
emp_3.fullname()
print(emp_4.mail + '\n')

print("Directly calling the method from a specific object")
emp_4.fullname()

print("")
#The above method can also be called in this manner (However the above manner is the general convention)
print("Using the class and specifing the instance here")
Employee.fullname(emp_4)
Zhang.Wei@gmail.com
Zhang Wei
Wang.L@gmail.com

Directly calling the method from a specific object
Wang L

Using the class and specifing the instance here
Wang L

Class Variables¶

There are two kinds of variables:

  • Class Variables: Variables that are shared among classes

  • Instance Varibales: Variables that store various objects that are defined over a class

Class variables remain same for all objects whereas instance variables defer with respect to the object specified in context

In [12]:
#For our example let us assume the company gives an annual raise which is same for all the employees

class Employee:

  raise_amount = 1.04 #this is a class variable

  def __init__(self,first,last,pay):
    self.first = first
    self.last = last
    self.mail = first + '.' + last + '@gmail.com'
    self.pay = pay

  def full_name(self):
    print('{} {}'.format(self.first,self.last))

  def apply_raise(self):
    self.pay = int(self.pay * Employee.raise_amount)
In [13]:
emp_4 = Employee('Wang','L',150000)
print(f"Before invoking apply_raise() the pay is: {emp_4.pay}")
emp_4.apply_raise()
print(f"After invoking apply_raise() the pay is: {emp_4.pay}")

print("\nThe Name space of an object: ")
print(emp_4.__dict__) #This is the syntax to access it

print("\nThe Name space of the class: ")
print(Employee.__dict__)  #This is the syntax to access it
print("")
Before invoking apply_raise() the pay is: 150000
After invoking apply_raise() the pay is: 156000

The Name space of an object: 
{'first': 'Wang', 'last': 'L', 'mail': 'Wang.L@gmail.com', 'pay': 156000}

The Name space of the class: 
{'__module__': '__main__', 'raise_amount': 1.04, '__init__': <function Employee.__init__ at 0x7e0df187fc40>, 'full_name': <function Employee.full_name at 0x7e0df187fce0>, 'apply_raise': <function Employee.apply_raise at 0x7e0df187fd80>, '__dict__': <attribute '__dict__' of 'Employee' objects>, '__weakref__': <attribute '__weakref__' of 'Employee' objects>, '__doc__': None}

The same function can be defined with self keyword instead of the class variable.

This would give us the flexibility to have different rates for different employees.

When the program runs now, it first checks if the object at hand has a rate variable or not. If it is not present in the object's namespace, then it goes and checks for it in the class.

In [14]:
class Employee:

  raise_amount = 1.04

  def __init__(self,first,last,pay):
    self.first = first
    self.last = last
    self.mail = first + '.' + last + '@gmail.com'
    self.pay = pay

  def full_name(self):
    print('{} {}'.format(self.first,self.last))

  def apply_raise(self):
    self.pay = int(self.pay * self.raise_amount)
In [15]:
emp_3 = Employee('Zhang','Wei',190000)
emp_4 = Employee('Wang','L',150000)

emp_3.raise_amount = 1.09

print(f"Here the object's rate variable is used since emp_3 has it: {emp_3.raise_amount}")
print(f"Here the Classe's rate variable is used since emp_4 does not have it: {emp_4.raise_amount}")

#This adds in more flexibility
print("\nThe Name space of the emp_3 object: ")
print(emp_3.__dict__)

print("\nThe Name space of the emp_4 object: ")
print(emp_4.__dict__)

print("\nThe Name space of the class: ")
print(Employee.__dict__)
Here the object's rate variable is used since emp_3 has it: 1.09
Here the Classe's rate variable is used since emp_4 does not have it: 1.04

The Name space of the emp_3 object: 
{'first': 'Zhang', 'last': 'Wei', 'mail': 'Zhang.Wei@gmail.com', 'pay': 190000, 'raise_amount': 1.09}

The Name space of the emp_4 object: 
{'first': 'Wang', 'last': 'L', 'mail': 'Wang.L@gmail.com', 'pay': 150000}

The Name space of the class: 
{'__module__': '__main__', 'raise_amount': 1.04, '__init__': <function Employee.__init__ at 0x7e0df18ac360>, 'full_name': <function Employee.full_name at 0x7e0df18ac400>, 'apply_raise': <function Employee.apply_raise at 0x7e0df18ac4a0>, '__dict__': <attribute '__dict__' of 'Employee' objects>, '__weakref__': <attribute '__weakref__' of 'Employee' objects>, '__doc__': None}

Class varibales can be dynamic in nature as well.

For example to track the number of Employees in the Organisation. I create a class variable for it in the __init__ method since the constructor get's called everytime a new Employee object is created.

In [16]:
class Employee:

  raise_amount = 1.04
  no_of_emps = 0

  def __init__(self,first,last,pay):
    self.first = first
    self.last = last
    self.mail = first + '.' + last + '@gmail.com'
    self.pay = pay

    Employee.no_of_emps += 1 # This class variable changes value as and when new objects are created

  def full_name(self):
    print('{} {}'.format(self.first,self.last))

  def apply_raise(self):
    self.pay = int(self.pay * self.raise_amount)
In [17]:
emp_1 = Employee('John','Paul',120000)
emp_2 = Employee('Rahul','jain',180000)

print("Current no of employees " + str(Employee.no_of_emps))

emp_3 = Employee('Zhang','Wei',190000)
emp_4 = Employee('Wang','L',150000)

print("Current no of employees " + str(Employee.no_of_emps))
Current no of employees 2
Current no of employees 4

Class and Static Methods¶

We know that there are both class and instance variables, similarly we can different types of methods:

In this section let us try to understand the different types of methods

The methods that we have seen so far (i.e. __init__ or full_name and more) contain self as one of the parameters, these methods are known as Regular methods.

Therefore, a regular method is a method that needs an instance of the class to work. Generally the first parameter of a regular method is always self.

In [18]:
class Employee:

  raise_amount = 1.04
  no_of_emps = 0

  def __init__(self,first,last,pay):
    self.first = first
    self.last = last
    self.mail = first + '.' + last + '@gmail.com'
    self.pay = pay

    Employee.no_of_emps += 1

  def full_name(self):
    print('{} {}'.format(self.first,self.last))

  def apply_raise(self):
    self.pay = int(self.pay * self.raise_amount)

We can convert a regular method into a class method by adding a decorator on top of it. These methods can access class-level data but not instance-level data unless explicitly passed an object. They help in adding functionality to the Class itself.

Similar to the regular method's self we the use the keyword cls present as the first argument of the method.

In [19]:
class Employee:

  raise_amount = 1.04
  no_of_emps = 0

  def __init__(self,first,last,pay):
    self.first = first
    self.last = last
    self.mail = first + '.' + last + '@gmail.com'
    self.pay = pay

    Employee.no_of_emps += 1

  def full_name(self):
    print('{} {}'.format(self.first,self.last))

  def apply_raise(self):
    self.pay = int(self.pay * Employee.raise_amount)

  @classmethod
  def set_raise_amt(cls, amount):
    cls.raise_amount = amount
In [20]:
emp_3 = Employee('Zhang','Wei',190000)
emp_4 = Employee('Wang','L',150000)

emp_4.raise_amount = 1.70

print(f"emp_3 has the default class variable rate: {emp_3.raise_amount}")
print(f"As seen above the class rate is: {Employee.raise_amount}")
print(f"Whereas emp_4 rate is a instance variable with the value: {emp_4.raise_amount}")

print("="*30)
Employee.set_raise_amt(1.69) #Invoking the class method
print("Class Method is invoked")
print("="*30)

print(f"The class variable rate's value has now changed to: {Employee.raise_amount}")
print(f"This implies the reate of emp_3 also changes to: {emp_3.raise_amount}")
print(f"However emp_4 rate stays the same at: {emp_4.raise_amount}")
emp_3 has the default class variable rate: 1.04
As seen above the class rate is: 1.04
Whereas emp_4 rate is a instance variable with the value: 1.7
==============================
Class Method is invoked
==============================
The class variable rate's value has now changed to: 1.69
This implies the reate of emp_3 also changes to: 1.69
However emp_4 rate stays the same at: 1.7

Class methods are generally used as alternative constructors, when you want the method to create instances in a specific way.

This is best understood with the help of an example given below:

In [21]:
#Suppose the names were stored in a database in the particular format given below:

emp_str = 'Rajnikanth-Kumar-10000000' #Format is "fistname-lastname-pay"

#When we fetch this data from the database, we would first need to segment out the single formatted string before creating an object

first,last,pay = emp_str.split('-') #Deformatting code
emp_formatted = Employee(first,last,pay)

print(emp_formatted.mail)
Rajnikanth.Kumar@gmail.com

What would happend when you want to fetch 1000 objects ? Instead of repeating the deformatting code, just change the way the constructor functions by creating a new class method.

In [22]:
class Employee:

  raise_amount = 1.04
  no_of_emps = 0

  def __init__(self,first,last,pay):
    self.first = first
    self.last = last
    self.mail = first + '.' + last + '@gmail.com'
    self.pay = pay

    Employee.no_of_emps += 1

  def full_name(self):
    print('{} {}'.format(self.first,self.last))

  def apply_raise(self):
    self.pay = int(self.pay * Employee.raise_amount)

  @classmethod
  def set_raise_amt(cls, amount):
    cls.raise_amount = amount

  #This method helps deformatted the string once we fetch them from the Database
  @classmethod
  def from_string(cls,emp_str):
    first,last,pay = emp_str.split('-')
    return cls(first,last,pay)
In [23]:
#Now we have multiple constructor implementations which helps us handle various scenarios
emp_str = Employee.from_string('Rajnikanth-Kumar-10000000')
print(emp_str.mail)
print(emp_str)
Rajnikanth.Kumar@gmail.com
<__main__.Employee object at 0x7e0df18a06b0>

We have seen that regular methods take in the instance i.e. the Self keyword and class methods take in the Class and they operate on instance and class data respectively.

However, Static methods are used when the method logically belongs to the class but doesn't need access to class or instance data.

You can a regular method into a Static Method by adding a decorator on top of it. These methods do not operate on an instance or the class itself.

In [24]:
class Employee:

  raise_amount = 1.04
  no_of_emps = 0

  def __init__(self,first,last,pay):
    self.first = first
    self.last = last
    self.mail = first + '.' + last + '@gmail.com'
    self.pay = pay

    Employee.no_of_emps += 1

  def full_name(self):
    print('{} {}'.format(self.first,self.last))

  def apply_raise(self):
    self.pay = int(self.pay * Employee.raise_amount)

  @classmethod
  def set_raise_amt(cls, amount):
    cls.raise_amount = amount

  @classmethod
  def from_string(cls,emp_str):
    first,last,pay = emp_str.split('-')
    return cls(first,last,pay)

  @staticmethod
  def is_workday(day):
    if day.weekday() == 5 or day.weekday() == 6:
      return False
    return True

Generally static methods act as **helper ** function that logically belong to a class but don't need to interact with a specific object or the class itself. Similar to the above is_workday() method

In [25]:
import datetime

my_date_1 = datetime.date(2016,7,10)
my_date_2 = datetime.date(2016,7,11)

print(Employee.is_workday(my_date_1))
print(Employee.is_workday(my_date_2))
False
True

Now that we have covered the basic terminologies and syntax, we will now learn about the fundamental and foundational principles of Object Oriented programming.

The 4 pillars of OOPs are:

  • Abstraction
  • Inheritance
  • Encapsulation
  • Polymorphism

Abstraction¶

Definition of Abstraction:

Abstraction: is the process of simplifying complex ideas by focusing on essential qualities and ignoring nonessential details

Similarly, here in the context of Object Oriented Programming it is the process of being able to invoke complex methods and use them while abstracting the internal working of them.

To drive the concept home, consider the usecase where the user wants to know the amount of tax to be paid by each Employee. This would require a complex calculation where the tax percentage would depend on the parameters such as the state the employee works from and the amount of income they receive.

In [26]:
class Employee:
    raise_amount = 1.04
    no_of_emps = 0

    def __init__(self, first, last, pay, location):
        self.first = first
        self.last = last
        self.mail = first + '.' + last + '@gmail.com'
        self.pay = pay
        self.location = location  # new attribute

        Employee.no_of_emps += 1

    def full_name(self):
        print('{} {}'.format(self.first, self.last))

    def apply_raise(self):
        self.pay = int(self.pay * Employee.raise_amount)

    @classmethod
    def set_raise_amt(cls, amount):
        cls.raise_amount = amount

    @classmethod
    def from_string(cls, emp_str, location):
        first, last, pay = emp_str.split('-')
        return cls(first, last, pay, location)

    @staticmethod
    def is_workday(day):
        if day.weekday() == 5 or day.weekday() == 6:
            return False
        return True

    #Tax calculation demonstrating abstraction
    def calculate_tax(self):
        """
        Calculates tax based on pay and location.
        User does not need to know formulas or state specific tax rules.
        """
        if self.location.lower() == "maryland":
            tax_rate = 0.05  # 5% state tax for simplicity
        elif self.location.lower() == "austin":
            tax_rate = 0.06  # 6% for Texas (Austin)
        elif self.location.lower() == "california":
            tax_rate = 0.08  # 8% for CA
        else:
            tax_rate = 0.05  # default

        tax = self.pay * tax_rate
        return round(tax, 2)

With the help of the above given code user calculates tax without worrying about the complex rules or formulas internally. The user just calls the calculate_tax() method, and all the location based logic is abstracted inside the class.

In [27]:
emp1 = Employee("Sai Srikar", "Ventrapragada", 100000, "Maryland")
emp2 = Employee("Aditya", "Sai", 120000, "Austin")

print(f"{emp1.first +" "+ emp1.last} has to pay tax of: ${emp1.calculate_tax()}")
print(f"{emp2.first +" "+ emp1.last} has to pay tax of: ${emp2.calculate_tax()}")
Sai Srikar Ventrapragada has to pay tax of: $5000.0
Aditya Ventrapragada has to pay tax of: $7200.0

Here in the above example abstraction with the help of OOPs hides the Users from having to see how tax rates are applied or the logic for each state.

It exposes only essential behavior of just having to call the method for each emp.

Apart from that this makes it to be easily extendable where later on you could add more states or complex deductions without changing the way the user interacts with the method.

Therefore, Abstraction isn't just about writing methods; it is about hiding unnecessary details and showing a clean interface.

The Key idea here is that the user should be able to use the class without needing to understand the inner workings of it.

Inheritance¶

Definition of Inheritance:

Inheritance: is the act of receiving assets, traits, or qualities from a preceding generation, such as inheriting property, genetic traits.

Similarly, here in the context of Object Oriented Programming it is the ability to be able to inhert the functionality of the a parent class(es) and then modify them according to your needs.

To drive the concept home, consider a situation where we have two different types of emloyees:

  • Developers
  • Managers
In [28]:
class Employee:

  raise_amount = 1.04
  no_of_emps = 0

  def __init__(self,first,last,pay):
    self.first = first
    self.last = last
    self.mail = first + '.' + last + '@gmail.com'
    self.pay = pay

    Employee.no_of_emps += 1

  def full_name(self):
    return '{} {}'.format(self.first,self.last)

  def apply_raise(self):
    self.pay = int(self.pay * Employee.raise_amount)

  @classmethod
  def set_raise_amt(cls, amount):
    cls.raise_amount = amount

  @classmethod
  def from_string(cls,emp_str):
    first,last,pay = emp_str.split('-')
    return cls(first,last,pay)

  @staticmethod
  def is_workday(day):
    if day.weekday() == 5 or day.weekday() == 6:
      return False
    return True

Here the Developer class (i.e. the subclass/derived class) inherits all the functionality from Employee class (i.e. the base class/super class) below:

In [29]:
class Developer(Employee): #Defined an subclass called Developer
  pass
In [30]:
dev_1 = Developer("Sai","Srikar",70000)

print(dev_1.mail)
print(dev_1.pay)
print(Developer.raise_amount) #All of these functionalities are inherited from Employee
dev_1.apply_raise()
print(dev_1.pay)
print()
print(help(Developer)) #This provides documentation of the class
Sai.Srikar@gmail.com
70000
1.04
72800

Help on class Developer in module __main__:

class Developer(Employee)
 |  Developer(first, last, pay)
 |
 |  Method resolution order:
 |      Developer
 |      Employee
 |      builtins.object
 |
 |  Methods inherited from Employee:
 |
 |  __init__(self, first, last, pay)
 |      Initialize self.  See help(type(self)) for accurate signature.
 |
 |  apply_raise(self)
 |
 |  full_name(self)
 |
 |  ----------------------------------------------------------------------
 |  Class methods inherited from Employee:
 |
 |  from_string(emp_str)
 |
 |  set_raise_amt(amount)
 |
 |  ----------------------------------------------------------------------
 |  Static methods inherited from Employee:
 |
 |  is_workday(day)
 |
 |  ----------------------------------------------------------------------
 |  Data descriptors inherited from Employee:
 |
 |  __dict__
 |      dictionary for instance variables
 |
 |  __weakref__
 |      list of weak references to the object
 |
 |  ----------------------------------------------------------------------
 |  Data and other attributes inherited from Employee:
 |
 |  no_of_emps = 1
 |
 |  raise_amount = 1.04

None
In [31]:
class Developer(Employee):
  raise_amount=1.10 #Changed the class variable

dev_1 = Developer("Sai","Srikar",70000)

print(dev_1.pay)
print(f"This is the value of raise amount in Employee class: {Employee.raise_amount}")
print(f"This is the value of raise amount in Developer class: {Developer.raise_amount}") #Modified the functionality from the base class
dev_1.apply_raise()
print(dev_1.pay)
70000
This is the value of raise amount in Employee class: 1.04
This is the value of raise amount in Developer class: 1.1
72800

As you can see in the above output,we modified the class variable for Developer subclass which has only effect on Developer instances.

Lets now look at what more can be modified.

In [32]:
class Developer(Employee):

  def __init__(self,first,last,pay,lang):
    super().__init__(first,last,pay) #Inheriting the default attributes from parent class
    self.lang = lang #New Additional attribute

print("We can see developers now have an additional attribute in their namespace: ")
dev_1 = Developer("Sai","Srikar",70000,"Python")
print(dev_1.__dict__,"\n")

print("Whereas the Employee object stays the same: ")
emp_5 = Employee("Geoffery","Hinton",270000)
print(emp_5.__dict__)
We can see developers now have an additional attribute in their namespace: 
{'first': 'Sai', 'last': 'Srikar', 'mail': 'Sai.Srikar@gmail.com', 'pay': 70000, 'lang': 'Python'} 

Whereas the Employee object stays the same: 
{'first': 'Geoffery', 'last': 'Hinton', 'mail': 'Geoffery.Hinton@gmail.com', 'pay': 270000}

super().__init__(first, last, pay) calls the constructor __init__ of the parent class(Employee here).

This implies all the attributes that the parent normally sets up (like first name, last name, pay, email, etc.) are properly initialized according to how they are defined in the Employee class.

Post which we add the new attribute programming language which is specific to the subclass Developer.

Now let us define a Manager class

In [33]:
class Manager(Employee):

  def __init__(self,first,last,pay,employees=None):
    super().__init__(first,last,pay)

    if employees is None:
      self.employees = [] #new attribute
    else:
      self.employees = employees

  #The below functions are new functionalities that can only be used by instances of Manager Class

  def add_emp(self,emp):
    if emp not in self.employees:
      self.employees.append(emp)

  def remove_emp(self,emp):
    if emp in self.employees:
      self.employees.remove(emp)

  def print_emps(self):
    for emp in self.employees:
      print("-->" , emp.full_name())

    return len(self.employees)
In [34]:
emp_5 = Employee("Geoffery","Hinton",270000)
dev_1 = Developer("Sai","Srikar",70000,"Python")

manager_1 = Manager("Sundar", "Pichai", 500000,[dev_1,emp_5])

print(manager_1.mail) #Regular derived funtionalities

#New functionalities
print(f"The employee's managed by the Manager {manager_1.first} are {manager_1.print_emps()}")

manager_1.remove_emp(emp_5)

print('\nAfter Firing an Employee, the employee under the manager are:')
manager_1.print_emps()
Sundar.Pichai@gmail.com
--> Sai Srikar
--> Geoffery Hinton
The employee's managed by the Manager Sundar are 2

After Firing an Employee, the employee under the manager are:
--> Sai Srikar
Out[34]:
1
In [35]:
print(isinstance(manager_1,Manager))
print(isinstance(manager_1,Employee))
print(isinstance(manager_1,Developer))
True
True
False

The isinstance method is a quick way to check what class does the object belong to.

Multiple Inheritance¶

In Python you can can inherit from multiple classes. If a class inherits from two or more classes, you will have to perform multiple inheritance. To understand this better, let us extend the Employee class example given above. Here we consider a situation where we create a new class called SeniorManagement that inherits multiple classes.

In [36]:
#Employee Class
from abc import ABC, abstractmethod

class Employee(ABC):  # Inherit from ABC to make abstract
    raise_amount = 1.04
    no_of_emps = 0

    def __init__(self, first, last, pay):
        self.first = first
        self.last = last
        self.mail = first + '.' + last + '@gmail.com'
        self.pay = pay

        Employee.no_of_emps += 1

    def full_name(self):
        return '{} {}'.format(self.first, self.last)

    def apply_raise(self):
        self.pay = int(self.pay * Employee.raise_amount)

    @classmethod
    def set_raise_amt(cls, amount):
        cls.raise_amount = amount

    @classmethod
    def from_string(cls, emp_str):
        first, last, pay = emp_str.split('-')
        return cls(first, last, pay)

    @staticmethod
    def is_workday(day):
        return day.weekday() < 5

    # --- Abstract Method ---
    @abstractmethod
    def work(self):
        """Every subclass must define this method"""
        pass

You can see a new addition to our Employee class above, here we haev a new abstract method work.

An abstract method is a method declared in an abstract class without an implementation, defined only by its signature. This is an extension of the **Abstraction ** pillar of OOPs. A subclass that inherits Employee must provide an implmentation for the method.

In [37]:
class Manager(Employee):

    def __init__(self, first, last, pay, employees=None):
        super().__init__(first, last, pay)

        if employees is None:
            self.employees = []
        else:
            self.employees = employees

    def add_emp(self, emp):
        if emp not in self.employees:
            self.employees.append(emp)

    def remove_emp(self, emp):
        if emp in self.employees:
            self.employees.remove(emp)

    def print_emps(self):
        for emp in self.employees:
            print("-->", emp.full_name())
        return len(self.employees)

    # Implement abstract method from Employee
    # def work(self):
    #     return f"{self.full_name()} is managing a team of {len(self.employees)} employees."

By defining abstract methods and classes we create a framework that is both flexible and easy to extend for different subclasses.

This aligns with the principle of making software components open for extension but closed for modification.

In [38]:
manager_1 = Manager()
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
/tmp/ipython-input-2186870507.py in <cell line: 0>()
----> 1 manager_1 = Manager()

TypeError: Can't instantiate abstract class Manager without an implementation for abstract method 'work'
In [39]:
from abc import ABC,abstractmethod
print(help(ABC))
Help on class ABC in module abc:

class ABC(builtins.object)
 |  Helper class that provides a standard way to create an ABC using
 |  inheritance.
 |
 |  Data and other attributes defined here:
 |
 |  __abstractmethods__ = frozenset()

None

The use of @abstractmethod in Python, made possible by the abc module, offers several key advantages that make it an essential tool in the arsenal of object-oriented programming. This decorator not only enforces the implementation of methods in subclasses but also promotes a clear and structured approach to software design.

In [40]:
class Manager(Employee):

    def __init__(self, first, last, pay, employees=None):
        super().__init__(first, last, pay)

        if employees is None:
            self.employees = []
        else:
            self.employees = employees

    def add_emp(self, emp):
        if emp not in self.employees:
            self.employees.append(emp)

    def remove_emp(self, emp):
        if emp in self.employees:
            self.employees.remove(emp)

    def print_emps(self):
        for emp in self.employees:
            print("-->", emp.full_name())
        return len(self.employees)

    # Implement abstract method from Employee
    def work(self):
        return f"{self.full_name()} is managing a team of {len(self.employees)} employees."
In [41]:
manager_1 = Manager('Sundar','Pichai',18000000)
In [42]:
class BenefitsMixin:
    """A Mixin class that provides benefits info"""
    def show_benefits(self):
        return ["Health Insurance", "401k", "Company Car"]

A "Mix-in" class is a class without an __init__ method, their purpose is just to add extra behavior or utility methods to other classes. You create them when you want to use one particular feature in a lot of different classes.

Method Resolution¶

In [43]:
class SeniorManagement(Manager, BenefitsMixin):
    """Combines Manager responsibilities + extra benefits"""

    def __init__(self, first, last, pay, employees=None, stock_options=0):
        super.__init__(first, last, pay, employees)
        self.stock_options = stock_options

    def work(self):
        #Implemented abstract method
        return (f"{self.full_name()} is making strategic decisions "
                f"for {len(self.employees)} employees "
                f"and has {self.stock_options} stock options.")

    def show_full_profile(self):
        return {
            "Name": self.full_name(),
            "Pay": self.pay,
            "Employees Managed": len(self.employees),
            "Benefits": self.show_benefits(),
            "Stock Options": self.stock_options
        }

When we create this Senior Management Class that inherits from multiple classes, the question we need to think about is:

  • when i perform super().__init__ which class does it go to ?
  • If there are methods or attributes of same name in the classes it inherits which one does it take on ?

Method Resolution Order (MRO) in Python defines the sequence in which Python searches for methods and attributes within a class hierarchy.

This implies the order in which we pass the multiple classes defines the sequence in which the attributes and methods are searched for.

In [44]:
SeniorManagement.__mro__
Out[44]:
(__main__.SeniorManagement,
 __main__.Manager,
 __main__.Employee,
 abc.ABC,
 __main__.BenefitsMixin,
 object)

As you can see first the sequence in which the class looks for methods and attributes here is:

Senior Management -> Manager -> Employee -> BenefitsMixin

This Sequence can be changed while creating the class

In [45]:
class SeniorManagement(BenefitsMixin,Manager):
    """Combines Manager responsibilities + extra benefits"""

    def __init__(self, first, last, pay, employees=None, stock_options=0):
        super().__init__(first, last, pay, employees)
        self.stock_options = stock_options

    def work(self):
        #Implemented abstract method
        return (f"{self.full_name()} is making strategic decisions "
                f"for {len(self.employees)} employees "
                f"and has {self.stock_options} stock options.")

    def show_full_profile(self):
        return {
            "Name": self.full_name(),
            "Pay": self.pay,
            "Employees Managed": len(self.employees),
            "Benefits": self.show_benefits(),
            "Stock Options": self.stock_options
        }
In [46]:
SeniorManagement.__mro__
Out[46]:
(__main__.SeniorManagement,
 __main__.BenefitsMixin,
 __main__.Manager,
 __main__.Employee,
 abc.ABC,
 object)

Here now the sequence is: Senior Management -> BenefitsMixin -> Manager -> Employee

In [47]:
dev_1 = Developer("Alice","Smith",90000,"Python")
dev_2 = Developer("James","Clark",100000,"c++")
mgr = Manager("Bob", "Johnson", 120000, [dev_1,dev_2])
senior = SeniorManagement("Charlie", "Brown", 200000, [emp1, mgr], stock_options=500)

print(mgr.work())
print(senior.work())
print(senior.show_full_profile())
Bob Johnson is managing a team of 2 employees.
Charlie Brown is making strategic decisions for 2 employees and has 500 stock options.
{'Name': 'Charlie Brown', 'Pay': 200000, 'Employees Managed': 2, 'Benefits': ['Health Insurance', '401k', 'Company Car'], 'Stock Options': 500}

There can be a class inherits from another derived class, forming a chain of inheritance leading to Multilevel Inheritance. There also be a case of when multiple classes inherit from the same base class leading to Hierarchical Inheritance.

Polymorphism¶

Definition of Polymorphism:

Polymorphism: comes from the Greek language and means "something that takes on multiple forms."

Here in OOPs, Polymorphism refers to a subclass's ability to adapt a method that already exists in its superclass to meet its needs. To put it another way, a subclass can use a method from its superclass as is or modify it as needed.

The main concepts of Polymorphism is:

Method Overriding: Method overriding occurs when a subclass (child class) provides its own specific implementation of a method that is already defined in its superclass (parent class).

In [48]:
class Employee:

  raise_amount = 1.04
  no_of_emps = 0

  def __init__(self,first,last,pay):
    self.first = first
    self.last = last
    self.mail = first + '.' + last + '@gmail.com'
    self.pay = pay

    Employee.no_of_emps += 1

  def full_name(self):
    return '{} {}'.format(self.first,self.last)

  def apply_raise(self):
    self.pay = int(self.pay * Employee.raise_amount)

  @classmethod
  def set_raise_amt(cls, amount):
    cls.raise_amount = amount

  @classmethod
  def from_string(cls,emp_str):
    first,last,pay = emp_str.split('-')
    return cls(first,last,pay)

  @staticmethod
  def is_workday(day):
    if day.weekday() == 5 or day.weekday() == 6:
      return False
    return True

  # This Function will be Overidded in the Developer Subclass
  def show_full_profile(self):
    return {
        "Name": self.full_name(),
        "Mail": self.mail,
        "Pay": self.pay,
    }
In [49]:
class Developer(Employee):

  def __init__(self,first,last,pay,lang):
    super().__init__(first,last,pay)
    self.lang = lang
#Here the function also includes an new attribute called languages and print's it out in skills section
  def show_full_profile(self):
    return {
        "Name": self.full_name(),
        "Mail": self.mail,
        "Skills":self.lang,
        "Pay": self.pay,
    }
In [50]:
emp_1 = Employee("Nathan","Daniel",270000)
dev_1 = Developer("Alice","Smith",90000,"Python")

print(emp_1.show_full_profile())
print(dev_1.show_full_profile())
{'Name': 'Nathan Daniel', 'Mail': 'Nathan.Daniel@gmail.com', 'Pay': 270000}
{'Name': 'Alice Smith', 'Mail': 'Alice.Smith@gmail.com', 'Skills': 'Python', 'Pay': 90000}

Method Overloading does not take place in Python since the language is dynamically typed. Variable arugments can fit in a method using *args / **kwargs. The last definition of the method wins.

Python also allows user defined classes to redefine how operators work for their objects. More on this is explain in the above linked Guide to Magic Methods

Another special concept that is not covered here is duck typing, you can learn more about it here What is Duck Typing ?

Encapsulation¶

Definition of Encapsulation:

Encapsulation: the action of enclosing something in or as if in a capsule.

Here in OOPs, Encapsulation refers to hiding internal details of a class and providing only what is necessary. This help protect important data from being changed directly and keeps the code secure and organized.

This is done with the help of access specifiers. These help in implementing encapsulation by controlling the visibility of data. They are:

Public attributes/methods: Accessible from anywhere.

Protected attributes/methods: Prefixed with a single underscore, in python this is a hint to other developers that the attribute/method should not be accessed directly from outside the class. It is mostly for internal use inside the specific class.

Private attributes/methods: Prefixed with a double underscore, in python this triggers name mangling, which technically makes the attribute harder to access from outside the class.

Let us for one last time continue to learn these different access specifiers with the Employee class example.

In [51]:
import datetime

class Employee:
    raise_amount = 1.04

    #Protected class variable (convention: single underscore)
    _company_name = "Tech Corp"

    #Private class variable (name mangling: double underscore)
    __company_secret_code = "TC2024"

    no_of_emps = 0

    def __init__(self, first, last, pay, ssn):
        #Public instance attributes
        self.first = first
        self.last = last
        self.pay = pay
        self.mail = first + '.' + last + '@gmail.com'

        #Protected instance attribute (single underscore)
        self._employee_id = f"EMP{Employee.no_of_emps + 1:04d}"

        #Private instance attribute (double underscore - name mangling)
        self.__ssn = ssn

        Employee.no_of_emps += 1

    def full_name(self):
        return '{} {}'.format(self.first, self.last)

    def apply_raise(self):
        self.pay = int(self.pay * Employee.raise_amount)

    #Protected method (single underscore)
    def _calculate_bonus(self):
        return self.pay * 0.10

    #Private method (double underscore)
    def __validate_ssn(self):
        return len(self.__ssn) == 9 and self.__ssn.isdigit()

    #Public method that uses private method
    def display_info(self):
        is_valid = self.__validate_ssn()
        return f"{self.full_name()} - Pay: ${self.pay} - Valid SSN: {is_valid}"

    #Getter for private attribute using @property
    @property
    def ssn(self):
        """Property to get masked SSN"""
        return f"***-**-{self.__ssn[-4:]}" #Observe how the private variable is being accessed here, more on this below

    #Setter for private attribute using @property.setter
    @ssn.setter
    def ssn(self, value):
        """Property setter for SSN with validation"""
        if len(value) == 9 and value.isdigit():
            self.__ssn = value #Observe how the private variable is being accessed here, more on this below
        else:
            raise ValueError("SSN must be 9 digits")

    #Deleter for private attribute
    @ssn.deleter
    def ssn(self):
        """Property deleter for SSN"""
        print("Deleting SSN...")
        self.__ssn = "000000000" #Observe how the private variable is being accessed here, more on this below

    #Traditional getter method for protected attribute
    def get_employee_id(self):
        return self._employee_id

    #Traditional setter method for protected attribute
    def set_employee_id(self, new_id):
        if new_id.startswith("EMP"):
            self._employee_id = new_id
        else:
            raise ValueError("Employee ID must start with 'EMP'")

    @classmethod
    def set_raise_amt(cls, amount):
        cls.raise_amount = amount

    @classmethod
    def from_string(cls, emp_str):
        first, last, pay = emp_str.split('-')
        return cls(first, last, int(pay))

    @staticmethod
    def is_workday(day):
        if day.weekday() == 5 or day.weekday() == 6:
            return False
        return True

Here, in the above Employee class we see it's modification where we now protected and private attributes and methods. If you looked at it closely you would see that whenever we are trying to modify any of the protected or private attributes we have specific methods for them.

We use the @property decorator for ssn to create a managed attribute that looks like simple data access but runs code behind the scenes. Whenever i initialise a ssn with a value, the setter methods runs in the background and check's if the ssn is valid or not. The getter method helps accessing the private variable in an easy manner, more on this later.

In short, @property offers the same control and safety as traditional get/set methods but with a cleaner, more intuitive attribute-like syntax.

In [52]:
emp1 = Employee("John", "Doe", 50000, "123456789")

print(f"\nEmployee 1:")
print(f"  Full Name: {emp1.full_name()}")
print(f"  Email (public): {emp1.mail}")
print(f"  Employee ID (protected via getter): {emp1.get_employee_id()}") #have to use a method to access the value
print(f"  SSN (property - masked): {emp1.ssn}") #using the getter method here
print(f"  Display Info: {emp1.display_info()}")

# Using property setter
emp1.ssn = "987654321"
print(f"  SSN after setter: {emp1.ssn}")
Employee 1:
  Full Name: John Doe
  Email (public): John.Doe@gmail.com
  Employee ID (protected via getter): EMP0001
  SSN (property - masked): ***-**-6789
  Display Info: John Doe - Pay: $50000 - Valid SSN: True
  SSN after setter: ***-**-4321

In Python, private attributes are not truly private, they are simply renamed, or "mangled," by the interpreter to prevent accidental access from outside the class or from subclasses.

Name Mangling: is the process where Python automatically prefixes private attributes with _ClassName to create a new, longer name. This prevents subclasses from accidentally overwriting these attributes.

In [53]:
class Manager(Employee):
    def __init__(self, first, last, pay, ssn, manager_code):
        super().__init__(first, last, pay, ssn)

        # This new variable is also named __ssn, but for a different purpose.
        # It becomes self._Manager__ssn
        self.__ssn = manager_code

    def get_manager_code(self):
        # This method accesses the manager's version of __ssn
        return f"Manager Code: {self.__ssn}"
In [54]:
mgr1 = Manager("Jane", "Doe", 100000, "987654321", "MGR123")

# Call the inherited method from the Employee class
print(mgr1.ssn)

# Call the method defined in the Manager class
print(mgr1.get_manager_code())
***-**-4321
Manager Code: MGR123

Without name mangling, if both were just named self.ssn, the Manager's assignment (self.ssn = manager_code) would overwrite the Employee's SSN, causing a data error.

Because of name mangling, the two variables are internally stored as:

mgr1._Employee__ssn (the parent's SSN)

mgr1._Manager__ssn (the manager's security code)

This ensures the internal state of the parent class is protected from accidental naming conflicts in the child class, which is the primary cause name mangling helps achieve.