Encapsulation & Polymorphism in Python






Encapsulation and polymorphism are two fundamental concepts in object-oriented programming that enhance the structure, flexibility, and security of Python programs. Encapsulation encapsulates data and methods within a class, providing data hiding and abstraction. WHile on the other hand Polymorphism allows objects of different classes to be treated interchangeably, promoting code reusability and extensibility.

In this article, we will delve into the concepts of encapsulation and polymorphism in Python, understand their significance, and explore how they can be leveraged to create robust and flexible object-oriented programs.



Encapsulation :

Encapsulation is a principle of object-oriented programming that bundles data and methods together within a class, hiding the internal implementation details and exposing only necessary interfaces. It allows for data abstraction, ensuring that the internal state of an object is protected and accessed only through defined methods.

Example:

              
                
                  class BankAccount:
                    def __init__(self, account_number, balance):
                        self._account_number = account_number
                        self._balance = balance
                    
                    def deposit(self, amount):
                        self._balance += amount
                    
                    def withdraw(self, amount):
                        if amount <= self._balance:
                            self._balance -= amount
                        else:
                            print("Insufficient balance")
                    
                    def get_balance(self):
                        return self._balance

                  # Creating an instance of the BankAccount class
                  account = BankAccount("1234567890", 1000)

                  # Accessing and modifying account balance through defined methods
                  account.deposit(500)
                  account.withdraw(200)
                  balance = account.get_balance()
                  print(balance)  # Output: 1300
                
              
            

In this example, the 'BankAccount' class encapsulates the account number and balance data. The data is hidden and can only be accessed or modified through the defined methods ('deposit()', 'withdraw()', 'get_balance()').



Benefits of Encapsulation :

Let's explore some commonly used operator overloading methods and their corresponding operators:

  • Data Hiding : Encapsulation hides the internal state of an object, preventing direct access and manipulation of data from outside the class. This protects data integrity and enhances security.
  • Abstraction : Encapsulation provides abstraction by exposing only essential interfaces to interact with an object. Users of the class can focus on how to use the class without worrying about the underlying implementation.
  • Modularity and Maintainability : Encapsulation promotes modularity by grouping related data and methods within a class. This enhances code organization and maintainability, as changes to the internal implementation can be isolated within the class.
  • Code Reusability : Encapsulation allows for code reuse, as objects encapsulating data and behavior can be instantiated and used in different parts of the program.



Polymorphism :

Polymorphism is a concept that allows objects of different classes to be treated interchangeably, providing a consistent interface. It enables code to be written in a generic manner, operating on objects based on their common behavior rather than specific types. Polymorphism promotes code reusability, extensibility, and flexibility.

Example:

              
                
                  class Shape:
                    def area(self):
                        pass

                  class Rectangle(Shape):
                    def __init__(self, width, height):
                        self.width = width
                        self.height = height
                    
                    def area(self):
                        return self.width * self.height

                  class Circle(Shape):
                    def __init__(self, radius):
                        self.radius = radius
                    
                    def area(self):
                        return 3.14 * self.radius**2

                  # Creating instances of different shape classes
                  rectangle = Rectangle(5, 3)
                  circle = Circle(2)

                  # Calculating areas using polymorphism
                  shapes = [rectangle, circle]
                  for shape in shapes:
                    print(shape.area())
                
              
            

In this example, the 'Shape' class defines a common interface with the 'area()' method. The 'Rectangle' and 'Circle' classes inherit from 'Shape' and provide their own implementations of the 'area()' method. Through polymorphism, we can calculate the areas of different shapes using a generic approach.

Polymorphism can be achieved through two main mechanisms: Static Polymorphism and Dynamic Polymorphism. These two types of polymorphism provide different approaches to achieving flexibility and code reuse in Python programs



