Python OOPs fundamentals

Programming
Author

Aayush Agrawal

Published

December 20, 2022

An introduction to Object Oriented programming using Python.

Increasingly it’s becoming important for Data professionals to become better at programming and modern programming is centered around Object Oriented programming paradigm. This article helps in explaining some important programming concepts which are mostly language agnostic but we will be using Python in this article.

Object-oriented programming (OOPs) is a programming paradigm that relies on the concept of classes and objects. The basic idea of OOP is to divide a sophisticated program into a number of objects that interact with each other to achieve the desired functionality. There are several advantages of using OOP for data science:

Overall, OOP can help data professionals organize and manage their code more effectively, making it easier to develop and maintain data science projects. Let’s dive into the OOPs concept.

1 What are Objects and Classes?

Classes are the blueprint for defining an Object. While an Object is a collection of data/properties and their behaviors/methods.

For example- Think of a class Bulb that will have a state (On/Off) and methods to turnOn and turnoff the bulb.

class Bulb():
    def __init__(self, onOff=False): self.onOff = onOff    
    def turnOn(self): self.onOff = True
    def turnOff(self): self.onOff = False

Now we can create multiple bulb objects from this Bulb class.

b1 = Bulb(onOff=True)
b2 = Bulb()
print(f"Bulb 1 state is :{b1.onOff}, Bulb 2 state is :{b2.onOff}")
Bulb 1 state is :True, Bulb 2 state is :False

b1 and b2 are objects of the class Bulb. Let’s use the turnOn and turnOff methods to update the bulb properties.

b1.turnOff(); b2.turnOn()
print(f"Bulb 1 state is :{b1.onOff}, Bulb 2 state is :{b2.onOff}")
Bulb 1 state is :False, Bulb 2 state is :True

We can see from the example above, a Bulb object contains the onOff property. Properties are variables that contain information regarding the object of a class and Methods like turnOn and turnOff in our Bulb class are functions that have access to the properties of a class. Methods can accept additional parameters, modify properties and return values.

2 Class and Instance variables

In Python, properties can be defined in two ways -

  • Class Variables - Class variables are shared by all objects of the class. A change in the class variable will change the value of that property in all the objects of the class.
  • Instance Variables - Instance variables are unique to each instance or object of the class. A change in instance variable will change the value of the property in that specific object only.
class Employee:
    # Creating a class variable
    companyName = "Microsoft"
    
    def __init__(self, name):
        # creating an instance variable
        self.name = name
    
e1 = Employee('Aayush')
e2 = Employee('John')

print(f'Name :{e1.name}')
print(f'Company Name: {e1.companyName}')
print(f'Name :{e2.name}')
print(f'Company Name: {e2.companyName}')
Name :Aayush
Company Name: Microsoft
Name :John
Company Name: Microsoft

We can see above, the class variable is defined outside of the initializer and the instance variable is defined inside the initializer.

Employee.companyName = "Amazon"
print(e1.companyName, e2.companyName)
Amazon Amazon

We can see above changing a class variable in the Employee class changes the class variable in all objects of the class. Most of the time we will be using instance variables but knowledge about class variables can come in handy. Let’s look at an interesting use of class variable -

class Employee:
    # Creating a class variable
    companyName = "Microsoft"
    companyEmployees = []
    
    def __init__(self, name):
        # creating an instance variable
        self.name = name
        self.companyEmployees.append(self.name)
    
e1 = Employee('Aayush')
e2 = Employee('John')

print(f'Name :{e1.name}')
print(f'Team Members: {e1.companyEmployees}')
print(f'Name :{e2.name}')
print(f'Company Name: {e2.companyEmployees}')
Name :Aayush
Team Members: ['Aayush', 'John']
Name :John
Company Name: ['Aayush', 'John']

We can see above, we are saving all objects of the Employee class in companyEmployees which is a list shared by all objects of the class Employee.

3 Class, Static and Instance methods

