Classes and Objects
Classes are blueprints for creating objects
A class defines what data an object holds (attributes) and what it can do (methods). Objects are individual instances created from that blueprint.
Imagine you're building a dog simulator. Every dog has a name and age (data), and every dog can bark and be described (behavior). Rather than writing separate code for each dog, you create a class that defines what "a dog" looks like, and then create individual dog objects from that template.
This is the core idea of OOP: group related data and behavior together into reusable, self-contained units. It makes your code more organized, easier to understand, and much easier to maintain as your programs grow.
Your First Class
Let's create a simple Dog class. Notice the __init__ method (pronounced "dunder init") — it's called automatically when you create a new dog. The self parameter refers to the specific dog being created or acted upon:
class Dog:
species = "Canis familiaris" # Class attribute
def __init__(self, name, age):
self.name = name # Instance attribute
self.age = age
def bark(self):
return f"{self.name} says Woof!"
def description(self):
return f"{self.name} is {self.age} years old"
rex = Dog("Rex", 5)
buddy = Dog("Buddy", 3)
print(rex.bark())
print(buddy.description())
print(f"Species: {rex.species}")
Rex says Woof! Buddy is 3 years old Species: Canis familiaris
self is a reference to the current instance — it's how a method knows which dog it's talking about. When you call rex.bark(), Python automatically passes rex as the self parameter. You never pass it yourself; you just need to include it in the method definition.
Notice the difference between class attributes (like species) and instance attributes (like name and age). Class attributes are shared by ALL dogs — every dog is "Canis familiaris". Instance attributes are unique to each dog — Rex has his own name and age, separate from Buddy's.
A Practical Example
Let's build something more realistic — a bank account. This demonstrates how OOP encapsulates data (balance) and protects it with methods that enforce rules (can't withdraw more than you have):
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self._balance = balance
def deposit(self, amount):
if amount <= 0:
raise ValueError("Amount must be positive")
self._balance += amount
return self._balance
def withdraw(self, amount):
if amount > self._balance:
raise ValueError("Insufficient funds")
self._balance -= amount
return self._balance
def __str__(self):
return f"Account({self.owner}: ${self._balance})"
account = BankAccount("Alice", 1000)
print(account.deposit(500))
print(account.withdraw(200))
print(account)
1500 1300 Account(Alice: $1300)
Inheritance
Inheritance is one of the four pillars of OOP (along with encapsulation, polymorphism, and abstraction). It lets you create a new class based on an existing one — the child class automatically gets all the parent's attributes and methods, and can add new ones or override existing ones.
Think of it like biology: a Cat is an Animal. A Dog is an Animal. Both share common traits (name, ability to speak) but have their own unique behaviors (cats purr, dogs learn tricks). Rather than duplicating the shared code, we define it once in the parent and let children inherit it:
class Animal:
def __init__(self, name, sound):
self.name = name
self.sound = sound
def speak(self):
return f"{self.name} says {self.sound}!"
class Cat(Animal):
def __init__(self, name):
super().__init__(name, "Meow")
def purr(self):
return f"{self.name} purrs..."
class Dog(Animal):
def __init__(self, name, tricks=None):
super().__init__(name, "Woof")
self.tricks = tricks or []
# Polymorphism
for animal in [Cat("Whiskers"), Dog("Rex"), Cat("Luna")]:
print(animal.speak())
Whiskers says Meow! Rex says Woof! Luna says Meow!
⚠️ Common Mistake: Forgetting super().__init__()
Wrong:
class Child(Parent):
def __init__(self, name, extra):
self.extra = extra
# Forgot super().__init__(name)!
Why: Parent's __init__ never runs, so parent attributes are missing.
Instead:
class Child(Parent):
def __init__(self, name, extra):
super().__init__(name)
self.extra = extra
Dunder Methods (Magic Methods)
Dunder methods (short for "double underscore") are special methods that let your objects integrate with Python's built-in operations. When you write len(my_list), Python actually calls my_list.__len__() behind the scenes. When you write a + b, Python calls a.__add__(b).
By defining these methods in your class, you can make your objects work with +, ==, len(), print(), for loops, and many more. This is what makes Python objects feel so natural — your custom classes can behave just like built-in types:
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Vector({self.x}, {self.y})"
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
def __eq__(self, other):
return self.x == other.x and self.y == other.y
def __len__(self):
return int((self.x**2 + self.y**2) ** 0.5)
v1 = Vector(3, 4)
v2 = Vector(1, 2)
print(v1 + v2)
print(len(v1))
print(v1 == Vector(3, 4))
Vector(4, 6) 5 True
| Dunder | Enables | Example |
|---|---|---|
__init__ | Constructor | obj = MyClass() |
__str__ | print() | print(obj) |
__repr__ | repr() | repr(obj) |
__add__ | + operator | a + b |
__eq__ | == comparison | a == b |
__len__ | len() | len(obj) |
__getitem__ | [] indexing | obj[key] |
__iter__ | for loops | for x in obj |
@property
class Circle:
def __init__(self, radius):
self.radius = radius
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value < 0:
raise ValueError("Radius must be positive")
self._radius = value
@property
def area(self):
import math
return math.pi * self._radius ** 2
c = Circle(5)
print(f"Area: {c.area:.2f}")
c.radius = 10
print(f"New area: {c.area:.2f}")
Area: 78.54 New area: 314.16
obj.attribute syntax.@dataclass
from dataclasses import dataclass, field
@dataclass
class User:
name: str
email: str
age: int
active: bool = True
tags: list = field(default_factory=list)
user1 = User("Alice", "[email protected]", 30)
user2 = User("Alice", "[email protected]", 30)
print(user1)
print(user1 == user2) # Auto __eq__
@dataclass(frozen=True)
class Point:
x: float
y: float
p = Point(3, 4)
print(p)
User(name='Alice', email='[email protected]', age=30, active=True, tags=[]) True Point(x=3, y=4)
🔍 Deep Dive: @dataclass vs Regular Class
Use @dataclass when your class is primarily a data container. Use regular classes for complex behavior or custom __init__ logic. Also consider NamedTuple for immutable alternatives.
@classmethod and @staticmethod
class Date:
def __init__(self, year, month, day):
self.year = year
self.month = month
self.day = day
def __str__(self):
return f"{self.year}-{self.month:02d}-{self.day:02d}"
@classmethod
def from_string(cls, date_str):
year, month, day = map(int, date_str.split("-"))
return cls(year, month, day)
@staticmethod
def is_valid(year, month, day):
return 1 <= month <= 12 and 1 <= day <= 31
d = Date.from_string("2024-12-25")
print(d)
print(Date.is_valid(2024, 13, 1))
2024-12-25 False
| Decorator | First Arg | Use For |
|---|---|---|
| (none) | self | Methods that access instance data |
@classmethod | cls | Alternative constructors |
@staticmethod | (none) | Utility functions |