§8  OOP in Python
Python Programming Series  ·  Article 8

Object-Oriented Programming in Python: A Complete Guide

Before OOP, code is usually written procedurally: one long list of instructions, with data passed from function to function. That works fine for small scripts, but it gets hard to manage as a program grows. Object-Oriented Programming groups related data and the functions that act on it into a single unit called an object, which makes code easier to read, reuse, and extend.

Classes & objects
All 5 types of inheritance
Polymorphism & duck typing
Encapsulation & abstraction

Procedural vs. OOP, Side by Side

python
# Procedural way
def deposit_money(account_name, balance, amount):
    balance += amount
    return balance

# OOP way: the action belongs to the object
account.deposit(500)
Aspect Procedural Object-Oriented
Focus Functions Objects and their interactions
Data Passed freely between functions Bundled inside the object
Code reuse Copy-paste Inheritance
Modeling the real world Awkward Natural

In the OOP version, the account knows how to deposit into itself. The data (the balance) and the behavior (the deposit action) live in the same place, which is the core idea behind everything else in this guide.

The Four Pillars of OOP

  1. Encapsulation: bundle data and methods together, and control who can access the data.
  2. Inheritance: build new classes by extending existing ones.
  3. Polymorphism: the same method name behaves differently depending on the object.
  4. Abstraction: expose only what's necessary, hide the complexity.
ENCAPSULATION Bundle data + control access ATM hides the internal wiring INHERITANCE Reuse + extend existing classes SavingsAccount is also an Account POLYMORPHISM Same name, different behavior .speak() → Meow, Woof, or Quack ABSTRACTION Define WHAT, hide HOW Brake pedal hides the hydraulics
The four pillars, at a glance

Big tech runs on exactly these ideas: every Google search, Gmail message, and YouTube video is an object with its own attributes and methods. Every Amazon product, order, and shipment is an object interacting with others. An Uber Ride object ties together a Driver, a Rider, a Car, and a Route. Netflix uses one base class to power Movie, Series, and Documentary objects that all stream the same way.

We'll use a banking application as the running example throughout.


1. Classes and Objects

A class is a blueprint that defines what an entity looks like and what it can do. An object is a specific instance built from that blueprint.

Analogy: the blueprint of a house is the class. It describes the rooms, doors, and windows, but you can't live in a blueprint. Your actual house, built from that blueprint with its own address and paint color, is the object. From one blueprint you can build many houses; from one class you can create many objects.

A few things worth internalizing: - A class defines both the structure (what data it holds) and the behavior (what it can do). - Each object gets its own copy of the data, with its own values. - All objects of the same class share the same methods. - Defining a class uses no memory by itself; memory is only used once you actually create objects from it.

__init__: The Constructor

__init__ is a special method Python calls automatically the moment you create an object. It sets up the object's starting data.

python
acc1 = Account('Rishabh', 101, 25000)
# Python automatically calls Account.__init__(acc1, 'Rishabh', 101, 25000)

self: The Object Referring to Itself

self is how a method (a function defined inside a class) refers to the specific object it's being called on. When you write acc1.deposit(500), Python translates that internally to Account.deposit(acc1, 500), passing the object itself as the first argument automatically. That's why every method inside a class must list self as its first parameter. Think of self as the word "I" in a sentence: it's how the object refers to its own data.

python
class Account:

    def __init__(self, name, accountnumber, balance):  # constructor
        self.name = name           # attribute: belongs to THIS object
        self.acc_no = accountnumber
        self.bal = balance
python
# Creating three different objects from the same class
acc1 = Account('Rishabh', 101, 25000)
acc2 = Account('Priya', 102, 67548)
acc3 = Account('Roshan', 103, 76000)

print(acc1.bal, acc1.name, acc1.acc_no)
print(acc2.bal, acc2.name, acc2.acc_no)
print(acc3.bal, acc3.name, acc3.acc_no)
25000 Rishabh 101
67548 Priya 102
76000 Roshan 103

A Class Can Model Anything

A class isn't limited to bank accounts. Here's a Student class, which reinforces that a class is just a blueprint for whatever entity you need:

python
class Student:

    def __init__(self, name, roll_number, marks):
        self.name = name
        self.roll_no = roll_number
        self.marks = marks

    def is_passing(self):
        return self.marks >= 50

    def get_grade(self):
        if self.marks >= 90:
            return 'A+'
        elif self.marks >= 75:
            return 'A'
        elif self.marks >= 60:
            return 'B'
        elif self.marks >= 50:
            return 'C'
        else:
            return 'F'
python
s1 = Student('Alice', 1, 92)
s2 = Student('Bob', 2, 47)
s3 = Student('Carol', 3, 68)

print(s1.name, '| Grade:', s1.get_grade(), '| Passing:', s1.is_passing())
print(s2.name, '| Grade:', s2.get_grade(), '| Passing:', s2.is_passing())
print(s3.name, '| Grade:', s3.get_grade(), '| Passing:', s3.is_passing())
Alice | Grade: A+ | Passing: True
Bob | Grade: F | Passing: False
Carol | Grade: B | Passing: True

