Module 6.1

Classes and Objects

Classes are blueprints for creating objects with data and behavior. Think of a class as a cookie cutter and objects as the cookies it produces. Each cookie has the same shape but can have different decorations. Classes bundle related data (attributes) and functions (methods) into reusable templates.

50 min
Intermediate
Hands-on
What You'll Learn
  • Class definition and object creation
  • The __init__ constructor method
  • Instance attributes and self keyword
  • Defining and calling methods
  • Class vs instance attributes
Contents
01

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.

Key Concept

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.

class Dog:
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
A class defines the structure. Each object has its own copy of attributes but shares method definitions.
The Object Formula
DATA
name = "Buddy"
age = 3
breed = "Lab"
+
METHODS
bark()
eat()
get_info()
=
OBJECT
Dog Object
(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.

Important

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}")
02

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
Step 1
Dog("Buddy", 3)
Call with args
Step 2
Create empty obj
self = new Dog
Step 3
__init__(self, ...)
Run constructor
Result
self.name = "Buddy"
self.age = 3
Python creates the object first, then passes it as "self" to __init__ along with your arguments.

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.

Core Concept

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)
The self Convention: While "self" is just a convention (not a keyword), always use it. Python passes the instance as the first argument, and "self" makes your code readable and consistent with the community standard.

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)
03

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.

Key Concept

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
04

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
CLASS ATTRIBUTE
(Shared by all)
Dog.species
= "Canine"
All dogs share this
dog1
(Instance)
name = "Buddy"
age = 3
Unique data
dog2
(Instance)
name = "Max"
age = 5
Unique data

Defining 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.

Comparison

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)
05

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.

Key Concept

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.

Best Practice

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 + obj2obj1.__add__(obj2)def __add__(self, other)
obj1 - obj2obj1.__sub__(obj2)def __sub__(self, other)
obj1 * obj2obj1.__mul__(obj2)def __mul__(self, other)
obj1 / obj2obj1.__truediv__(obj2)def __truediv__(self, other)
obj1 == obj2obj1.__eq__(obj2)def __eq__(self, other)
obj1 < obj2obj1.__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!
06

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.

Principle

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.

Pattern

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"
Blueprint contains:
  • 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"
Memory allocation:
  • 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"
Behavior:
  • 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"
Each object stores only instance attributes. Class attributes and methods are stored once in the class and shared by all instances.

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:
1 c.increment(5) → Python looks up increment on c
2 Found in Counter class → Bound to c as self
3 Becomes: Counter.increment(c, 5)
4 self.value += amount → c.value = 10 + 5 = 15
5 return self.value → Returns 15
result = 15, c.value is now 15

Special Methods in Action

See how Python operators translate to special method calls:

Addition Operator
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 + p2p1.__add__(p2)
Equality Operator
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 == p2p1.__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.

Knowledge Check

1 What is the purpose of __init__ in a class?
2 What does "self" refer to in a method?
3 Which attribute type is shared by all instances?
4 What method provides a string representation of an object?
5 How do you create an object from a class called Person?
6 What happens if you modify a class attribute through an instance?