Skip to content

Embracing Object-Oriented Programming πŸ¦Έβ€β™€οΈπŸ° ​

Greetings, aspiring heroes! Today, we embark on a journey into the realm of Object-Oriented Programming (OOP). Just as superheroes have unique abilities and identities, OOP allows us to model real-world entities in our code. Let's dive in and discover how classes and objects can empower your Python programs.

The Many Paradigms of Programming 🧭 ​

Question: What are the different styles or paradigms of programming?

  • Procedural Programming: Writing procedures or functions that perform operations on data.
  • Functional Programming: Building programs using pure functions, avoiding shared state.
  • Object-Oriented Programming: Organizing code into objects that combine data and behavior.
  • Mathematical Programming: Using mathematical constructs and logic to solve problems.

Today, we'll focus on Object-Oriented Programming.

Modeling the Supercar: Introducing Classes and Objects πŸš— ​

Imagine you're tasked with designing a fleet of superhero vehicles.

Defining a Car Without OOP ​

python
def car(name, engine, wheels, doors):
    return {"name": name, "engine": engine, "wheels": wheels, "doors": doors}

print(car("Ferrari", "V8", 4, 2))

Output:

python
{'name': 'Ferrari', 'engine': 'V8', 'wheels': 4, 'doors': 2}

Question: What's missing when we represent a car as a simple dictionary?

  • Lack of behavior (methods) associated with the car.
  • No encapsulation of data and functions.

Introducing the Car Class ​

Concept: A class is a blueprint for creating objects. It defines attributes (data) and methods (behavior).

python
class Car:
    def __init__(self, name, engine, wheels, doors):
        self.name = name
        self.engine = engine
        self.wheels = wheels
        self.doors = doors

    def horn(self):
        return f"{self.name} says Vroom, Vroom πŸ“―"
  • __init__: The constructor method called when creating a new object.
  • self: Refers to the instance of the class.

Creating Car Objects (Instances) ​

python
ferrari = Car("Ferrari", "V8", 4, 2)
toyota = Car("Toyota", "V4", 4, 4)

print(toyota.horn())
print(ferrari.horn())

Output:

Toyota says Vroom, Vroom πŸ“―
Ferrari says Vroom, Vroom πŸ“―

Task 1: Building a Super Bank 🏦 ​

Our superheroes need a secure place to manage their finances.

Task 1.1: Create a BankAccount Class ​

Attributes:

  • acc_no: Account number
  • name: Account holder's name
  • balance: Account balance

Task

Define a BankAccount class with the above attributes.

Solution ​

Answer
python
class BankAccount:
    def __init__(self, acc_no, name, balance):
        self.acc_no = acc_no
        self.name = name
        self.balance = balance

Task 1.2: Create Three Bank Account Objects ​

Task

Create three instances of BankAccount for different superheroes.

Solution ​

Answer
python
account1 = BankAccount(101, "Iron Man", 1_000_000)
account2 = BankAccount(102, "Captain America", 500_000)
account3 = BankAccount(103, "Black Widow", 750_000)

Enhancing the Bank with Methods πŸ’³ ​

Our heroes need to perform transactions on their accounts.

Task 2.1: Implement display_balance Method ​

Objective: Display the account balance in a user-friendly format.

Task

Add a method display_balance to BankAccount that returns a string like:

Iron Man, your balance is β‚Ή1,000,000

Solution ​

Answer
python
class BankAccount:
    def __init__(self, acc_no, name, balance):
        self.acc_no = acc_no
        self.name = name
        self.balance = balance

    def display_balance(self):
        return f"{self.name}, your balance is β‚Ή{self.balance:,}"

Task 2.2: Implement withdraw Method ​

Objective: Allow superheroes to withdraw money from their accounts.

Task

Add a method withdraw that subtracts the amount from the balance if sufficient funds are available.

Solution ​

Answer
python
class BankAccount:
    # ... [previous code] ...
    def withdraw(self, amount):
        if amount > self.balance:
            return f"Insufficient funds. {self.display_balance()}"
        self.balance -= amount
        return f"Transaction successful. {self.display_balance()}"

Task 2.3: Implement deposit Method ​

Objective: Allow superheroes to deposit money into their accounts.

Task

Add a method deposit that adds the amount to the balance.

Solution ​

Answer
python
class BankAccount:
    # ... [previous code] ...
    def deposit(self, amount):
        if amount <= 0:
            return "Please provide a valid amount."
        self.balance += amount
        return f"Successfully deposited. {self.display_balance()}"

