Core OOP Principles in Python: Inheritance, Polymorphism, Encapsulation, and Abstraction
Imagine you're building a house. You wouldn't start by laying bricks randomly. You'd first design a blueprint — a plan that defines every room, door, and window.
Object-Oriented Programming (OOP) in Python works the same way. A class is your blueprint, and an object is the actual house you build from it.
Let's leave behind confusing jargon. In this guide, you'll learn OOP by building real things, step-by-step. No prior knowledge needed. Let's begin.
Quick Navigation
What is Object-Oriented Programming (OOP)?
OOP is a way of writing code that mimics how we think about the real world. Instead of just a list of instructions, we organize our code around "objects."
An object is a single unit that contains both data (what it knows) and actions (what it can do).
The Core Idea: Bundle related data and functions into one neat package. This makes your code easier to manage, reuse, and understand as it grows.
Real-World Analogy: A Car
- Data (Attributes/Properties): color, model, speed, fuel level.
- Actions (Methods/Functions): start(), accelerate(), brake(), honk().
In OOP, we'd create a single Car "blueprint" (class) that holds all this together. Every individual car you create from this blueprint is an object.
Your First Class: Defining a Blueprint
A class is defined using the class keyword. By convention, class names start with a capital letter.
pass is a placeholder telling Python "nothing to execute here, but this block is syntactically valid."# This is a blueprint. It doesn't create anything yet.
class Dog:
pass # 'pass' means "do nothing for now"
This Dog class is an empty blueprint. It's like a form that hasn't been filled out.
The __init__ Method: The Object's Birth Certificate
The most important method in a class is __init__() (that's two underscores before and after "init"). This is the initializer or constructor.
It's automatically called when you create a new object from the class. Its job is to set up the initial data for the object.
name and age you pass in and stores them onto the object using self.name and self.age, so each dog remembers its own details. It also prints a birth announcement so you can literally watch the constructor fire.class Dog:
# This runs every time we create a new Dog
def __init__(self, name, age):
self.name = name # Store the name
self.age = age # Store the age
print(f"A new dog named {name} is born!")
Good practice: Think of self as the object itself. Inside the class, self.name means "the name of *this specific* dog object." It's how the object refers to itself.
Avoid: Forget to include self as the first parameter in every method you define inside a class. Python uses it to know which object is calling the method.
Creating Objects: Building from the Blueprint
Creating an object from a class is called instantiation. You call the class name like a function.
__init__ for each one, which is why you see two "birth" announcements print immediately. Each object — my_dog and friends_dog — keeps its own independent copy of name and age, proven by accessing .name and .age separately below.# Let's build two dogs from our Dog blueprint
my_dog = Dog("Rex", 3) # Output: A new dog named Rex is born!
friends_dog = Dog("Bella", 5) # Output: A new dog named Bella is born!
print(my_dog.name) # Output: Rex
print(friends_dog.age) # Output: 5
You've just created two independent objects (my_dog and friends_dog) from the single class (Dog). Each has its own separate data.
Adding Methods: Teaching Your Objects Actions
Methods are just functions defined inside a class. They define what an object can do.
bark() reads self.name to personalize the woof, while have_birthday() actually mutates the object's state by incrementing self.age. This is your first real look at objects doing something, not just holding data.class Dog:
def __init__(self, name, age):
self.name = name
self.age = age
# Method 1: Bark
def bark(self):
print(f"{self.name} says: Woof! Woof!")
# Method 2: Have a birthday
def have_birthday(self):
self.age += 1
print(f"Happy Birthday, {self.name}! You are now {self.age} years old.")
# Let's use our enhanced Dog class
my_dog = Dog("Rex", 3)
my_dog.bark() # Output: Rex says: Woof! Woof!
my_dog.have_birthday() # Output: Happy Birthday, Rex! You are now 4 years old.
Notice how methods can use the object's data (self.name, self.age) and even change it (self.age += 1).
A Practical Example: Building a Bank Account
Let's use a practical example to see how the pieces fit together. We'll create a BankAccount class.
Step 1: The Blueprint with Core Data
class BankAccount:
def __init__(self, account_holder, initial_balance=0):
self.holder = account_holder
self.balance = initial_balance
print(f"Account created for {self.holder}. Balance: ${self.balance}")
Step 2: Add Essential Actions (Methods)
deposit() validates that the amount is positive before adding it to the balance — never trust unvalidated input. withdraw() runs a two-sided check (0 < amount <= self.balance) to block both negative withdrawals and overdrafts. display_balance() is a simple read-only reporter. Together, these three methods control every way this account's balance can change — nothing touches self.balance from outside directly.class BankAccount:
def __init__(self, account_holder, initial_balance=0):
self.holder = account_holder
self.balance = initial_balance
def deposit(self, amount):
"""Add money to the account."""
if amount > 0:
self.balance += amount
print(f"Deposited ${amount}. New balance: ${self.balance}")
else:
print("Deposit amount must be positive.")
def withdraw(self, amount):
"""Take money from the account."""
if 0 < amount <= self.balance:
self.balance -= amount
print(f"Withdrew ${amount}. New balance: ${self.balance}")
else:
print("Invalid withdrawal amount or insufficient funds.")
def display_balance(self):
"""Show the current balance."""
print(f"Account holder: {self.holder}")
print(f"Current balance: ${self.balance}")
Step 3: Use the Bank Account
# Create an account for Alice
alice_account = BankAccount("Alice", 100)
alice_account.deposit(50) # Deposited $50. New balance: $150
alice_account.withdraw(30) # Withdrew $30. New balance: $120
alice_account.withdraw(200) # Invalid withdrawal amount or insufficient funds.
alice_account.display_balance()
# Output:
# Account holder: Alice
# Current balance: $120
Important Tip: The data (self.balance) is protected inside the object. The only way to change it is through the object's own methods (deposit(), withdraw()). This is a key OOP concept called encapsulation.
Leveling Up: Advanced Class Concepts
With the basic model in place, we can look at a few concepts that become useful in larger programs.
1. Class Attributes vs. Instance Attributes
- Instance Attribute: Unique to each object (like
self.balance). My bank balance is different from yours. - Class Attribute: Shared by ALL objects of the class. Defined directly under the class, not inside
__init__.
bank_name and interest_rate live directly under the class, not inside __init__, meaning both acc1 and acc2 read the exact same value without either instance storing its own separate copy. Compare this to self.holder and self.balance, which stay unique per account.class BankAccount:
# Class Attribute - shared by all accounts
bank_name = "Python Trust Bank"
interest_rate = 0.02 # 2% interest for everyone
def __init__(self, holder, balance=0):
# Instance Attributes - unique to each account
self.holder = holder
self.balance = balance
def show_bank_info(self):
# Access class attribute using the class name
print(f"This account is with {BankAccount.bank_name}.")
print(f"The current interest rate is {BankAccount.interest_rate*100}%.")
# Using it
acc1 = BankAccount("Alice", 500)
acc2 = BankAccount("Bob", 1000)
print(acc1.bank_name) # Output: Python Trust Bank
print(acc2.bank_name) # Output: Python Trust Bank
acc1.show_bank_info()
# Output: This account is with Python Trust Bank.
# Output: The current interest rate is 2.0%.
Changing a class attribute affects all objects instantly.
2. The __str__ Method: Making Objects Readable
The __str__ method defines what happens when you print your object. It should return a nice, readable string.
print() an object directly. Without __str__, Python would show you something cryptic like <__main__.BankAccount object at 0x...>. By overriding __str__, we control exactly what gets displayed — here, a clean, human-readable summary of the account.class BankAccount:
def __init__(self, holder, balance=0):
self.holder = holder
self.balance = balance
def __str__(self):
# This is what print() will show
return f"BankAccount(holder='{self.holder}', balance=${self.balance})"
my_acc = BankAccount("Charlie", 750)
print(my_acc) # Output: BankAccount(holder='Charlie', balance=$750)
Without __str__, print(my_acc) would show a confusing memory address.
Putting It All Together: A Complete Student Management System
The next example puts the pieces together in a small system.
school_name, total_students), lists and dictionaries as object data, multiple methods working together, and a custom __str__. Pay close attention to Student.total_students += 1 inside __init__ — it's a shared counter that increments every time any new student is created, which is exactly why each student automatically gets a unique, auto-incrementing student_id. Read it line by line to see the whole OOP picture click into place.class Student:
# Class Attribute
school_name = "Digital Horizons High"
total_students = 0 # A counter shared by all
def __init__(self, name, grade_level):
# Instance Attributes
self.name = name
self.grade_level = grade_level
self.courses = [] # Start with an empty course list
self.grades = {} # Dictionary: course -> grade
# Update the shared counter
Student.total_students += 1
self.student_id = Student.total_students
print(f"Welcome, {self.name}! Your ID is {self.student_id}.")
def enroll(self, course_name):
"""Enroll student in a new course."""
if course_name not in self.courses:
self.courses.append(course_name)
self.grades[course_name] = None # No grade yet
print(f"{self.name} enrolled in {course_name}.")
else:
print(f"{self.name} is already in {course_name}.")
def assign_grade(self, course_name, grade):
"""Assign a grade for a course."""
if course_name in self.courses:
self.grades[course_name] = grade
print(f"Grade {grade} recorded for {self.name} in {course_name}.")
else:
print(f"Error: {self.name} is not enrolled in {course_name}.")
def calculate_gpa(self):
"""Calculate the student's average grade (GPA)."""
valid_grades = [g for g in self.grades.values() if g is not None]
if valid_grades:
average = sum(valid_grades) / len(valid_grades)
return round(average, 2)
return 0.0
def get_report_card(self):
"""Generate a formatted report card."""
report = f"\n--- Report Card for {self.name} ---\n"
report += f"School: {Student.school_name}\n"
report += f"Grade Level: {self.grade_level}\n"
report += "Courses & Grades:\n"
for course in self.courses:
grade = self.grades.get(course, "No Grade")
report += f" - {course}: {grade}\n"
report += f"Current GPA: {self.calculate_gpa()}\n"
return report
def __str__(self):
return f"Student(ID:{self.student_id}, Name:{self.name}, GPA:{self.calculate_gpa()})"
# ==== Let's Run the System ====
print(f"School: {Student.school_name}")
# Create students
student1 = Student("Emma", 10)
student2 = Student("Liam", 11)
# Enroll them in courses
student1.enroll("Math")
student1.enroll("Science")
student2.enroll("Math")
student2.enroll("History")
# Assign grades
student1.assign_grade("Math", 88)
student1.assign_grade("Science", 92)
student2.assign_grade("Math", 95)
student2.assign_grade("History", 85)
# Get report cards
print(student1.get_report_card())
print(student2.get_report_card())
# Print students using __str__
print(student1) # Output: Student(ID:1, Name:Emma, GPA:90.0)
print(student2) # Output: Student(ID:2, Name:Liam, GPA:90.0)
print(f"\nTotal students in system: {Student.total_students}")
Good practice: Use OOP when you have multiple items that share the same kind of data and behavior. It keeps your code organized like a well-structured filing cabinet, not a pile of papers.
Why OOP? The Big Benefits
- Organization: Code is grouped logically around objects (like Student, BankAccount).
- Reusability: Write a class once (like
Dog), and create countless objects from it. - Maintenance: Fixing a bug in a class fixes it for all objects using that class.
- Modeling Reality: It's intuitive. We naturally think in terms of objects and their interactions.
Frequently Asked Questions
Why does Python require self explicitly, instead of handling it implicitly like some other languages?
Python follows the principle "explicit is better than implicit." Under the hood, my_dog.bark() is actually translated to Dog.bark(my_dog) — Python passes the calling object as the first argument automatically, but it still requires the parameter to be named in the method signature so there's no hidden magic. This makes it crystal clear, even to someone reading the class in isolation, which object each method operates on.
What actually happens in memory when you mutate a class attribute versus an instance attribute?
A class attribute is stored once, on the class object itself, and every instance looks it up through a reference chain if it doesn't have its own copy. The moment you do acc1.interest_rate = 0.05, though, Python creates a brand-new instance attribute on acc1 that shadows the class attribute — it does not modify the shared value. Only assigning through the class name (BankAccount.interest_rate = 0.05) changes it for every object at once. This distinction trips up even experienced developers.
In the BankAccount example, what could go wrong if you allowed direct access to self.balance instead of routing changes through methods?
Without validation logic gatekeeping the balance, any part of your codebase could set account.balance = -500 or assign a string by mistake, silently corrupting your data with no audit trail. Routing all mutations through deposit() and withdraw() means every change is validated and logged in one place — this is the practical value of encapsulation, not just a theoretical OOP buzzword.
What's the danger of using a mutable default argument in __init__, like def __init__(self, courses=[])?
Default argument values in Python are evaluated only once, when the function is defined — not each time it's called. If you used a mutable default like a list, every object created without passing courses explicitly would share the exact same underlying list, so appending to one student's courses would silently appear on every other student too. That's precisely why the Student class initializes self.courses = [] fresh inside the constructor body instead of using it as a default parameter value — a classic gotcha worth remembering for interviews.
How does __str__ differ from __repr__, and which one should production logging code rely on?
__str__ is meant for a readable, end-user-facing description (what print() uses), while __repr__ is meant to be an unambiguous, developer-facing representation — ideally one that could recreate the object if pasted back into Python. If you only define __str__, Python falls back to it for repr() too in simple cases, but for production logging and debugging, it's best practice to implement both, since log aggregators often call repr() on objects internally.
If two accounts share a class attribute like interest_rate, how would you give just one account a different rate without breaking the others?
You'd assign the new value directly on that specific instance, e.g. acc1.interest_rate = 0.045. This creates an instance attribute that shadows the class-level one only for acc1; acc2 and every future account continue reading the original shared class value. This pattern is commonly used for one-off overrides — like a promotional rate for a single customer — without touching the shared default.
How would you explain encapsulation in an interview using the BankAccount example, without reciting textbook definitions?
A strong answer frames it around risk control: encapsulation means the balance can only change through methods that enforce rules, so a caller can never accidentally set a negative balance or skip validation. It's less about "hiding data" as an abstract concept, and more about guaranteeing that every state change goes through the same trusted gatekeeper — which is exactly what interviewers are listening for.
What real-world signal tells you it's time to refactor procedural code into a class-based (OOP) design?
The clearest signal is when you notice the same group of variables (like name, balance, holder) being passed together between multiple functions repeatedly, or when several functions all operate on the same dictionary structure. That repetition of "these things always travel together" is the exact shape a class is built to solve — bundling the data and the functions that act on it into one unit, as this article's Student and BankAccount examples demonstrate.
Comments
Post a Comment