Object-Oriented Programming with Python (OOP)
Tags: python, oop
Published: 2024-03-03 15:31:05

---
title: Object-Oriented Programming with Python (OOP)
published: true
description: 
tags: python,oop
# cover_image: https://direct_url_to_image.jpg
# Use a ratio of 100:42 for best results.
# published_at: 2024-03-03 13:01 +0000
---

In this article I'll show how we can work with python using (OOP) Object-Oriented Programming. 

But what is OOP? 

OOP is a paradigm that uses objects and classes to structure software applications. It enables developers to create modular, reusable code, and model real-world scenarios more intuitively.

When you work with OOP we have four concepts: Encapsulation, Abstraction, Inheritance and Polymorphism.

*Encapsulation* is the mechanism of hiding the internal state of an object and requiring all interaction to be performed through an object's methods.

To illustrate the concept of Encapsulation, let's consider a simple example of a class that models a bank account. 

Encapsulation is achieved by making the account balance private (not directly accessible from outside the class) and providing methods to deposit and withdraw money, ensuring that the balance cannot be directly changed from outside the class and is only modified through these controlled operations.

```python
class Bank:
    def __init__(self, initial_balance=0):
        self.__balance = initial_balance  # Private attribute

    def deposit(self, amount):
        if amount > 0:
            self.__balance += amount
            print(f"${amount} deposited. New balance: ${self.__balance}.")
        else:
            print("Deposit amount must be positive.")

    def withdraw(self, amount):
        if 0 < amount <= self.__balance:
            self.__balance -= amount
            print(f"${amount} withdrawn. New balance: ${self.__balance}.")
        else:
            print("Insufficient balance or invalid withdrawal amount.")

    def get_balance(self):
        return self.__balance
```

If you try to access the private attribute directly will raise an AttributeError:

```python
from bank import Bank

account = Bank(1000)
print(account.__balance)
```

*Output*

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/d2ke28dnacf5amjr2yd2.png)

This example showed a simple example with encapsulation.

*Abstraction* involves hiding the complex reality while exposing only the necessary parts. It is a concept aimed at reducing complexity and allowing the programmer to focus on interactions at a higher level. 

To illustrate the concept of Abstraction using the Bank class example, let's refine our class to separate the complexity of managing account transactions from the user interface.

Abstraction will allows us to hide the detailed implementation of how transactions are processed and focus on the interaction with the bank account at a higher level.

In this example, we'll add a method to handle checking for overdrafts, a feature that encapsulates the complexity of managing an overdraft protection mechanism but exposes a simple interface for deposit and withdrawal operations.

```python
class Bank:
    def __init__(self, initial_balance=0):
        self.__balance = initial_balance
        self.__overdraft_limit = -500  # Private attribute to manage overdrafts

    def deposit(self, amount):
        if amount > 0:
            self.__balance += amount
            print(f"${amount} deposited. New balance: ${self.__balance}.")
        else:
            print("Deposit amount must be positive.")

    def withdraw(self, amount):
        if amount <= 0:
            print("Withdrawal amount must be positive.")
            return

        if self.__check_overdraft(amount):
            self.__balance -= amount
            print(f"${amount} withdrawn. New balance: ${self.__balance}.")
        else:
            print("Withdrawal denied. Overdraft limit reached.")

    def get_balance(self):
        return self.__balance

    def __check_overdraft(self, amount):
        """Private method to check if withdrawal exceeds overdraft limit."""
        return (self.__balance - amount) >= self.__overdraft_limit
```

To test this class:

```python
from bank import Bank

account = Bank(100)
account.deposit(200)
account.withdraw(250)  # Within overdraft limit
account.withdraw(500)  # Exceeds overdraft limit
print(f"Current balance: ${account.get_balance()}.")

#Output
$200 deposited. New balance: $300.
$250 withdrawn. New balance: $50.
$500 withdrawn. New balance: $-450.
Current balance: $-450.
```

In this example, __check_overdraft is a method used to encapsulate the complexity of checking whether a withdrawal would exceed the overdraft limit.

