Introduction to Object-Oriented Programming (OOP) in Python
From the Classes(python) curriculum
TL;DR
Object-Oriented Programming (OOP) is a powerful way to organize your code by modeling real-world entities as "objects" that combine data and behavior. It helps you write more organized, reusable, and maintainable Python programs. You'll learn core concepts like classes, objects, attributes, and methods to build structured applications.
1. The Mental Model
Think of OOP as building with LEGOs instead of just loose bricks. You're creating self-contained units (the LEGO pieces) that have specific characteristics and actions. When you program with OOP, you define blueprints for these units, then you can easily create many copies of them to solve your problem. This makes your code much easier to understand and manage, especially as projects grow.
2. The Core Material
Object-Oriented Programming (OOP) is a programming paradigm that organizes software design around "objects" rather than "functions and logic." In Python, everything is an object, but OOP allows us to create our own custom objects.
2.1 Classes and Objects: The Blueprint and the House

Photo by Mikhail Nilov on Pexels
At the heart of OOP are classes and objects.
* A class is like a blueprint or a cookie cutter. It defines the structure and behavior that its instances will have. It doesn't do anything on its own; it just describes what something can be.
* An object (or instance) is a concrete realization of a class. It's like the house built from the blueprint, or a specific cookie made from the cutter. You can create many objects from a single class.
Let's look at a simple Dog class:
class Dog:
# Class attribute - shared by all instances of Dog
species = "Canis familiaris"
def __init__(self, name, breed):
"""
The constructor method.
It initializes a new Dog object when it's created.
'self' refers to the instance of the class.
"""
self.name = name # Instance attribute
self.breed = breed # Instance attribute
def bark(self):
"""
An instance method.
It defines an action that a Dog object can perform.
"""
return f"{self.name} says Woof!"
# Creating objects (instances) from the Dog class
my_dog = Dog("Buddy", "Golden Retriever")
your_dog = Dog("Lucy", "Beagle")
# Accessing attributes
print(f"My dog's name is {my_dog.name} and she is a {my_dog.breed}.")
print(f"Your dog's name is {your_dog.name} and he is a {your_dog.breed}.")
# Accessing a class attribute
print(f"All dogs belong to the species: {Dog.species}")
# Calling methods
print(my_dog.bark())
print(your_dog.bark())
In this example:
* Dog is the class.
* my_dog and your_dog are objects (instances) of the Dog class.
* species is a class attribute, shared by all Dog objects.
* name and breed are instance attributes, unique to each Dog object.
* __init__ is a special method called the constructor. It's automatically called when you create a new object. self is a reference to the current instance of the class.
* bark is an instance method, defining an action Dog objects can perform.
2.2 Encapsulation: Bundling Data and Behavior

Photo by panumas nikhomkhai on Pexels
Encapsulation is about bundling the data (attributes) and methods (behaviors) that operate on the data into a single unit, which is the object. It also means restricting direct access to some of an object's components, preventing unintended modification. This is often achieved through conventions in Python (using a single or double underscore prefix for "private" attributes) rather than strict access modifiers like private in other languages.
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.__balance = balance # Using __ to hint that it's "private"
def deposit(self, amount):
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):
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 get_balance(self):
return self.__balance
# Create an account
account = BankAccount("Alice")
print(f"Initial balance: {account.get_balance()}")
account.deposit(100)
account.withdraw(30)
account.withdraw(200) # This will fail
print(f"Final balance: {account.get_balance()}")
# Trying to access __balance directly (discouraged)
# print(account.__balance) # This would raise an AttributeError if not for name mangling
print(account._BankAccount__balance) # This works, but it's an anti-pattern
Here, __balance is "encapsulated." We provide deposit, withdraw, and get_balance methods to interact with it, ensuring that the balance is always handled correctly. The __ prefix triggers Python's name mangling, making direct access slightly harder (e.g., _BankAccount__balance), but it doesn't truly prevent it like private in Java or C++. It's a strong convention to indicate "don't touch this directly."
2.3 Inheritance: Building on Existing Code

Photo by Pixabay on Pexels
Inheritance allows a new class (the child or derived class) to inherit attributes and methods from an existing class (the parent or base class). This promotes code reuse and establishes a "is-a" relationship (e.g., a Cat is a Animal).
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
raise NotImplementedError("Subclass must implement abstract method")
def eat(self):
return f"{self.name} is eating."
class Dog(Animal): # Dog inherits from Animal
def __init__(self, name, breed):
super().__init__(name) # Call the parent's constructor
self.breed = breed
def speak(self): # Overriding the speak method
return f"{self.name} says Woof!"
class Cat(Animal): # Cat inherits from Animal
def __init__(self, name, color):
super().__init__(name)
self.color = color
def speak(self): # Overriding the speak method
return f"{self.name} says Meow!"
# Create objects
doggo = Dog("Rex", "German Shepherd")
kitty = Cat("Whiskers", "Orange")
print(doggo.speak())
print(doggo.eat()) # Inherited method
print(kitty.speak())
print(kitty.eat()) # Inherited method
Here, Dog and Cat are child classes of Animal. They inherit name and eat() from Animal and also define their own speak() method, overriding the parent's generic one. super().__init__(name) is crucial for calling the parent class's constructor to initialize inherited attributes.
2.4 Polymorphism: "Many Forms"

