Programming with PythonUnit 712 min read

OOP in Python: Classes, Objects, Inheritance, Polymorphism & Encapsulation

Unit 7 of Programming with Python covers object-oriented programming (OOP) principles in Python, including classes, objects, inheritance, polymorphism, encapsulation, and abstraction, with real-world applications and exam-focused examples.

TAKEAWAYS:

  • Understand classes and objects as blueprints and instances, and how to define them using class and __init__ methods.
  • Master inheritance (single, multilevel, multiple, hierarchical) to reuse and extend code efficiently.
  • Apply polymorphism (method overriding and operator overloading) to write flexible and dynamic code.
  • Use encapsulation (private/public attributes, getters/setters) to protect data integrity.
  • Learn abstraction via abstract classes and interfaces to design modular and maintainable systems.
  • Trace execution of OOP concepts with visuals and code examples to solve exam problems confidently.

Classes and Objects: The Building Blocks of OOP

Object-Oriented Programming (OOP) is a paradigm that organizes code into objects (instances of classes) that contain data (attributes) and behavior (methods). Python supports OOP natively, making it easier to model real-world entities.

120005001
State after `account1.deposit(200)`: Alice's balance updates from ₹1000 to ₹1200 (Bob's balance remains ₹500)
100005001
Initial state: Alice's balance = ₹1000, Bob's balance = ₹500

Definitions:

  • Class: A blueprint for creating objects. It defines attributes (data) and methods (functions) that the objects will have.
  • Object: An instance of a class. Objects are concrete entities created from a class.
  • Attribute: Variables that belong to a class or object (e.g., name, age).
  • Method: Functions defined inside a class that operate on the object’s data.

Syntax:

class ClassName:
    # Class attributes (shared by all objects)
    class_attribute = value

    def __init__(self, param1, param2):
        # Instance attributes (unique to each object)
        self.instance_attribute1 = param1
        self.instance_attribute2 = param2

    def method_name(self):
        # Method definition
        return result

Example: Modeling a BankAccount

class BankAccount:
    # Class attribute
    bank_name = "Global Bank"

    def __init__(self, account_holder, balance=0):
        self.account_holder = account_holder
        self.balance = balance

    def deposit(self, amount):
        self.balance += amount
        return f"Deposited ${amount}. New balance: ${self.balance}"

    def withdraw(self, amount):
        if amount > self.balance:
            return "Insufficient funds!"
        self.balance -= amount
        return f"Withdrew ${amount}. New balance: ${self.balance}"

Visualizing Object Creation:

classDiagram
    class BankAccount {
        -bank_name: str
        -account_holder: str
        -balance: float
        +deposit(amount: float): None
        +withdraw(amount: float): None
    }
    class Customer {
        -name: str
        -account: BankAccount
        +create_account(): None
    }
    BankAccount "1" o-- "1" Customer : "has"
    note for Customer "Customer owns one BankAccount"

Worked Example: Creating Objects

# Create two objects of BankAccount
account1 = BankAccount("Alice", 1000)
account2 = BankAccount("Bob", 500)

# Call methods
print(account1.deposit(200))  # Deposited $200. New balance: $1200
print(account2.withdraw(100)) # Withdrew $100. New balance: $400

State After Each Operation:

Operation account1 (Alice) account2 (Bob)
Initialization balance=1000 balance=500
account1.deposit(200) balance=1200 balance=500
account2.withdraw(100) balance=1200 balance=400

Inheritance: Reusing and Extending Code

Inheritance allows a child class (subclass) to inherit attributes and methods from a parent class (superclass). This promotes code reusability and hierarchical classification.

BankAccountSavingsAccountCurrentAccount
Inheritance hierarchy: SavingsAccount and CurrentAccount inherit from BankAccount

Types of Inheritance:

  1. Single Inheritance: A child class inherits from one parent.
  2. Multilevel Inheritance: A child class inherits from another child class (grandchild).
  3. Multiple Inheritance: A child class inherits from multiple parents.
  4. Hierarchical Inheritance: Multiple child classes inherit from one parent.

Syntax:

class ParentClass:
    def parent_method(self):
        pass

class ChildClass(ParentClass):
    def child_method(self):
        pass

Example: Modeling SavingsAccount Inheriting from BankAccount

class SavingsAccount(BankAccount):
    def __init__(self, account_holder, balance=0, interest_rate=0.05):
        super().__init__(account_holder, balance)
        self.interest_rate = interest_rate

    def add_interest(self):
        interest = self.balance * self.interest_rate
        self.balance += interest
        return f"Added ${interest:.2f} interest. New balance: ${self.balance:.2f}"

Visualizing Inheritance Hierarchy:

classDiagram
    class BankAccount {
        +deposit(amount): str
        +withdraw(amount): str
    }
    class SavingsAccount {
        +interest_rate: float
        +add_interest(): str
    }
    BankAccount <|-- SavingsAccount : "is-a"

Worked Example: Using Inheritance

savings_account = SavingsAccount("Charlie", 2000)
print(savings_account.add_interest())  # Added $100.00 interest. New balance: $2100.00

State After add_interest():

Attribute Value Before Value After
balance $2000.00 $2100.00
interest_rate 0.05 0.05

Polymorphism: One Interface, Many Forms