In Python classes, we have three types of methods -

  • Class Methods - Class methods work with class variables and are accessible using the class name rather than its object.
  • Static Methods - Static methods are methods that are usually limited to class only and not their objects. They don’t typically modify or access class and instance variables. They are used as utility functions inside the class and we don’t want the inherited class to modify them.
  • Instance Methods - Instance methods are the most used methods and have access to instance variables within the class. They can also take new parameters to perform desired operations.
class Employee:
    # Creating a class variable
    companyName = "Microsoft"
    companyEmployees = []
    
    def __init__(self, name):
        # creating an instance variable
        self.name = name
        self.companyEmployees.append(self.name)
    
    @classmethod
    def getCompanyName(cls): # This is a class method
         return cls.companyName
    
    @staticmethod
    def plusTwo(x): # This is a static method
        return x+2
    
    def getName(self): # This is an instance method
        return self.name
    
e1 = Employee('Aayush')
print(f"Calling class method. Company name is {e1.getCompanyName()}")
print(f"Calling Static method. {e1.plusTwo(2)}")
print(f"Calling instance method. Employee name is {e1.getName()}")
Calling class method. Company name is Microsoft
Calling Static method. 4
Calling instance method. Employee name is Aayush

We can see above we use the @classmethod decorator to define the class method. cls is used to refer to the class just as self is used to refer to the object of the class. The class method at least takes one argument cls.

Note

We can use any other name instead of cls but cls is used as a convention.

We use @staticmethod decorator to define static class plusTwo. We can see that static methods don’t take any argument like self and cls.

The most commonly used methods are instance methods and they can be defined without a decorator within the class. Just like the class method they take at least one argument which is self by convention.

Note

We can use any other name instead of self but self is used as a convention.

4 Access Modifiers

Access modifiers limit access to the variables and functions of a class. There are three types of access modifiers - public, protected, and private.

4.1 Public Attributes

Public attributes are those methods and properties which can be accessed anywhere inside and outside of the class. By default, all the member variables and functions are public.

class Employee:
    def __init__(self, name):
        self.name = name ## Public variable
        
    def getName(self): ## Public method
        return self.name

e1 = Employee("Aayush")
print(f"Employee Name: {e1.getName()}")
Employee Name: Aayush

In the case above, both property name and method getName are public attributes.

4.2 Protected Attributes

Protected attributes are similar to public attributes which can be accessed within the class and also available to subclasses. The only difference is the convention, which is to define each protected member with a single underscore “_”.

class Employee:
    def __init__(self, name, project):
        self.name = name ## Public variable
        self._project = project ## Protected variable
        
    def getName(self): ## Public method
        return self.name
    
    def _getProject(self): ## Protected method
        return self._project
    
e1 = Employee("Aayush", "Project Orland")
print(f"Employee Name: {e1.getName()}")
print(f"Project Name: {e1._getProject()}")
Employee Name: Aayush
Project Name: Project Orland

In the case above, both property _project and method _getProject are protected attributes.

4.3 Private Attributes

Private attributes are accessible within the class but not outside of the class. To define a private attribute, prefix the method or property with the double underscore”_“.

class Employee:
    def __init__(self, name, project, salary):
        self.name = name ## Public variable
        self._project = project ## Protected variable
        self.__salary = salary
        
    def getName(self): ## Public method
        return self.name
    
    def _getProject(self): ## Protected method
        return self._project
    
    def __getSalary(self): ## Protected method
        return self.__salary
    
e1 = Employee("Aayush", "Project Orland", "3500")
print(f"Employee Name: {e1.getName()}")
print(f"Project Name: {e1.__getSalary()}") 
Employee Name: Aayush
AttributeError: 'Employee' object has no attribute '__getSalary'

We can see above, __salary property and __getSalary method are both private attributes and when we call them outside of the class they throw an error that the 'Employee' object has no attribute '__getSalary'.

5 Encapsulation

Encapsulation in OOP refers to binding data and the methods to manipulate that data together in a single unit, that is, class. Encapsulation is usually used to hide the state and representation of the object from the outside. A good use of encapsulation is to make all properties private of a class to prevent direct access from outside and use public methods to let the outside world communicate with the class.