This method is an abstraction that hides the specifics of how overdraft checking is performed from the user, who simply needs to call withdraw without worrying about the internal overdraft rules.

*Inheritance* is a mechanism that allows a class to inherit properties and methods from another class. The class that inherits the properties and methods is known as the child or subclass, and the class from which properties and methods are inherited is known as the parent or superclass. 

To demonstrate the concept of Inheritance, suppose we want to introduce a Savings that inherits from Bank but also earns interest over time. This subclass will inherit the properties and methods of Bank (such as depositing, withdrawing, and checking balance) and add its own functionality for interest calculation.

```python
class Bank:
    def __init__(self, initial_balance=0):
        self.__balance = initial_balance

    def deposit(self, amount):
        if amount > 0:
            self.__balance += amount
            print(f"${amount} deposited. New balance: ${self.__balance}.")
        else:
            print("Deposit amount must be positive.")

    def withdraw(self, amount):
        if 0 < amount <= self.__balance:
            self.__balance -= amount
            print(f"${amount} withdrawn. New balance: ${self.__balance}.")
        else:
            print("Insufficient balance or invalid withdrawal amount.")

    def get_balance(self):
        return self.__balance

class SavingsAccount(Bank):
    def __init__(self, initial_balance=0, interest_rate=0.02):
        super().__init__(initial_balance)  # Initialize superclass part of the object
        self.interest_rate = interest_rate  # Additional attribute for SavingsAccount

    def add_interest(self):
        interest_amount = self.get_balance() * self.interest_rate
        self.deposit(interest_amount)
        print(f"Interest added: ${interest_amount}. New balance: ${self.get_balance()}.")
```
To test:

```python
from bank import SavingsAccount

savings_account = SavingsAccount(1000, 0.05)

# Using inherited methods
savings_account.deposit(500)
savings_account.withdraw(200)

# Using the new method specific to SavingsAccount
savings_account.add_interest()

```

#output

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3jcud9u8ib3o0880x12x.png)

The SavingsAccount class inherits from Bank class, which means it automatically has the deposit, withdraw, and get_balance methods.

The SavingsAccount constructor calls the superclass constructor (super().__init__(initial_balance)) to initialize the inherited part of the object and then initializes its own interest_rate attribute.

A new method add_interest is added to SavingsAccount, demonstrating the subclass's ability to extend the functionality of the superclass. This method calculates interest based on the current balance and the account's interest rate, then deposits the interest into the account.

The last concept is Polymorphism.

*Polymorphism* meaning "many shapes," refers to the ability of different objects to respond, each in its own way, to the same message (or method call).

To complete our exploration of the four pillars of OOP, lets undestand whats is Polymorphism.

We'll extend the Bank and SavingsAccount classes to showcase how different classes can be treated uniformly while still executing their specific implementations of a method. 

Polymorphism allows methods to do different things based on the object it is acting upon, even if they share the same interface.

In this context, let's introduce a method called end_of_month_process that behaves differently in Bank and SavingsAccount. 

For a regular Bank, this might simply print a statement balance, while for a SavingsAccount, it could add interest to the account before printing the balance.

Update your classes Bank and SavingsAccount with this methods below:

```python
#Bank
def end_of_month_process(self):
        print(f"Monthly statement: Current balance is ${self.get_balance()}.")

#SavingsAccount
def end_of_month_process(self):
        self.add_interest()
        print(f"Monthly statement with interest: Current balance is ${self.get_balance()}.")
```

To test:

```python
# Demonstrating polymorphism
accounts = [Bank(1000), SavingsAccount(1000, 0.05)]

for account in accounts:
    account.end_of_month_process()
    print("---")
```

#output

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/otg98dukqgew7ok0o2x1.png)

In this example, both Bank and SavingsAccount have an end_of_month_process method. When called on an instance of BankAccount, it simply prints the current balance. When called on an instance of SavingsAccount, it first adds interest to the account, then prints the balance including the interest.

This example completed our exploration of the four concepts of OOP with python.