Procedural vs. OOP, Side by Side
# 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
- Encapsulation: bundle data and methods together, and control who can access the data.
- Inheritance: build new classes by extending existing ones.
- Polymorphism: the same method name behaves differently depending on the object.
- Abstraction: expose only what's necessary, hide the complexity.
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.
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.
class Account:
def __init__(self, name, accountnumber, balance): # constructor
self.name = name # attribute: belongs to THIS object
self.acc_no = accountnumber
self.bal = balance
# 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:
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'
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.
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.
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)
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.
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}'
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.
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)
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.
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.
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:
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.
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.')
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:
print(account.__balance) # fails: this exact name doesn't exist
AttributeError: 'Account' object has no attribute '__balance'
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.
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()}%'
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:
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
- Single: one child inherits from one parent.
- Multiple: one child inherits from two or more parents.
- Multilevel: a chain, grandparent → parent → child.
- Hierarchical: multiple children inherit from the same parent.
- 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:
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
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:
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.'
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:
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!'
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.
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
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.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.
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:
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.
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.')
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.
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:
- Method overriding: a child class redefines a parent's method (just covered above).
- Method overloading: the same method name with a different number of parameters. Python handles this differently from languages like Java.
- 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:
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:
# 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
# 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:
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.
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.
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
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?