class Employee:
    def __init__(self, name, project, salary):
        self.__name = name ## Public variable
        self.__project = project ## Protected variable
        self.__salary = salary
        
    def getName(self): ## Public method
        return self.__name
    
e1 = Employee("Aayush", "Project Orland", "3500")
print(f"Employee Name: {e1.getName()}")
Employee Name: Aayush

Encapsulation has several advantages -

  • Properties of the class can be hidden from the outside world
  • More control over what the outside world can access from the class

A good example of encapsulation would be an access control class based on username and password.

class Auth:
    def __init__(self, userName=None, password=None):
        self.__userName = userName
        self.__password = password
        
    def login(self, userName, password):
        if (self.__userName == userName) and (self.__password == password):
            print (f"Access granted to {userName}")
        else:
            print("Invalid credentials")
            
e1 = Auth("Aayush", "whatever")
e1.login("Aayush", "whatever") ## This will grant access

e1.login("Aayush", "aasdasd") ## This will say invalid creds
e1.__password ## This will raise an error as private properties can't be accessed from outside.
Access granted to Aayush
Invalid credentials
AttributeError: 'Auth' object has no attribute '__password'

As we can see above __username and __password are protected properties and can only be used by the class to grand or reject access requests.

6 Inheritance

Inheritance provides a way to create new classes from the existing classes. The new class will inherit all the non-private attributes(properties and methods) from the existing class. The new class can be called a child class and the existing class can be called a parent class.

import math
class Shape:
    def __init__(self, name):
        self.name = name
        
    def getArea(self):
        pass
    
    def printDetails(self):
        print(f"This shape is called {self.name} and area is {self.getArea()}.")
        
class Square(Shape):
    def __init__(self, edge):
        ## calling the constructor from parent class Shape
        Shape.__init__(self, name = "Square")
        self.edge = edge
    
    ## Overiding the getArea function
    def getArea(self):
        return self.edge**2
    
class Circle(Shape):
    def __init__(self, radius):
        ## calling the constructor from parent class Shape
        Shape.__init__(self, name = "Circle")
        self.radius = radius
    ## Overiding the getArea function
    def getArea(self):
        return math.pi * (self.radius**2)
    
obj1 = Square(4)
obj1.printDetails()

obj2 = Circle(3)
obj2.printDetails()
This shape is called Square and area is 16.
This shape is called Circle and area is 28.274333882308138.

We can see above we defined a parent class Shape and then we inherited it to create a Square and Circle child class. While defining the Square and Circle class we overwrote the getArea function pertinent to the class but we used the printDetails function from the parent class to print details about child classes. The more common example in the machine learning world would be to create your own models in Pytorch where we inherit from nn.Module class to create a new model.

6.1 Use of super() Function

super() function comes into play when we implement inheritance. The super() function is used to refer to the parent class without explicitly naming the class. super() function can be used to access parent class properties, calling the parent class, and can be used as initializers. Let’s look at the example above and see how we can modify the Square class to use super() function.

class Shape:
    maxArea = 100
    def __init__(self, name): self.name = name
    def getArea(self): pass
    def printDetails(self): 
        print(f"This shape is called {self.name} and area is {self.getArea()}.")
        

class Square(Shape):
    maxArea = 50
    def __init__(self, edge):
        super().__init__(name = "Square") ## Initializing parent class
        self.edge = edge
    
    def getName(self):
        return super().maxArea
    
    def getArea(self):
        return self.edge**2
    
    def printDetails(self):
        super().printDetails() ## Calling a parent class function
        print(f"Max area from Shape class: {super().maxArea}") ## Accessing parent class property
        print(f"Max area from Square class: {self.maxArea}")

obj1 = Square(4)
obj1.getName()
obj1.printDetails()
This shape is called Square and area is 16.
Max area from Shape class: 100
Max area from Square class: 50

As we can see in the example above we have used -

  • super().__init__ to initialize the parent Shape class
  • super().printDetails() function to use a method from parent class
  • super().maxArea to access a property of a parent class