__str__: Controlling What print() Shows

Printing an object without any special setup shows something unhelpful, like <__main__.Account object at 0x0000013183...>. __str__ is a dunder method (named for its double underscores) that controls what appears when you use print() or str() on an object. Python calls it automatically; you never call obj.__str__() directly.

python
class Account:
    def __init__(self, name, accountnumber, balance):
        self.name = name
        self.acc_no = accountnumber
        self.bal = balance

    def __str__(self):
        return f'Account #{self.acc_no} belongs to {self.name} | Balance: ${self.bal}'

Think of __str__ as the label on a product: without it, all a stranger sees is a box; with it, they see what's actually inside.

Amazon's Product Class

Amazon has over 350 million products, and every one is an object. The Product class defines the shared structure (an ASIN, Amazon's unique product ID, title, price, rating), while each object holds the unique data for one specific product.

python
class Product:

    def __init__(self, asin, title, price, seller, rating=0.0, review_count=0):
        self.asin = asin
        self.title = title
        self.price = price
        self.seller = seller
        self.rating = rating
        self.review_count = review_count

    def __str__(self):
        return f'[{self.asin}] {self.title} | ${self.price} | {self.rating}/5 ({self.review_count} reviews)'

    def apply_discount(self, percent):
        self.price = round(self.price * (1 - percent / 100), 2)

    def add_review(self, new_rating):
        total_combined_rating = self.rating * self.review_count + new_rating
        self.review_count += 1
        self.rating = round(total_combined_rating / self.review_count, 1)
python
laptop = Product('B09G9HDGY2', 'Apple MacBook Pro 14-inch', 1999.99, 'Apple Inc.', 4.8, 2341)
headphones = Product('B07Q9MJKBV', 'Sony WH-1000XM4 Headphones', 279.99, 'Sony', 4.7, 18923)

laptop.apply_discount(15)      # Prime Day 15% off
headphones.add_review(5)       # a new 5-star review comes in
print(laptop)
print(headphones)
[B09G9HDGY2] Apple MacBook Pro 14-inch | $1699.99 | 4.8/5 (2341 reviews)
[B07Q9MJKBV] Sony WH-1000XM4 Headphones | $279.99 | 4.7/5 (18924 reviews)

add_review is worth walking through, because the trick behind it comes up constantly in real systems. Amazon stores only two numbers per product, the current average rating and the total review count, never every individual rating anyone has ever left (storing and re-reading millions of ratings for one product would be far too slow). So when a new review comes in, the method reverse-engineers the historical total from what it already has: total = average × count. For the headphones, that's 4.7 × 18923 = 88938.1. Add the new 5-star rating to get 88943.1, bump the count to 18924, and divide to get the fresh average. Three lines, and the full rating history was never needed.


2. Class Variables vs. Instance Variables

There are two kinds of data in a class, and the difference matters:

Type Defined where Shared? Example
Instance variable Inside __init__ with self. No, unique per object customer name, balance
Class variable At class level, outside __init__ Yes, same for every object bank name, IFSC code, counter

Analogy: a class variable is a shared whiteboard in an office; every object reads the exact same thing off it. An instance variable is each person's private notebook; every object's copy is independent. Change the whiteboard, and everyone sees the change instantly. Scribble in your own notebook, and nobody else's changes.

python
class Account:
    counter = 1                  # class variable: shared by every Account
    bank_name = 'PyBank'         # class variable: same for all accounts
    ifsc_code = 'PYBN0001234'    # class variable: same branch for all

    def __init__(self, name, balance=0):
        self.name = name                # instance variable
        self.acc_no = Account.counter   # read the shared counter
        Account.counter += 1            # then bump it for the next account
        self.bal = balance              # instance variable

    def __str__(self):
        return f'[{Account.bank_name}] Account #{self.acc_no} | {self.name} | ${self.bal}'
python
acc1 = Account('Rishabh', 25000)
acc2 = Account('Priya', 67548)
acc3 = Account('Roshan', 76000)

print('Total accounts created so far:', Account.counter - 1)
print('Bank IFSC Code:', Account.ifsc_code)
Total accounts created so far: 3
Bank IFSC Code: PYBN0001234

The counter class variable is what lets the class hand out sequential account numbers automatically, a genuinely useful pattern worth remembering on its own.

Uber's Ride Counter and Surge Pricing

Uber processes over 25 million rides a day, with data living at two different levels: total_rides and surge_multiplier are class variables (they apply globally), while the rider, driver, and fare of any specific ride are instance variables.

python
class Ride:
    total_rides = 0          # class variable: tracks every ride ever created
    surge_multiplier = 1.0   # class variable: changed globally during peak hours

    def __init__(self, rider, driver, pickup, destination, base_fare):
        self.rider = rider
        self.driver = driver
        self.destination = destination
        self.base_fare = base_fare
        Ride.total_rides += 1
        self.ride_id = Ride.total_rides

    def get_final_fare(self):
        return round(self.base_fare * Ride.surge_multiplier, 2)
python
ride1 = Ride('Alice', 'Carlos', 'Marina District', 'SFO Airport', 45.00)
ride2 = Ride('Bob', 'Priya', 'Downtown', 'Oakland', 22.50)

# Friday night: surge kicks in, and ONE change affects every ride created after it
Ride.surge_multiplier = 2.3
ride3 = Ride('Carol', 'Sam', 'Tenderloin', 'Caltrain', 15.00)

print(f'Ride 3 fare with surge: ${ride3.get_final_fare()}')
print('Total rides so far:', Ride.total_rides)
Ride 3 fare with surge: $34.5
Total rides so far: 3

3. Methods: Functions Inside a Class

Methods are just functions defined inside a class. They always take self first so they can read and change the object's own data, which is what lets objects do things: deposit money, send a message, play a song.

python
class Account:
    def __init__(self, name, accountnumber, balance):
        self.name = name
        self.acc_no = accountnumber
        self.bal = balance

    def deposit(self, amount):
        if amount > 0:
            self.bal += amount
            print(f"Deposited ${amount} to {self.name}'s account. New balance: ${self.bal}")
        else:
            print('Deposit amount must be positive.')

    def withdraw(self, amount):
        if amount <= 0:
            print('Withdraw amount must be positive.')
        elif amount > self.bal:
            print('Insufficient funds!')
        else:
            self.bal -= amount
            print(f"Withdrew ${amount} from {self.name}'s account. New balance: ${self.bal}")

    def transfer(self, amount, other_account):
        if amount <= 0:
            print('Transfer amount must be positive.')
        elif amount > self.bal:
            print('Insufficient funds to transfer!')
        else:
            self.withdraw(amount)
            other_account.deposit(amount)
            print('Transfer complete.')

transfer calls withdraw on itself and deposit on the other account; objects interacting with other objects is exactly how the real world works, and exactly why bundling data with behavior pays off.

python
acc1 = Account('Rishabh', 101, 25000)
acc2 = Account('Priya', 102, 67548)

acc1.deposit(10000)
acc1.transfer(20000, acc2)
Deposited $10000 to Rishabh's account. New balance: $35000
Withdrew $20000 from Rishabh's account. New balance: $15000
Deposited $20000 to Priya's account. New balance: $87548
Transfer complete.

4. Encapsulation

Encapsulation means bundling data and methods together, and controlling who can actually access that data.

Why We Need It

Without encapsulation, anyone can reach in and set invalid data directly:

python
class Account:
    def __init__(self, name, balance):
        self.name = name
        self.balance = balance   # public attribute; anyone can change it

acc = Account('Alice', 5000)
acc.balance = -99999999          # nothing stops this; it should never be allowed

Encapsulation fixes this by making attributes private and forcing access through controlled methods.

Access Levels in Python

Convention Syntax Meaning
Public self.name Accessible from anywhere (the default)
Protected self._name Single underscore: "please don't touch this" (a convention, not enforced)
Private self.__name Double underscore: name-mangled, harder to reach from outside

Analogy: think of these three levels like rooms in your own house. Public is the front yard, anyone walking by can see it. Protected is the living room, meant for family and close friends, though a stranger who lets themselves in technically still could. Private is a locked diary in your room: nobody's supposed to read it, but a determined person who really wants to could still pick the lock. That last point matters more than it sounds: Python doesn't truly block access to private attributes. It uses name mangling, quietly renaming self.__balance to self._Account__balance internally. You can still reach it from outside, but only by deliberately going out of your way, which is exactly the point: the double underscore is a strong "keep out" sign, not an unbreakable lock.

Getters and Setters

Since private attributes can't be accessed directly, controlled access comes through two kinds of methods: a getter reads and returns the private value, and a setter writes it, but only after validating what's being written.

python
class Account:
    def __init__(self, name, balance=0):
        self.name = name
        self.__balance = balance       # private

    def deposit(self, amount):
        if amount > 0:
            self.__balance += amount

    def get_balance(self):             # getter: safe read
        return self.__balance

    def set_balance(self, amount):     # setter: write WITH validation
        if isinstance(amount, (int, float)) and amount >= 0:
            self.__balance = amount
            print(f'Balance updated to ${self.__balance}')
        else:
            print('Invalid amount. Balance must be a non-negative number.')
python
account = Account('John Doe', 100)
print(f'Current balance: ${account.get_balance()}')

account.set_balance('abc')     # rejected: not a number
account.set_balance(-500)      # rejected: negative
account.set_balance(9999)      # accepted
Current balance: $100
Invalid amount. Balance must be a non-negative number.
Invalid amount. Balance must be a non-negative number.
Balance updated to $9999

The name-mangling behavior mentioned earlier is easy to see directly on this same object:

python
print(account.__balance)                  # fails: this exact name doesn't exist
AttributeError: 'Account' object has no attribute '__balance'
python
print(account._Account__balance)          # works: this is the REAL, mangled name
9999

The setter is the gatekeeper here. It's the same idea as a bank teller: you don't reach into the vault yourself, you ask the teller, and the teller checks the rules before touching anything.

Apple's Credit Card: Encapsulated Financial Data

When you add a card to Apple Wallet, Apple never stores or displays your full card number; the real number stays private, and the app only ever shows the last four digits. Stripe follows the same pattern with tokenization, covered later in this guide.

python
class CreditCard:

    def __init__(self, holder_name, card_number, cvv, credit_limit):
        self.holder_name = holder_name
        self.__card_number = str(card_number)   # private, never exposed in full
        self.__cvv = cvv                        # private
        self.__balance = 0
        self.__credit_limit = credit_limit

    def get_masked_number(self):
        return f'**** **** **** {self.__card_number[-4:]}'

    def get_available_credit(self):
        return self.__credit_limit - self.__balance

    def get_utilization(self):
        return round((self.__balance / self.__credit_limit) * 100, 1)

    def charge(self, amount, merchant):
        if amount <= 0 or amount > self.get_available_credit():
            print(f'Declined at {merchant}.')
            return False
        self.__balance += amount
        print(f'Approved: ${amount} at {merchant} | Remaining credit: ${self.get_available_credit()}')
        return True

    def pay_bill(self, amount):
        payment = min(amount, self.__balance)   # can't ever overpay
        self.__balance -= payment
        print(f'Payment of ${payment} received. Remaining balance: ${self.__balance}')

    def __str__(self):
        return f'Card: {self.get_masked_number()} | {self.holder_name} | Utilization: {self.get_utilization()}%'
python
card = CreditCard('John Doe', '4111111111111234', 123, 5000)

card.charge(1500, 'Amazon')
card.charge(2000, 'Apple Store')
card.charge(2000, 'Best Buy')   # should be declined

print(card)
card.pay_bill(1000)
Approved: $1500 at Amazon | Remaining credit: $3500
Approved: $2000 at Apple Store | Remaining credit: $1500
Declined at Best Buy.
Card: **** **** **** 1234 | John Doe | Utilization: 70.0%
Payment of $1000 received. Remaining balance: $2500

Every getter here exposes a calculated value (a masked number, a percentage) without ever revealing the private data it was computed from.


5. Inheritance

Inheritance lets a new class (the child or subclass) reuse and extend the code of an existing class (the parent or superclass). It models the "is-a" relationship: a SavingsAccount is an Account. Without inheritance, deposit() and withdraw() would need to be copy-pasted into every account type; with it, you write them once in the parent and every child gets them automatically.

super(): Calling the Parent's Method

When a child class defines its own __init__, it usually still needs the parent's __init__ to run, so the parent's attributes get set up properly. super() does exactly that:

python
class CurrentAccount(Account):
    def __init__(self, name, acc_no, balance, gender):
        super().__init__(name, acc_no, balance)   # run Account's setup first
        self.gender = gender                       # then add the child's own data

Think of super() as passing work up the chain: let the parent handle what it already knows how to do, then add whatever is specific to the child on top.

The Five Types of Inheritance

  1. Single: one child inherits from one parent.
  2. Multiple: one child inherits from two or more parents.
  3. Multilevel: a chain, grandparent → parent → child.
  4. Hierarchical: multiple children inherit from the same parent.
  5. Hybrid: a mix of the patterns above.

5.1 Single Inheritance

One parent, one child, nothing more. SavingsAccount reuses everything Account already does, and adds a small transaction limit of its own on top:

python
class Account:
    def __init__(self, name, accountnumber, balance):
        self.name = name
        self.acc_no = accountnumber
        self.bal = balance
        self.numtrans = 0     # transactions done today
        self.maxtrans = 2     # max transactions allowed per day

    def deposit(self, amount):
        if amount > 0 and self.numtrans < self.maxtrans:
            self.bal += amount
            self.numtrans += 1
            print(f'Deposited ${amount}. Balance: ${self.bal}')
        elif self.numtrans >= self.maxtrans:
            print('Transaction limit reached for today.')
        else:
            print('Deposit amount must be positive.')


class SavingsAccount(Account):
    pass   # 'pass' means no extra code: it uses everything from Account, unchanged
python
sav1 = SavingsAccount('Vinay', 301, 90800)

sav1.deposit(5000)   # transaction 1
sav1.deposit(5000)   # transaction 2
sav1.deposit(5000)   # refused: the daily limit is 2
Deposited $5000. Balance: $95800
Deposited $5000. Balance: $100800
Transaction limit reached for today.

SavingsAccount gets deposit(), the transaction counter, everything, entirely for free. The moment a second class also starts inheriting from Account (as you'll see in section 5.4), the pattern has become hierarchical rather than single, since single inheritance describes one parent feeding exactly one child.

5.2 Multiple Inheritance

A child can inherit from several parents at once. A Tesla is a Vehicle, an ElectricVehicle, and uses GPSNavigator, all at the same time:

python
class Vehicle:
    def __init__(self, make, model):
        self.make = make
        self.model = model
    def start(self):
        return f'{self.make} {self.model} is starting...'

class ElectricVehicle:
    def __init__(self, battery_kwh):
        self.battery = battery_kwh
        self.charge_level = 100
    def get_range(self):
        miles = int(self.battery * (self.charge_level / 100) * 2.5)
        return f'{miles} miles'

class GPSNavigator:
    def get_location(self):
        return '37.7749 N, 122.4194 W'
    def navigate(self, destination):
        return f'Routing to {destination} from {self.get_location()}'

class TeslaModel3(Vehicle, ElectricVehicle, GPSNavigator):
    def __init__(self, model, autopilot=True):
        Vehicle.__init__(self, 'Tesla', model)
        ElectricVehicle.__init__(self, battery_kwh=75)
        self.autopilot = autopilot

    def self_drive(self, destination):
        if self.autopilot:
            return f'Autopilot engaged. {self.navigate(destination)} | Range: {self.get_range()}'
        return 'Autopilot not available.'
python
tesla = TeslaModel3('Model 3')
print(tesla.start())                  # from Vehicle
print(tesla.get_range())              # from ElectricVehicle
print(tesla.self_drive('Palo Alto'))  # TeslaModel3's own method, using methods from ALL parents
Tesla Model 3 is starting...
187 miles
Autopilot engaged. Routing to Palo Alto from 37.7749 N, 122.4194 W | Range: 187 miles

5.3 Multilevel Inheritance

A chain, where each level adds more specific behavior than the one before it:

python
class Animal:
    def breathe(self):
        return 'Breathing oxygen.'

class Mammal(Animal):              # Mammal IS an Animal
    def feed_young_with_milk(self):
        return 'Feeding young with milk.'

class Dog(Mammal):                 # Dog IS a Mammal (which is also an Animal)
    def bark(self):
        return 'Woof!'
python
dog = Dog()
print(dog.breathe())               # inherited from Animal, two levels up
print(dog.feed_young_with_milk())  # inherited from Mammal, one level up
print(dog.bark())                  # Dog's own method

print(isinstance(dog, Dog), isinstance(dog, Mammal), isinstance(dog, Animal))
Breathing oxygen.
Feeding young with milk.
Woof!
True True True

isinstance() confirming True all the way up the chain is the clearest proof that a Dog really is a Mammal and an Animal simultaneously, not just something that happens to share a few methods with them.

5.4 Hierarchical Inheritance

Multiple children sharing one parent. This is the pattern a real bank actually needs: several account types, all built on the same core.

python
class BankAccount:
    def __init__(self, holder, acc_no, balance):
        self.holder = holder
        self.acc_no = acc_no
        self.balance = balance
    def __str__(self):
        return f'{type(self).__name__} | {self.holder} | ${self.balance}'

class SavingsAccount(BankAccount):
    interest_rate = 0.04
    def apply_interest(self):
        earned = self.balance * self.interest_rate
        self.balance += earned
        print(f'Interest earned: ${round(earned, 2)}. New balance: ${round(self.balance, 2)}')

class CurrentAccount(BankAccount):
    overdraft_limit = 10000
    def withdraw(self, amount):
        if amount <= self.balance + self.overdraft_limit:
            self.balance -= amount
            print(f'Withdrew ${amount}. Balance: ${self.balance}')
        else:
            print('Amount exceeds overdraft limit.')

class FixedDepositAccount(BankAccount):
    def __init__(self, holder, acc_no, balance, lock_months):
        super().__init__(holder, acc_no, balance)
        self.lock_months = lock_months
    def is_locked(self, months_passed):
        locked = months_passed < self.lock_months
        print(f'FD after {months_passed} months:', 'LOCKED' if locked else 'FD MATURED, can withdraw now')
        return locked
python
savings = SavingsAccount('Alice', 101, 50000)
current = CurrentAccount('Bob', 102, 2000)
fd = FixedDepositAccount('Carol', 103, 100000, lock_months=12)

savings.apply_interest()
current.withdraw(10000)   # within the overdraft limit
current.withdraw(5000)    # exceeds it
fd.is_locked(6)
fd.is_locked(13)
Interest earned: $2000.0. New balance: $52000.0
Withdrew $10000. Balance: $-8000
Amount exceeds overdraft limit.
FD after 6 months: LOCKED
FD after 13 months: FD MATURED, can withdraw now

All three share BankAccount's core (holder, acc_no, balance, __str__), and each adds its own specialty on top: interest, overdraft, or a lock-in period. This is the natural home for "multiple children, one parent," the pattern that section 5.1 was deliberately kept free of, so single and hierarchical inheritance stay easy to tell apart.

5.1 SINGLE INHERITANCE Account SavingsAccount ONE parent. ONE child. That's it. 5.4 HIERARCHICAL INHERITANCE BankAccount Savings Account Current Account Fixed Deposit Account ONE parent. MULTIPLE children.
Single inheritance (one parent, one child) vs. hierarchical inheritance (one parent, many children) — easy to conflate, worth keeping distinct

5.5 Hybrid Inheritance and the MRO

When a class inherits from multiple parents that share a common ancestor, Python needs a rule for which parent's version of a method to use. This is the classic Diamond Problem:

        A
       / \
      B   C
       \ /
        D

If both B and C define hello(), and D inherits from both, which one does D get? Analogy: imagine getting conflicting advice from two mentors, and a company policy that says "always defer to whichever mentor is listed first on your onboarding form." Python's rule works the same way: check the class itself first, then walk through its parents in the order they were listed.

python
class A:
    def hello(self): return 'Hello from A'
class B(A):
    def hello(self): return 'Hello from B'
class C(A):
    def hello(self): return 'Hello from C'
class D(B, C):   # B is listed before C
    pass

d = D()
print(d.hello())     # 'Hello from B'

for cls in D.__mro__:
    print(f'  -> {cls.__name__}')
Hello from B
  -> D
  -> B
  -> C
  -> A
  -> object

D(B, C) lists B first, so Python checks D, then B (found it), and never even needs to look at C or A. D.__mro__ shows the exact order Python committed to.


6. Polymorphism

Polymorphism means "many forms": the same method name produces different behavior depending on which object it's called on.

The Clearest Possible Example

Before touching banking again, here's polymorphism in its simplest form. Three unrelated animals all have a speak() method, and each one behaves completely differently:

python
class Cat:
    def speak(self): return 'Meow!'
class Dog:
    def speak(self): return 'Woof!'
class Duck:
    def speak(self): return 'Quack!'

animals = [Cat(), Dog(), Duck()]
for animal in animals:
    print(f'{type(animal).__name__}: {animal.speak()}')
Cat: Meow!
Dog: Woof!
Duck: Quack!

Method Overriding: Polymorphism Through Inheritance

The most common form of polymorphism is method overriding: a child class redefines a method it inherited. SavingsAccount and CurrentAccount both inherit withdraw() from Account, but each provides its own version, and Python runs the right one automatically based on the object's actual type.

python
class Account:
    def __init__(self, name, accountnumber, balance):
        self.name = name
        self.acc_no = accountnumber
        self.bal = balance

    def withdraw(self, amount):
        # The default version every account type inherits, unless it overrides this
        if amount > 0 and amount <= self.bal:
            self.bal -= amount
            print(f'Withdrew ${amount}. Balance: ${self.bal}')
        else:
            print('Insufficient funds.')


class SavingsAccount(Account):
    # Override: savings accounts charge a $1 fee per withdrawal
    def withdraw(self, amount):
        fee = 1
        total = amount + fee
        if amount > 0 and total <= self.bal:
            self.bal -= total
            print(f'[SavingsAccount] Withdrew ${amount} + ${fee} fee. Balance: ${self.bal}')
        else:
            print('Insufficient funds after applying the fee.')

class CurrentAccount(Account):
    # Override: no fee for current accounts
    def withdraw(self, amount):
        if amount > 0 and amount <= self.bal:
            self.bal -= amount
            print(f'[CurrentAccount] Withdrew ${amount}. Balance: ${self.bal}')
        else:
            print('Insufficient funds.')
python
savings = SavingsAccount('Alice', 'SA123', 100)
current = CurrentAccount('Bob', 'CA456', 200)

savings.withdraw(20)   # same method NAME as current.withdraw...
current.withdraw(20)   # ...but different behavior, because of the object's TYPE
[SavingsAccount] Withdrew $20 + $1 fee. Balance: $79
[CurrentAccount] Withdrew $20. Balance: $180

Duck Typing: Python's Most Flexible Polymorphism

Python doesn't even require a shared parent class for polymorphism. If two objects both happen to have a method with the same name, you can use them interchangeably; Python only checks at runtime whether the method exists, never the object's type. This is called duck typing: "if it walks like a duck and quacks like a duck, treat it like a duck."

An e-commerce checkout that needs to work with Stripe, PayPal, Apple Pay, and crypto is a natural fit: none of these share a parent class, but they all have a .process() method, and that's all Python needs.

python
class StripePayment:
    def process(self, amount): return f'Stripe: processed ${amount} via card'
class PayPalPayment:
    def process(self, amount): return f'PayPal: processed ${amount} via PayPal account'
class ApplePayPayment:
    def process(self, amount): return f'Apple Pay: processed ${amount} via device token'
class CryptoPayment:
    def process(self, amount):
        btc = round(amount / 60000, 6)   # a rough, illustrative conversion rate
        return f'Crypto: processed {btc} BTC (≈${amount})'

def checkout(payment_method, amount):
    # Works with ANY object that has a .process() method, no shared parent needed
    print(payment_method.process(amount))

for method in [StripePayment(), PayPalPayment(), ApplePayPayment(), CryptoPayment()]:
    checkout(method, 99.99)
Stripe: processed $99.99 via card
PayPal: processed $99.99 via PayPal account
Apple Pay: processed $99.99 via device token
Crypto: processed 0.001667 BTC (≈$99.99)

checkout never checks what type payment_method is. Adding a fifth payment provider later needs zero changes to checkout itself.


7. Overriding, Overloading, and Operator Overloading

Three related, easily-confused concepts:

  1. Method overriding: a child class redefines a parent's method (just covered above).
  2. Method overloading: the same method name with a different number of parameters. Python handles this differently from languages like Java.
  3. Operator overloading: redefining what operators like +, ==, < do for your own objects.

Method Overloading

In Java or C++, you can define two methods with the same name but different parameters, and the language automatically picks the right one. Python does not support this. Define a method twice, and the second definition silently replaces the first:

python
class Calculator:
    def add(self, a, b):
        return a + b
    def add(self, a, b, c):    # this REPLACES the add(a, b) above
        return a + b + c

calc = Calculator()
calc.add(3, 4)
TypeError: Calculator.add() missing 1 required positional argument: 'c'

Analogy: it's like a coffee shop that only ever keeps the last recipe card pinned to the wall for a drink named "Latte." Write a new recipe under the same name, and the old one isn't available anymore, it's simply gone. Python simulates overloading with default values or *args instead:

python
# Fix 1: default parameter values
class Calculator:
    def add(self, a, b=0, c=0):
        return a + b + c

calc = Calculator()
print(calc.add(5))          # 5
print(calc.add(5, 10))      # 15
print(calc.add(5, 10, 15))  # 30
python
# Fix 2: *args accepts any number of arguments (the most flexible option)
class Calculator:
    def add(self, *args):
        return sum(args)

calc = Calculator()
print(calc.add(5, 10, 15, 20))   # 50

Operator Overloading

Python's built-in operators already behave differently depending on type:

python
print('Python' + 'Programming')    # strings: concatenation
print([1, 2, 3] + [4, 5, 6])      # lists: joins them together
print(10 + 12)                      # numbers: addition
PythonProgramming
[1, 2, 3, 4, 5, 6]
22

The same + symbol, three completely different behaviors depending on type: that's operator overloading already happening inside Python itself. You can define this same behavior for your own classes too, for example making acc1 + acc2 produce a joint account.


8. Abstraction

Abstraction means hiding complexity and exposing only what's necessary. Pressing a car's brake pedal doesn't require understanding the hydraulics, the brake pads, or the ABS sensors; you just know it slows the car down. That's abstraction.

Abstraction vs. Encapsulation

These two get confused constantly:

Abstraction Encapsulation
What it hides Implementation (how things work) Data (internal state)
The question it answers "What does this do?" "Who can access this data?"
Example A brake pedal hides the hydraulics __balance hides the bank balance

Implementing Abstraction in Python

The approach: build a base class that defines which methods must exist, leaves them deliberately unimplemented, and raises an error if a subclass forgets to fill one in. raise NotImplementedError(...) signals an unimplemented method.

Analogy: think of the base class as a job posting. It lists the required skills ("must be able to process a payment, must be able to issue a refund") without doing any of the work itself. Any subclass that gets "hired" is expected to actually deliver on that list; if it doesn't, the gap gets caught immediately rather than causing confusion months later.

python
class Account:
    # A template base class: defines WHAT every account must do,
    # leaves HOW to each subclass.
    def __init__(self, name, accountnumber, balance):
        self.name = name
        self.acc_no = accountnumber
        self.bal = balance

    def deposit(self, amount):
        raise NotImplementedError('Subclasses must implement their own deposit() method')

    def withdraw(self, amount):
        raise NotImplementedError('Subclasses must implement their own withdraw() method')


class SavingsAccount(Account):
    def deposit(self, amount):
        if amount > 0:
            self.bal += amount
            print(f'[Savings] Deposited ${amount}. Balance: ${self.bal}')

    def withdraw(self, amount):
        fee = 1
        total = amount + fee
        if amount > 0 and total <= self.bal:
            self.bal -= total
            print(f'[Savings] Withdrew ${amount} + ${fee} fee. Balance: ${self.bal}')


# A subclass that forgets to implement the required methods
class BrokenAccount(Account):
    pass

broken = BrokenAccount('Test', 999, 0)
broken.deposit(100)   # raises NotImplementedError immediately
NotImplementedError: Subclasses must implement their own deposit() method

The base class acts as an enforceable contract: any subclass must fill in the required methods, or it fails loudly the moment it's used, not silently somewhere down the line.

Payment Gateways as a Plugin Architecture

This is one of the most common real uses of abstraction in production software. Companies like Shopify, Airbnb, and Amazon need to support many payment providers (Stripe, PayPal, Square) without rewriting their checkout logic every time a new one is added. The fix: a PaymentGateway base class defines the contract, and each provider fills it in its own way.

python
class PaymentGateway:
    # Base class: the contract every gateway must follow
    def charge(self, amount, token):
        raise NotImplementedError('Every gateway must implement charge()')
    def refund(self, transaction_id, amount):
        raise NotImplementedError('Every gateway must implement refund()')

    def charge_with_tax(self, amount, token, tax_rate=0.1):
        # Shared logic, lives once in the base class, calls self.charge()
        # which each subclass overrides in its own way
        total = round(amount * (1 + tax_rate), 2)
        return self.charge(total, token)


class StripeGateway(PaymentGateway):
    _counter = 1000   # a simple, predictable way to generate transaction IDs

    def charge(self, amount, token):
        StripeGateway._counter += 1
        txn_id = f'ch_stripe_{StripeGateway._counter}'
        return {'status': 'success', 'id': txn_id, 'amount': amount, 'gateway': 'Stripe'}

    def refund(self, transaction_id, amount):
        print(f'[Stripe] Refunding ${amount} for transaction {transaction_id}')
        return True


class PayPalGateway(PaymentGateway):
    _counter = 5000   # PayPal's own counter, kept separate from Stripe's

    def charge(self, amount, token):
        PayPalGateway._counter += 1
        txn_id = f'PAY-{PayPalGateway._counter}'
        return {'status': 'success', 'id': txn_id, 'amount': amount, 'gateway': 'PayPal'}

    def refund(self, transaction_id, amount):
        print(f'[PayPal] Refunding ${amount} for transaction {transaction_id}')
        return True


class OrderProcessor:
    def __init__(self, gateway):
        self.gateway = gateway   # accepts ANY gateway that follows the contract

    def process_order(self, order_amount, card_token, customer_name):
        result = self.gateway.charge_with_tax(order_amount, card_token)
        if result['status'] == 'success':
            print(f"Order complete! [{result['gateway']}] Transaction ID: {result['id']}")
        return result
python
stripe_processor = OrderProcessor(StripeGateway())
stripe_processor.process_order(89.99, 'tok_visa_4242', 'Alice')

# A completely different gateway, running through the exact same OrderProcessor,
# with zero changes to OrderProcessor itself
paypal_processor = OrderProcessor(PayPalGateway())
paypal_processor.process_order(89.99, 'tok_pp_sandbox', 'Bob')
Order complete! [Stripe] Transaction ID: ch_stripe_1001
Order complete! [PayPal] Transaction ID: PAY-5001

OrderProcessor never needs to know which gateway it's holding; it only calls methods the contract guarantees exist. This is the Open/Closed Principle: the system stays open for extension (add a new gateway any time) but closed for modification (OrderProcessor itself never needs to change), and swapping StripeGateway() for PayPalGateway() above without touching a single line of OrderProcessor is exactly that in action.

A quick note on the transaction ID above: it's built from a simple counter, not from Python's built-in hash() function. hash() on a string produces a different result nearly every time a Python program restarts (a deliberate security feature), so it isn't suited to generating an ID meant to stay the same. A plain counter is simpler and gives predictable, stable IDs.

What Is a Token?

A token is a randomly generated string that safely stands in for a real credit card number. Instead of a real number like 4111 1111 1111 1234 flowing through every part of a system, the payment gateway replaces it with a meaningless string like tok_visa_4242, and that's what actually moves through the rest of the code. This process is called tokenization.

Analogy: it's a coat check ticket. You hand over your coat (the real card number) once, at the door, and get back a numbered ticket that's completely useless to anyone else. Every step after that only ever needs the ticket, never the coat itself.


Summary

Concept What it does Real-world analogy
Classes & Objects Blueprint + instances created from it A house blueprint → actual houses
__init__ Sets up the object when it's created Filling out a form to open a bank account
self The object referring to itself "I" in a sentence
__str__ Controls what print() shows The label on a product
Class variables Shared across every object The interest rate applied to every account
Instance variables Unique to each object Your own personal bank balance
Encapsulation Bundle data, control access Public yard, family living room, locked private diary
Getter / Setter Controlled access to private data A bank teller fetches from the vault for you
Inheritance Reuse and extend a parent class A SavingsAccount is also an Account
super() Calls the parent's method Passing work up the chain to your manager
Polymorphism Same method name, different behavior .speak() → Meow, Woof, Quack
Duck typing If it has the method, use it Any payment method with .process() works
Method overloading Same name, flexible parameters add(5), add(5, 10), add(5, 10, 15) all work
Operator overloading Redefine +, ==, < for your class acc1 + acc2 could create a joint account
Abstraction Define WHAT, leave HOW to subclasses A brake pedal hides the hydraulics
NotImplementedError Forces a subclass to implement a method A job posting listing required skills
Tokenization A safe stand-in for sensitive data A coat check ticket instead of the coat itself

Every one of these ideas traces back to the same starting point: bundling data with the behavior that acts on it. Once a class feels like a natural blueprint rather than a syntax to memorize, inheritance, polymorphism, and abstraction stop being separate rules and start looking like different answers to the same question: how do objects share code, behave differently, and hide what doesn't need to be seen?

Every idea here traces back to one starting point: bundling data with the behavior that acts on it. Once a class feels like a natural blueprint rather than syntax to memorize, inheritance, polymorphism, and abstraction stop being separate rules and start looking like different answers to the same question: how do objects share code, behave differently, and hide what doesn't need to be seen?