Encapsulation: Protecting Our Assets πŸ”’ ​

To prevent unauthorized access, we need to secure our bank accounts.

Private Attributes ​

Concept: In Python, attributes prefixed with __ (double underscores) are considered private.

python
class SecureBankAccount:
    def __init__(self, acc_no, name, balance):
        self.acc_no = acc_no
        self.name = name
        self.__balance = balance  # Private attribute

Accessing Private Attributes ​

Question: How can we access or modify private attributes?

  • Through methods that control access (getters and setters).
  • Direct access is discouraged to maintain encapsulation.

Class Variables and Methods πŸ›οΈ ​

Some properties are shared across all accounts, like the bank's interest rate.

Class Variables ​

python
class Bank:
    interest_rate = 0.02  # Class variable
    no_of_accounts = 0

    def __init__(self, acc_no, name, balance):
        self.acc_no = acc_no
        self.name = name
        self.__balance = balance
        Bank.no_of_accounts += 1  # Updating class variable

Class Methods ​

Concept: Methods that operate on the class itself rather than instances.

python
@classmethod
def update_interest_rate(cls, new_rate):
    cls.interest_rate = new_rate

Static Methods ​

Concept: Methods that do not access instance or class variables.

python
@staticmethod
def bank_info():
    return "Welcome to Super Bank!"

Task 3: Applying Interest and Tracking Accounts πŸ“ˆ ​

Task 3.1: Implement apply_interest Method ​

Objective: Apply interest to the account balance.

Task

Add a method apply_interest that increases the balance based on the interest rate.

Solution ​

Answer
python
class BankAccount:
    interest_rate = 0.02
    # ... [previous code] ...
    def apply_interest(self):
        self.balance += self.balance * BankAccount.interest_rate

Task 3.2: Display Total Number of Accounts ​

Objective: Keep track of how many accounts have been created.

Task

Use a class variable no_of_accounts and a method to display the total number of accounts.

Solution ​

Answer
python
class BankAccount:
    no_of_accounts = 0
    # ... [previous code] ...
    def __init__(self, acc_no, name, balance):
        self.acc_no = acc_no
        self.name = name
        self.balance = balance
        BankAccount.no_of_accounts += 1

    @staticmethod
    def display_total_accounts():
        return f"Total accounts in the bank: {BankAccount.no_of_accounts}"

Inheritance: Powering Up with Super Classes πŸ¦Έβ€β™‚οΈ ​

Superheroes often have abilities inherited from others.

Defining a Base Class Animal ​

python
class Animal:
    def __init__(self, name):
        self.name = name

    def speak(self):
        return "Some generic sound"

Creating a Derived Class Dog ​

python
class Dog(Animal):
    def speak(self):
        return "Woof Woof!! πŸ•"

    def run(self):
        return "🐢 wags tail!!"

Using the Classes ​

python
toby = Animal("Toby")
maxy = Dog("Maxy")

print(toby.speak())  # Output: Some generic sound
print(maxy.speak())  # Output: Woof Woof!! πŸ•
print(maxy.run())    # Output: 🐢 wags tail!!

Method Overriding and Super Calls πŸ¦Ήβ€β™‚οΈ ​

Question: What if we want to extend the __init__ method in a subclass?

Extending Initialization ​

python
class Dog(Animal):
    def __init__(self, name, speed):
        super().__init__(name)
        self.speed = speed

    def speak(self):
        return "Woof Woof!! πŸ•"

    def run(self):
        return f"🐢 runs at {self.speed} km/h!"

Tip: Use super().__init__(...) to call the parent class's constructor.

Task 4: Specialized Bank Accounts πŸ’Ό ​

Our superheroes need different types of accounts.

Task: Create SavingsAccount and CheckingAccount ​

  • SavingsAccount: Higher interest rate (e.g., 5%).
  • CheckingAccount: Transaction fee for withdrawals.

Task

Create subclasses SavingsAccount and CheckingAccount with specific behaviors.

Solution 1: Overriding Class Variables ​

Answer
python
class SavingsAccount(BankAccount):
    interest_rate = 0.05  # Overriding the class variable

    def apply_interest(self):
        self.balance += self.balance * SavingsAccount.interest_rate

class CheckingAccount(BankAccount):
    transaction_fee = 10

    def withdraw(self, amount):
        total_amount = amount + CheckingAccount.transaction_fee
        return super().withdraw(total_amount)

Explanation:

  • In SavingsAccount, we override the interest_rate class variable and adjust the apply_interest method accordingly.
  • In CheckingAccount, we override the withdraw method to include a transaction fee.

