What Are Classes?
A class is a blueprint that defines the structure and behavior of objects. Objects are instances of classes that hold actual data. Think of a class as a template that specifies what data an object will contain and what actions it can perform.
Object = Data + Methods
An object bundles together data (attributes) and functions (methods) that operate on that data. This encapsulation keeps related code organized and reusable. Instead of scattered variables and functions, everything about an entity lives together in one object.
Why it matters: OOP lets you model real-world entities naturally. A Dog object has name and age (data) plus bark() and eat() (behavior) all in one place.
ATTRIBUTES (Data)
self.name
"Buddy"
self.age
3
self.breed
"Labrador"
METHODS (Behavior)
__init__()
Constructor
bark()
"Woof!"
eat(food)
Eat action
get_info()
Return info
The Object Formula
age = 3
breed = "Lab"
eat()
get_info()
(Complete
Entity)
Creating Your First Class
Define a class using the class keyword followed by the class name (PascalCase convention). Create objects by calling the class like a function.
# Define a simple class
class Dog:
pass # Empty class placeholder
# Create objects (instances) of the class
dog1 = Dog()
dog2 = Dog()
# Each object is independent
print(type(dog1)) # Output:
print(dog1 == dog2) # Output: False (different objects)
This code demonstrates the fundamental syntax for creating Python classes. The class keyword followed by the class name defines a new type. The pass keyword serves as a placeholder for an empty class body that we will fill later. Calling the class name like a function (Dog()) creates a new independent object in memory. Each call produces a separate instance with its own identity, which is why comparing them returns False.
Adding Attributes Directly
You can add attributes to objects dynamically after creation. However, this approach is not recommended for real code since different objects might have inconsistent attributes.
class Dog:
pass
# Create object and add attributes
dog1 = Dog()
dog1.name = "Buddy"
dog1.age = 3
dog2 = Dog()
dog2.name = "Max"
# dog2 has no age attribute!
print(dog1.name) # Output: Buddy
print(dog2.name) # Output: Max
This example shows dynamic attribute assignment where you add attributes after object creation. While this works, it creates inconsistent objects since dog2 lacks an age attribute. Accessing dog2.age would raise an AttributeError. This approach is generally discouraged in production code because different objects of the same class may have different structures, making code harder to maintain and debug.
Naming Conventions
Python follows specific naming conventions for classes and objects. Class names use PascalCase (each word capitalized), while object names and attributes use snake_case (lowercase with underscores). Following these conventions makes your code more readable and consistent with the Python community standards.
Examples: class BankAccount: (PascalCase), my_account = BankAccount() (snake_case), account_balance (snake_case attribute)
Practice: Class Basics
Task: Define an empty class called Car. Create two car objects and verify they are different instances.
Show Solution
# Define empty Car class
class Car:
pass
# Create two instances
car1 = Car()
car2 = Car()
# Verify they are different objects
print(car1 is car2) # Output: False
print(type(car1)) # Output:
Task: Create a Book class. Make an object and add title, author, and pages attributes. Print them.
Show Solution
class Book:
pass
book = Book()
book.title = "Python Basics"
book.author = "John Doe"
book.pages = 250
print(f"{book.title} by {book.author}")
# Output: Python Basics by John Doe
Task: Create a Student class. Make three student objects with name and grade attributes. Store them in a list and print each student's info.
Show Solution
class Student:
pass
# Create students
s1, s2, s3 = Student(), Student(), Student()
s1.name, s1.grade = "Alice", "A"
s2.name, s2.grade = "Bob", "B"
s3.name, s3.grade = "Carol", "A"
students = [s1, s2, s3]
for s in students:
print(f"{s.name}: {s.grade}")
The __init__ Method
The __init__ method is a special constructor that runs automatically when you create an object. It initializes the object's attributes, ensuring every instance starts with proper data. The self parameter refers to the object being created.
How __init__ Works
Dog("Buddy", 3)
Create empty obj
__init__(self, ...)
self.name = "Buddy"self.age = 3
Basic __init__ Usage
Define __init__ with self as the first parameter (always required), followed by any parameters you need for initialization.
class Dog:
def __init__(self, name, age):
self.name = name # Instance attribute
self.age = age # Instance attribute
# Create objects with initial values
dog1 = Dog("Buddy", 3)
dog2 = Dog("Max", 5)
print(dog1.name) # Output: Buddy
print(dog2.age) # Output: 5
This code demonstrates the standard way to create classes with the __init__ constructor method. The self parameter is automatically passed by Python and refers to the object being created. When you call Dog("Buddy", 3), Python first creates an empty Dog object, then calls __init__ with that object as self. The self.name = name pattern stores the passed argument as an instance attribute, making it accessible throughout the object's lifetime.
The Constructor Pattern
The __init__ method is Python's constructor (technically an initializer). It does not create the object but initializes it with starting values. Python calls __init__ automatically after creating the object in memory. This ensures every object starts in a valid, consistent state with all required attributes properly set.
Pattern: def __init__(self, param1, param2): → self.attr1 = param1 → Object created with both attributes.
Default Parameter Values
You can provide default values for parameters, making some arguments optional when creating objects.
class Dog:
def __init__(self, name, age=1, breed="Unknown"):
self.name = name
self.age = age
self.breed = breed
# Different ways to create objects
dog1 = Dog("Buddy") # Uses defaults
dog2 = Dog("Max", 3) # Custom age, default breed
dog3 = Dog("Rex", 2, "Husky") # All custom values
print(f"{dog1.name}, {dog1.age}, {dog1.breed}")
# Output: Buddy, 1, Unknown
This example shows how to use default parameter values in __init__ for flexibility. Required parameters (name) must be provided, while optional parameters (age, breed) use defaults if not specified. Parameters with defaults must come after required parameters in the function signature. This pattern allows creating objects with varying levels of detail while ensuring essential data is always captured.
| Pattern | Syntax | Use Case | Example |
|---|---|---|---|
| Required Only | def __init__(self, x, y): |
Must provide all values | Point(3, 4) |
| With Defaults | def __init__(self, x, y=0): |
Optional parameters | Point(3) or Point(3, 4) |
| Keyword Args | def __init__(self, **kwargs): |
Flexible attributes | Config(debug=True, port=8080) |
| Mixed | def __init__(self, name, *args): |
Variable arguments | Team("A", p1, p2, p3) |
Practice: The __init__ Method
Task: Create a Rectangle class with width and height attributes. Initialize them via __init__. Create a rectangle and print its dimensions.
Show Solution
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
rect = Rectangle(10, 5)
print(f"Width: {rect.width}, Height: {rect.height}")
# Output: Width: 10, Height: 5
Task: Create a BankAccount class with owner (required) and balance (default 0). Create two accounts, one with initial deposit and one without.
Show Solution
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
acc1 = BankAccount("Alice", 1000)
acc2 = BankAccount("Bob")
print(f"{acc1.owner}: ${acc1.balance}") # Alice: $1000
print(f"{acc2.owner}: ${acc2.balance}") # Bob: $0
Task: Create a Product class with name, price, and quantity. In __init__, ensure price and quantity are non-negative (set to 0 if negative). Print the product info.
Show Solution
class Product:
def __init__(self, name, price, quantity):
self.name = name
self.price = max(0, price) # Ensure non-negative
self.quantity = max(0, quantity)
p1 = Product("Laptop", 999, 10)
p2 = Product("Phone", -50, -5) # Invalid values
print(f"{p1.name}: ${p1.price} x {p1.quantity}")
print(f"{p2.name}: ${p2.price} x {p2.quantity}")
# Phone: $0 x 0 (corrected)
Instance Methods
Methods are functions defined inside a class that operate on instance data. They always take self as the first parameter, giving them access to the object's attributes. Methods define the behavior of your objects.
| Method Type | First Parameter | Access To | Common Use |
|---|---|---|---|
Instance method |
self | Instance attributes | Object behavior |
__init__ |
self | Instance attributes | Initialize object |
__str__ |
self | Instance attributes | String representation |
Defining Instance Methods
Define methods like regular functions but inside the class body. Always include self as the first parameter to access the object's data.
class Dog:
def __init__(self, name, age):
self.name = name
self.age = age
def bark(self):
print(f"{self.name} says: Woof!")
def get_info(self):
return f"{self.name} is {self.age} years old"
dog = Dog("Buddy", 3)
dog.bark() # Output: Buddy says: Woof!
print(dog.get_info()) # Output: Buddy is 3 years old
This code illustrates defining instance methods within a class. Methods are functions that belong to a class and operate on object data. The bark() method uses self.name to access the instance's name attribute and print a personalized message. The get_info() method returns a formatted string using both attributes. When you call dog.bark(), Python automatically passes the dog object as self, allowing the method to access that specific dog's data.
Method vs Function
A method is a function defined inside a class that operates on instance data via the self parameter. The key difference from regular functions is that methods are called on objects (object.method()) and have automatic access to the object's state. This binding of data and behavior together is the core principle of object-oriented programming.
Remember: Functions are standalone. Methods belong to objects and access their data through self.
Methods That Modify State
Methods can modify the object's attributes, changing its state. This is how objects maintain and update their data over time.
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
def deposit(self, amount):
self.balance += amount
print(f"Deposited ${amount}. New balance: ${self.balance}")
def withdraw(self, amount):
if amount <= self.balance:
self.balance -= amount
print(f"Withdrew ${amount}. New balance: ${self.balance}")
else:
print("Insufficient funds!")
acc = BankAccount("Alice", 100)
acc.deposit(50) # Deposited $50. New balance: $150
acc.withdraw(30) # Withdrew $30. New balance: $120
This BankAccount class demonstrates methods that modify object state. The deposit() method adds to the balance, while withdraw() includes validation logic to prevent overdrafts. The balance attribute changes over time as methods are called, showing how objects maintain and update their internal state. This encapsulation of data (balance) with operations (deposit, withdraw) is a fundamental OOP pattern that keeps related code organized together.
Methods That Return Values
Methods can compute and return values based on the object's state. This is useful for calculations, data retrieval, and transformations.
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
def perimeter(self):
return 2 * (self.width + self.height)
def is_square(self):
return self.width == self.height
rect = Rectangle(5, 3)
print(f"Area: {rect.area()}") # Area: 15
print(f"Perimeter: {rect.perimeter()}") # Perimeter: 16
print(f"Is square: {rect.is_square()}") # Is square: False
This Rectangle class shows methods that return computed values without modifying state. The area() method calculates width times height, perimeter() computes the sum of all sides, and is_square() returns a boolean indicating whether dimensions are equal. These methods provide a clean interface for getting information about the rectangle while hiding the calculation details from the caller.
The __str__ Method
The __str__ method returns a string representation of the object. It is called automatically when you print an object or convert it to a string.
class Dog:
def __init__(self, name, age):
self.name = name
self.age = age
def __str__(self):
return f"Dog({self.name}, {self.age} years)"
dog = Dog("Buddy", 3)
print(dog) # Output: Dog(Buddy, 3 years)
# Without __str__, you'd see: <__main__.Dog object at 0x...>
The __str__ method returns a human-readable string representation of your object. Python calls this method automatically when you print an object or use str() on it. Without __str__, printing shows a cryptic memory address that is not useful for debugging. Always define __str__ for classes you create, returning a clear description of the object's key attributes. This simple addition dramatically improves code debugging and logging.
Practice: Instance Methods
Task: Create a Rectangle class with width and height. Add an area() method that returns width * height. Test it with a 5x4 rectangle.
Show Solution
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
rect = Rectangle(5, 4)
print(f"Area: {rect.area()}") # Output: Area: 20
Task: Create a Counter class starting at 0. Add increment() and get_count() methods. Increment 3 times and print the count.
Show Solution
class Counter:
def __init__(self):
self.count = 0
def increment(self):
self.count += 1
def get_count(self):
return self.count
c = Counter()
c.increment()
c.increment()
c.increment()
print(c.get_count()) # Output: 3
Task: Create a Circle class with radius. Add area() and circumference() methods using pi = 3.14159. Create a circle with radius 5 and print both values.
Show Solution
class Circle:
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14159 * self.radius ** 2
def circumference(self):
return 2 * 3.14159 * self.radius
circle = Circle(5)
print(f"Area: {circle.area():.2f}") # Area: 78.54
print(f"Circumference: {circle.circumference():.2f}") # 31.42
Task: Create a TodoList class with an empty tasks list. Add methods: add_task(task), complete_task(task), and show_tasks(). Test all three methods.
Show Solution
class TodoList:
def __init__(self):
self.tasks = []
def add_task(self, task):
self.tasks.append(task)
def complete_task(self, task):
if task in self.tasks:
self.tasks.remove(task)
def show_tasks(self):
for task in self.tasks:
print(f"- {task}")
todo = TodoList()
todo.add_task("Learn Python")
todo.add_task("Build project")
todo.show_tasks()
todo.complete_task("Learn Python")
todo.show_tasks()
Task: Create a Player class with name and health (default 100). Add take_damage(amount), heal(amount), and is_alive() methods. Health should not go below 0 or above 100. Simulate combat.
Show Solution
class Player:
def __init__(self, name, health=100):
self.name = name
self.health = health
def take_damage(self, amount):
self.health = max(0, self.health - amount)
def heal(self, amount):
self.health = min(100, self.health + amount)
def is_alive(self):
return self.health > 0
player = Player("Hero")
player.take_damage(30)
print(f"{player.name}: {player.health}HP") # 70HP
player.heal(50) # Capped at 100
print(f"Alive: {player.is_alive()}") # True
Class vs Instance Attributes
Class attributes are shared by all instances, while instance attributes are unique to each object. Understanding the difference prevents subtle bugs and helps you design better classes.
Class vs Instance Attributes
Dog.species= "Canine"
name = "Buddy"age = 3name = "Max"age = 5Defining Both Types
Class attributes are defined directly in the class body. Instance attributes are defined inside __init__ using self.
class Dog:
# Class attribute (shared by all instances)
species = "Canine"
count = 0
def __init__(self, name, age):
# Instance attributes (unique to each)
self.name = name
self.age = age
Dog.count += 1 # Modify class attribute
dog1 = Dog("Buddy", 3)
dog2 = Dog("Max", 5)
print(dog1.species) # Canine (accessed via instance)
print(Dog.species) # Canine (accessed via class)
print(Dog.count) # 2 (shared counter)
This example demonstrates the difference between class and instance attributes. Class attributes (species, count) are defined at the class level and shared by all instances. Instance attributes (name, age) are created in __init__ with self and are unique to each object. The Dog.count counter increments each time a new dog is created, tracking total instances. Access class attributes via the class name (Dog.species) to make the shared nature explicit.
Attribute Types Summary
| Aspect | Class Attribute | Instance Attribute |
|---|---|---|
| Definition Location | Class body (outside __init__) | Inside __init__ with self |
| Shared/Unique | Shared by all instances | Unique to each instance |
| Access | ClassName.attr or self.attr | self.attr only |
| Use Case | Constants, counters, defaults | Object-specific data |
The Shadowing Trap
Be careful when modifying class attributes through instances. Assigning via an instance creates a new instance attribute that shadows the class attribute.
class Dog:
species = "Canine"
def __init__(self, name):
self.name = name
dog1 = Dog("Buddy")
dog2 = Dog("Max")
# This creates an INSTANCE attribute on dog1!
dog1.species = "Modified"
print(dog1.species) # Modified (instance attr)
print(dog2.species) # Canine (class attr)
print(Dog.species) # Canine (unchanged!)
This code reveals a common pitfall with class attributes. When you assign dog1.species = "Modified", Python creates a new instance attribute on dog1 rather than modifying the class attribute. The class attribute remains unchanged for other instances and the class itself. To truly modify a class attribute, use the class name: Dog.species = "Modified". This distinction is crucial to understand to avoid subtle bugs in your object-oriented code.
Practice: Class vs Instance Attributes
Task: Create a Car class with a class attribute wheels = 4 and instance attributes make and model. Create two cars and print their wheels.
Show Solution
class Car:
wheels = 4 # Class attribute
def __init__(self, make, model):
self.make = make
self.model = model
car1 = Car("Toyota", "Camry")
car2 = Car("Honda", "Civic")
print(f"{car1.make} has {car1.wheels} wheels")
print(f"{car2.make} has {Car.wheels} wheels")
Task: Create an Employee class that tracks total employees using a class attribute. Each new employee increments the counter. Add a class method to get the count.
Show Solution
class Employee:
total_employees = 0
def __init__(self, name, department):
self.name = name
self.department = department
Employee.total_employees += 1
@classmethod
def get_total(cls):
return cls.total_employees
e1 = Employee("Alice", "Engineering")
e2 = Employee("Bob", "Marketing")
e3 = Employee("Carol", "Engineering")
print(f"Total employees: {Employee.get_total()}") # 3
Task: Create a Config class with a class attribute debug = False. Show how assigning via an instance shadows the class attribute. Then modify via class name correctly.
Show Solution
class Config:
debug = False
c1 = Config()
c2 = Config()
# Shadowing - creates instance attribute
c1.debug = True
print(f"c1.debug: {c1.debug}") # True (instance)
print(f"c2.debug: {c2.debug}") # False (class)
# Correct way - modify class attribute
Config.debug = True
print(f"After class modification:")
print(f"c2.debug: {c2.debug}") # True (from class)
Special Methods (Dunder Methods)
Special methods (also called dunder methods for "double underscore") let your objects work with Python's built-in operators and functions. They enable features like comparison, arithmetic, iteration, and more, making your objects feel like native Python types.
What Are Dunder Methods?
Dunder methods are special methods surrounded by double underscores (__method__). Python calls them automatically in response to certain operations. For example, __add__ is called when you use the + operator, and __len__ is called by the len() function. By implementing these methods, you control how your objects behave with standard Python syntax.
Why it matters: Custom objects can behave like built-in types: comparable, sortable, hashable, and usable with operators.
| Method | Called By | Purpose | Example |
|---|---|---|---|
__str__ |
str(obj), print() |
Human-readable string | "Dog: Buddy" |
__repr__ |
repr(obj), debugger |
Developer string | "Dog('Buddy', 3)" |
__len__ |
len(obj) |
Return length | len(playlist) → 10 |
__eq__ |
obj1 == obj2 |
Equality comparison | p1 == p2 → True |
__lt__ |
obj1 < obj2 |
Less than comparison | p1 < p2 → False |
__add__ |
obj1 + obj2 |
Addition operator | v1 + v2 → Vector |
__getitem__ |
obj[key] |
Index/key access | data[0] → item |
__iter__ |
for x in obj |
Make iterable | for item in obj |
__repr__ vs __str__
Both methods return strings, but serve different purposes. __str__ is for users (readable), __repr__ is for developers (unambiguous, ideally eval-able).
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __str__(self):
return f"Point at ({self.x}, {self.y})"
def __repr__(self):
return f"Point({self.x}, {self.y})"
p = Point(3, 4)
print(str(p)) # Point at (3, 4) - user-friendly
print(repr(p)) # Point(3, 4) - recreatable
print(p) # Point at (3, 4) - uses __str__
This Point class implements both string representation methods. The __str__ method returns a friendly description for end users, while __repr__ returns a string that could recreate the object if passed to eval(). When you print an object, Python uses __str__ if available, falling back to __repr__ if not. Always implement __repr__ at minimum, as it is also used in debuggers and interactive consoles.
repr() Should Be Unambiguous
The __repr__ output should ideally be valid Python code that recreates the object. If that is not possible, use angle brackets with a description: <Point x=3 y=4>. This convention helps developers understand exactly what object they are looking at during debugging sessions.
Rule of thumb: If in doubt, implement __repr__. If you only implement one, make it __repr__ since Python will use it as a fallback for __str__.
Comparison Methods: __eq__ and Others
Implement comparison methods to make objects comparable with standard operators. This enables sorting and equality checks.
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
def __eq__(self, other):
if not isinstance(other, Person):
return False
return self.name == other.name and self.age == other.age
def __lt__(self, other):
return self.age < other.age
def __repr__(self):
return f"Person('{self.name}', {self.age})"
p1 = Person("Alice", 30)
p2 = Person("Bob", 25)
p3 = Person("Alice", 30)
print(p1 == p3) # True (same name and age)
print(p1 < p2) # False (30 is not less than 25)
print(sorted([p1, p2])) # [Person('Bob', 25), Person('Alice', 30)]
This Person class implements __eq__ for equality comparison and __lt__ for less-than comparison. The __eq__ method first checks if the other object is a Person using isinstance() to avoid errors when comparing with incompatible types. The __lt__ method compares by age, which enables sorting. When you implement __lt__, Python can derive other comparisons. This lets you use sorted() and comparison operators naturally with your custom objects.
The __len__ Method
Implement __len__ to make your objects work with the built-in len() function. This is useful for container-like classes.
class Playlist:
def __init__(self, name):
self.name = name
self.songs = []
def add_song(self, song):
self.songs.append(song)
def __len__(self):
return len(self.songs)
def __repr__(self):
return f"Playlist('{self.name}', {len(self)} songs)"
playlist = Playlist("Road Trip")
playlist.add_song("Song A")
playlist.add_song("Song B")
playlist.add_song("Song C")
print(len(playlist)) # 3
print(playlist) # Playlist('Road Trip', 3 songs)
The Playlist class wraps a list of songs and exposes its length through __len__. When you call len(playlist), Python automatically calls playlist.__len__(). This method should return a non-negative integer representing the object's size or count. Notice how __repr__ uses len(self) internally, demonstrating that special methods can call each other. This pattern makes container classes feel like native Python collections.
Arithmetic Operators: __add__ and Friends
Implement arithmetic methods to use operators like +, -, *, / with your objects. This is common for numeric or vector-like classes.
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
def __sub__(self, other):
return Vector(self.x - other.x, self.y - other.y)
def __mul__(self, scalar):
return Vector(self.x * scalar, self.y * scalar)
def __repr__(self):
return f"Vector({self.x}, {self.y})"
v1 = Vector(3, 4)
v2 = Vector(1, 2)
print(v1 + v2) # Vector(4, 6)
print(v1 - v2) # Vector(2, 2)
print(v1 * 3) # Vector(9, 12)
This Vector class implements __add__ for the + operator, __sub__ for -, and __mul__ for scalar multiplication. Each method returns a new Vector object rather than modifying the original, following immutability best practices. The expression v1 + v2 becomes v1.__add__(v2) behind the scenes. This operator overloading makes mathematical code much more readable compared to calling methods like v1.add(v2).
Operator to Method Mapping
| Operator | Python Calls | Your Method |
|---|---|---|
obj1 + obj2 | obj1.__add__(obj2) | def __add__(self, other) |
obj1 - obj2 | obj1.__sub__(obj2) | def __sub__(self, other) |
obj1 * obj2 | obj1.__mul__(obj2) | def __mul__(self, other) |
obj1 / obj2 | obj1.__truediv__(obj2) | def __truediv__(self, other) |
obj1 == obj2 | obj1.__eq__(obj2) | def __eq__(self, other) |
obj1 < obj2 | obj1.__lt__(obj2) | def __lt__(self, other) |
len(obj) | obj.__len__() | def __len__(self) |
str(obj) | obj.__str__() | def __str__(self) |
Making Objects Iterable: __iter__
Implement __iter__ to make your objects work in for loops. Return an iterator (often using yield or returning self).
class NumberRange:
def __init__(self, start, end):
self.start = start
self.end = end
def __iter__(self):
current = self.start
while current <= self.end:
yield current
current += 1
def __len__(self):
return max(0, self.end - self.start + 1)
nums = NumberRange(1, 5)
for n in nums:
print(n, end=" ") # 1 2 3 4 5
print(list(nums)) # [1, 2, 3, 4, 5]
The NumberRange class implements __iter__ using a generator with yield. When you use a for loop on an object, Python calls __iter__ to get an iterator, then repeatedly calls next() on it. Using yield creates a generator that handles the iteration state automatically. This pattern lets you create memory-efficient iterables that generate values on demand rather than storing them all in memory at once.
Practice: Special Methods
Task: Create a Book class with title and author. Implement __repr__ that returns a string like Book('Title', 'Author').
Show Solution
class Book:
def __init__(self, title, author):
self.title = title
self.author = author
def __repr__(self):
return f"Book('{self.title}', '{self.author}')"
book = Book("1984", "George Orwell")
print(repr(book)) # Book('1984', 'George Orwell')
Task: Create a Point class with x and y coordinates. Implement __eq__ to compare points by their coordinates. Test with equal and unequal points.
Show Solution
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
if not isinstance(other, Point):
return False
return self.x == other.x and self.y == other.y
p1 = Point(3, 4)
p2 = Point(3, 4)
p3 = Point(1, 2)
print(p1 == p2) # True
print(p1 == p3) # False
Task: Create a ShoppingCart class that stores items. Implement __len__ to return the number of items. Add an add_item method.
Show Solution
class ShoppingCart:
def __init__(self):
self.items = []
def add_item(self, item):
self.items.append(item)
def __len__(self):
return len(self.items)
cart = ShoppingCart()
cart.add_item("Apple")
cart.add_item("Banana")
cart.add_item("Orange")
print(len(cart)) # 3
Task: Create a Money class with amount and currency. Implement __add__ that only allows adding Money with the same currency. Raise ValueError for mismatched currencies.
Show Solution
class Money:
def __init__(self, amount, currency):
self.amount = amount
self.currency = currency
def __add__(self, other):
if self.currency != other.currency:
raise ValueError("Cannot add different currencies")
return Money(self.amount + other.amount, self.currency)
def __repr__(self):
return f"Money({self.amount}, '{self.currency}')"
m1 = Money(100, "USD")
m2 = Money(50, "USD")
print(m1 + m2) # Money(150, 'USD')
# m3 = Money(100, "EUR")
# print(m1 + m3) # ValueError!
Best Practices for Classes
Writing good classes requires more than just syntax knowledge. Following established best practices makes your code more maintainable, readable, and Pythonic. These guidelines help you design classes that are easy to use and understand.
Single Responsibility Principle
Each class should have one reason to change—one responsibility. A User class should handle user data, not also send emails and manage database connections. If a class does too much, split it into smaller, focused classes. This makes testing easier and reduces the impact of changes.
Ask yourself: "Can I describe what this class does in one sentence without using 'and'?" If not, it might be doing too much.
Use Meaningful Names
Class names should be nouns that clearly describe what the class represents. Method names should be verbs describing actions.
# Bad: Vague or misleading names
class Data:
def process(self): pass
def do_thing(self): pass
# Good: Clear, descriptive names
class CustomerOrder:
def calculate_total(self): pass
def apply_discount(self, percent): pass
def mark_as_shipped(self): pass
This comparison shows the importance of descriptive naming. The Data class tells us nothing about what data it holds or what it does. In contrast, CustomerOrder immediately communicates its purpose. Method names like calculate_total and apply_discount describe exactly what they do. Good names act as documentation, making code self-explanatory and reducing the need for comments.
Keep __init__ Simple
The constructor should only initialize attributes. Avoid complex logic, I/O operations, or side effects in __init__.
# Bad: Complex logic in __init__
class Report:
def __init__(self, filepath):
self.filepath = filepath
self.data = self._load_file() # I/O in constructor
self.processed = self._process() # Complex logic
self._send_notification() # Side effect!
# Good: Simple initialization
class Report:
def __init__(self, filepath):
self.filepath = filepath
self.data = None
self.processed = False
def load(self):
"""Load data from file."""
self.data = self._read_file()
return self
def process(self):
"""Process the loaded data."""
if self.data is None:
raise ValueError("Call load() first")
# Processing logic here
self.processed = True
return self
The improved Report class separates initialization from action. The constructor only sets up attributes with default values. Loading and processing are explicit methods the user calls when ready. This design is more flexible since users can create objects without immediately triggering file I/O, and errors are more predictable. It also makes the class easier to test and mock.
Defensive Programming
Validate inputs in your methods to catch errors early with clear messages. Check types with isinstance(), validate ranges, and raise appropriate exceptions. This prevents cryptic errors later and makes debugging much easier when something goes wrong.
Remember: It is better to fail fast with a clear error than to silently produce wrong results.
Validate Input Early
Check parameters at the start of methods and raise clear exceptions for invalid input.
class BankAccount:
def __init__(self, owner, initial_balance=0):
if not owner or not isinstance(owner, str):
raise ValueError("Owner must be a non-empty string")
if initial_balance < 0:
raise ValueError("Initial balance cannot be negative")
self.owner = owner
self.balance = initial_balance
def withdraw(self, amount):
if not isinstance(amount, (int, float)):
raise TypeError("Amount must be a number")
if amount <= 0:
raise ValueError("Amount must be positive")
if amount > self.balance:
raise ValueError(f"Insufficient funds: {self.balance} available")
self.balance -= amount
return self.balance
This BankAccount class validates all inputs thoroughly. The constructor checks that owner is a non-empty string and balance is non-negative. The withdraw method validates the amount type, ensures it is positive, and checks for sufficient funds. Each validation raises a specific exception with a clear message. This defensive approach catches bugs at their source rather than letting them propagate through the system.
Use Properties for Controlled Access
Properties let you add validation or computation to attribute access without changing the interface.
class Temperature:
def __init__(self, celsius=0):
self._celsius = celsius
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("Temperature below absolute zero!")
self._celsius = value
@property
def fahrenheit(self):
return self._celsius * 9/5 + 32
@fahrenheit.setter
def fahrenheit(self, value):
self.celsius = (value - 32) * 5/9
temp = Temperature(25)
print(temp.fahrenheit) # 77.0
temp.fahrenheit = 100 # Sets celsius to 37.78
print(temp.celsius) # 37.777...
Properties provide a clean interface while maintaining control over attribute access. The celsius property validates that temperatures do not go below absolute zero. The fahrenheit property is computed on-the-fly from celsius, ensuring consistency. Both properties have setters, so users can assign to either attribute naturally. The underscore prefix on _celsius indicates it is internal, while the properties provide the public interface.
Class Design Checklist
DO
- Use PascalCase for class names
- Use snake_case for methods/attributes
- Keep classes focused (single responsibility)
- Implement __repr__ for all classes
- Validate inputs early
- Document with docstrings
- Use properties for computed attributes
DON'T
- Put complex logic in __init__
- Use vague names like "data" or "info"
- Create classes that do too many things
- Modify class attributes via instances
- Ignore type checking for critical methods
- Use mutable default arguments
- Forget to call super().__init__ in subclasses
Add Docstrings
Document your classes and methods with docstrings. They appear in help() and IDE tooltips.
class Rectangle:
"""
A rectangle defined by width and height.
Attributes:
width (float): The width of the rectangle.
height (float): The height of the rectangle.
Example:
>>> rect = Rectangle(10, 5)
>>> rect.area()
50
"""
def __init__(self, width, height):
"""Initialize a Rectangle with given dimensions."""
self.width = width
self.height = height
def area(self):
"""Calculate and return the area of the rectangle."""
return self.width * self.height
def perimeter(self):
"""Calculate and return the perimeter of the rectangle."""
return 2 * (self.width + self.height)
# View documentation
help(Rectangle)
Well-written docstrings serve as inline documentation that stays with your code. The class docstring describes what the class represents, lists its attributes, and provides a usage example. Method docstrings briefly describe what each method does. Python's help() function displays these docstrings, and IDEs use them for autocomplete tooltips. Following conventions like Google style or NumPy style makes docstrings consistent across projects.
Interactive Demonstrations
Visualize how classes and objects work with these interactive examples that break down the concepts step by step.
Class Builder Visualizer
See how a class definition translates into objects with their own data:
Object Creation Flow
1. Class Definition (Blueprint)
class Student:
school = "Python Academy"
def __init__(self, name, grade):
self.name = name
self.grade = grade
def study(self, hours):
return f"{self.name} studied {hours}h"
- Class attribute: school
- Constructor: __init__
- Method: study
2. Object Creation
# Create two students
alice = Student("Alice", "A")
bob = Student("Bob", "B")
# Each gets own attributes
# alice.name = "Alice"
# alice.grade = "A"
# bob.name = "Bob"
# bob.grade = "B"
- Two separate objects created
- Each has own name, grade
- Both share school attribute
3. Using Objects
# Call methods
print(alice.study(3))
# "Alice studied 3h"
# Access shared attribute
print(alice.school)
# "Python Academy"
print(bob.school)
# "Python Academy"
- Methods use instance data
- Class attrs shared by all
- Each object is independent
Object Inspector: Memory Model
Understand how objects are stored in memory and how attributes are resolved:
Object Memory Layout
┌─────────────────────────────────────────────────────────────────────────┐
│ CLASS: Student │
├─────────────────────────────────────────────────────────────────────────┤
│ Class Attributes (shared): │
│ ┌─────────────────────┐ │
│ │ school = "Academy" │◄─────────────────────┐ │
│ └─────────────────────┘ │ │
│ │ │
│ Methods (shared): │ │
│ ┌─────────────────────┐ │ │
│ │ __init__(self,...) │ │ │
│ │ study(self, hours) │ │ │
│ └─────────────────────┘ │ │
└───────────────────────────────────────────────┼─────────────────────────┘
│
┌───────────────────────────────────┼───────────────────┐
│ │ │
▼ ▼ ▼
┌───────────────────┐ ┌───────────────────┐ (more instances)
│ OBJECT: alice │ │ OBJECT: bob │
├───────────────────┤ ├───────────────────┤
│ name = "Alice" │ │ name = "Bob" │
│ grade = "A" │ │ grade = "B" │
│ ──────────────────│ │ ──────────────────│
│ → points to class │ │ → points to class │
└───────────────────┘ └───────────────────┘
ATTRIBUTE LOOKUP: alice.school
1. Check alice's instance attributes → Not found
2. Check Student class attributes → Found! Return "Academy"
Method Call Visualizer
Step through what happens when you call a method on an object:
Method Call Breakdown
class Counter:
def __init__(self, start=0):
self.value = start
def increment(self, amount=1):
self.value += amount
return self.value
c = Counter(10)
result = c.increment(5)
Execution Steps:
c.increment(5) → Python looks up increment on c
Counter.increment(c, 5)
self.value += amount → c.value = 10 + 5 = 15
return self.value → Returns 15
Special Methods in Action
See how Python operators translate to special method calls:
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __add__(self, other):
return Point(
self.x + other.x,
self.y + other.y
)
p1 = Point(1, 2)
p2 = Point(3, 4)
p3 = p1 + p2 # Point(4, 6)
p1 + p2 → p1.__add__(p2)
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __eq__(self, other):
return (self.x == other.x and
self.y == other.y)
p1 = Point(1, 2)
p2 = Point(1, 2)
p1 == p2 # True
p1 == p2 → p1.__eq__(p2)
Key Takeaways
Classes Are Blueprints
A class defines the structure and behavior for objects. Objects are instances created from class blueprints with their own data.
__init__ Initializes Objects
The __init__ method runs automatically when creating objects. Use it to set up initial attribute values and ensure consistency.
Self References the Object
The self parameter gives methods access to the instance's attributes. Python passes it automatically when you call methods.
Methods Define Behavior
Instance methods operate on object data. They can read attributes, modify state, and return computed values.
Class vs Instance Attributes
Class attributes are shared by all instances. Instance attributes (set via self) are unique to each object.
Use __str__ for Debugging
Define __str__ to return a meaningful string representation. It makes printing objects informative instead of cryptic.