Polymorphism allows methods to behave differently based on the object calling them. It includes:

  1. Method Overriding: Redefining a parent class method in a child class.
  2. Operator Overloading: Redefining operators (+, -, etc.) for custom behavior.

Example: Overriding withdraw() in SavingsAccount

class SavingsAccount(BankAccount):
    def withdraw(self, amount):
        if amount > self.balance * 0.9:  # Allow only 90% withdrawal
            return "Cannot withdraw more than 90% of balance!"
        return super().withdraw(amount)

Worked Example: Polymorphic Behavior

account = SavingsAccount("Dave", 1000)
print(account.withdraw(950))  # Cannot withdraw more than 90% of balance!

Operator Overloading Example: Custom + for BankAccount

class BankAccount:
    def __init__(self, balance):
        self.balance = balance

    def __add__(self, other):
        return BankAccount(self.balance + other.balance)

account1 = BankAccount(500)
account2 = BankAccount(300)
combined = account1 + account2
print(combined.balance)  # 800

Encapsulation: Hiding Data and Protecting Integrity

Encapsulation restricts direct access to some attributes/methods to prevent unintended interference. Use:

  • Private attributes: Prefix with _ (convention) or __ (name mangling).
  • Getters/Setters: Control access to attributes.

Example: Encapsulating balance in BankAccount

class BankAccount:
    def __init__(self, balance):
        self.__balance = balance  # Private attribute

    def get_balance(self):
        return self.__balance

    def set_balance(self, amount):
        if amount >= 0:
            self.__balance = amount
        else:
            raise ValueError("Balance cannot be negative!")

Worked Example: Using Getters/Setters

account = BankAccount(1000)
print(account.get_balance())  # 1000
account.set_balance(1500)
print(account.get_balance())  # 1500
account.set_balance(-100)     # Raises ValueError

Abstraction: Simplifying Complexity

Abstraction hides complex implementation details and exposes only essential features. Use:

  • Abstract Classes: Define methods that must be implemented by child classes.
  • Interfaces: Pure abstract classes (Python uses ABC module).

Example: Abstract Shape Class

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        pass

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

    def area(self):
        return 3.14 * self.radius ** 2

Worked Example: Using Abstraction

circle = Circle(5)
print(circle.area())  # 78.5

In the Real World

  1. eSewa (Nepal):

    • Uses classes and objects to model users, transactions, and payment gateways. For example, a User class might have attributes like user_id, balance, and methods like make_payment().
    • Inheritance is used to extend functionality for premium users (e.g., PremiumUser inheriting from User with additional features like priority support).
  2. Khalti (Nepal):

    • Implements encapsulation to protect sensitive data like transaction IDs and user passwords. Getters/setters ensure controlled access to these attributes.
    • Polymorphism is used in payment processing, where different payment methods (e.g., CreditCardPayment, MobilePayment) override a common process_payment() method.
  3. Pathao (Nepal):

    • Uses object-oriented design to model drivers, riders, and routes. For example, a Route class might have methods like calculate_distance() and estimate_fare(), while a Driver class inherits from a User class and adds attributes like vehicle_type.
    • Abstraction simplifies the ride-booking process by hiding complex logic (e.g., GPS routing, fare calculation) behind high-level methods like book_ride().

Comparison Table: OOP Concepts

Concept Definition Example Use Case Advantages
Class Blueprint for objects BankAccount, User Code organization, reusability
Inheritance Child class inherits from parent SavingsAccount inherits from BankAccount Avoids code duplication, extends functionality
Polymorphism Same method, different behavior Overriding withdraw() in SavingsAccount Flexible and dynamic code
Encapsulation Hiding data with accessors Private __balance with getters/setters Data protection, controlled modifications
Abstraction Hiding complexity Abstract Shape class Simplifies design, focuses on essentials

Common Pitfalls and Best Practices

  1. Avoid Overusing Inheritance:

    • Prefer composition (using objects as attributes) over deep inheritance hierarchies.
    • Example: Instead of SavingsAccount inheriting from BankAccount, compose SavingsAccount with a BankAccount object.
  2. Use Getters/Setters Judiciously:

    • Overusing them can lead to verbose code. Only use them when validation or additional logic is needed.
  3. Leverage Python’s Dynamic Nature:

    • Python allows modifying classes and objects at runtime, but use this feature sparingly for clarity.
  4. Document Your Classes:

    • Use docstrings to explain the purpose, attributes, and methods of your classes.

Exam Tip

  1. Understand the Syntax:

    • Memorize keywords like class, def, self, super(), and __init__. Exams often test syntax correctness.
  2. Trace Execution:

    • For questions involving method calls or inheritance, trace the flow step-by-step. Draw diagrams (like the ones above) to visualize object states.
  3. Practical Applications:

    • Expect questions that ask you to model real-world scenarios (e.g., a library system, bank transactions) using OOP principles. Practice designing classes for such scenarios.
  4. Common Exam Questions:

    • Define a class with given attributes and methods.
    • Extend a class using inheritance and override methods.
    • Explain polymorphism with an example of method overriding or operator overloading.
    • Implement encapsulation using private attributes and accessors.
    • Debug code involving OOP concepts (e.g., incorrect use of self or super()).
  5. Code Tracing:

    • For trace questions, create a table showing the state of objects after each operation (like the examples above). This is a high-scoring technique in exams.

Based on the TU BITM syllabus for Programming with Python (IT243), unit 7.

Discussion

Loading…