Solution 2: Using Initialization Parameters ​

Answer
python
class SavingsAccount(BankAccount):
    def __init__(self, acc_no, name, balance, interest_rate=0.05):
        super().__init__(acc_no, name, balance)
        self.interest_rate = interest_rate

    def apply_interest(self):
        self.balance += self.balance * self.interest_rate

class CheckingAccount(BankAccount):
    def __init__(self, acc_no, name, balance, transaction_fee=10):
        super().__init__(acc_no, name, balance)
        self.transaction_fee = transaction_fee

    def withdraw(self, amount):
        total_amount = amount + self.transaction_fee
        return super().withdraw(total_amount)

Explanation:

  • We introduce instance variables self.interest_rate and self.transaction_fee that can be set during initialization.
  • This allows for more flexibility if different accounts have different rates or fees.

Solution 3: Implementing Additional Methods ​

Answer
python
class SavingsAccount(BankAccount):
    interest_rate = 0.05

    def apply_interest(self):
        super().apply_interest()  # Uses the parent method, but with overridden interest_rate

    def add_bonus(self, bonus_amount):
        self.balance += bonus_amount
        return f"Bonus added! {self.display_balance()}"

class CheckingAccount(BankAccount):
    transaction_fee = 10

    def withdraw(self, amount):
        total_amount = amount + CheckingAccount.transaction_fee
        return super().withdraw(total_amount)

    def order_checks(self):
        fee = 50  # Fixed fee for ordering checks
        if self.balance >= fee:
            self.balance -= fee
            return f"Checks ordered successfully. {self.display_balance()}"
        else:
            return f"Insufficient funds to order checks. {self.display_balance()}"

Explanation:

  • In SavingsAccount, we use super().apply_interest() to utilize the parent's method but still benefit from the overridden interest_rate.
  • We add an add_bonus method to showcase adding new functionality.
  • In CheckingAccount, we introduce an order_checks method to demonstrate additional behaviors specific to checking accounts.

Using the Specialized Accounts ​

python
# Creating instances
savings = SavingsAccount(201, "Thor", 200_000)
checking = CheckingAccount(202, "Loki", 150_000)

# Applying interest
savings.apply_interest()
print(savings.display_balance())  # Should reflect the higher interest rate

# Withdrawing with transaction fee
print(checking.withdraw(50_000))  # Should deduct amount plus transaction fee

# Ordering checks
print(checking.order_checks())

Sample Output:

Thor, your balance is β‚Ή210,000.0
Transaction successful. Loki, your balance is β‚Ή99,990
Checks ordered successfully. Loki, your balance is β‚Ή99,940

Magic Methods: Adding Special Powers ✨ ​

Magic methods allow objects to interact with Python's built-in functions.

The __str__ and __repr__ Methods ​

Question: How can we define how our objects are printed?

python
class BankAccount:
    # ... [previous code] ...
    def __str__(self):
        return f"Account({self.acc_no}): {self.name} with balance β‚Ή{self.balance:,}"

    def __repr__(self):
        return f"BankAccount({self.acc_no}, '{self.name}', {self.balance})"

The __add__ Method ​

Question: Can we define custom behavior for the + operator?

python
def __add__(self, other):
    if isinstance(other, BankAccount):
        return self.balance + other.balance
    return NotImplemented

Example:

python
total_balance = account1 + account2
print(f"Total balance: β‚Ή{total_balance:,}")

Pitfalls and Best Practices 🚧 ​

Accessing Private Attributes ​

Pitfall

Attempting to access private attributes directly can lead to errors.

python
print(account1.__balance)  # AttributeError

Solution: Use methods to access private data.

Method Overriding ​

Pitfall

Forgetting to call super().__init__() in a subclass can result in missing initializations.

python
class Dog(Animal):
    def __init__(self, name, speed):
        self.name = name  # Missing super().__init__(name)
        self.speed = speed

Solution: Always call super().__init__() when overriding __init__.

Conclusion πŸŽ‰ ​

By embracing Object-Oriented Programming, you've unlocked the ability to model complex systems in a way that mirrors the real world. Classes and objects allow you to encapsulate data and behavior, promote code reuse through inheritance, and create more maintainable and scalable programs.

Farewell, Aspiring Hero! πŸ‘‹ ​

Your journey into the realm of OOP has added powerful tools to your coding arsenal. Continue to explore and practice these concepts, and you'll be well on your way to becoming a Python superhero!