🕯️ Magic Note
Python is object-oriented from the ground up. Everything in Python is an object. Every string, every list, every function, every module. When you call “hello”.upper(), you are using OOP. You have been using OOP throughout this entire course. Now you will learn how to create your own objects.
Python
# Defining a simple class
class Dog:
pass # Empty class (just a placeholder)
# Creating objects from the class
buddy = Dog()
max = Dog()
print(type(buddy)) # <class ‘__main__.Dog’>
print(type(max)) # <class ‘__main__.Dog’>
print(buddy is max) # False (different objects)
Python
# String is a class. “hello” and “world” are objects (instances)
text1 = “hello”
text2 = “world”
print(type(text1)) # <class ‘str’>
print(type(text2)) # <class ‘str’>
# List is a class. [1,2,3] is an object
numbers = [1, 2, 3]
print(type(numbers)) # <class ‘list’>
🕯️ Magic Note
The terms “class” and “type” are often used interchangeably in Python. class Dog: creates a new type. Instances of that type are objects.
Python
class Dog:
def __init__(self, name, age):
self.name = name # Attribute: name
self.age = age # Attribute: age
# Creating objects (__init__ is called automatically)
buddy = Dog(“Buddy”, 3)
max = Dog(“Max”, 5)
print(buddy.name) # Buddy
print(buddy.age) # 3
print(max.name) # Max
print(max.age) # 5
Python
class Dog:
def __init__(self, name, age):
self.name = name
self.age = age
self.energy = 100
def bark(self):
print(f”{self.name} says: Woof!”)
def run(self, distance):
self.energy -= distance
print(f”{self.name} ran {distance} meters. Energy is now {self.energy}”)
def sleep(self):
self.energy = 100
print(f”{self.name} slept and regained full energy!”)
# Using methods
buddy = Dog(“Buddy”, 3)
buddy.bark() # Buddy says: Woof!
buddy.run(30) # Buddy ran 30 meters. Energy is now 70
buddy.sleep() # Buddy slept and regained full energy!
🕯️ Magic Note
When you call buddy.bark(), Python automatically passes the object buddy as the self parameter. This is why methods can access the object’s attributes.
Python
class Dog:
# Class attribute (shared by all dogs)
species = “Canis familiaris”
def __init__(self, name, age):
# Instance attributes (unique to each dog)
self.name = name
self.age = age
buddy = Dog(“Buddy”, 3)
max = Dog(“Max”, 5)
# Both dogs share the same class attribute
print(buddy.species) # Canis familiaris
print(max.species) # Canis familiaris
# Access via the class itself
print(Dog.species) # Canis familiaris
# Changing class attribute changes it for all instances
Dog.species = “Canis lupus familiaris”
print(buddy.species) # Canis lupus familiaris
print(max.species) # Canis lupus familiaris
Python
class Student:
# Dangerous: mutable class attribute
courses = [] # Shared by all students!
def __init__(self, name):
self.name = name
def add_course(self, course):
self.courses.append(course) # Modifies the class attribute!
ali = Student(“Ali”)
sara = Student(“Sara”)
ali.add_course(“Python”)
sara.add_course(“JavaScript”)
print(ali.courses) # [‘Python’, ‘JavaScript’] (not what Ali expects)
print(sara.courses) # [‘Python’, ‘JavaScript’] (not what Sara expects)
# Correct approach: use instance attribute
class StudentCorrect:
def __init__(self, name):
self.name = name
self.courses = [] # Each student gets their own list
def add_course(self, course):
self.courses.append(course)
| Pillar | Meaning | Benefit |
|---|---|---|
| Encapsulation | Bundling data and methods together. Hiding internal details. | Protects data integrity, reduces complexity |
| Inheritance | Creating new classes based on existing ones. Reusing code. | Eliminates redundancy, establishes relationships |
| Polymorphism | Same interface, different implementations. One name, many forms. | Flexibility, extensibility |
| Abstraction | Hiding complex implementation details. Showing only essentials. | Simplifies usage, reduces cognitive load |
Python
# Example overview of the four pillars (simplified)
# (Each will be covered in full detail later)
# Encapsulation: data and methods together
class BankAccount:
def __init__(self, balance):
self._balance = balance # Internal attribute (convention)
def deposit(self, amount):
self._balance += amount # Controlled access
# Inheritance: reusing code
class SavingsAccount(BankAccount): # Inherits from BankAccount
def add_interest(self):
self._balance *= 1.05
# Polymorphism: same method name, different behavior
class Cat:
def sound(self):
return “Meow”
class Dog:
def sound(self):
return “Woof”
animals = [Cat(), Dog()]
for animal in animals:
print(animal.sound()) # Same call, different results
# Abstraction: hiding complexity
class CoffeeMachine:
def make_coffee(self):
self._heat_water()
self._grind_beans()
self._brew()
# User only needs to call make_coffee()
def _heat_water(self): pass # Internal method (convention)
def _grind_beans(self): pass
def _brew(self): pass
- Organizes code around real-world entities and relationships
- Reuses code through inheritance (DRY principle)
- Protects data through encapsulation
- Makes code more modular and maintainable
- Easier to model complex systems with interacting parts
Python
class BankAccount:
# Class attribute (shared by all accounts)
bank_name = “Feloriya National Bank”
def __init__(self, owner, initial_balance=0):
# Instance attributes
self.owner = owner
self._balance = initial_balance # Internal attribute (underscore convention)
self._transaction_history = []
def deposit(self, amount):
if amount <= 0:
raise ValueError(“Deposit amount must be positive”)
self._balance += amount
self._transaction_history.append(f”+{amount}”)
print(f”Deposited {amount}. New balance: {self._balance}”)
def withdraw(self, amount):
if amount <= 0:
raise ValueError(“Withdrawal amount must be positive”)
if amount > self._balance:
raise ValueError(“Insufficient funds”)
self._balance -= amount
self._transaction_history.append(f”-{amount}”)
print(f”Withdrew {amount}. New balance: {self._balance}”)
def get_balance(self):
# Getter method (encapsulation)
return self._balance
def get_history(self):
return self._transaction_history.copy() # Return a copy to prevent modification
def __str__(self):
# Magic method called by str() and print()
return f”Account(owner={self.owner}, balance={self._balance})”
# Using the class
account = BankAccount(“Feloriya”, 1000)
account.deposit(500)
account.withdraw(200)
print(f”Final balance: {account.get_balance()}”)
print(account) # Uses __str__ method
print(BankAccount.bank_name) # Access class attribute
- Forgetting the self parameter in method definitions
- Forgetting to use self when accessing attributes inside methods
- Using mutable class attributes when you meant instance attributes
- Creating classes that are too big (doing too many things)
- Using classes when a simple function or dictionary would suffice
- Not using __init__ to initialize attributes
- What is the difference between a class and an object?
- What does the __init__ method do?
- What is the purpose of the self parameter?
- What is the difference between a class attribute and an instance attribute?
- Name the four pillars of OOP.
- Write a simple Car class with attributes make, model, and year, and a method display_info().
⚡ Whisper
OOP is a way of seeing the world. You look at your problem and you ask: what are the things here? A bank has accounts. A game has players and enemies. A store has products and customers. Each thing has data (color, size, price) and behaviors (buy, sell, move, attack). You bundle them together. You call them objects. This is not magic. It is just a different way to organize your thoughts. Sometimes OOP is perfect. Sometimes it is overkill. The art is knowing when. But learning OOP changes how you think about code. You start seeing patterns. You start reusing structures. You start building systems that mirror the real world. This lesson is the door. The next lessons will take you through the rooms. Walk slowly. Try every example. Build your own classes. The object-oriented path is wide. Many have walked it before you. Now it is your turn.