Types of Polymorphism :

  • Duck Typing : Duck typing is a form of polymorphism that focuses on the behaviour of an object rather than its type. It allows objects to be treated interchangeably if they support a common set of methods or attributes.
  • Method Overriding : Method overriding allows a subclass to provide its implementation for a method defined in the superclass. The method in the subclass overrides the behaviour of the same-named method in the superclass.
  • Function Overloading : Function overloading refers to defining multiple functions with the same name but different parameter lists. The appropriate function is called based on the number and type of arguments passed.
  • Operator Overloading : Operator overloading enables objects to define their behaviour for built-in operators (+, -, *, etc.). It allows objects to use operators in a way that makes sense for their specific class.


Benefits of Polymorphism :

  • Code Reusability : Polymorphism promotes code reusability by allowing objects of different classes to be used interchangeably. This flexibility avoids the need for redundant code and simplifies program design.
  • Extensibility : Polymorphism enables easy extension of existing code by adding new classes that conform to the same interface. New functionality can be incorporated without modifying existing code.
  • Maintainability : Polymorphism improves code maintainability by reducing code duplication and allowing changes to be localized to specific classes or methods.


Static Polymorphism (Compile-time Polymorphism) :

Static polymorphism, also known as compile-time polymorphism, refers to polymorphic behaviour that is determined at compile time. This type of polymorphism is achieved through function overloading.

  • Function Overloading :

    Function overloading allows a class to define multiple methods with the same name but different parameter lists. The appropriate method to be called is determined based on the number and type of arguments passed to the method during compile-time.

    Example:

                      
                        
                          class MathOperations:
                            def add(self, x, y):
                                return x + y
    
                            def add(self, x, y, z):
                                return x + y + z
    
                          # Creating an instance of the MathOperations class
                          math_op = MathOperations()
    
                          print(math_op.add(2, 3))       # Output: TypeError: add() missing 1 required positional argument: 'z'
                          print(math_op.add(2, 3, 4))    # Output: 9
                        
                      
                    

    In this example, the 'MathOperations' class attempts to perform method overloading for the 'add()' method. However, Python does not support true method overloading like some other languages (e.g., Java). The last definition of 'add()' overwrites the previous one, leading to a TypeError when calling the method with two arguments.



Dynamic Polymorphism (Runtime Polymorphism) :

Dynamic polymorphism, also known as runtime polymorphism, refers to polymorphic behaviour that is determined at runtime. This type of polymorphism is achieved through function overriding and duck typing.:

  • Function Overriding : Function overriding allows a subclass to provide its implementation for a function that is already defined in its superclass. The function in the subclass overrides the behaviour of the same-named function in the superclass.

    Example:
                      
                        
                          class Animal:
                            def make_sound(self):
                                return "Generic sound"
    
                          class Dog(Animal):
                            def make_sound(self):
                                return "Bark"
    
                          class Cat(Animal):
                            def make_sound(self):
                                return "Meow"
    
                          # Creating instances of the Dog and Cat classes
                          dog = Dog()
                          cat = Cat()
    
                          print(dog.make_sound())   # Output: Bark
                          print(cat.make_sound())   # Output: Meow                
                        
                      
                    

    In this example, the 'Dog' and 'Cat' classes override the 'make_sound()' method from the 'Animal' class. When calling the method on the instances of 'Dog' and 'Cat', the overridden methods in the subclasses are invoked, demonstrating runtime polymorphism.

  • Duck Typing : Duck typing is a form of dynamic polymorphism that focuses on an object's behavior rather than its type. If an object supports a specific set of methods or attributes, it can be treated as if it belongs to a particular class or type.

    Example:
                      
                        
                          class Duck:
                            def quack(self):
                                return "Quack"
                      
                          class Person:
                            def quack(self):
                                return "I can't quack, but I can imitate one!"
    
                          def make_duck_quack(duck_or_person):
                            return duck_or_person.quack()
    
                          # Creating instances of the Duck and Person classes
                          duck = Duck()
                          person = Person()
    
                          print(make_duck_quack(duck))      # Output: Quack
                          print(make_duck_quack(person))    # Output: I can't quack, but I can imitate one!                
                        
                      
                    

    In this example, the 'make_duck_quack()' function takes either a 'Duck' object or a 'Person' object. As long as the input object has a 'quack()' method, it can be treated as a "duck" for the purpose of calling the function. This demonstrates dynamic polymorphism through duck typing.