Photo by Markus Spiske on Pexels
Polymorphism means "many forms." In OOP, it allows objects of different classes to be treated as objects of a common type. It often works hand-in-hand with inheritance. If Dog and Cat both inherit from Animal and have a speak() method, we can call speak() on either a Dog or Cat object without needing to know its exact type, and it will do the "right" thing.
def make_animal_speak(animal):
print(animal.speak())
make_animal_speak(doggo) # Rex says Woof!
make_animal_speak(kitty) # Whiskers says Meow!
# Another example with a list of animals
animals = [Dog("Fido", "Poodle"), Cat("Garfield", "Tabby")]
for animal in animals:
print(animal.name)
print(animal.speak())
The make_animal_speak function works with any object that has a speak() method, demonstrating polymorphism. Each object speak()s in its own specific way.
Here's a visual summary of these core concepts:
graph TD
A[Class] --> B[Object (Instance)]
B -- has --> C[Attributes]
B -- performs --> D[Methods]
subgraph Encapsulation
E[Object] -- bundles --> C
E -- bundles --> D
E -- controls access to --> C
end
F[Parent Class] -- "is-a" relationship --> G[Child Class]
G -- inherits --> H[Attributes & Methods] from F
G -- can override --> H
F -- "many forms" --> I[Polymorphism]
J[Different Objects] -- treated uniformly via --> I
J -- e.g., calling same method on --> G
3. Worked Example
Let's build a simple Library system using OOP principles. We'll have Book objects and a Library object to manage them.
class Book:
def __init__(self, title, author, isbn):
self.title = title
self.author = author
self.isbn = isbn
self.is_checked_out = False # Initial state
def __str__(self):
"""String representation of the Book object."""
status = "Checked Out" if self.is_checked_out else "Available"
return f'"{self.title}" by {self.author} (ISBN: {self.isbn}) - Status: {status}'
def check_out(self):
if not self.is_checked_out:
self.is_checked_out = True
return f'"{self.title}" has been checked out.'
else:
return f'"{self.title}" is already checked out.'
def return_book(self):
if self.is_checked_out:
self.is_checked_out = False
return f'"{self.title}" has been returned.'
else:
return f'"{self.title}" was not checked out.'
class Library:
def __init__(self, name):
self.name = name
self.books = [] # A list to store Book objects
def add_book(self, book):
if isinstance(book, Book): # Ensure we're adding a Book object
self.books.append(book)
print(f'Added "{book.title}" to the {self.name} library.')
else:
print("Can only add Book objects to the library.")
def list_all_books(self):
if not self.books:
print(f"The {self.name} library currently has no books.")
return
print(f"\n--- Books in {self.name} ---")
for book in self.books:
print(book) # Uses the __str__ method of the Book object
print("--------------------------")
def find_book(self, search_term, by="title"):
found_books = []
for book in self.books:
if by == "title" and search_term.lower() in book.title.lower():
found_books.append(book)
elif by == "author" and search_term.lower() in book.author.lower():
found_books.append(book)
elif by == "isbn" and search_term == book.isbn:
found_books.append(book)
return found_books
# --- Let's use our Library system ---
my_library = Library("City Central Library")
# Create some book objects
book1 = Book("The Hitchhiker's Guide to the Galaxy", "Douglas Adams", "978-0345391803")
book2 = Book("Pride and Prejudice", "Jane Austen", "978-0141439518")
book3 = Book("Python Crash Course", "Eric Matthes", "978-1593279288")
# Add books to the library
my_library.add_book(book1)
my_library.add_book(book2)
my_library.add_book(book3)
# List all books
my_library.list_all_books()
# Perform some actions
print(book1.check_out())
my_library.list_all_books() # See the status change
print(book1.check_out()) # Try checking out again
print(book2.check_out())
my_library.list_all_books()
print(book1.return_book())
my_library.list_all_books()
# Find books
print("\n--- Search Results ---")
found = my_library.find_book("python", by="title")
if found:
for b in found:
print(f"Found: {b}")
else:
print("No books found matching 'python'.")
found_austin = my_library.find_book("Austen", by="author")
if found_austin:
for b in found_austin:
print(f"Found by author: {b}")
In this example:
* The Book class encapsulates book data (title, author, isbn, is_checked_out) and behaviors (check_out, return_book).
* The Library class manages a collection of Book objects. It has its own data (name, books list) and behaviors (add_book, list_all_books, find_book).
* The __str__ method in Book is a special method that defines how an object should be represented as a string, making print(book) output readable.
4. Production Pitfalls & Best Practices
4.1 Real-World Best Practices
- Use
__init__for Initial State: Always initialize an object's state (its attributes) in the__init__method. This makes sure every new object starts in a predictable and valid condition. - Favor Composition Over Inheritance: While inheritance is useful, don't overuse it. Often, creating objects that contain other objects (composition) is more flexible and maintains simpler hierarchies than deep inheritance chains. Think "has-a" relationships (
Carhas anEngine) rather than "is-a" (Engineis aCar). - Encapsulate State with Properties (Getters/Setters): For more controlled access to attributes, especially when validation or side effects are needed, use Python's
@propertydecorator to create "getters" and "setters" without sacrificing readability. - Define
__str__and__repr__: Implement__str__for a human-readable string representation (like ourBookexample) and__repr__for an unambiguous, developer-focused representation (useful for debugging, often showing how to recreate the object). - Small Classes, Single Responsibility: Aim for classes that are small and have a single, well-defined responsibility. This makes them easier to understand, test, and maintain.
4.2 Common Bugs & Anti-Patterns
-
Modifying Class Attributes Accidentally:
Sometimes, new Pythonistas mistakenly use class attributes where instance attributes are needed, leading to unintended side effects across all instances.BAD Code:
```python
class BadExample:
data_list = [] # This is a class attribute!def add_item(self, item): self.data_list.append(item)obj1 = BadExample()
obj2 = BadExample()obj1.add_item("item A")
obj2.add_item("item B")print(obj1.data_list) # Output: ['item A', 'item B'] - Uh oh!
print(obj2.data_list) # Output: ['item A', 'item B'] - Both modified the same list!
```FIXED Code:
```python
class GoodExample:
def init(self):
self.data_list = [] # Correct: instance attributedef add_item(self, item): self.data_list.append(item)obj1 = GoodExample()
obj2 = GoodExample()obj1.add_item("item A")
obj2.add_item("item B")print(obj1.data_list) # Output: ['item A']
print(obj2.data_list) # Output: ['item B'] - Correct, each has its own list.
`` 2. **Forgettingsuper().initin Inheritance:** When a child class has its owninitmethod, it must explicitly call the parent'sinit` if it wants to inherit the parent's initialized attributes.BAD Code:
```python
class Parent:
def init(self, value):
self.value = valueclass Child(Parent):
def init(self, value, extra): # Forgets to call parent's init
self.extra = extra # self.value won't be set!child_obj = Child(10, "foo")
print(child_obj.value) # AttributeError: 'Child' object has no attribute 'value'
```
FIXED Code:
```python
class Parent:
def init(self, value):
self.value = valueclass Child(Parent):
def init(self, value, extra):
super().init(value) # Correct: calls Parent's init
self.extra = extrachild_obj = Child(10, "foo")
print(child_obj.value) # Output: 10
print(child_obj.extra) # Output: foo
```
4.3 When to Use What (Decision Matrix)
| Situation / Goal | OOP Approach to Consider | Why |
|---|---|---|
| Modeling real-world entities | Classes and Objects | Provides a natural way to represent entities with data and behavior. |
| Reusing common functionality | Inheritance | Allows child classes to inherit and extend parent class features. |
| Hiding internal details | Encapsulation (e.g., private attributes with __) |
Protects data integrity and simplifies external interaction. |
| Handling different types uniformly | Polymorphism | Enables writing flexible code that works with various object types. |
| Bundling data and operations | Methods on objects | Keeps related data and behavior together, improving organization. |
| Managing a collection of items | A class that contains other objects (Composition) | Cleaner than deep inheritance; represents "has-a" relationships. |
5. Now Try It
You've seen how Book and Library work. Your task is to extend this system.
Create a new class called Member. A Member should have a name, a member_id, and a list of borrowed_books.
Add a method to the Member class to borrow_book(book) and return_book(book). When a member borrows or returns a book, it should update both the Member's borrowed_books list AND the Book object's is_checked_out status. Make sure a member can't borrow a book already checked out and can't return a book they haven't borrowed.
Success looks like: You can create a Member, have them borrow a Book (which updates the book's status), and then return it, confirming all statuses update correctly.
Frequently asked about Introduction to Object-Oriented Programming (OOP) in Python
Study this next
Get the full Classes(python) curriculum
Clone the complete plan to your dashboard for unlimited AI-generated notes, practice quizzes, and a personalised revision schedule.
Save this course free