There are many advantages of inheritance -

  1. Reusability - Inheritance makes the code reusable. Common methods and properties can be stored in a parent class and child classes can inherit these methods.
  2. Modification - Code modification becomes easier if we use inheritance, if we want to make a change in the base class function it will be propagated to the child classes.
  3. Extensibility - We can derive new classes from the old ones by keeping things we need in the derived class.

7 Polymorphism

Polymorphism refers to the same object exhibiting different forms and behaviors. For example consider our shape class which could be a square, rectangle, polygon, etc. Instead of writing multiple functions to get the area of these shapes, we can use a common function like getArea() and implement this function in the derived class.

import math
class Shape:
    def __init__(self, name):
        self.name = name
        
    def getArea(self):
        pass
    
    def printDetails(self):
        print(f"This shape is called {self.name} and area is {self.getArea()}.")
        
class Square(Shape):
    def __init__(self, edge):
        ## calling the constructor from parent class Shape
        Shape.__init__(self, name = "Square")
        self.edge = edge
    
    ## Overiding the getArea function
    def getArea(self):
        return self.edge**2
    
class Circle(Shape):
    def __init__(self, radius):
        ## calling the constructor from parent class Shape
        Shape.__init__(self, name = "Circle")
        self.radius = radius
    ## Overiding the getArea function
    def getArea(self):
        return math.pi * (self.radius**2)
    
obj1 = Square(4)
print(f"Area of this {obj1.name} is {obj1.getArea()}")

obj2 = Circle(3)
print(f"Area of this {obj2.name} is {obj2.getArea()}")
Area of this Square is 16
Area of this Circle is 28.274333882308138

As we can see above there is a pre-defined dummy method called getArea in the Shape class. We override this method in the Square and Circle class. This technique is called method overriding. The advantage of method overriding is that the derived class can write its own specific implementation based on the requirement while using the same function name.

7.1 Abstract base classes

Abstract base classes define a set of methods and properties that a class must implement in order to inherit the parent class. This is a useful technique to enforce that certain functions within the derived class must exist. To define an abstract base class, we use the abc module. The abstract base class inherits from the built-in ABC class and we use the decorator @abstractmethod to declare an abstract method.

from abc import ABC, abstractmethod

class Shape(ABC):
    def __init__(self, name):
        self.name = name
    
    @abstractmethod
    def getArea(self):
        pass
    
    def printDetails(self):
        print(f"This shape is called {self.name} and area is {self.getArea()}.")
        
class Square(Shape):
    def __init__(self, edge):
        ## calling the constructor from parent class Shape
        Shape.__init__(self, name = "Square")
        self.edge = edge
    
obj1 = Square(4)
print(f"Area of this {obj1.name} is {obj1.getArea()}")
TypeError: Can't instantiate abstract class Square with abstract methods getArea

We can see above that we have created a Shape class from the ABC class which has an abstract method getArea. Since our child class Square didn’t have getArea implemented we get an error instantiating this class.

from abc import ABC, abstractmethod

class Shape(ABC):
    def __init__(self, name):
        self.name = name
    
    @abstractmethod
    def getArea(self):
        pass
    
    def printDetails(self):
        print(f"This shape is called {self.name} and area is {self.getArea()}.")
        
class Square(Shape):
    def __init__(self, edge):
        ## calling the constructor from parent class Shape
        Shape.__init__(self, name = "Square")
        self.edge = edge
    
    def getArea(self): return self.edge**2
    
obj1 = Square(4)
print(f"Area of this {obj1.name} is {obj1.getArea()}")
Area of this Square is 16

We can see above, once we implemented the getArea method, the code runs fine.

Abstract base classes serve as a blueprint for derived classes to implement methods that are required to run the function appropriately.

8 Conclusion

In this article, we learned about what is object-oriented programming and key concepts using Python. A good understanding of these concepts will lay a solid foundation for any software professional to write and understand python code better.

I hope you enjoyed reading it. If there is any feedback on the code or just the blog post, feel free to comment below or reach out on LinkedIn.