Encapsulation and Inheritance | Object Oriented Programming in Python - Part 2

A Quick Recap:

Before we explore the core principles of Object-Oriented Programming (OOP), let’s quickly revisit the concepts of classes and objects, that we discussed in this post: Classes and Objects | ThePygrammer

In OOP, a class acts as a blueprint for creating objects. It defines the properties (attributes) and behaviors (methods) that the objects of that class will have. An object is simply an instance of a class, meaning it holds the actual data and functionality defined by the class.

Introduction:

Object-Oriented Programming (OOP) isn’t just about creating classes and objects—it’s built on four fundamental principles that help developers write more organized, reusable, and maintainable code. These principles— Encapsulation, Inheritance, Polymorphism, and Abstraction —work together to make complex software systems more manageable by modeling real-world concepts in a logical and structured way. Let’s explore each of these pillars and see how they contribute to writing better code.

Encapsulation

Encapsulation is the concept of bundling data (attributes) and methods (functions) that operate on that data into a single unit, typically a class. It also restricts access to some of an object’s components, meaning that the internal state of an object can be hidden from the outside world, which helps prevent accidental modification of data. In simple words - it is creating a box, containing all the variables and methods that box needs. We can understand this better with an example:

Consider a bank account. What functions and data would the bank account contain or need? Obviously, it would store how much money you have, which would be a variable. You must be able to deposit money into the account and withdraw from it. You should also be able to see how much money you have whenever you want. Can we represent this as a class called BankAccount, containing all these features? Here is how you would go about it: 

class BankAccount:
    def __init__(self, owner, balance=0):
        """Initialize the bank account with the owner's name and an optional initial balance."""
        self.owner = owner  # The account owner's name
        self.balance = balance  # The initial balance, default is 0

    def deposit(self, amount):
        """Deposit a specified amount into the account."""
        if amount > 0:
            self.balance += amount
            print(f"Deposited ${amount}. New balance is ${self.balance}.")
        else:
            print("Deposit amount must be positive.")

    def withdraw(self, amount):
        """Withdraw a specified amount from the account, if sufficient balance exists."""
        if amount > self.balance:
            print(f"Insufficient funds. Your balance is ${self.balance}.")
        elif amount > 0:
            self.balance -= amount
            print(f"Withdrew ${amount}. New balance is ${self.balance}.")
        else:
            print("Withdrawal amount must be positive.")

    def get_balance(self):
        """Display the current balance."""
        print(f"Current balance: ${self.balance}")

# Creating a bank account object for an owner named 'Alice'
account = BankAccount('Alice', 1000)

# Depositing money
account.deposit(500)

# Withdrawing money
account.withdraw(300)

# Trying to withdraw more than the current balance
account.withdraw(1500)

# Checking the balance
account.get_balance()

and our output here would be:

Deposited $500. New balance is $1500.
Withdrew $300. New balance is $1200.
Insufficient funds. Your balance is $1200.
Current balance: $1200

The concept we follow here, where instead of creating multiple random functions, we box them all inside a class - is called Encapsulation.

Inheritance

Inheritance allows a class to inherit attributes and methods from another class. This promotes code reuse, as common functionalities can be defined in a base class and reused in derived classes. Inheritance also makes code easier to maintain and extend. In simple words - it allows us to reuse previously used or created code in other classes. 

Let us consider an example - We have a class called Vehicle, where we have defined make and model of that vehicle. Now, we want to build a car, which also has a make and model, as it is a vehicle, but also has a special attribute called doors. Let us also consider a truck. The truck as well, being a vehicle, has its own make and model, but also has a unique attribute called capacity. Do we need to create the same make and model attributes for car and truck separately? No, because we can say that car and truck are both vehicles and all vehicles have a make and model. How can we show this in code? 

# Base class: Vehicle
class Vehicle:
    def __init__(self, make, model):
        self.make = make
        self.model = model

    def start(self):
        print(f"The {self.make} {self.model} is starting.")

# Derived class: Car
class Car(Vehicle):
    def __init__(self, make, model, doors):
        super().__init__(make, model)  # Inheriting from Vehicle
        self.doors = doors  # Additional attribute for Car

    def start(self):
        print(f"The {self.make} {self.model} car with {self.doors} doors is starting.")

# Derived class: Truck
class Truck(Vehicle):
    def __init__(self, make, model, capacity):
        super().__init__(make, model)  # Inheriting from Vehicle
        self.capacity = capacity  # Additional attribute for Truck

    def start(self):
        print(f"The {self.make} {self.model} truck with a capacity of {self.capacity} tons is starting.")

# Example usage:
vehicle = Vehicle("Generic", "Vehicle")
vehicle.start()  # Output: The Generic Vehicle is starting.

car = Car("Toyota", "Corolla", 4)
car.start()  # Output: The Toyota Corolla car with 4 doors is starting.

truck = Truck("Ford", "F-150", 5)
truck.start()  # Output: The Ford F-150 truck with a capacity of 5 tons is starting.

When we specify a parent class as an attribute to the new class, the new class inherits the features of the parent class. Now, both Car and Truck will have make and model, and also their own unique features - doors, and capacity respectively.

These are two of the four pillars of Object-Oriented Programming. The remaining two pillars will be the feature of the next post, so be sure to catch that. Until then, keep Python-ing and I'll catch you soon.

Comments